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>; readonly healingCasts: number; readonly healingCastsByAbility: Readonly>; readonly healingPackets: number; readonly rawAttemptedHealing: number; readonly resolvedHealing: number; readonly effectiveHealing: number; readonly overheal: number; readonly overhealByAbility: Readonly>; 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>; readonly cooldownRemainingMsAfter: Readonly>; 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 = {}; const healingCastsByAbility: Record = {}; const overhealByAbility: Record = {}; const healingCastStarts: NonNullable[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); });