366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
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),
|
||
);
|
||
}
|
||
});
|
||
});
|