import { beforeEach, describe, expect, it } from "vitest"; import { auraAdjustedCharacterStats, PLAYER_AURA_ENTITY_ID, useCombatStore } from "./combatStore"; import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry"; import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry"; import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities"; import { EMPTY_ITEM_STATS } from "./itemStats"; import { createConfiguredPartyMember, createSpectatorDungeonParty, usePartyStore, } from "./partyStore"; import type { InventoryItem } from "./lootTypes"; import { deriveCharacterStats } from "./wotlkStats"; import { resolvedEquipmentItems } from "./heirloomEquipment"; const LEVELS = [1, 9, 10, 30, 60, 79, 80] as const; const FIVE_IDENTITY_SPECTATOR_SEED = "rom-native-five-generated-60"; function nativePriestTestTrinket(): InventoryItem { const instanceId = "rom-native-priest-level-sync-trinket"; return { instanceId, itemId: 990_001, itemKey: instanceId, name: "Native Priest level-sync test trinket", quality: "rare", inventorySlot: "trinket", armorType: null, weaponType: null, itemLevel: 30, sourceItemLevel: 30, stats: { ...EMPTY_ITEM_STATS, romNative: { stamina: 250, health: 500, mana: 180 } }, quantity: 1, sourceBossKey: "integration-test", sourceBossName: "Integration Test", acquiredAt: 0, }; } describe("native RoM stats for the five live classes", () => { beforeEach(() => { clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); usePartyStore.setState({ members: [] }); useCombatStore.getState().initializeCharacter({ classId: "rom-priest", level: 1 }); }); it.each(LEVELS)("keeps player, generated, and configured actor stats aligned at level %i", (level) => { usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, level, "rom"); const generatedMembers = usePartyStore.getState().members; expect(generatedMembers).toHaveLength(5); for (const identity of ROM_FIVE_IDENTITIES) { const generated = generatedMembers.find((member) => member.name === identity.name); expect(generated, identity.name).toBeDefined(); if (!generated) throw new Error(`Generated RoM party did not include ${identity.name}.`); const secondaryClassId = level >= 10 ? identity.secondaryClassId : null; expect(generated).toMatchObject({ classId: identity.classId, raceId: identity.raceId, role: identity.role, level, secondaryClassId, statRuleset: "rom", }); usePartyStore.setState({ members: generatedMembers }); useCombatStore.getState().initializeCharacter({ classId: identity.classId, raceId: identity.raceId, gender: generated.gender, role: identity.role, specializationId: generated.specializationId, secondaryClassId, level, statRuleset: "rom", }); useCombatStore.getState().synchronizeActors(); const playerState = useCombatStore.getState(); const playerActor = playerState.actors[PLAYER_AURA_ENTITY_ID]; const generatedActor = playerState.actors[generated.id]; expect(playerState.statRuleset).toBe("rom"); expect(playerActor).toMatchObject({ kind: "player", classId: identity.classId, statRuleset: "rom", level }); expect(generatedActor).toMatchObject({ kind: "companion", classId: identity.classId, statRuleset: "rom", level, }); const configured = createConfiguredPartyMember({ name: identity.name, classId: identity.classId, secondaryClassId: identity.secondaryClassId, raceId: identity.raceId, gender: generated.gender, specialization: generated.specialization, specializationId: generated.specializationId ?? undefined, role: identity.role, statRuleset: "rom", }, level, 0, `rom-native-configured-${identity.classId}-${level}`); expect(configured).toMatchObject({ level, secondaryClassId, statRuleset: "rom" }); const playerGear = playerState.gearStats; expect(generated.gearStats).toEqual(playerGear); expect(configured.gearStats).toEqual(playerGear); const playerBaseNative = deriveCharacterStats( identity.classId, level, playerGear, identity.raceId, {}, secondaryClassId, generated.gender, "rom", ).romNative!; const playerNative = auraAdjustedCharacterStats( deriveCharacterStats( identity.classId, level, playerGear, identity.raceId, {}, secondaryClassId, generated.gender, "rom", ), playerState.auras, PLAYER_AURA_ENTITY_ID, ).romNative!; const generatedNative = deriveCharacterStats( generated.classId, generated.level, generated.gearStats, generated.raceId, {}, generated.secondaryClassId, generated.gender, generated.statRuleset, ).romNative!; const configuredNative = deriveCharacterStats( configured.classId, configured.level, configured.gearStats, configured.raceId, {}, configured.secondaryClassId, configured.gender, configured.statRuleset, ).romNative!; expect(generatedNative).toEqual(playerBaseNative); expect(configuredNative).toEqual(playerBaseNative); expect(playerState.maxHealth).toBeCloseTo(playerBaseNative.health.max, 5); expect(generated.maxHealth).toBeCloseTo(generatedNative.health.max, 5); expect(generatedActor?.maxHealth).toBeCloseTo(generatedNative.health.max, 5); if (playerNative.usesMana) { expect(playerState.maxResourcePools.mana).toBeCloseTo(playerNative.mana.max, 5); expect(generated.resource).toBe(generated.maxResource); expect(generated.resourceMaximums?.mana).toBeCloseTo(generatedNative.mana.max, 5); expect(generatedActor?.resources.mana?.maximum).toBeCloseTo(generatedNative.mana.max, 5); expect(configured.resource).toBe(configured.maxResource); expect(configured.resourceMaximums?.mana).toBeCloseTo(configured.maxResource, 5); } else { expect(playerBaseNative.mana.max).toBe(0); expect(playerState.maxResourcePools.mana ?? 0).toBe(0); expect(generated.resourceMaximums?.mana ?? 0).toBe(0); } usePartyStore.setState({ members: [configured] }); useCombatStore.getState().synchronizeActors(); const configuredActor = useCombatStore.getState().actors[configured.id]; expect(configuredActor).toMatchObject({ kind: "companion", classId: identity.classId, statRuleset: "rom", level, }); expect(configuredActor?.maxHealth).toBeCloseTo(configuredNative.health.max, 5); if (configuredNative.usesMana) { expect(configuredActor?.resources.mana?.maximum).toBeCloseTo(configuredNative.mana.max, 5); } } }); it("applies the level 10 secondary unlock while party actors scale through level 80", () => { usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, 1, "rom"); for (const level of LEVELS) { usePartyStore.getState().syncLevel(level); useCombatStore.getState().synchronizeActors(); for (const identity of ROM_FIVE_IDENTITIES) { const member = usePartyStore.getState().members.find((candidate) => candidate.name === identity.name)!; const secondaryClassId = level >= 10 ? identity.secondaryClassId : null; const native = deriveCharacterStats( identity.classId, level, member.gearStats, identity.raceId, {}, secondaryClassId, member.gender, "rom", ).romNative!; const actor = useCombatStore.getState().actors[member.id]; expect(member).toMatchObject({ level, secondaryClassId, statRuleset: "rom" }); expect(member.maxHealth, `${identity.name} at level ${level}`).toBeCloseTo(native.health.max, 5); expect(member.health, `${identity.name} full health at level ${level}`).toBe(member.maxHealth); expect(actor, `${identity.name} actor at level ${level}`).toMatchObject({ level, statRuleset: "rom", maxHealth: member.maxHealth, }); if (native.usesMana) { expect(member.resourceMaximums?.mana).toBeCloseTo(native.mana.max, 5); expect(member.resourcePools.mana).toBe(member.resourceMaximums?.mana); expect(actor?.resources.mana?.maximum).toBeCloseTo(native.mana.max, 5); expect(actor?.resources.mana?.current).toBe(actor?.resources.mana?.maximum); } } } }); it("retains wounded and defeated RoM members safely while their native caps grow", () => { usePartyStore.getState().initializeSpectatorParty(FIVE_IDENTITY_SPECTATOR_SEED, 30, "rom"); const membersAt30 = usePartyStore.getState().members; const wounded = membersAt30.find((member) => member.name === "Serenna")!; const defeated = membersAt30.find((member) => member.name === "Rorik")!; const woundedHealthAt30 = Math.round(wounded.maxHealth * 0.43); const woundedManaAt30 = Math.round(wounded.maxResource * 0.37); usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === wounded.id ? { ...member, health: woundedHealthAt30, resource: woundedManaAt30, resourcePools: { ...member.resourcePools, mana: woundedManaAt30 }, } : member.id === defeated.id ? { ...member, health: 0, status: "down" } : member), })); usePartyStore.getState().syncLevel(60); const woundedAt60 = usePartyStore.getState().members.find((member) => member.id === wounded.id)!; const defeatedAt60 = usePartyStore.getState().members.find((member) => member.id === defeated.id)!; expect(woundedAt60.health).toBeLessThan(woundedAt60.maxHealth); expect(woundedAt60.health).toBeLessThanOrEqual(woundedAt60.maxHealth); expect(woundedAt60.health / woundedAt60.maxHealth) .toBeCloseTo(woundedHealthAt30 / wounded.maxHealth, 2); expect(woundedAt60.resource).toBeLessThan(woundedAt60.maxResource); expect(woundedAt60.resource).toBeLessThanOrEqual(woundedAt60.maxResource); expect(woundedAt60.resource / woundedAt60.maxResource) .toBeCloseTo(woundedManaAt30 / wounded.maxResource, 2); expect(defeatedAt60).toMatchObject({ health: 0, status: "down", level: 60 }); }); it("keeps a configured Priest's full native Mana exact and bounds wounded pools when gear changes", () => { const identity = ROM_FIVE_IDENTITIES.find((candidate) => candidate.classId === "rom-priest")!; const member = createConfiguredPartyMember({ name: identity.name, classId: identity.classId, secondaryClassId: identity.secondaryClassId, raceId: identity.raceId, gender: "male", specialization: "Primary", role: identity.role, statRuleset: "rom", }, 30, 0, "rom-native-priest-gear-sync"); expect(member.resource).toBe(member.maxResource); expect(member.resourceMaximums?.mana).toBe(member.maxResource); const healthAt30 = Math.round(member.maxHealth * 0.43); const manaAt30 = Math.round(member.maxResource * 0.37); const wounded = { ...member, health: healthAt30, resource: manaAt30, resourcePools: { ...member.resourcePools, mana: manaAt30 }, }; usePartyStore.setState({ members: [wounded] }); const trinket = nativePriestTestTrinket(); usePartyStore.getState().syncEquipment({ [member.id]: { trinket: trinket.instanceId } }, [trinket]); const resized = usePartyStore.getState().members[0]!; const native = deriveCharacterStats( identity.classId, 30, resized.gearStats, identity.raceId, {}, identity.secondaryClassId, "male", "rom", ).romNative!; expect(resized.maxHealth).toBeCloseTo(native.health.max, 5); expect(resized.health).toBeLessThanOrEqual(resized.maxHealth); expect(resized.health).toBeLessThan(resized.maxHealth); expect(resized.health / resized.maxHealth).toBeCloseTo(healthAt30 / member.maxHealth, 2); expect(resized.maxResource).toBeCloseTo(native.mana.max, 5); expect(resized.resourceMaximums?.mana).toBeCloseTo(native.mana.max, 5); expect(resized.resource).toBeLessThanOrEqual(resized.maxResource); expect(resized.resource).toBeLessThan(resized.maxResource); expect(resized.resource / resized.maxResource).toBeCloseTo(manaAt30 / member.maxResource, 2); }); it("selects native stats from the explicit mode, while omitted configured/player modes stay legacy WoW", () => { for (const identity of ROM_FIVE_IDENTITIES) { const configured = createConfiguredPartyMember({ name: identity.name, classId: identity.classId, secondaryClassId: identity.secondaryClassId, raceId: identity.raceId, gender: "male", specialization: "Primary", role: identity.role, }, 30, 0, `rom-native-legacy-${identity.classId}`); expect(configured.statRuleset).toBe("wow"); usePartyStore.setState({ members: [configured] }); useCombatStore.getState().initializeCharacter({ classId: identity.classId, raceId: identity.raceId, gender: "male", role: identity.role, specializationId: configured.specializationId, secondaryClassId: identity.secondaryClassId, level: 30, }); useCombatStore.getState().synchronizeActors(); const state = useCombatStore.getState(); expect(state.statRuleset).toBe("wow"); expect(state.actors[PLAYER_AURA_ENTITY_ID]?.statRuleset).toBe("wow"); expect(state.actors[configured.id]?.statRuleset).toBe("wow"); expect(deriveCharacterStats( identity.classId, 30, state.gearStats, identity.raceId, {}, identity.secondaryClassId, "male", "wow", ).romNative).toBeUndefined(); } }); it("clamps configured and synchronized actor levels to the shared 1–80 range", () => { const identity = ROM_FIVE_IDENTITIES[0]!; const configured = (level: number) => createConfiguredPartyMember({ name: identity.name, classId: identity.classId, secondaryClassId: identity.secondaryClassId, raceId: identity.raceId, gender: "male", specialization: "Primary", role: identity.role, statRuleset: "rom", }, level, 0, `rom-native-level-cap-${String(level)}`); expect(configured(81)).toMatchObject({ level: 80, secondaryClassId: identity.secondaryClassId }); for (const invalidLevel of [Number.NaN, Number.POSITIVE_INFINITY, 0]) { expect(configured(invalidLevel)).toMatchObject({ level: 1, secondaryClassId: null }); } usePartyStore.setState({ members: [configured(30)] }); for (const level of [81, Number.NaN, Number.POSITIVE_INFINITY, 0]) { usePartyStore.getState().syncLevel(level); const member = usePartyStore.getState().members[0]!; expect(member.level).toBe(level === 81 ? 80 : 1); expect(member.secondaryClassId).toBe(level === 81 ? identity.secondaryClassId : null); } }); it("keeps Training gear capped and owner-kind parity intact at levels 79 and 80", () => { for (const identity of ROM_FIVE_IDENTITIES) { const snapshots = new Map>(); for (const level of [79, 80]) { const secondaryClassId = identity.secondaryClassId; const specializationId = createConfiguredPartyMember({ name: identity.name, classId: identity.classId, secondaryClassId, raceId: identity.raceId, gender: "male", specialization: "Primary", role: identity.role, statRuleset: "rom", }, level, 0, `rom-native-training-${identity.classId}-${level}`).specializationId; const context = { classId: identity.classId, secondaryClassId, specializationId, role: identity.role, level, } as const; const playerItems = resolvedEquipmentItems({}, [], { ...context, ownerId: `training-player-${identity.classId}`, ownerKind: "player", }); const partyItems = resolvedEquipmentItems({}, [], { ...context, ownerId: `training-party-${identity.classId}`, ownerKind: "party", }); expect(playerItems.length).toBeGreaterThan(0); expect(playerItems.every((item) => item.itemLevel === 79)).toBe(true); expect(partyItems.map((item) => item.itemLevel)).toEqual(playerItems.map((item) => item.itemLevel)); expect(playerItems.every((item) => item.name.includes("Training") && item.sourceBossName === "Training Armory" && item.quality === "heirloom")).toBe(true); expect(partyItems.map(({ instanceId: _instanceId, ...item }) => item)).toEqual( playerItems.map(({ instanceId: _instanceId, ...item }) => item), ); snapshots.set(level, playerItems); } const at79 = snapshots.get(79)!; const at80 = snapshots.get(80)!; expect(at80.map(({ instanceId: _instanceId, ...item }) => item)).toEqual( at79.map(({ instanceId: _instanceId, ...item }) => item), ); } }); });