Files
healer-man/src/game/romFiveNativeStats.integration.test.ts
T
2026-10-04 13:13:35 -04:00

366 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, ReturnType<typeof resolvedEquipmentItems>>();
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),
);
}
});
});