forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
39458d5de6
commit
6cc1b9217f
203 files changed
+133929
-31271
No files matched your search
@@ -0,0 +1,250 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user