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