Files
healer-man/src/game/dungeonBossScaling.test.ts
T
2026-09-19 21:01:51 -04:00

181 lines
11 KiB
TypeScript

import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { finalDungeonBoss } from "./dungeonRunStore";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
import { subscribeCombatEvents } from "./combatEvents";
import { bossDifficultyMultipliers } from "./dungeonDifficulty";
import { dungeonEnemyHealthBaseline } from "./dungeonLevelScaling";
import { abilitiesForClass } from "./abilityCatalog";
import type { ContentCategoryId } from "../app/contentCategories";
const dungeon = requireDungeonDefinition("ragefire-chasm");
const final = finalDungeonBoss(dungeon)!;
const samples: { level: number; seed: string; boss: unknown; final: unknown }[] = [];
afterAll(() => {
if (process.env.BOSS_BALANCE_REPORT !== "1") return;
mkdirSync("artifacts/boss-difficulty", { recursive: true });
writeFileSync("artifacts/boss-difficulty/ragefire-balance.json", `${JSON.stringify({ samples }, null, 2)}\n`);
});
function fight(bossId: string, level: number, difficulty: number, seed: string, mechanics = false, playerDamage = false,
characterSource: ContentCategoryId = "rom", encounterDungeon = dungeon) {
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
const entity = encounterDungeon.entities[encounterDungeon.staticSpawns.find((spawn) => spawn.id === bossId)!.entityId];
const scaling = dungeonEnemyScaling(encounterDungeon, entity, bossId, level, "normal", difficulty, characterSource);
const combat = useCombatStore.getState();
const classId = characterSource === "wow" ? (playerDamage ? "mage" : "priest") : (playerDamage ? "rom-mage" : "rom-priest");
combat.initializeCharacter({ classId, raceId: characterSource === "wow" ? "human" : "rom-human", level });
combat.setPlayerPosition([-18, 0, 0]);
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, playerDamage ? "damage" : "healer", 5, characterSource);
combat.synchronizeActors();
combat.registerMob(bossId, { name: entity.name, boss: true, serverEntry: entity.combat?.serverEntry,
...scaling, attacks: entity.combat?.attacks, xpReward: 0 });
setMobPosition(bossId, [0, 0, 0]);
for (const member of usePartyStore.getState().members) {
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, bossId, true);
}
let firstDamage: number | undefined;
let incomingDamage = 0; let healing = 0;
const heal = abilitiesForClass(classId).find((ability) => ability.target === "friendly"
&& ability.effects.some((effect) => effect.kind === "heal") && (ability.unlockLevel ?? 1) <= level)!;
const damage = abilitiesForClass(classId).find((ability) => ability.target === "hostile"
&& ability.effects.some((effect) => effect.kind === "damage"))!;
combat.selectMob(bossId);
const stop = subscribeCombatEvents((event) => {
if (event.kind === "damage" && event.targetActorId === bossId && event.effectiveAmount > 0) firstDamage ??= event.occurredAt;
if (event.kind === "damage" && event.sourceActorId === bossId) incomingDamage += event.effectiveAmount;
if (event.kind === "healing") healing += event.effectiveAmount;
});
combat.damageMob(bossId, 1, 1000);
try {
for (let now = 1100; now <= 241000; now += 100) {
combat.tick(.1, now); advanceDungeonPartyCombat(now);
if (playerDamage && !useCombatStore.getState().activeCast) combat.castAbility(damage.id, undefined, now);
if (mechanics) {
combat.advanceMobCombat(now);
const injured = usePartyStore.getState().members.filter((member) => member.health > 0)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0];
if (injured && injured.health < injured.maxHealth * .8 && !useCombatStore.getState().activeCast) {
usePartyStore.getState().selectMember(injured.id);
combat.castAbility(heal.id, { targetActorId: injured.id, now });
}
}
const mob = useCombatStore.getState().mobs[bossId];
if (mob.dead) return { seconds: ((mob.diedAt ?? now) - firstDamage!) / 1000,
incomingDamage, healing, attacks: mob.attackRevision,
maxHealth: mob.maxHealth, classes: usePartyStore.getState().members.map((member) => member.classId) };
}
return { seconds: null, maxHealth: scaling.maxHealth };
} finally { stop(); }
}
describe("dungeon boss durability and difficulty", () => {
const stockades = requireDungeonDefinition("stormwind-stockade");
const stockadesBoss = stockades.bosses[0];
const stockadesEntity = stockades.entities[stockades.staticSpawns.find((entry) => entry.id === stockadesBoss.id)!.entityId];
it("uses the party health curve for trash regardless of dungeon catalog", () => {
const abbey = requireDungeonDefinition("forsaken-abbey");
const trashFrom = (definition: typeof stockades) => {
const spawn = definition.staticSpawns.find((entry) => definition.entities[entry.entityId].kind === "mob")!;
return { entity: definition.entities[spawn.entityId], instanceId: spawn.id };
};
const stockadesTrash = trashFrom(stockades);
const abbeyTrash = trashFrom(abbey);
for (const source of ["wow", "rom", "coa"] as const) {
const expected = dungeonEnemyHealthBaseline(5, source);
expect(dungeonEnemyScaling(stockades, stockadesTrash.entity, stockadesTrash.instanceId,
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
expect(dungeonEnemyScaling(abbey, abbeyTrash.entity, abbeyTrash.instanceId,
5, "normal", 1, source)).toMatchObject({ level: 5, maxHealth: expected });
}
});
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
expect(easy).toMatchObject({ level: 4, maxHealth: 1500 });
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
.toBe(1500 * bossDifficultyMultipliers(10).health);
// The dungeon catalog must not choose the companion power curve.
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
});
for (const level of [1, 3, 8, 20, 40, 80]) {
it(`preserves WoW boss mechanic windows in Stockades at level ${level}`, () => {
for (const seed of ["wow-health-1", "wow-health-2", "wow-health-3"]) {
const ordinary = fight(stockadesBoss.id, level, 1, seed, false, false, "wow", stockades);
const last = fight(finalDungeonBoss(stockades)!.id, level, 1, seed, false, false, "wow", stockades);
samples.push({ level, seed, boss: ordinary, final: last });
// Combat advances in 100 ms ticks and the first action cadence varies by
// generated party. Keep a half-second tolerance around the 25s design floor.
expect(ordinary.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(24.5);
expect(last.seconds ?? 240, JSON.stringify({ level, seed, ordinary, last })).toBeGreaterThanOrEqual(45);
}
}, 30000);
}
it("allows real Stockades boss mechanics during a level-three WoW Priest party fight", () => {
const result = fight(stockadesBoss.id, 3, 1, "wow-health-1", true, false, "wow", stockades);
expect(result.seconds, JSON.stringify(result)).not.toBeNull();
expect(result.seconds!).toBeLessThanOrEqual(180);
expect(result.attacks).toBeGreaterThan(0);
expect(result.incomingDamage).toBeGreaterThan(0);
});
it("increases boss health and damage at every slider step without changing trash", () => {
const spawn = dungeon.staticSpawns.find((entry) => entry.id === final.id)!;
const entity = dungeon.entities[spawn.entityId];
const trash = dungeon.staticSpawns.find((entry) => dungeon.entities[entry.entityId].kind === "mob")!;
const easy = dungeonEnemyScaling(dungeon, entity, final.id, 8, "normal", 1);
for (let difficulty = 2; difficulty <= 10; difficulty += 1) {
const scaled = dungeonEnemyScaling(dungeon, entity, final.id, 8, "normal", difficulty);
expect(scaled.maxHealth).toBe(easy.maxHealth * bossDifficultyMultipliers(difficulty).health);
expect(scaled.damageMultiplier).toBe(easy.damageMultiplier * bossDifficultyMultipliers(difficulty).damage);
expect(dungeonEnemyScaling(dungeon, dungeon.entities[trash.entityId], trash.id, 8, "normal", difficulty))
.toEqual(dungeonEnemyScaling(dungeon, dungeon.entities[trash.entityId], trash.id, 8, "normal", 1));
}
});
it("allows real boss attacks and Priest healing before Ragefire's final boss dies", () => {
const result = fight(final.id, 8, 1, "rom-health-1", true);
expect(result.seconds).not.toBeNull(); expect(result.seconds!).toBeGreaterThanOrEqual(45);
expect(result.attacks).toBeGreaterThan(5); expect(result.incomingDamage).toBeGreaterThan(0);
expect(result.healing).toBeGreaterThan(0);
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
});
it("retains encounter time with a damage-role Mage contributing to the RoM party", () => {
for (const level of [1, 8, 20, 40, 80]) {
const ordinary = dungeon.bosses.find((boss) => boss.id !== final.id)!;
const boss = fight(ordinary.id, level, 1, "rom-health-1", false, true);
const last = fight(final.id, level, 1, "rom-health-1", false, true);
samples.push({ level, seed: "player-mage", boss, final: last });
expect(boss.seconds).not.toBeNull(); expect(last.seconds).not.toBeNull();
expect(boss.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(25);
expect(last.seconds!, JSON.stringify({ level, boss, last })).toBeGreaterThanOrEqual(45);
}
});
for (const level of [1, 8, 20, 40, 80]) {
it(`gives a RoM party time to play Ragefire boss mechanics at level ${level}`, () => {
const ordinary = dungeon.bosses.find((boss) => boss.id !== final.id)!;
for (let index = 0; index < 12; index += 1) {
const seed = `rom-health-${index}`;
const boss = fight(ordinary.id, level, 1, seed);
const last = fight(final.id, level, 1, seed);
samples.push({ level, seed, boss, final: last });
expect(boss.seconds).not.toBeNull(); expect(last.seconds).not.toBeNull();
expect(boss.seconds!).toBeGreaterThanOrEqual(25);
expect(last.seconds!).toBeGreaterThanOrEqual(45);
}
}, 30000);
}
});