forked from phenom/healer-man
269 lines
12 KiB
TypeScript
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);
|
|
});
|