Files
healer-man/src/game/romNativeEncounterPressure.integration.test.ts
2026-10-04 13:13:35 -04:00

251 lines
14 KiB
TypeScript

import { afterAll, describe, expect, it } from "vitest";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { PLAYER_AGGRO_ID } from "./aggro";
import { useCombatStore } from "./combatStore";
import { subscribeCombatEvents } from "./combatEvents";
import { subscribeCombatPresentation } from "./combatPresentation";
import { useGameStore } from "./store";
import { usePartyStore, createConfiguredPartyMember } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition,
updatePartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonEnemyScaling } from "./dungeonBossScaling";
import { BenchmarkController } from "./benchmark/benchmarkController";
import { BENCHMARK_SCENARIOS } from "./benchmark/benchmarkScenarios";
import type { EnemyAttackDefinition } from "./enemyAttacks";
const dungeon = requireDungeonDefinition("barren-caves");
const bossEntity = dungeon.entities["rw-barren-caves-101462"];
const bossSpawn = dungeon.staticSpawns.find((spawn) => spawn.entityId === bossEntity.id)!;
const BOSS_ID = bossSpawn.id;
const priest = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
const report: unknown[] = [];
afterAll(() => {
if (process.env.ROM_NATIVE_PRESSURE_REPORT !== "1") return;
const directory = resolve("artifacts/rom-enemy-followup");
mkdirSync(directory, { recursive: true });
writeFileSync(resolve(directory, "pressure.json"), `${JSON.stringify({
scope: "Explicit native five / Barren Caves Anglo diagnostic, levels 10/30/60/80. Scripted positions, normal HP budgets; no scene navigation or readiness certification.",
samples: report,
}, null, 2)}\n`);
});
function setup(level = 30, stacked = false, attacks?: readonly EnemyAttackDefinition[], activeIdentity = priest) {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
useGameStore.getState().activateDungeon("barren-caves", "normal", { bossDifficulty: 1 });
const combat = useCombatStore.getState();
combat.initializeCharacter({ ...activeIdentity, level, statRuleset: "rom" });
combat.setPlayerPosition([stacked || activeIdentity.role === "tank" ? 2 : 10, 0, 0]);
usePartyStore.getState().initializeParty("native-pressure", level, useCombatStore.getState().health, activeIdentity.role, 5, "rom");
const members = ROM_FIVE_IDENTITIES.filter((identity) => identity !== activeIdentity).map((identity, index) =>
createConfiguredPartyMember({ ...identity, statRuleset: "rom", specialization: "Primary",
talentTreeId: "" }, level, index, "native-pressure"));
usePartyStore.setState({ members, command: "defend" });
combat.synchronizeActors();
const scaling = dungeonEnemyScaling(dungeon, bossEntity, BOSS_ID, level, "normal", 1, "rom", "rom");
combat.registerMob(BOSS_ID, { name: "Anglo", boss: true, runewakerNpcId: 101462,
...scaling, xpReward: 0, attacks: attacks ?? bossEntity.combat?.attacks });
setMobPosition(BOSS_ID, [0, 0, 0]);
for (const member of members) {
const distance = stacked || member.classId === "rom-knight" || member.classId === "rom-rogue" ? 2
: member.classId === "rom-scout" ? 8 : 10;
registerPartyRuntimePosition(member.id, [distance, 0, 0]);
updatePartyRuntimeTargetVisibility(member.id, BOSS_ID, true);
}
const tank = members.find((member) => member.role === "tank")
?? { id: PLAYER_AGGRO_ID, maxHealth: useCombatStore.getState().maxHealth };
combat.damageMob(BOSS_ID, 1, 1_000, { actorId: tank.id, role: "tank" });
combat.selectMob(BOSS_ID);
return { tank, members };
}
function measurePressure(level: number, stacked: boolean, activeHealer: boolean) {
const { tank } = setup(level, stacked);
const controller = new BenchmarkController();
let incoming = 0;
let rawIncoming = 0;
let absorbed = 0;
let effectiveHealing = 0;
let overheal = 0;
let healerCasts = 0;
let invalid = 0;
let resourceBlocked = 0;
let tankLow = 1;
let partyLow = 1;
let manaLow = 1;
let endAt = 1_000;
const outgoingByAbility: Record<string, number> = {};
const castsByAbility: Record<string, number> = {};
const controls = new Set<string>();
const otherDamage: Record<string, number> = {};
const invalidDetails: unknown[] = [];
const castAbility = useCombatStore.getState().castAbility;
useCombatStore.setState({ castAbility: (...args) => {
const result = castAbility(...args);
if (!result.ok && result.reason) invalidDetails.push({ abilityId: result.abilityId, reason: result.reason,
duringCompletion: Boolean(args[3]) });
return result;
} });
const stopCasts = subscribeCombatPresentation((message) => {
if (message.kind !== "event" || message.event.sourceActorId !== PLAYER_AGGRO_ID || message.event.phase !== "release") return;
const id = `${message.event.abilityId}:${message.event.name}`;
castsByAbility[id] = (castsByAbility[id] ?? 0) + 1;
});
const stop = subscribeCombatEvents((event) => {
if (event.sourceActorId === BOSS_ID && event.kind === "damage") {
incoming += event.effectiveAmount;
rawIncoming += event.rawAmount;
absorbed += event.absorbed;
}
if (event.sourceActorId === PLAYER_AGGRO_ID && event.kind === "healing") {
effectiveHealing += event.effectiveAmount;
overheal += event.overheal;
const id = event.abilityId ?? "unknown";
outgoingByAbility[id] = (outgoingByAbility[id] ?? 0) + event.effectiveAmount;
}
if (event.kind === "damage" && event.sourceActorId !== BOSS_ID && event.targetActorId !== BOSS_ID) {
const id = `${event.sourceActorId}:${event.abilityId}`;
otherDamage[id] = (otherDamage[id] ?? 0) + event.effectiveAmount;
}
});
try {
for (let now = 1_100; now <= 121_000; now += 100) {
endAt = now;
const combat = useCombatStore.getState();
combat.tick(0.1, now);
combat.advanceMobCombat(now);
advanceDungeonPartyCombat(now);
if (activeHealer && !useCombatStore.getState().mobs[BOSS_ID].dead && useCombatStore.getState().health > 0) {
const decision = controller.act(now, "healer", { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 }, false);
healerCasts += Number(decision.cast);
invalid += decision.invalidFailures;
resourceBlocked += Number(decision.resourceBlocked);
}
const state = useCombatStore.getState();
const members = usePartyStore.getState().members;
tankLow = Math.min(tankLow, state.actors[tank.id]?.health! / tank.maxHealth);
partyLow = Math.min(partyLow, state.health / state.maxHealth,
...members.map((member) => member.health / member.maxHealth));
manaLow = Math.min(manaLow, (state.resourcePools.mana ?? 0) / (state.maxResourcePools.mana ?? 1));
for (const aura of state.auras) if (aura.sourceId === BOSS_ID && aura.definition.id.startsWith("enemy-control:")) {
controls.add(aura.targetId);
}
if (state.mobs[BOSS_ID].dead || state.health <= 0) break;
}
} finally { stop(); stopCasts(); useCombatStore.setState({ castAbility }); }
const state = useCombatStore.getState();
const result = { level, stacked, activeHealer, seconds: (endAt - 1_000) / 1_000,
clear: state.mobs[BOSS_ID].dead, damage: incoming, rawIncoming, absorbed, effectiveHealing, overheal,
playerMaxHealth: state.maxHealth, tankMaxHealth: state.actors[tank.id].maxHealth,
healerCasts, resourceBlocked, invalid, tankLow, partyLow, manaLow,
finalMana: state.resourcePools.mana, maxMana: state.maxResourcePools.mana,
playerDead: state.health <= 0,
deadCompanions: usePartyStore.getState().members.filter((member) => member.health <= 0).map((member) => member.classId),
mechanics: state.mobs[BOSS_ID].abilityCastCounts, controlledActors: [...controls], outgoingByAbility, castsByAbility, otherDamage, invalidDetails };
report.push(result);
return result;
}
describe("native RoM encounter and healing pressure", () => {
it.each(["Trample", "Bash"])("resolves %s against current positions after its warning", (name) => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === name)!;
const { members } = setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
const releases: string[] = [];
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === BOSS_ID) releases.push(message.event.phase);
});
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs[BOSS_ID].activeCast?.name).toBe(name);
useCombatStore.getState().setPlayerPosition([8, 0, 0]);
const mage = members.find((member) => member.classId === "rom-mage")!;
updatePartyRuntimePosition(mage.id, 12, 0, 0);
useCombatStore.getState().advanceMobCombat(2_899);
expect(useCombatStore.getState().combatEvents.some((event) => event.abilityId === attack.id)).toBe(false);
useCombatStore.getState().advanceMobCombat(2_900);
stopPresentation();
const hits = useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage" && event.abilityId === attack.id);
expect(new Set(hits.map((event) => event.targetActorId))).toEqual(new Set(members.filter((member) => member.id !== mage.id).map((member) => member.id)));
expect(hits.every((event) => event.school === "physical")).toBe(true);
expect(releases).toContain("cast-start");
expect(releases).toContain("release");
});
it("lets dispelling Gulp restore the controlled actor before natural expiry", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Gulp")!;
const { tank } = setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
useCombatStore.getState().advanceMobCombat(1_100);
useCombatStore.getState().advanceMobCombat(2_900);
expect(useCombatStore.getState().controlMechanic).toBe("stun");
expect(useCombatStore.getState().actors[tank.id].controlledUntil).toBeGreaterThan(2_900);
expect(useCombatStore.getState().dispelActor(PLAYER_AGGRO_ID, ["magic"], 1, 3_000)).toBe(1);
expect(useCombatStore.getState().controlledUntil).toBeLessThanOrEqual(3_000);
expect(useCombatStore.getState().dispelActor(tank.id, ["magic"], 1, 3_000)).toBe(1);
expect(useCombatStore.getState().actors[tank.id].controlledUntil).toBeLessThanOrEqual(3_000);
});
it("lets a native companion counter an interruptible boss warning", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Trample")!;
setup(30, true, [{ ...attack, initialCooldownMs: [0, 0] }]);
useCombatStore.getState().advanceMobCombat(1_100);
expect(useCombatStore.getState().mobs[BOSS_ID].activeCast).not.toBeNull();
const counters: string[] = [];
const stop = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === BOSS_ID) counters.push(message.event.phase);
});
for (let now = 1_200; now < 2_900; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
const boss = useCombatStore.getState().mobs[BOSS_ID];
stop();
expect(boss.activeCast).toBeNull();
expect(counters).toContain("cast-cancel");
expect(useCombatStore.getState().combatEvents.some((event) => event.kind === "damage" && event.abilityId === attack.id)).toBe(false);
});
it("lets a ranged native Priest companion dispel Gulp from the player tank", () => {
const attack = bossEntity.combat!.attacks.find((entry) => entry.name === "Gulp")!;
const knight = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-knight")!;
const { members } = setup(30, false, [{ ...attack, initialCooldownMs: [0, 0] }], knight);
const healer = members.find((member) => member.classId === "rom-priest")!;
useCombatStore.getState().advanceMobCombat(1_100);
useCombatStore.getState().advanceMobCombat(2_900);
expect(useCombatStore.getState().controlledUntil).toBe(5_900);
expect(useCombatStore.getState().actors[healer.id].controlledUntil).toBeLessThan(2_900);
for (let now = 3_000; now < 5_900 && useCombatStore.getState().controlledUntil > now; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
expect(useCombatStore.getState().controlledUntil).toBe(0);
expect(useCombatStore.getState().controlMechanic).toBeNull();
});
it("records active-healer and idle-healer pressure in the explicit native cohort", () => {
for (const level of [10, 30, 60, 80]) for (const stacked of [false, true]) {
const active = measurePressure(level, stacked, true);
const idle = measurePressure(level, stacked, false);
// Record failures before asserting the cohort, retaining every level in
// the pressure report when a new demand pattern exposes a policy gap.
expect(active.damage).toBeGreaterThan(0);
expect(active.healerCasts).toBeGreaterThan(0);
// A safe pull may permit the healer to deal damage throughout; require
// healing across the cohort rather than inventing demand in every pull.
expect(active.effectiveHealing).toBeGreaterThanOrEqual(0);
expect(idle.healerCasts).toBe(0);
expect(idle.effectiveHealing).toBe(0);
expect(Number.isFinite(active.manaLow)).toBe(true);
expect(Number.isFinite(active.partyLow)).toBe(true);
}
expect(report.some((sample) => (sample as { effectiveHealing: number }).effectiveHealing > 0)).toBe(true);
expect(report.filter((sample) => (sample as { activeHealer: boolean }).activeHealer)
.map((sample) => (sample as { invalid: number }).invalid)).toEqual(Array(8).fill(0));
}, 180_000);
});