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

269 lines
12 KiB
TypeScript

import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { contentCategoryForClass } from "../app/characterCatalog";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { finalDungeonBoss } from "./dungeonRunStore";
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { abilitiesForCharacter } from "./abilityCatalog";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
const LEVELS = [1, 10, 30, 60, 79, 80] as const;
const RUN_LIMIT_MS = 120_000;
const dungeon = requireDungeonDefinition("barren-caves");
const trashSpawn = dungeon.staticSpawns.find((spawn) => dungeon.entities[spawn.entityId]?.kind === "mob")!;
const trashEntity = dungeon.entities[trashSpawn.entityId];
const anglo = finalDungeonBoss(dungeon)!;
const angloSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === anglo.id)!;
const angloEntity = dungeon.entities[angloSpawn.entityId];
interface HealthSample {
readonly identity: string;
readonly classId: string;
readonly level: number;
readonly runLimitMs: number;
readonly target: "trash" | "Anglo";
readonly mobLevel: number;
readonly maxHealth: number;
readonly remainingHealth: number;
readonly mobDead: boolean;
readonly mobAttackRevision: number;
readonly playerHealth: number;
readonly defeatedCompanions: readonly string[];
readonly secondsFromFirstPartyHit: number | null;
readonly firstPartyHitAtMs: number | null;
readonly incomingDamage: number;
readonly healing: number;
readonly partyClassIds: readonly string[];
readonly partyRoles: readonly string[];
}
const samples: HealthSample[] = [];
afterAll(() => {
if (process.env.ROM_FIVE_NATIVE_DUNGEON_HEALTH_REPORT !== "1") return;
const directory = resolve("artifacts/rom-five-native-dungeon-health");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({
diagnostic: "Native RoM identity cohort; companion damage is simulated, with player healing when the player is the Priest.",
levels: LEVELS,
runLimitMs: RUN_LIMIT_MS,
samples,
}, null, 2)}\n`);
});
function initializeNativeIdentityParty(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number, seed: string) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon("barren-caves", "normal", { gameMode: "dungeon", bossDifficulty: 1 });
const secondaryClassId = level >= 10 ? activeIdentity.secondaryClassId : null;
const combat = useCombatStore.getState();
combat.initializeCharacter({
classId: activeIdentity.classId,
statRuleset: "rom",
raceId: activeIdentity.raceId,
secondaryClassId,
role: activeIdentity.role,
level,
});
combat.setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health,
activeIdentity.role, 5, "rom");
const companions = ROM_FIVE_IDENTITIES
.filter((identity) => identity.classId !== activeIdentity.classId)
.map((identity, index) => createConfiguredPartyMember({
name: identity.name,
classId: identity.classId,
statRuleset: "rom",
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
raceId: identity.raceId,
specialization: "Primary",
talentTreeId: "",
role: identity.role,
}, level, index, seed));
usePartyStore.setState({ members: companions });
combat.synchronizeActors();
return companions;
}
function measurePartyPull(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number,
target: "trash" | "Anglo"): HealthSample {
const entity = target === "Anglo" ? angloEntity : trashEntity;
const spawn = target === "Anglo" ? angloSpawn : trashSpawn;
const boss = entity.kind === "boss";
const runLimitMs = target === "Anglo" && level === 1 ? 240_000 : RUN_LIMIT_MS;
const seed = `rom-five-native:${activeIdentity.classId}:${level}:${target}`;
const companions = initializeNativeIdentityParty(activeIdentity, level, seed);
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1,
contentCategoryForClass(activeIdentity.classId));
const combat = useCombatStore.getState();
combat.registerMob(spawn.id, {
name: entity.name,
serverEntry: entity.combat?.serverEntry,
boss,
...scaling,
attacks: entity.combat?.attacks,
xpReward: 0,
});
setMobPosition(spawn.id, [0, 0, 0]);
for (const member of companions) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, spawn.id, true);
}
// This opens the pull before event collection, matching the existing RoM durability diagnostic.
combat.damageMob(spawn.id, 1, 0);
let firstPartyHitAtMs: number | null = null;
let diedAtMs: number | null = null;
let incomingDamage = 0;
let healing = 0;
const stop = subscribeCombatEvents((event) => {
if (event.targetActorId === spawn.id && event.kind === "damage"
&& event.effectiveAmount > 0 && event.sourceActorId !== "player") {
firstPartyHitAtMs ??= event.occurredAt;
}
if (event.sourceActorId === spawn.id && event.kind === "damage") incomingDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
try {
const priestHeal = activeIdentity.classId === "rom-priest"
? abilitiesForCharacter(activeIdentity.classId, level >= 10 ? activeIdentity.secondaryClassId : null)
.find((ability) => ability.target === "friendly"
&& ability.effects.some((effect) => effect.kind === "heal")
&& (ability.unlockLevel ?? 1) <= level)
: undefined;
for (let now = 100; now <= runLimitMs; now += 100) {
combat.tick(0.1, now);
advanceDungeonPartyCombat(now);
combat.advanceMobCombat(now);
if (priestHeal && !useCombatStore.getState().activeCast) {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
if (tank && tank.health < tank.maxHealth * 0.65) {
usePartyStore.getState().selectMember(tank.id);
combat.castAbility(priestHeal.id, undefined, now);
}
}
const mob = useCombatStore.getState().mobs[spawn.id];
if (mob.dead) {
diedAtMs = mob.diedAt ?? now;
break;
}
}
} finally {
stop();
}
const mob = useCombatStore.getState().mobs[spawn.id];
const combatAfterPull = useCombatStore.getState();
const companionsAfterPull = usePartyStore.getState().members;
return {
identity: activeIdentity.name,
classId: activeIdentity.classId,
level,
runLimitMs,
target,
mobLevel: mob.level,
maxHealth: mob.maxHealth,
remainingHealth: mob.health,
mobDead: mob.dead,
mobAttackRevision: mob.attackRevision,
playerHealth: combatAfterPull.health,
defeatedCompanions: companionsAfterPull.filter((member) => member.health <= 0).map((member) => member.classId),
secondsFromFirstPartyHit: diedAtMs === null || firstPartyHitAtMs === null
? null
: (diedAtMs - firstPartyHitAtMs) / 1_000,
firstPartyHitAtMs,
incomingDamage,
healing,
partyClassIds: [activeIdentity.classId, ...companions.map((member) => member.classId)],
partyRoles: [activeIdentity.role, ...companions.map((member) => member.role)],
};
}
describe("native RoM five identity dungeon health diagnostic", () => {
it("keeps the player-origin RoM curve when each canonical identity enters actual WoW and RoM trash", () => {
const wowDungeon = requireDungeonDefinition("stormwind-stockade");
const wowTrashSpawn = wowDungeon.staticSpawns.find((spawn) => wowDungeon.entities[spawn.entityId]?.kind === "mob")!;
const wowTrash = wowDungeon.entities[wowTrashSpawn.entityId];
for (const identity of ROM_FIVE_IDENTITIES) {
expect(contentCategoryForClass(identity.classId)).toBe("rom");
for (const level of LEVELS) {
const expected = dungeonEnemyHealthBaseline(level, "rom");
const romActual = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id, level, "normal", 1,
contentCategoryForClass(identity.classId));
const wowActual = dungeonEnemyScaling(wowDungeon, wowTrash, wowTrashSpawn.id, level, "normal", 1,
contentCategoryForClass(identity.classId));
expect(romActual.maxHealth).toBe(expected * (trashEntity.combat?.healthMultiplier ?? 1));
expect(wowActual.maxHealth).toBe(expected * (wowTrash.combat?.healthMultiplier ?? 1));
expect(romActual.level).toBe(level);
expect(wowActual.level).toBe(level);
}
}
});
it("keeps RoM origin scaling for a WoW-ruleset RoM player with WoW party members", () => {
const player = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
const level = 30;
useCombatStore.getState().initializeCharacter({
classId: player.classId,
statRuleset: "wow",
raceId: player.raceId,
level,
});
usePartyStore.getState().initializeParty("rom-origin-mixed-party", level,
useCombatStore.getState().health, "healer", 5, "wow");
const source = contentCategoryForClass(useCombatStore.getState().classId);
const mixedParty = usePartyStore.getState();
expect(useCombatStore.getState().statRuleset).toBe("wow");
expect(source).toBe("rom");
expect(mixedParty.categoryId).toBe("wow");
expect(mixedParty.members.some((member) => !member.classId.startsWith("rom-"))).toBe(true);
const selectedDungeon = requireDungeonDefinition("stormwind-stockade");
const spawn = selectedDungeon.staticSpawns
.find((entry) => selectedDungeon.entities[entry.entityId]?.kind === "mob")!;
const entity = selectedDungeon.entities[spawn.entityId];
expect(dungeonEnemyScaling(selectedDungeon, entity, spawn.id, level, "normal", 1, source).maxHealth)
.toBe(dungeonEnemyHealthBaseline(level, "rom") * (entity.combat?.healthMultiplier ?? 1));
});
it("reports campaign health and observed party damage against real Barren Caves spawns", () => {
expect(trashEntity.kind).toBe("mob");
expect(angloEntity.name).toBe("Anglo");
expect(angloEntity.kind).toBe("boss");
for (const identity of ROM_FIVE_IDENTITIES) {
for (const level of LEVELS) {
const trash = measurePartyPull(identity, level, "trash");
const boss = measurePartyPull(identity, level, "Anglo");
samples.push(trash, boss);
const expectedTrashHealth = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id,
level, "normal", 1, "rom").maxHealth;
const expectedAngloHealth = dungeonEnemyScaling(dungeon, angloEntity, angloSpawn.id,
level, "normal", 1, "rom").maxHealth;
expect(trash.maxHealth).toBe(Math.round(expectedTrashHealth));
expect(boss.maxHealth).toBe(Math.round(expectedAngloHealth));
expect(trash.mobLevel).toBe(scaledDungeonEnemyLevel(level, false));
expect(boss.mobLevel).toBe(scaledDungeonEnemyLevel(level, true));
expect(trash.partyClassIds).toHaveLength(5);
expect(new Set(trash.partyClassIds)).toEqual(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.classId)));
expect(boss.partyClassIds).toEqual(trash.partyClassIds);
expect(boss.partyRoles).toEqual(trash.partyRoles);
}
}
}, 240_000);
});