Use hybrid WoW-stat gear for five RoM builds

This commit is contained in:
phenom committed 2026-09-23 18:23:31 -04:00
1 parent f4a7d33f74
commit 8e95480d4f
10 files changed
+134 -29

No files matched your search

+1 -1
View File
@@ -57,7 +57,7 @@ const ARMOR_RANK: Readonly<Record<string, number>> = Object.freeze({
const SHIELD_CLASSES = new Set<ClassId>([
"warrior", "paladin", "shaman",
"knight-of-xoroth", "guardian", "cultist", "starcaller", "sun-cleric",
"rom-warrior", "rom-knight", "rom-warden", "rom-champion",
"rom-warrior", "rom-knight", "rom-priest", "rom-warden", "rom-champion",
]);
const WEAPON_CLASSES: Readonly<Record<string, ReadonlySet<BaseClassId>>> = Object.freeze({
+17 -8
View File
@@ -24,6 +24,7 @@ import type { InventoryItem } from "./lootTypes";
import type { PartyRole } from "./partyRoles";
import {
gearProfileFor,
isRomHybridGearClass,
specializationScore,
type GearArchetype,
type SpecializationDefinition,
@@ -119,6 +120,8 @@ function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: Specializ
if (slot === "off-hand" && profile.weaponLayout === "dual-wield") return "dagger";
if (slot === "ranged") return profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
if (slot !== "main-hand") return null;
if (classId === "rom-scout" && profile.weaponLayout === "ranged") return "dagger";
if (classId === "rom-mage") return profile.weaponLayout === "two-hand" ? "staff" : "dagger";
if (profile.weaponLayout === "caster") {
return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId)
? "mace"
@@ -129,8 +132,8 @@ function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: Specializ
return "mace";
}
function inventorySlotFor(slot: EquipmentSlot, layout: WeaponLayout): string {
if (slot === "main-hand") return layout === "two-hand" || layout === "ranged" ? "two-hand" : "one-hand";
function inventorySlotFor(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string {
if (slot === "main-hand") return layout === "two-hand" || (layout === "ranged" && classId !== "rom-scout") ? "two-hand" : "one-hand";
return slot;
}
@@ -212,13 +215,16 @@ function heirloomItem(
if (cached) return cached;
const armorType = armorTypeForSlot(context.classId, slot, layout);
const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout });
const inventorySlot = inventorySlotFor(slot, layout);
const inventorySlot = inventorySlotFor(context.classId, slot, layout);
const weight = SLOT_WEIGHT[slot];
const budget = Math.max(2, level * weight * 1.18);
const armor = armorType && armorType !== "shield"
? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1)))
: armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0;
const sourceStats = statDistribution(profile.gearArchetype, budget, armor);
const distributedStats = statDistribution(profile.gearArchetype, budget, armor);
const sourceStats = armorType === "shield" && isRomHybridGearClass(context.classId)
? { ...distributedStats, blockValue: Math.max(1, Math.round(level * weight * 0.45)) }
: distributedStats;
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
let stats = itemStatsFor({
itemId: 9_000_000,
@@ -232,11 +238,11 @@ function heirloomItem(
sourceWeapon,
});
const currentScore = specializationScore(stats, profile);
const catalogScore = highestCatalogScore(context.classId, slot, level, profile)
+ (slot === "main-hand" && ["two-hand", "ranged"].includes(layout)
const catalogScore = isRomHybridGearClass(context.classId) ? 0 : highestCatalogScore(context.classId, slot, level, profile)
+ (slot === "main-hand" && inventorySlot === "two-hand"
? highestCatalogScore(context.classId, "off-hand", level, profile)
: 0);
const conflictSafety = slot === "main-hand" && ["two-hand", "ranged"].includes(layout) ? 1.05 : 1.02;
const conflictSafety = slot === "main-hand" && inventorySlot === "two-hand" ? 1.05 : 1.02;
const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore;
if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore);
const normalizedScore = specializationScore(stats, profile);
@@ -277,7 +283,10 @@ export function resolvedEquipmentLoadout(
): Readonly<Partial<Record<EquipmentSlot, InventoryItem>>> {
const profile = gearProfileFor(context.classId, context.specializationId, context.role);
const result: Partial<Record<EquipmentSlot, InventoryItem>> = {};
for (const slot of [...BASE_SLOTS, ...weaponSlots(profile.weaponLayout)]) result[slot] = heirloomItem(context, profile, slot);
for (const slot of [...BASE_SLOTS, ...weaponSlots(profile.weaponLayout)]) {
if (isRomHybridGearClass(context.classId) && slot === "wrist") continue;
result[slot] = heirloomItem(context, profile, slot);
}
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
const earned = assignments[context.ownerId] ?? {};
for (const slot of EQUIPMENT_SLOTS) {
+50 -4
View File
@@ -3,20 +3,22 @@ import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"
import { isValidRomClassPair } from "../app/romDualClass";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { readinessStatusFor } from "./companionSpecReadiness";
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
import { classCanEquipItem } from "./equipment";
import { equipmentStatsForItems, resolvedEquipmentLoadout } from "./heirloomEquipment";
import { partyRolesForClass } from "./partyRoles";
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
import { ROM_ORDINARY_ABILITIES_BY_CLASS } from "./romAbilityCatalog";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { romDualClassBaseStats, romNativeClassBaseStats } from "./romStats";
import { romGearProfile } from "./specializationCatalog";
import { deriveCharacterStats } from "./wotlkStats";
const CASES = [
{ classId: "rom-priest", role: "healer", layout: "caster" },
{ classId: "rom-priest", role: "healer", layout: "weapon-shield" },
{ classId: "rom-knight", role: "tank", layout: "weapon-shield" },
{ classId: "rom-scout", role: "damage", layout: "ranged" },
{ classId: "rom-rogue", role: "damage", layout: "dual-wield" },
{ classId: "rom-mage", role: "damage", layout: "caster" },
{ classId: "rom-mage", role: "damage", layout: "two-hand" },
] as const;
describe("RoM five class validation scope", () => {
@@ -126,11 +128,55 @@ describe("RoM five class validation scope", () => {
level: 60,
});
if (classId === "rom-knight") expect(loadout["off-hand"]?.armorType).toBe("shield");
if (classId === "rom-scout") expect(loadout.ranged?.weaponType).toBe("bow");
if (classId === "rom-priest") {
expect(loadout["main-hand"]?.weaponType).toBe("mace");
expect(loadout["off-hand"]?.armorType).toBe("shield");
}
if (classId === "rom-scout") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["main-hand"]?.inventorySlot).toBe("one-hand");
expect(loadout.ranged?.weaponType).toBe("bow");
}
if (classId === "rom-rogue") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
}
if (classId === "rom-mage") {
expect(loadout["main-hand"]?.weaponType).toBe("staff");
expect(loadout["main-hand"]?.inventorySlot).toBe("two-hand");
expect(loadout["off-hand"]).toBeUndefined();
}
expect(loadout.wrist).toBeUndefined();
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!)).toBe(true);
}
});
it("grows the five hybrid combat loadouts through the heirloom levels", () => {
for (const { classId, role } of CASES) {
const identity = ROM_FIVE_IDENTITIES.find((entry) => entry.classId === classId && entry.role === role)!;
let priorHealth = 0;
let priorPower = 0;
for (const level of [1, 20, 40, 60, 79]) {
const loadout = resolvedEquipmentLoadout({}, [], {
ownerId: `rom-hybrid:${classId}`,
ownerKind: "player",
classId,
specializationId: null,
role,
level,
});
const gear = equipmentStatsForItems(Object.values(loadout).filter((item) => item !== undefined));
const combat = deriveCharacterStats(classId, level, gear, identity.raceId, {}, level >= 10 ? identity.secondaryClassId : null);
const power = classId === "rom-scout" ? combat.rangedAttackPower
: classId === "rom-rogue" || classId === "rom-knight" ? combat.attackPower
: combat.spellPower;
expect(combat.health, `${classId} health at ${level}`).toBeGreaterThan(priorHealth);
expect(power, `${classId} power at ${level}`).toBeGreaterThan(priorPower);
expect(Object.values(loadout).every((item) => item?.itemLevel === level)).toBe(true);
if (classId === "rom-knight" || classId === "rom-priest") expect(combat.blockValue).toBeGreaterThan(0);
priorHealth = combat.health;
priorPower = power;
}
}
});
+2 -2
View File
@@ -50,10 +50,10 @@ describe("specialization registry", () => {
});
it("gives the five RoM primary roles their appropriate weapon layouts", () => {
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("caster");
expect(romGearProfile("rom-priest", "healer").weaponLayout).toBe("weapon-shield");
expect(romGearProfile("rom-knight", "tank").weaponLayout).toBe("weapon-shield");
expect(romGearProfile("rom-scout", "damage").weaponLayout).toBe("ranged");
expect(romGearProfile("rom-rogue", "damage").weaponLayout).toBe("dual-wield");
expect(romGearProfile("rom-mage", "damage").weaponLayout).toBe("caster");
expect(romGearProfile("rom-mage", "damage").weaponLayout).toBe("two-hand");
});
});
+28
View File
@@ -45,6 +45,23 @@ const WEIGHTS: Readonly<Record<GearArchetype, Readonly<Partial<Record<NumericIte
"non-block-tank": Object.freeze({ agility: 0.66, strength: 0.28, stamina: 1, armor: 0.14, defenseRating: 0.78, dodgeRating: 0.86, parryRating: 0.46, expertiseRating: 0.4 }),
});
const ROM_FIVE_HYBRID_PROFILES: Readonly<Partial<Record<ClassId, Readonly<{
role: PartyRole;
gearArchetype: GearArchetype;
weaponLayout: WeaponLayout;
statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
}>>>> = Object.freeze({
"rom-knight": Object.freeze({ role: "tank", gearArchetype: "block-tank", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS["block-tank"], armor: 0.16, blockValue: 0.76 }) }),
"rom-priest": Object.freeze({ role: "healer", gearArchetype: "healer", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS.healer, spirit: 0.7, mp5: 0.95 }) }),
"rom-scout": Object.freeze({ role: "damage", gearArchetype: "ranged-dps", weaponLayout: "ranged", statWeights: Object.freeze({ ...WEIGHTS["ranged-dps"], rangedAttackPower: 0.6 }) }),
"rom-rogue": Object.freeze({ role: "damage", gearArchetype: "agility-dps", weaponLayout: "dual-wield", statWeights: Object.freeze({ ...WEIGHTS["agility-dps"], expertiseRating: 0.78 }) }),
"rom-mage": Object.freeze({ role: "damage", gearArchetype: "spell-dps", weaponLayout: "two-hand", statWeights: Object.freeze({ ...WEIGHTS["spell-dps"], spellPower: 1.1 }) }),
});
export function isRomHybridGearClass(classId: ClassId): boolean {
return ROM_FIVE_HYBRID_PROFILES[classId] !== undefined;
}
type SpecTuple = readonly [
classId: ClassId,
displayName: string,
@@ -212,6 +229,17 @@ export function specializationScore(stats: ItemStats, definition: Specialization
/** RoM has no saved specialization; derive a complete gear profile from primary class and activity role. */
export function romGearProfile(classId: ClassId, role: PartyRole): SpecializationDefinition {
const definition = classById(classId);
const hybrid = ROM_FIVE_HYBRID_PROFILES[classId];
if (hybrid?.role === role) return Object.freeze({
id: `${classId}:role-${role}`,
classId,
displayName: `${definition.name} ${role}`,
role,
talentTree: "Primary class",
gearArchetype: hybrid.gearArchetype,
weaponLayout: hybrid.weaponLayout,
statWeights: hybrid.statWeights,
});
const base = baseClassFor(classId);
const caster = ["priest", "mage", "warlock"].includes(base) || definition.primaryStat === "intellect" || definition.primaryStat === "spirit" || definition.primaryStat === "wisdom";
const gearArchetype: GearArchetype = role === "healer"