forked from phenom/healer-man
152 lines
7.3 KiB
TypeScript
152 lines
7.3 KiB
TypeScript
import { afterAll, describe, expect, it } from "vitest";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { useCombatStore } from "./combatStore";
|
|
import { usePartyStore } from "./partyStore";
|
|
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
|
import { BARREN_CAVES_ENTITIES } from "./generated/barrenCavesPopulation.generated";
|
|
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
|
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
|
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
|
import { subscribeCombatEvents } from "./combatEvents";
|
|
import { abilitiesForClass } from "./abilityCatalog";
|
|
|
|
const report: unknown[] = [];
|
|
const trash = BARREN_CAVES_ENTITIES["rw-barren-caves-101456"];
|
|
const boss = BARREN_CAVES_ENTITIES["rw-barren-caves-101462"];
|
|
|
|
afterAll(() => {
|
|
if (process.env.ROM_DUNGEON_HEALTH_REPORT !== "1") return;
|
|
const directory = resolve("artifacts/rom-dungeon-health");
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({ results: report }, null, 2)}\n`);
|
|
});
|
|
|
|
function initializePull(level: number, seed: string, maxHealth: number, isBoss = false): void {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useCombatStore.getState().initializeCharacter({ classId: "rom-priest", raceId: "rom-human", level });
|
|
useCombatStore.getState().setPlayerPosition([-18, 0, 0]);
|
|
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health, "healer", 5, "rom");
|
|
useCombatStore.getState().synchronizeActors();
|
|
const definition = isBoss ? boss : trash;
|
|
useCombatStore.getState().registerMob("pull", {
|
|
name: definition.name,
|
|
boss: isBoss,
|
|
level: scaledDungeonEnemyLevel(level, isBoss),
|
|
maxHealth,
|
|
healthMultiplier: definition.combat.healthMultiplier,
|
|
attacks: definition.combat.attacks,
|
|
xpReward: 0,
|
|
});
|
|
setMobPosition("pull", [0, 0, 0]);
|
|
for (const member of usePartyStore.getState().members) {
|
|
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
|
updatePartyRuntimeTargetVisibility(member.id, "pull", true);
|
|
}
|
|
useCombatStore.getState().damageMob("pull", 1, 100);
|
|
}
|
|
|
|
function measurePull(level: number, seed: string, maxHealth: number, isBoss = false) {
|
|
initializePull(level, seed, maxHealth, isBoss);
|
|
let firstDamageAt: number | null = null;
|
|
let diedAt: number | null = null;
|
|
let openingHealth = maxHealth;
|
|
const stop = subscribeCombatEvents((event) => {
|
|
if (event.targetActorId !== "pull") return;
|
|
if (event.kind === "damage" && event.effectiveAmount > 0) firstDamageAt ??= event.occurredAt;
|
|
});
|
|
try {
|
|
for (let now = 1_000; now <= 121_000; now += 100) {
|
|
useCombatStore.getState().tick(0.1, now);
|
|
advanceDungeonPartyCombat(now);
|
|
const mob = useCombatStore.getState().mobs.pull;
|
|
if (firstDamageAt !== null && now <= firstDamageAt + 1_000) openingHealth = mob.health;
|
|
if (mob.dead) {
|
|
diedAt = mob.diedAt ?? now;
|
|
break;
|
|
}
|
|
}
|
|
return { maxHealth, seconds: diedAt === null || firstDamageAt === null ? null : (diedAt - firstDamageAt) / 1_000,
|
|
openingHealth, classes: usePartyStore.getState().members.map((member) => member.classId) };
|
|
} finally {
|
|
stop();
|
|
}
|
|
}
|
|
|
|
describe("RoM dungeon enemy durability", () => {
|
|
for (const level of [1, 2, 4, 8, 9, 20, 40, 70, 80]) {
|
|
it(`Barren Caves trash survives a full RoM party's opening volley at level ${level}`, () => {
|
|
for (let seed = 0; seed < 12; seed += 1) {
|
|
const partySeed = `rom-health-${seed}`;
|
|
const before = measurePull(level, partySeed, 72 + level * 22);
|
|
const after = measurePull(level, partySeed, dungeonEnemyHealthBaseline(level, "rom"));
|
|
report.push({ level, seed: partySeed, before, after });
|
|
expect(after.openingHealth, JSON.stringify({ level, before, after })).toBeGreaterThan(0);
|
|
expect(after.seconds, JSON.stringify({ level, before, after })).not.toBeNull();
|
|
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeGreaterThanOrEqual(3);
|
|
expect(after.seconds!, JSON.stringify({ level, before, after })).toBeLessThanOrEqual(30);
|
|
}
|
|
});
|
|
}
|
|
|
|
it("retains Barren Caves' authored boss health multiplier", () => {
|
|
const level = 8;
|
|
const maxHealth = dungeonEnemyHealthBaseline(scaledDungeonEnemyLevel(level, true), "rom")
|
|
* boss.combat.healthMultiplier;
|
|
const result = measurePull(level, "rom-health-0", maxHealth, true);
|
|
report.push({ level, boss: boss.name, ...result });
|
|
expect(result.seconds).not.toBeNull();
|
|
expect(result.seconds!).toBeGreaterThanOrEqual(20);
|
|
expect(result.seconds!).toBeLessThanOrEqual(120);
|
|
});
|
|
|
|
it("allows enemy attacks and Priest healing before an opening pull ends", () => {
|
|
const level = 1;
|
|
initializePull(level, "rom-health-0", dungeonEnemyHealthBaseline(level, "rom"));
|
|
const heal = abilitiesForClass("rom-priest").find((ability) => ability.name === "Urgent Heal")!;
|
|
let incomingDamage = 0;
|
|
let healing = 0;
|
|
const stop = subscribeCombatEvents((event) => {
|
|
if (event.sourceActorId === "pull" && event.kind === "damage") incomingDamage += event.effectiveAmount;
|
|
if (event.kind === "healing") healing += event.effectiveAmount;
|
|
});
|
|
try {
|
|
for (let now = 1_000; now <= 31_000; now += 100) {
|
|
useCombatStore.getState().tick(0.1, now);
|
|
advanceDungeonPartyCombat(now);
|
|
useCombatStore.getState().advanceMobCombat(now);
|
|
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
|
if (tank.health < tank.maxHealth && !useCombatStore.getState().activeCast) {
|
|
usePartyStore.getState().selectMember(tank.id);
|
|
useCombatStore.getState().castAbility(heal.id, undefined, now);
|
|
}
|
|
if (useCombatStore.getState().mobs.pull.dead) break;
|
|
}
|
|
expect(useCombatStore.getState().mobs.pull.dead).toBe(true);
|
|
expect(incomingDamage).toBeGreaterThan(0);
|
|
expect(healing).toBeGreaterThan(0);
|
|
expect(usePartyStore.getState().members.every((member) => member.health > 0)).toBe(true);
|
|
} finally {
|
|
stop();
|
|
}
|
|
});
|
|
|
|
it("updates loaded enemies on level-up while retaining wounds and fallen enemies", () => {
|
|
const initial = dungeonEnemyHealthBaseline(1, "rom");
|
|
const scaled = dungeonEnemyHealthBaseline(8, "rom");
|
|
initializePull(1, "rom-health-level-up", initial);
|
|
useCombatStore.getState().damageMob("pull", initial / 2 - 1, 1_000);
|
|
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
|
expect(useCombatStore.getState().mobs.pull).toMatchObject({ level: 8, maxHealth: scaled, health: scaled / 2,
|
|
attackDamage: 20 });
|
|
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 8, maxHealth: scaled });
|
|
expect(useCombatStore.getState().mobs.pull.health).toBe(scaled / 2);
|
|
useCombatStore.getState().damageMob("pull", scaled, 2_000);
|
|
useCombatStore.getState().registerMob("pull", { name: trash.name, level: 20,
|
|
maxHealth: dungeonEnemyHealthBaseline(20, "rom") });
|
|
expect(useCombatStore.getState().mobs.pull).toMatchObject({ dead: true, health: 0 });
|
|
});
|
|
});
|