forked from phenom/healer-man
410 lines
17 KiB
TypeScript
410 lines
17 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 {
|
|
abilitiesForCharacter,
|
|
learnedAbilityForBuild,
|
|
type AbilityDefinition,
|
|
} from "./abilityCatalog";
|
|
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,
|
|
updatePartyRuntimeTargetVisibility,
|
|
} from "./partyRuntimeRegistry";
|
|
import { partyAbilityCosts } from "./partyAbilityAi";
|
|
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
|
import { requireDungeonDefinition } from "./dungeonRegistry";
|
|
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
|
|
|
const dungeon = requireDungeonDefinition("barren-caves");
|
|
const bossEntity = dungeon.entities["rw-barren-caves-101462"];
|
|
const bossSpawn = dungeon.staticSpawns.find((spawn) => spawn.entityId === bossEntity.id)!;
|
|
const bossIdentity = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-knight")!;
|
|
const priestIdentity = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
|
|
const RUNS_PER_LEVEL = 3;
|
|
const PULL_LIMIT_MS = 150_000;
|
|
|
|
interface SustainPullReport {
|
|
readonly level: number;
|
|
readonly pull: number;
|
|
readonly bossId: string;
|
|
readonly healerId: string;
|
|
readonly startMana: number;
|
|
readonly minimumMana: number;
|
|
readonly endMana: number;
|
|
readonly startingManaCapacity: number;
|
|
readonly peakObservedMana: number;
|
|
readonly resolvedManaCostEstimate: number;
|
|
readonly elapsedMs: number;
|
|
readonly bossCleared: boolean;
|
|
readonly playerDead: boolean;
|
|
readonly deadNpcClasses: readonly string[];
|
|
readonly hostileDamage: number;
|
|
readonly healerActions: number;
|
|
readonly healerActionsByAbility: Readonly<Record<string, number>>;
|
|
readonly healingCasts: number;
|
|
readonly healingCastsByAbility: Readonly<Record<string, number>>;
|
|
readonly healingPackets: number;
|
|
readonly rawAttemptedHealing: number;
|
|
readonly resolvedHealing: number;
|
|
readonly effectiveHealing: number;
|
|
readonly overheal: number;
|
|
readonly overhealByAbility: Readonly<Record<string, number>>;
|
|
readonly healingCastStarts: readonly {
|
|
readonly at: number;
|
|
readonly ability: string;
|
|
readonly target: string;
|
|
readonly targetHealth: number | null;
|
|
readonly targetMaxHealth: number | null;
|
|
readonly targetHealthFraction: number | null;
|
|
readonly combatActorHealth: number | null;
|
|
readonly castTimeMs: number;
|
|
readonly pendingHotIds: readonly string[];
|
|
readonly pendingHotTicks: number;
|
|
}[];
|
|
readonly healPackets: readonly {
|
|
readonly at: number;
|
|
readonly ability: string;
|
|
readonly target: string;
|
|
readonly targetHealthBefore: number | null;
|
|
readonly targetHealthAfter: number | null;
|
|
readonly targetMaxHealth: number | null;
|
|
readonly raw: number;
|
|
readonly effective: number;
|
|
readonly overheal: number;
|
|
}[];
|
|
}
|
|
|
|
interface RestReport {
|
|
readonly level: number;
|
|
readonly betweenPulls: readonly [number, number];
|
|
readonly healerId: string;
|
|
readonly restMs: number;
|
|
readonly startMana: number;
|
|
readonly endMana: number;
|
|
readonly recoveredMana: number;
|
|
readonly startHealth: number;
|
|
readonly endHealth: number;
|
|
readonly maxHealth: number;
|
|
readonly cooldownRemainingMsBefore: Readonly<Record<string, number>>;
|
|
readonly cooldownRemainingMsAfter: Readonly<Record<string, number>>;
|
|
readonly elapsedMs: number;
|
|
}
|
|
|
|
const reports: SustainPullReport[] = [];
|
|
const rests: RestReport[] = [];
|
|
|
|
afterAll(() => {
|
|
if (process.env.ROM_NATIVE_SUSTAIN_REPORT !== "1") return;
|
|
const directory = resolve("artifacts/rom-enemy-followup");
|
|
mkdirSync(directory, { recursive: true });
|
|
writeFileSync(resolve(directory, "native-sustain-2026-10-04.json"), `${JSON.stringify({
|
|
scope: "Targeted repeated native RoM five pulls at levels 60 and 80. The player occupies the Knight tank seat; the Priest is an owned NPC. Original Anglo attacks and health budgets are retained. Three pulls per level use a 10-second and a 90-second no-enemy interval. No readiness claim.",
|
|
pulls: reports,
|
|
rests,
|
|
}, null, 2)}\n`);
|
|
});
|
|
|
|
function partyByClass(classId: string) {
|
|
return usePartyStore.getState().members.find((member) => member.classId === classId)!;
|
|
}
|
|
|
|
function setupLevel(level: number) {
|
|
clearMobRuntimeRegistry();
|
|
clearPartyRuntimeRegistry();
|
|
resetPartyRuntimeClock();
|
|
useGameStore.getState().activateDungeon("barren-caves", "normal", { bossDifficulty: 1 });
|
|
const combat = useCombatStore.getState();
|
|
combat.initializeCharacter({ ...bossIdentity, level, statRuleset: "rom" });
|
|
combat.setPlayerPosition([2, 0, 0]);
|
|
usePartyStore.getState().initializeParty(`native-sustain:${level}`, level,
|
|
useCombatStore.getState().health, "tank", 5, "rom");
|
|
const members = ROM_FIVE_IDENTITIES
|
|
.filter((identity) => identity.classId !== bossIdentity.classId)
|
|
.map((identity, index) => createConfiguredPartyMember({
|
|
...identity,
|
|
statRuleset: "rom",
|
|
specialization: "Primary",
|
|
talentTreeId: "",
|
|
}, level, index, `native-sustain:${level}`));
|
|
usePartyStore.setState({ members, command: "attack" });
|
|
combat.synchronizeActors();
|
|
for (const member of members) {
|
|
registerPartyRuntimePosition(member.id, [member.classId === "rom-rogue" ? 2
|
|
: member.classId === "rom-scout" ? 8 : 10, 0, 0]);
|
|
}
|
|
return members;
|
|
}
|
|
|
|
function registerAnglo(level: number, pull: number, now: number): string {
|
|
const id = `native-sustain-anglo-${level}-${pull}`;
|
|
const scaling = dungeonEnemyScaling(dungeon, bossEntity, bossSpawn.id, level, "normal", 1, "rom", "rom");
|
|
const combat = useCombatStore.getState();
|
|
combat.registerMob(id, {
|
|
name: bossEntity.name,
|
|
boss: true,
|
|
runewakerNpcId: bossEntity.combat?.runewakerNpcId,
|
|
...scaling,
|
|
xpReward: 0,
|
|
attacks: bossEntity.combat?.attacks,
|
|
});
|
|
setMobPosition(id, [0, 0, 0]);
|
|
for (const member of usePartyStore.getState().members) updatePartyRuntimeTargetVisibility(member.id, id, true);
|
|
combat.damageMob(id, 1, now, { actorId: PLAYER_AGGRO_ID, role: "tank" });
|
|
combat.selectMob(id);
|
|
return id;
|
|
}
|
|
|
|
function measurePull(level: number, pull: number, now: number): { report: SustainPullReport; endedAt: number } {
|
|
const healer = partyByClass(priestIdentity.classId);
|
|
const healerId = healer.id;
|
|
const bossId = registerAnglo(level, pull, now);
|
|
const learnedById = new Map(abilitiesForCharacter(healer.classId, healer.secondaryClassId)
|
|
.map((ability) => learnedAbilityForBuild(ability, level, healer.talentRanks))
|
|
.filter((ability): ability is AbilityDefinition => ability !== null)
|
|
.map((ability) => [ability.id, ability] as const));
|
|
const initialMana = healer.resourcePools.mana ?? 0;
|
|
const startingManaCapacity = healer.resourceMaximums?.mana
|
|
?? (healer.resourceType === "mana" ? healer.maxResource : 0);
|
|
let minimumMana = initialMana;
|
|
let peakObservedMana = initialMana;
|
|
let resolvedManaCostEstimate = 0;
|
|
let hostileDamage = 0;
|
|
let healerActions = 0;
|
|
let healingCasts = 0;
|
|
let healingPackets = 0;
|
|
let rawAttemptedHealing = 0;
|
|
let resolvedHealing = 0;
|
|
let effectiveHealing = 0;
|
|
let overheal = 0;
|
|
const healerActionsByAbility: Record<string, number> = {};
|
|
const healingCastsByAbility: Record<string, number> = {};
|
|
const overhealByAbility: Record<string, number> = {};
|
|
const healingCastStarts: NonNullable<SustainPullReport["healingCastStarts"]>[number][] = [];
|
|
const healPackets: SustainPullReport["healPackets"] extends readonly (infer T)[] ? T[] : never = [];
|
|
const stopPresentation = subscribeCombatPresentation((message) => {
|
|
if (message.kind !== "event" || message.event.actorGroup !== "party"
|
|
|| message.event.sourceActorId !== healerId) return;
|
|
const ability = learnedById.get(message.event.abilityId);
|
|
const name = ability?.name ?? message.event.name;
|
|
if (message.event.phase === "cast-start" && ability?.effects.some((effect) => ["heal", "hot", "shield"].includes(effect.kind))) {
|
|
const targetId = message.event.targetActorId;
|
|
const combatState = useCombatStore.getState();
|
|
const target = targetId
|
|
? combatState.actors[targetId]
|
|
: undefined;
|
|
const partyTarget = targetId
|
|
? usePartyStore.getState().members.find((candidate) => candidate.id === targetId)
|
|
: undefined;
|
|
const targetHealth = targetId === PLAYER_AGGRO_ID
|
|
? combatState.health
|
|
: partyTarget?.health ?? target?.health ?? null;
|
|
const targetMaxHealth = targetId === PLAYER_AGGRO_ID
|
|
? combatState.maxHealth
|
|
: partyTarget?.maxHealth ?? target?.maxHealth ?? null;
|
|
const pendingHots = targetId
|
|
? combatState.effects.filter((effect) => effect.kind === "hot"
|
|
&& effect.targetId === targetId && effect.remainingTicks > 1)
|
|
: [];
|
|
healingCastStarts.push({
|
|
at: message.event.occurredAt,
|
|
ability: name,
|
|
target: targetId ?? "unknown",
|
|
targetHealth,
|
|
targetMaxHealth,
|
|
targetHealthFraction: targetHealth !== null && targetMaxHealth !== null && targetMaxHealth > 0
|
|
? targetHealth / targetMaxHealth : null,
|
|
combatActorHealth: target?.health ?? null,
|
|
castTimeMs: ability.castTimeMs,
|
|
pendingHotIds: [...new Set(pendingHots.map((effect) => effect.sourceAbilityId))],
|
|
pendingHotTicks: pendingHots.reduce((total, effect) => total + effect.remainingTicks, 0),
|
|
});
|
|
}
|
|
if (message.event.phase !== "release") return;
|
|
healerActions += 1;
|
|
healerActionsByAbility[name] = (healerActionsByAbility[name] ?? 0) + 1;
|
|
if (ability) {
|
|
const costs = partyAbilityCosts(partyByClass(priestIdentity.classId), ability, message.event.occurredAt);
|
|
resolvedManaCostEstimate += costs.filter((cost) => cost.resource === "mana").reduce((total, cost) => total + cost.amount, 0);
|
|
if (ability.effects.some((effect) => ["heal", "hot", "shield"].includes(effect.kind))) {
|
|
healingCasts += 1;
|
|
healingCastsByAbility[name] = (healingCastsByAbility[name] ?? 0) + 1;
|
|
}
|
|
}
|
|
});
|
|
const stopCombat = subscribeCombatEvents((event) => {
|
|
if (event.sourceActorId === bossId && event.kind === "damage") hostileDamage += event.effectiveAmount;
|
|
if (event.sourceActorId !== healerId || event.kind !== "healing") return;
|
|
healingPackets += 1;
|
|
rawAttemptedHealing += event.rawAmount;
|
|
resolvedHealing += event.effectiveAmount + event.overheal;
|
|
effectiveHealing += event.effectiveAmount;
|
|
overheal += event.overheal;
|
|
const ability = learnedById.get(event.abilityId ?? "");
|
|
const name = ability?.name ?? event.abilityId ?? "unknown";
|
|
overhealByAbility[name] = (overhealByAbility[name] ?? 0) + event.overheal;
|
|
const actor = useCombatStore.getState().actors[event.targetActorId];
|
|
healPackets.push({
|
|
at: event.occurredAt,
|
|
ability: name,
|
|
target: event.targetActorId,
|
|
// `appendCombatEvent` publishes synchronously inside healActor's Zustand updater,
|
|
// before the returned actor state (with effective healing applied) is committed.
|
|
targetHealthBefore: actor?.health ?? null,
|
|
targetHealthAfter: actor ? Math.min(actor.maxHealth, actor.health + event.effectiveAmount) : null,
|
|
targetMaxHealth: actor?.maxHealth ?? null,
|
|
raw: event.rawAmount,
|
|
effective: event.effectiveAmount,
|
|
overheal: event.overheal,
|
|
});
|
|
});
|
|
const startAt = now;
|
|
try {
|
|
for (; now - startAt < PULL_LIMIT_MS; now += 100) {
|
|
const combat = useCombatStore.getState();
|
|
combat.tick(0.1, now);
|
|
combat.advanceMobCombat(now);
|
|
advanceDungeonPartyCombat(now);
|
|
const currentHealer = partyByClass(priestIdentity.classId);
|
|
const mana = currentHealer.resourcePools.mana ?? 0;
|
|
minimumMana = Math.min(minimumMana, mana);
|
|
peakObservedMana = Math.max(peakObservedMana, mana);
|
|
if (combat.mobs[bossId]?.dead || combat.health <= 0) break;
|
|
}
|
|
} finally {
|
|
stopPresentation();
|
|
stopCombat();
|
|
}
|
|
const combat = useCombatStore.getState();
|
|
const party = usePartyStore.getState();
|
|
const currentHealer = party.members.find((member) => member.id === healerId)!;
|
|
const report: SustainPullReport = {
|
|
level,
|
|
pull,
|
|
bossId,
|
|
healerId,
|
|
startMana: initialMana,
|
|
minimumMana,
|
|
endMana: currentHealer.resourcePools.mana ?? 0,
|
|
startingManaCapacity,
|
|
peakObservedMana,
|
|
resolvedManaCostEstimate,
|
|
elapsedMs: now - startAt,
|
|
bossCleared: combat.mobs[bossId]?.dead ?? false,
|
|
playerDead: combat.health <= 0,
|
|
deadNpcClasses: party.members.filter((member) => member.health <= 0).map((member) => member.classId),
|
|
hostileDamage,
|
|
healerActions,
|
|
healerActionsByAbility,
|
|
healingCasts,
|
|
healingCastsByAbility,
|
|
healingPackets,
|
|
rawAttemptedHealing,
|
|
resolvedHealing,
|
|
effectiveHealing,
|
|
overheal,
|
|
overhealByAbility,
|
|
healingCastStarts,
|
|
healPackets,
|
|
};
|
|
reports.push(report);
|
|
return { report, endedAt: now };
|
|
}
|
|
|
|
function restBetweenPulls(level: number, betweenPulls: readonly [number, number], now: number, restMs: number): number {
|
|
useCombatStore.getState().removeMob(`native-sustain-anglo-${level}-${betweenPulls[0]}`);
|
|
useCombatStore.getState().clearTarget();
|
|
const healerAtStart = partyByClass(priestIdentity.classId);
|
|
const startMana = healerAtStart.resourcePools.mana ?? 0;
|
|
const startHealth = healerAtStart.health;
|
|
const maxHealth = healerAtStart.maxHealth;
|
|
const cooldownRemainingMsBefore = Object.fromEntries(Object.entries(healerAtStart.cooldowns)
|
|
.filter(([, readyAt]) => readyAt > now)
|
|
.map(([abilityId, readyAt]) => [abilityId, readyAt - now]));
|
|
const startAt = now;
|
|
for (; now - startAt < restMs; now += 100) {
|
|
const combat = useCombatStore.getState();
|
|
combat.tick(0.1, now);
|
|
combat.advanceMobCombat(now);
|
|
advanceDungeonPartyCombat(now);
|
|
}
|
|
const healerAtEnd = partyByClass(priestIdentity.classId);
|
|
const endMana = healerAtEnd.resourcePools.mana ?? 0;
|
|
const cooldownRemainingMsAfter = Object.fromEntries(Object.entries(healerAtEnd.cooldowns)
|
|
.filter(([, readyAt]) => readyAt > now)
|
|
.map(([abilityId, readyAt]) => [abilityId, readyAt - now]));
|
|
rests.push({
|
|
level,
|
|
betweenPulls,
|
|
healerId: healerAtEnd.id,
|
|
restMs,
|
|
startMana,
|
|
endMana,
|
|
recoveredMana: endMana - startMana,
|
|
startHealth,
|
|
endHealth: healerAtEnd.health,
|
|
maxHealth,
|
|
cooldownRemainingMsBefore,
|
|
cooldownRemainingMsAfter,
|
|
elapsedMs: now - startAt,
|
|
});
|
|
return now;
|
|
}
|
|
|
|
describe("native RoM repeated-pull healing sustain", () => {
|
|
it("records owned NPC Priest Mana, healing, overheal, and between-pull recovery at levels 60 and 80", () => {
|
|
for (const level of [60, 80]) {
|
|
setupLevel(level);
|
|
let now = 1_000;
|
|
for (let pull = 1; pull <= RUNS_PER_LEVEL; pull += 1) {
|
|
const result = measurePull(level, pull, now);
|
|
expect(result.report.healerId).not.toBe(PLAYER_AGGRO_ID);
|
|
expect(result.report.healerActions).toBeGreaterThan(0);
|
|
expect(result.report.resolvedManaCostEstimate).toBeGreaterThan(0);
|
|
expect(result.report.startingManaCapacity).toBeGreaterThan(result.report.minimumMana);
|
|
expect(result.report.minimumMana).toBeGreaterThan(0);
|
|
expect(result.report.healingCasts).toBeGreaterThan(0);
|
|
expect(result.report.bossCleared).toBe(true);
|
|
expect(result.report.playerDead).toBe(false);
|
|
expect(result.report.deadNpcClasses).toEqual([]);
|
|
expect(result.report.resolvedHealing).toBeCloseTo(
|
|
result.report.effectiveHealing + result.report.overheal,
|
|
6,
|
|
);
|
|
expect(result.report.overheal).toBeGreaterThanOrEqual(0);
|
|
now = result.endedAt;
|
|
if (pull < RUNS_PER_LEVEL) {
|
|
const restMs = pull === 1 ? 10_000 : 90_000;
|
|
now = restBetweenPulls(level, [pull, pull + 1], now, restMs);
|
|
}
|
|
}
|
|
}
|
|
const fullHealthNativeDirectHeals = reports.flatMap((report) => report.healingCastStarts)
|
|
.filter((cast) => cast.ability === "Heal" && cast.targetHealth !== null
|
|
&& cast.targetMaxHealth !== null && cast.targetHealth >= cast.targetMaxHealth);
|
|
expect(fullHealthNativeDirectHeals).toEqual([]);
|
|
expect(reports.flatMap((report) => report.healingCastStarts)
|
|
.some((cast) => cast.ability === "Heal" && cast.pendingHotTicks > 0
|
|
&& cast.targetHealthFraction !== null && cast.targetHealthFraction <= 0.6)).toBe(true);
|
|
expect(reports).toHaveLength(6);
|
|
expect(rests).toHaveLength(4);
|
|
for (const rest of rests) {
|
|
expect(rest.healerId).toBe(partyByClass(priestIdentity.classId).id);
|
|
expect(rest.recoveredMana).toBeGreaterThan(0);
|
|
expect(rest.endHealth).toBe(rest.startHealth);
|
|
for (const [abilityId, remainingBefore] of Object.entries(rest.cooldownRemainingMsBefore)) {
|
|
const remainingAfter = rest.cooldownRemainingMsAfter[abilityId];
|
|
if (remainingAfter !== undefined) expect(remainingAfter).toBeLessThanOrEqual(remainingBefore);
|
|
}
|
|
}
|
|
}, 180_000);
|
|
});
|