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