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,409 @@
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user