Honor RoM off-hand and ranged equipment slots

This commit is contained in:
phenom committed 2026-09-23 19:59:21 -04:00
1 parent 0206fda9e2
commit 7449abdad2
15 files changed
+150 -29

No files matched your search

+13
View File
@@ -3807,3 +3807,16 @@ Vite's existing large-chunk advisory remains informational.
60, and 79. Targeted Shadoj reports at levels 20, 60, and 79 are under
`artifacts/balance-lab/rom-hybrid-l*-shadoj/`; they exposed low Mage damage
and a level-79 Priest death in each seed, so balance remains Needs Testing.
## RoM independent weapon slots (2026-09-23)
- Rebuilt and ran `node scripts/playtest-rom-five.cjs` after adding the trained
off-hand and ranged heirlooms. The Rogue roster preview and Wailing Caverns
scene showed a complete character body and starter blade; companion bodies,
shields, and weapons remained visible. The Dwarf Mage roster still showed its
body and two-hand staff. The playtest reported no browser page errors.
Evidence: `artifacts/playtest/rom-five/roster-preview.png`,
`dungeon-rogue.png`, and `dwarf-mage-roster.png` in the same directory.
- These level-1 screenshots do not show the later training unlocks. Focused
equipment tests verify the Knight/Scout bow at level 10, Rogue projectile at
level 12, and Scout/Rogue and Rogue off-hand weapons at level 16.
+4 -4
View File
@@ -16,7 +16,7 @@ The five named builds and live NPCs use these race-legal pairings. Their seconda
`src/game/romFiveIdentities.ts` owns these identities for the live NPC templates and targeted canonical builds. The manual party uses Priest as the active healer and the other four classes as companions. This explicit cohort does not change Ready-only random parties, bracket certification, or the companion readiness registry. Elite skills can appear in a paired kit through the existing RoM catalog; focused checks now cover their translated effects, selected live casts, and source-conditional procs.
`npm run test:rom-five` checks the named pairings, legal races, level-10 unlock, role-specific hybrid heirloom layouts and level growth, ordinary and pair-elite ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. The five loadouts use Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. Their equipment uses the same level budget and WoW-shaped fields as the WoW heirlooms, allocated by RoM role. `src/game/wotlkStats.ts` gives these five WoW-equivalent base attribute, HP, and Mana curves with a 10% same-level secondary contribution from level 10. Other RoM classes retain native growth. Mage/Warlock is a RoM dwarf with the Human Mage Wrath curve as its base reference because Dwarf Mage has no Wrath 3.3.5 row.
`npm run test:rom-five` checks the named pairings, legal races, level-10 unlock, role-specific hybrid heirloom layouts and level growth, ordinary and pair-elite ability coverage, and focused runtime mechanics. Knight is a tank seat in RoM generated parties. The equipment model has separate main-hand, off-hand, and ranged positions. Knight/Scout uses mace, shield, and bow from level 10; Priest/Knight uses mace and shield; Scout/Rogue uses dagger and bow, gaining an off-hand dagger at level 16; Rogue/Warrior gains a projectile at level 12 and an off-hand dagger at level 16; Mage/Warlock uses a two-handed staff. Priest and Mage retain empty ranged positions because their accepted pairings do not grant a ranged weapon. Their equipment uses the same level budget and WoW-shaped fields as the WoW heirlooms, allocated by RoM role. `src/game/wotlkStats.ts` gives these five WoW-equivalent base attribute, HP, and Mana curves with a 10% same-level secondary contribution from level 10. Other RoM classes retain native growth. Mage/Warlock is a RoM dwarf with the Human Mage Wrath curve as its base reference because Dwarf Mage has no Wrath 3.3.5 row.
## Findings and fixes in this pass
@@ -31,7 +31,7 @@ The five named builds and live NPCs use these race-legal pairings. Their seconda
## Accepted pair skill checks
`src/game/romPriestKnightOverrides.ts`, `src/game/romScoutRogueOverrides.ts`, and `src/game/romMageWarlockOverrides.ts` specialize the five accepted pairs from source components. `src/game/romCombatMechanics.ts` selects their conditional cast effects; `src/game/combatStore.ts` handles proc actions, damage schools, conditional cooldowns, line and chain targeting, and timed effects. `src/game/partyRuntime.ts` installs learned NPC passive auras on their first cast and passes passive and target tags into the shared selector. The three pair validation files are part of `npm run test:rom-five`; the focused run passed 227 tests across eleven files on 2026-09-23. These include direct healing, area healing and damage, taunts, resource changes, offensive and defensive auras, crowd control, source-conditional skill checks, and selected live player/companion casts. Pure effect tests are evidence for translation and selection, not proof that every encounter timing is correct.
`src/game/romPriestKnightOverrides.ts`, `src/game/romScoutRogueOverrides.ts`, and `src/game/romMageWarlockOverrides.ts` specialize the five accepted pairs from source components. `src/game/romCombatMechanics.ts` selects their conditional cast effects; `src/game/combatStore.ts` handles proc actions, damage schools, conditional cooldowns, line and chain targeting, and timed effects. `src/game/partyRuntime.ts` installs learned NPC passive auras on their first cast and passes passive and target tags into the shared selector. The three pair validation files are part of `npm run test:rom-five`; the focused run passed 228 tests across eleven files on 2026-09-23. These include direct healing, area healing and damage, taunts, resource changes, offensive and defensive auras, crowd control, source-conditional skill checks, gear-slot unlocks, and selected live player/companion casts. Pure effect tests are evidence for translation and selection, not proof that every encounter timing is correct.
The hidden source references are now executable through their parent skills where RuneWaker treats them as replacements: Priest/Knight Wave Armor `499967`, Knight/Scout Holy Chains `499925` and Blood Arrow `499924`, Scout/Rogue Reflected Shot `499535` and Wrist Attack `499537`, Rogue/Warrior Poison Spray `499477` and Whirlwind `499481`, and Mage/Warlock Soul Pain `498746`, Plasma Arrow `498749`, Elements of Pride `498755`/`498756`, and Elemental Extraction `499812`/`499851`. Priest/Knight Devotion Halo `499966` is a separate level-70 button: it gives the whole party Wisdom and reduces other members' actual Mana spending. Poisonous Spit supplies its own Poison Powder and Poison Spray consumes the selected target's Infection poison before spreading its upgraded poison. Toxic Splash can grant Rage on Infection. The source-only Holy Chains trigger `499910` has no measurable player-facing payload in the extracted data, so no extra effect is invented.
@@ -40,7 +40,7 @@ The RuneWaker NPC object database now supplies Undead and Demon family labels th
Remaining source and runtime gaps:
1. Some source proc chances and magnitudes still use explicit hybrid estimates where the extracted source does not provide a direct WoW-stat conversion. NPC proc execution and resource side effects need more end-to-end coverage across encounters.
2. The current Knight heirloom uses mace/shield, so Knight/Scout bow effects require a separate legal ranged loadout. Rogue/Warrior One-Hand Axe Mastery does not benefit the current dual-dagger loadout.
2. Rogue/Warrior One-Hand Axe Mastery does not benefit the current dual-dagger loadout. The newly equipped bow, projectile, and off-hand slots change the level-12-and-up gear budget, so previous balance diagnostics need repeating before promotion.
3. Mage/Warlock damage remains low relative to Scout and Rogue. Check its AI rotation, cast cadence, and WoW-equivalent scaling before adjusting encounter tuning or promoting RoM.
## Combat diagnostic
@@ -65,7 +65,7 @@ After the mechanics corrections, the targeted Shadoj run at level 60 with the sa
2. Expand source-row and live NPC-cast audits beyond these corrected skills. Unknown NPC family IDs are permissive until their source records are classified; basic semantic coverage does not prove full tooltip parity.
3. Exercise behind-target requirements in a live spatial scene. The headless balance runner has no spatial target provider, so its Rogue damage can include casts that a live scene would reject.
4. Validate Priest throughput against encounters that actually demand sustained healing, and run RoM NPC seat tests after the mechanics above are resolved. The current manual run leaves Priest player-controlled.
5. Verify both Rogue daggers visually from an unobstructed angle. The live roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and weapon silhouettes, but the off-hand dagger is unclear at their camera angles. Keep RoM entries at Needs Testing until the full class mechanics and role checks pass.
5. Verify the level-16 Rogue off-hand dagger visually from an unobstructed angle. The level-1 roster and dungeon screenshots in `artifacts/playtest/rom-five/` confirm rendered bodies and starter weapon silhouettes; they cannot show the later off-hand unlock. Keep RoM entries at Needs Testing until the full class mechanics and role checks pass.
The RoM source catalog and generated data remain authoritative for ability descriptions and components. The older `rom-five-primary-*` results predate paired builds. The later `rom-hybrid-*` and `rom-hybrid-wowbase-*` manual reports contain the named secondary classes even though the report's specialization column reads "Primary" for the primary orientation.
+1 -1
View File
@@ -6,7 +6,7 @@ Status: **WoW-stat hybrid chosen for gameplay; native source model retained as r
- `src/game/wotlkStats.ts` gives the five accepted primaries their corresponding Wrath base attribute, HP, and Mana curves. At level 10 the secondary contributes 10% of its WoW-equivalent curve **at the primary's level**. Human curves cover Priest, Knight, Rogue, and Mage; Scout uses Night Elf. Durnik remains a RoM dwarf, but Human Mage is the base-curve fallback because Wrath 3.3.5 has no Dwarf Mage row. Other RoM classes still use `src/game/romStats.ts` native growth. The shared secondary level is the game's progression rule, even though the historical source allowed separate class levels.
- `src/game/wotlkStats.ts` maps native Wisdom into WoW Spirit only on the remaining native-growth path, then derives HP, Mana, attack power, spell power, avoidance, and ratings with WoW rules. This is the selected compatibility adapter, not a historical RoM conversion. In particular, the global Mana multiplier and Stamina-to-HP rule are not source RoM formulas.
- `src/game/specializationCatalog.ts` defines distinct hybrid gear profiles for the five builds. `src/game/heirloomEquipment.ts` gives these five WoW-stat heirlooms at levels 1–79 from a level budget without matching WoW boss drops. The legal layouts are Knight mace/shield, Priest mace/shield, Scout dagger/bow, Rogue dual daggers, and Mage staff. The five hybrid loadouts omit the WoW wrist slot; `src/game/equipment.ts` permits Priest shields. These are active gameplay gear, while the native source curves remain references. Other RoM classes retain their existing fallback gear path until separately validated.
- `src/game/specializationCatalog.ts` defines distinct hybrid gear profiles for the five builds. `src/game/heirloomEquipment.ts` gives these five WoW-stat heirlooms at levels 1–79 from a level budget without matching WoW boss drops. The equipment model has independent main-hand, off-hand, and ranged slots. Knight/Scout gains a bow at level 10 alongside mace/shield; Scout/Rogue gains an off-hand dagger at 16 alongside dagger/bow; Rogue/Warrior gains a projectile at 12 and an off-hand dagger at 16. Priest/Knight uses mace/shield and Mage/Warlock uses a staff; their ranged slots remain empty. The five hybrid loadouts omit the WoW wrist slot; `src/game/equipment.ts` permits Priest shields. These are active gameplay gear, while the native source curves remain references. Other RoM classes retain their existing fallback gear path until separately validated.
- `src/game/runewakerLootCatalog.ts` keeps imported RoM drops as non-equippable `misc` items with zero stats. The loot importer preserves names and probabilities but does not decode native equipment slots, base stats, or enhancements. `src/game/lootPolicy.ts` suppresses **all** boss item drops during leveling; boss cards are awarded through a separate path. At level 80, imported RoM boss items still lack usable equipment data.
## Source stat model
+1
View File
@@ -316,6 +316,7 @@ export function OnlineSessionBridge() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
specializationId: combat.specializationId,
role: combat.gearRole,
level: combat.level,
+15 -2
View File
@@ -4045,6 +4045,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId,
secondaryClassId,
specializationId,
role: gearRole,
level: progression.level,
@@ -4110,6 +4111,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -4382,9 +4384,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
equipItem: (instanceId, ownerId = PLAYER_EQUIPMENT_OWNER_ID) => {
const state = get();
const ownerMember = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? null
: usePartyStore.getState().members.find((member) => member.id === ownerId);
const ownerClassId = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? state.classId
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.classId;
: ownerMember?.classId;
if (!ownerClassId) {
set({ feedback: nextFeedback("error", "That party member is not available.") });
return false;
@@ -4395,6 +4400,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId,
ownerClassId,
instanceId,
{
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
},
);
if (!change.ok) {
set({ feedback: nextFeedback("error", change.reason ?? "That item cannot be equipped.") });
@@ -4403,11 +4412,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const item = state.inventory.find((candidate) => candidate.instanceId === instanceId);
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
? "You"
: usePartyStore.getState().members.find((member) => member.id === ownerId)?.name ?? "Companion";
: ownerMember?.name ?? "Companion";
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -4458,6 +4468,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -5040,6 +5051,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
@@ -5356,6 +5368,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: state.classId,
secondaryClassId: state.secondaryClassId,
specializationId: state.specializationId,
role: state.gearRole,
level: state.level,
+26 -3
View File
@@ -44,6 +44,11 @@ export interface EquipmentChange {
readonly reason?: string;
}
export interface EquipmentClassContext {
readonly secondaryClassId?: ClassId | null;
readonly level?: number;
}
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
const ARMOR_RANK: Readonly<Record<string, number>> = Object.freeze({
@@ -62,6 +67,7 @@ const SHIELD_CLASSES = new Set<ClassId>([
const WEAPON_CLASSES: Readonly<Record<string, ReadonlySet<BaseClassId>>> = Object.freeze({
bow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
crossbow: new Set<BaseClassId>(["warrior", "hunter", "rogue"]),
dagger: new Set<BaseClassId>(["warrior", "hunter", "rogue", "priest", "shaman", "mage", "warlock", "druid"]),
mace: new Set<BaseClassId>(["warrior", "paladin", "priest", "death-knight", "shaman", "druid"]),
staff: new Set<BaseClassId>(["warrior", "hunter", "priest", "shaman", "mage", "warlock", "druid"]),
@@ -75,7 +81,23 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
}
export function classCanEquipItem(classId: ClassId, item: InventoryItem): boolean {
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
const slot = equipmentSlotForItem(item);
if (!slot) return false;
const level = context.level ?? Number.POSITIVE_INFINITY;
if (classId.startsWith("rom-")) {
if (slot === "off-hand" && item.weaponType
&& level < 16) return false;
if (slot === "off-hand" && item.weaponType
&& classId !== "rom-rogue" && context.secondaryClassId !== "rom-rogue") return false;
if (slot === "ranged") {
if (item.weaponType === "bow" || item.weaponType === "crossbow") return classId === "rom-scout"
|| (context.secondaryClassId === "rom-scout" && level >= 10);
if (item.weaponType === "projectile" || item.weaponType === "throwing") return (classId === "rom-rogue"
|| context.secondaryClassId === "rom-rogue") && level >= 12;
return false;
}
}
if (item.armorType === "shield" && !SHIELD_CLASSES.has(classId)) return false;
if (item.armorType && item.armorType !== "shield") {
const requiredRank = ARMOR_RANK[item.armorType] ?? Number.POSITIVE_INFINITY;
@@ -85,7 +107,7 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem): boolea
const allowedClasses = WEAPON_CLASSES[item.weaponType];
if (!allowedClasses?.has(baseClassFor(classId))) return false;
}
return equipmentSlotForItem(item) !== null;
return true;
}
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
@@ -114,12 +136,13 @@ export function equipInventoryItem(
ownerId: string,
ownerClassId: ClassId,
instanceId: string,
classContext: EquipmentClassContext = {},
): EquipmentChange {
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
const slot = equipmentSlotForItem(item);
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
if (!classCanEquipItem(ownerClassId, item)) {
if (!classCanEquipItem(ownerClassId, item, classContext)) {
return { ok: false, assignments, reason: `That class cannot equip ${item.name}.` };
}
+23 -9
View File
@@ -41,6 +41,7 @@ export interface HeirloomContext {
readonly ownerId: string;
readonly ownerKind: HeirloomOwnerKind;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
@@ -96,18 +97,30 @@ function effectiveLevel(context: HeirloomContext): number {
return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1)));
}
function weaponSlots(layout: WeaponLayout): readonly EquipmentSlot[] {
function weaponSlots(context: HeirloomContext, layout: WeaponLayout): readonly EquipmentSlot[] {
let slots: readonly EquipmentSlot[];
switch (layout) {
case "dual-wield":
case "weapon-shield":
return ["main-hand", "off-hand"];
slots = ["main-hand", "off-hand"];
break;
case "ranged":
return ["main-hand", "ranged"];
slots = ["main-hand", "ranged"];
break;
case "caster":
return ["main-hand", "off-hand", "ranged"];
slots = ["main-hand", "off-hand", "ranged"];
break;
default:
return ["main-hand"];
slots = ["main-hand"];
}
if (!isRomHybridGearClass(context.classId)) return slots;
const level = effectiveLevel(context);
const resolved = new Set(slots);
if (context.classId === "rom-rogue" && level < 16) resolved.delete("off-hand");
if (context.classId === "rom-rogue" && level >= 12) resolved.add("ranged");
if (context.classId === "rom-knight" && context.secondaryClassId === "rom-scout" && level >= 10) resolved.add("ranged");
if (context.classId === "rom-scout" && context.secondaryClassId === "rom-rogue" && level >= 16) resolved.add("off-hand");
return [...resolved];
}
function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null {
@@ -117,8 +130,9 @@ function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponL
}
function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null {
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 === "off-hand" && (profile.weaponLayout === "dual-wield" || classId === "rom-scout")) return "dagger";
if (slot === "ranged") return classId === "rom-rogue" ? "projectile"
: 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";
@@ -283,7 +297,7 @@ 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)]) {
for (const slot of [...BASE_SLOTS, ...weaponSlots(context, profile.weaponLayout)]) {
if (isRomHybridGearClass(context.classId) && slot === "wrist") continue;
result[slot] = heirloomItem(context, profile, slot);
}
@@ -291,7 +305,7 @@ export function resolvedEquipmentLoadout(
const earned = assignments[context.ownerId] ?? {};
for (const slot of EQUIPMENT_SLOTS) {
const item = earned[slot] ? itemById.get(earned[slot]!) : null;
if (!item || !classCanEquipItem(context.classId, item)) continue;
if (!item || !classCanEquipItem(context.classId, item, context)) continue;
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"];
if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") {
result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand");
+2
View File
@@ -789,6 +789,7 @@ function partyAbilityTargetIsValid(
if (ability.dbcSpellId === 490311 || romAbilityNeedsShield(ability)) {
const loadout = resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId, role: member.role, level: member.level,
});
if (ability.dbcSpellId === 490311 && loadout["main-hand"]?.weaponType !== "dagger") return false;
@@ -1222,6 +1223,7 @@ function executePartyAbility(
&& aura.definition.utilityTags?.includes("rom-charged")),
mainHandDagger: member.classId === "rom-rogue" && resolvedEquipmentLoadout(combat.equipment, combat.inventory, {
ownerId: member.id, ownerKind: "party", classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId, role: member.role, level: member.level,
})["main-hand"]?.weaponType === "dagger",
casterMaxHealth: member.maxHealth,
+2
View File
@@ -350,6 +350,7 @@ function createMember(
ownerId: id,
ownerKind: "party",
classId: template.classId,
secondaryClassId: level >= ROM_SECONDARY_UNLOCK_LEVEL ? template.secondaryClassId ?? null : null,
specializationId,
role,
level,
@@ -696,6 +697,7 @@ export const usePartyStore = create<PartyState>((set, get) => ({
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
+42 -3
View File
@@ -3,7 +3,7 @@ import { abilitiesForCharacter, learnedAbilityForBuild } from "./abilityCatalog"
import { isValidRomClassPair } from "../app/romDualClass";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { readinessStatusFor } from "./companionSpecReadiness";
import { classCanEquipItem } from "./equipment";
import { classCanEquipItem, equipInventoryItem } from "./equipment";
import { equipmentStatsForItems, resolvedEquipmentLoadout } from "./heirloomEquipment";
import { partyRolesForClass } from "./partyRoles";
import { createConfiguredPartyMember, createDungeonParty, createSpectatorDungeonParty, usePartyStore } from "./partyStore";
@@ -152,34 +152,72 @@ describe("RoM five class validation scope", () => {
ownerId: `rom-five:${classId}`,
ownerKind: "party",
classId,
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
specializationId: null,
role,
level: 60,
});
if (classId === "rom-knight") expect(loadout["off-hand"]?.armorType).toBe("shield");
if (classId === "rom-knight") {
expect(loadout["off-hand"]?.armorType).toBe("shield");
expect(loadout.ranged?.weaponType).toBe("bow");
}
if (classId === "rom-priest") {
expect(loadout["main-hand"]?.weaponType).toBe("mace");
expect(loadout["off-hand"]?.armorType).toBe("shield");
expect(loadout.ranged).toBeUndefined();
}
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");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
}
if (classId === "rom-rogue") {
expect(loadout["main-hand"]?.weaponType).toBe("dagger");
expect(loadout["off-hand"]?.weaponType).toBe("dagger");
expect(loadout.ranged?.weaponType).toBe("projectile");
}
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.ranged).toBeUndefined();
}
expect(loadout.wrist).toBeUndefined();
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!)).toBe(true);
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
level: 60,
})).toBe(true);
}
});
it("opens the independent ranged and off-hand positions at the source training levels", () => {
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
resolvedEquipmentLoadout({}, [], {
ownerId: `rom-slots:${classId}`,
ownerKind: "player",
classId,
secondaryClassId: level >= 10 ? secondaryClassId : null,
specializationId: null,
role: classId === "rom-knight" ? "tank" : "damage",
level,
});
expect(loadout("rom-knight", "rom-scout", 9).ranged).toBeUndefined();
expect(loadout("rom-knight", "rom-scout", 10).ranged?.weaponType).toBe("bow");
expect(loadout("rom-scout", "rom-rogue", 15)["off-hand"]).toBeUndefined();
expect(loadout("rom-scout", "rom-rogue", 16)["off-hand"]?.weaponType).toBe("dagger");
expect(loadout("rom-rogue", "rom-warrior", 11).ranged).toBeUndefined();
expect(loadout("rom-rogue", "rom-warrior", 12).ranged?.weaponType).toBe("projectile");
expect(loadout("rom-rogue", "rom-warrior", 15)["off-hand"]).toBeUndefined();
expect(loadout("rom-rogue", "rom-warrior", 16)["off-hand"]?.weaponType).toBe("dagger");
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
});
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)!;
@@ -190,6 +228,7 @@ describe("RoM five class validation scope", () => {
ownerId: `rom-hybrid:${classId}`,
ownerKind: "player",
classId,
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
specializationId: null,
role,
level,
+4 -1
View File
@@ -144,6 +144,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const gearRole = useCombatStore((state) => state.gearRole);
const [payload, setPayload] = useState<AvatarWarmupPayload | null>(null);
@@ -162,6 +163,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
secondaryClassId,
specializationId,
role: gearRole,
level,
@@ -179,6 +181,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
@@ -210,7 +213,7 @@ function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
return () => {
active = false;
};
}, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, specializationId]);
}, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, secondaryClassId, specializationId]);
if (!payload) return null;
if (!payload.modelUrls.length) {
+1
View File
@@ -301,6 +301,7 @@ function PartyActors({
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
+3 -1
View File
@@ -476,6 +476,7 @@ export function PlayerRig() {
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const gearRole = useCombatStore((state) => state.gearRole);
const equipmentItems = useMemo(
() => activeCharacter ? resolvedEquipmentItems(
@@ -485,12 +486,13 @@ export function PlayerRig() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
secondaryClassId,
specializationId,
role: gearRole,
level,
},
) : [],
[activeCharacter, equipment, gearRole, inventory, level, specializationId],
[activeCharacter, equipment, gearRole, inventory, level, secondaryClassId, specializationId],
);
const bodyRef = useRef<RapierRigidBody>(null);
const avatarRef = useRef<Object3D>(null);
+3 -1
View File
@@ -30,6 +30,7 @@ function itemType(inventorySlot: string): string {
export function BossLootWindow() {
const reward = useCombatStore((state) => state.bossLootRewards[0] ?? null);
const classId = useCombatStore((state) => state.classId);
const secondaryClassId = useCombatStore((state) => state.secondaryClassId);
const specializationId = useCombatStore((state) => state.specializationId);
const role = useCombatStore((state) => state.gearRole);
const level = useCombatStore((state) => state.level);
@@ -52,6 +53,7 @@ export function BossLootWindow() {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player" as const,
classId,
secondaryClassId,
specializationId,
role,
level,
@@ -88,7 +90,7 @@ export function BossLootWindow() {
<b aria-hidden="true">◆</b><p><strong>{BOSS_CARD_RARITY_INFO[rarity].label} Boss Card</strong><small>{reward.bossName} · Collection log</small></p><em>+1</em>
</article>)}
{reward.items.map((item) => {
const comparison = classCanEquipItem(classId, item)
const comparison = classCanEquipItem(classId, item, { secondaryClassId, level })
? compareEquipmentUpgrade(item, loadout, profile, heirloomContext)
: null;
return <article
+10 -4
View File
@@ -53,6 +53,7 @@ interface EquipmentOwner {
readonly name: string;
readonly detail: string;
readonly classId: ClassId;
readonly secondaryClassId?: ClassId | null;
readonly specializationId: string | null;
readonly role: PartyRole;
readonly level: number;
@@ -189,6 +190,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const playerClassId = useCombatStore((state) => state.classId);
const playerSecondaryClassId = useCombatStore((state) => state.secondaryClassId);
const playerLevel = useCombatStore((state) => state.level);
const playerMaxHealth = useCombatStore((state) => state.maxHealth);
const playerGearStats = useCombatStore((state) => state.gearStats);
@@ -204,6 +206,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
name: character?.name ?? "You",
detail: "Player",
classId: playerClassId,
secondaryClassId: playerSecondaryClassId,
specializationId: playerSpecializationId,
role: playerGearRole,
level: playerLevel,
@@ -214,12 +217,13 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
name: member.name,
detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
ownerKind: "party" as const,
})),
], [character?.name, members, playerClassId, playerGearRole, playerLevel, playerSpecializationId]);
], [character?.name, members, playerClassId, playerSecondaryClassId, playerGearRole, playerLevel, playerSpecializationId]);
const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID);
const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? "");
const [statPage, setStatPage] = useState<StatPage>("attributes");
@@ -240,13 +244,14 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
ownerId: owner.id,
ownerKind: owner.ownerKind,
classId: owner.classId,
secondaryClassId: owner.secondaryClassId,
specializationId: owner.specializationId,
role: owner.role,
level: owner.level,
};
const loadout = useMemo(
() => resolvedEquipmentLoadout(equipment, inventory, heirloomContext),
[equipment, inventory, owner.classId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
[equipment, inventory, owner.classId, owner.secondaryClassId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId],
);
const earnedLoadout = equipment[owner.id] ?? {};
const sortedInventory = useMemo(() => [...inventory].sort((left, right) => (
@@ -285,7 +290,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null;
const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected) : false;
const compatible = inspected ? classCanEquipItem(owner.classId, inspected, owner) : false;
const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext);
const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role);
const inspectedComparison = inspected && compatible
@@ -345,6 +350,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
ownerId: candidate.id,
ownerKind: candidate.ownerKind,
classId: candidate.classId,
secondaryClassId: candidate.secondaryClassId,
specializationId: candidate.specializationId,
role: candidate.role,
level: candidate.level,
@@ -415,7 +421,7 @@ function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) {
{sortedInventory.map((item) => {
const assignedId = equippedOwnerId(equipment, item.instanceId);
const assignedOwner = assignedId ? ownerById.get(assignedId) : null;
const canEquip = classCanEquipItem(owner.classId, item);
const canEquip = classCanEquipItem(owner.classId, item, owner);
const comparison = canEquip ? compareEquipmentUpgrade(item, loadout, gearProfile, heirloomContext) : null;
return (
<ControllerButton