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); });