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,268 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { contentCategoryForClass } from "../app/characterCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatEvents } from "./combatEvents";
|
||||
import { dungeonEnemyScaling } from "./dungeonBossScaling";
|
||||
import { finalDungeonBoss } from "./dungeonRunStore";
|
||||
import { dungeonEnemyHealthBaseline, scaledDungeonEnemyLevel } from "./dungeonLevelScaling";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { advanceDungeonPartyCombat, partyCombatRangeBand, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRuntimeTargetVisibility } from "./partyRuntimeRegistry";
|
||||
import { abilitiesForCharacter } from "./abilityCatalog";
|
||||
import { ROM_FIVE_IDENTITIES } from "./romFiveIdentities";
|
||||
|
||||
const LEVELS = [1, 10, 30, 60, 79, 80] as const;
|
||||
const RUN_LIMIT_MS = 120_000;
|
||||
const dungeon = requireDungeonDefinition("barren-caves");
|
||||
const trashSpawn = dungeon.staticSpawns.find((spawn) => dungeon.entities[spawn.entityId]?.kind === "mob")!;
|
||||
const trashEntity = dungeon.entities[trashSpawn.entityId];
|
||||
const anglo = finalDungeonBoss(dungeon)!;
|
||||
const angloSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === anglo.id)!;
|
||||
const angloEntity = dungeon.entities[angloSpawn.entityId];
|
||||
|
||||
interface HealthSample {
|
||||
readonly identity: string;
|
||||
readonly classId: string;
|
||||
readonly level: number;
|
||||
readonly runLimitMs: number;
|
||||
readonly target: "trash" | "Anglo";
|
||||
readonly mobLevel: number;
|
||||
readonly maxHealth: number;
|
||||
readonly remainingHealth: number;
|
||||
readonly mobDead: boolean;
|
||||
readonly mobAttackRevision: number;
|
||||
readonly playerHealth: number;
|
||||
readonly defeatedCompanions: readonly string[];
|
||||
readonly secondsFromFirstPartyHit: number | null;
|
||||
readonly firstPartyHitAtMs: number | null;
|
||||
readonly incomingDamage: number;
|
||||
readonly healing: number;
|
||||
readonly partyClassIds: readonly string[];
|
||||
readonly partyRoles: readonly string[];
|
||||
}
|
||||
|
||||
const samples: HealthSample[] = [];
|
||||
|
||||
afterAll(() => {
|
||||
if (process.env.ROM_FIVE_NATIVE_DUNGEON_HEALTH_REPORT !== "1") return;
|
||||
const directory = resolve("artifacts/rom-five-native-dungeon-health");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, "report.json"), `${JSON.stringify({
|
||||
diagnostic: "Native RoM identity cohort; companion damage is simulated, with player healing when the player is the Priest.",
|
||||
levels: LEVELS,
|
||||
runLimitMs: RUN_LIMIT_MS,
|
||||
samples,
|
||||
}, null, 2)}\n`);
|
||||
});
|
||||
|
||||
function initializeNativeIdentityParty(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number, seed: string) {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useGameStore.getState().activateDungeon("barren-caves", "normal", { gameMode: "dungeon", bossDifficulty: 1 });
|
||||
|
||||
const secondaryClassId = level >= 10 ? activeIdentity.secondaryClassId : null;
|
||||
const combat = useCombatStore.getState();
|
||||
combat.initializeCharacter({
|
||||
classId: activeIdentity.classId,
|
||||
statRuleset: "rom",
|
||||
raceId: activeIdentity.raceId,
|
||||
secondaryClassId,
|
||||
role: activeIdentity.role,
|
||||
level,
|
||||
});
|
||||
combat.setPlayerPosition([-18, 0, 0]);
|
||||
|
||||
usePartyStore.getState().initializeParty(seed, level, useCombatStore.getState().health,
|
||||
activeIdentity.role, 5, "rom");
|
||||
const companions = ROM_FIVE_IDENTITIES
|
||||
.filter((identity) => identity.classId !== activeIdentity.classId)
|
||||
.map((identity, index) => createConfiguredPartyMember({
|
||||
name: identity.name,
|
||||
classId: identity.classId,
|
||||
statRuleset: "rom",
|
||||
secondaryClassId: level >= 10 ? identity.secondaryClassId : null,
|
||||
raceId: identity.raceId,
|
||||
specialization: "Primary",
|
||||
talentTreeId: "",
|
||||
role: identity.role,
|
||||
}, level, index, seed));
|
||||
usePartyStore.setState({ members: companions });
|
||||
combat.synchronizeActors();
|
||||
return companions;
|
||||
}
|
||||
|
||||
function measurePartyPull(activeIdentity: (typeof ROM_FIVE_IDENTITIES)[number], level: number,
|
||||
target: "trash" | "Anglo"): HealthSample {
|
||||
const entity = target === "Anglo" ? angloEntity : trashEntity;
|
||||
const spawn = target === "Anglo" ? angloSpawn : trashSpawn;
|
||||
const boss = entity.kind === "boss";
|
||||
const runLimitMs = target === "Anglo" && level === 1 ? 240_000 : RUN_LIMIT_MS;
|
||||
const seed = `rom-five-native:${activeIdentity.classId}:${level}:${target}`;
|
||||
const companions = initializeNativeIdentityParty(activeIdentity, level, seed);
|
||||
const scaling = dungeonEnemyScaling(dungeon, entity, spawn.id, level, "normal", 1,
|
||||
contentCategoryForClass(activeIdentity.classId));
|
||||
const combat = useCombatStore.getState();
|
||||
combat.registerMob(spawn.id, {
|
||||
name: entity.name,
|
||||
serverEntry: entity.combat?.serverEntry,
|
||||
boss,
|
||||
...scaling,
|
||||
attacks: entity.combat?.attacks,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(spawn.id, [0, 0, 0]);
|
||||
for (const member of companions) {
|
||||
registerPartyRuntimePosition(member.id, [-partyCombatRangeBand(member).preferred, 0, 0]);
|
||||
updatePartyRuntimeTargetVisibility(member.id, spawn.id, true);
|
||||
}
|
||||
// This opens the pull before event collection, matching the existing RoM durability diagnostic.
|
||||
combat.damageMob(spawn.id, 1, 0);
|
||||
|
||||
let firstPartyHitAtMs: number | null = null;
|
||||
let diedAtMs: number | null = null;
|
||||
let incomingDamage = 0;
|
||||
let healing = 0;
|
||||
const stop = subscribeCombatEvents((event) => {
|
||||
if (event.targetActorId === spawn.id && event.kind === "damage"
|
||||
&& event.effectiveAmount > 0 && event.sourceActorId !== "player") {
|
||||
firstPartyHitAtMs ??= event.occurredAt;
|
||||
}
|
||||
if (event.sourceActorId === spawn.id && event.kind === "damage") incomingDamage += event.effectiveAmount;
|
||||
if (event.kind === "healing") healing += event.effectiveAmount;
|
||||
});
|
||||
try {
|
||||
const priestHeal = activeIdentity.classId === "rom-priest"
|
||||
? abilitiesForCharacter(activeIdentity.classId, level >= 10 ? activeIdentity.secondaryClassId : null)
|
||||
.find((ability) => ability.target === "friendly"
|
||||
&& ability.effects.some((effect) => effect.kind === "heal")
|
||||
&& (ability.unlockLevel ?? 1) <= level)
|
||||
: undefined;
|
||||
for (let now = 100; now <= runLimitMs; now += 100) {
|
||||
combat.tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
combat.advanceMobCombat(now);
|
||||
if (priestHeal && !useCombatStore.getState().activeCast) {
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
|
||||
if (tank && tank.health < tank.maxHealth * 0.65) {
|
||||
usePartyStore.getState().selectMember(tank.id);
|
||||
combat.castAbility(priestHeal.id, undefined, now);
|
||||
}
|
||||
}
|
||||
const mob = useCombatStore.getState().mobs[spawn.id];
|
||||
if (mob.dead) {
|
||||
diedAtMs = mob.diedAt ?? now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
|
||||
const mob = useCombatStore.getState().mobs[spawn.id];
|
||||
const combatAfterPull = useCombatStore.getState();
|
||||
const companionsAfterPull = usePartyStore.getState().members;
|
||||
return {
|
||||
identity: activeIdentity.name,
|
||||
classId: activeIdentity.classId,
|
||||
level,
|
||||
runLimitMs,
|
||||
target,
|
||||
mobLevel: mob.level,
|
||||
maxHealth: mob.maxHealth,
|
||||
remainingHealth: mob.health,
|
||||
mobDead: mob.dead,
|
||||
mobAttackRevision: mob.attackRevision,
|
||||
playerHealth: combatAfterPull.health,
|
||||
defeatedCompanions: companionsAfterPull.filter((member) => member.health <= 0).map((member) => member.classId),
|
||||
secondsFromFirstPartyHit: diedAtMs === null || firstPartyHitAtMs === null
|
||||
? null
|
||||
: (diedAtMs - firstPartyHitAtMs) / 1_000,
|
||||
firstPartyHitAtMs,
|
||||
incomingDamage,
|
||||
healing,
|
||||
partyClassIds: [activeIdentity.classId, ...companions.map((member) => member.classId)],
|
||||
partyRoles: [activeIdentity.role, ...companions.map((member) => member.role)],
|
||||
};
|
||||
}
|
||||
|
||||
describe("native RoM five identity dungeon health diagnostic", () => {
|
||||
it("keeps the player-origin RoM curve when each canonical identity enters actual WoW and RoM trash", () => {
|
||||
const wowDungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
const wowTrashSpawn = wowDungeon.staticSpawns.find((spawn) => wowDungeon.entities[spawn.entityId]?.kind === "mob")!;
|
||||
const wowTrash = wowDungeon.entities[wowTrashSpawn.entityId];
|
||||
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
expect(contentCategoryForClass(identity.classId)).toBe("rom");
|
||||
for (const level of LEVELS) {
|
||||
const expected = dungeonEnemyHealthBaseline(level, "rom");
|
||||
const romActual = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id, level, "normal", 1,
|
||||
contentCategoryForClass(identity.classId));
|
||||
const wowActual = dungeonEnemyScaling(wowDungeon, wowTrash, wowTrashSpawn.id, level, "normal", 1,
|
||||
contentCategoryForClass(identity.classId));
|
||||
expect(romActual.maxHealth).toBe(expected * (trashEntity.combat?.healthMultiplier ?? 1));
|
||||
expect(wowActual.maxHealth).toBe(expected * (wowTrash.combat?.healthMultiplier ?? 1));
|
||||
expect(romActual.level).toBe(level);
|
||||
expect(wowActual.level).toBe(level);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps RoM origin scaling for a WoW-ruleset RoM player with WoW party members", () => {
|
||||
const player = ROM_FIVE_IDENTITIES.find((identity) => identity.classId === "rom-priest")!;
|
||||
const level = 30;
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: player.classId,
|
||||
statRuleset: "wow",
|
||||
raceId: player.raceId,
|
||||
level,
|
||||
});
|
||||
usePartyStore.getState().initializeParty("rom-origin-mixed-party", level,
|
||||
useCombatStore.getState().health, "healer", 5, "wow");
|
||||
|
||||
const source = contentCategoryForClass(useCombatStore.getState().classId);
|
||||
const mixedParty = usePartyStore.getState();
|
||||
expect(useCombatStore.getState().statRuleset).toBe("wow");
|
||||
expect(source).toBe("rom");
|
||||
expect(mixedParty.categoryId).toBe("wow");
|
||||
expect(mixedParty.members.some((member) => !member.classId.startsWith("rom-"))).toBe(true);
|
||||
const selectedDungeon = requireDungeonDefinition("stormwind-stockade");
|
||||
const spawn = selectedDungeon.staticSpawns
|
||||
.find((entry) => selectedDungeon.entities[entry.entityId]?.kind === "mob")!;
|
||||
const entity = selectedDungeon.entities[spawn.entityId];
|
||||
expect(dungeonEnemyScaling(selectedDungeon, entity, spawn.id, level, "normal", 1, source).maxHealth)
|
||||
.toBe(dungeonEnemyHealthBaseline(level, "rom") * (entity.combat?.healthMultiplier ?? 1));
|
||||
});
|
||||
|
||||
it("reports campaign health and observed party damage against real Barren Caves spawns", () => {
|
||||
expect(trashEntity.kind).toBe("mob");
|
||||
expect(angloEntity.name).toBe("Anglo");
|
||||
expect(angloEntity.kind).toBe("boss");
|
||||
|
||||
for (const identity of ROM_FIVE_IDENTITIES) {
|
||||
for (const level of LEVELS) {
|
||||
const trash = measurePartyPull(identity, level, "trash");
|
||||
const boss = measurePartyPull(identity, level, "Anglo");
|
||||
samples.push(trash, boss);
|
||||
|
||||
const expectedTrashHealth = dungeonEnemyScaling(dungeon, trashEntity, trashSpawn.id,
|
||||
level, "normal", 1, "rom").maxHealth;
|
||||
const expectedAngloHealth = dungeonEnemyScaling(dungeon, angloEntity, angloSpawn.id,
|
||||
level, "normal", 1, "rom").maxHealth;
|
||||
expect(trash.maxHealth).toBe(Math.round(expectedTrashHealth));
|
||||
expect(boss.maxHealth).toBe(Math.round(expectedAngloHealth));
|
||||
expect(trash.mobLevel).toBe(scaledDungeonEnemyLevel(level, false));
|
||||
expect(boss.mobLevel).toBe(scaledDungeonEnemyLevel(level, true));
|
||||
expect(trash.partyClassIds).toHaveLength(5);
|
||||
expect(new Set(trash.partyClassIds)).toEqual(new Set(ROM_FIVE_IDENTITIES.map((entry) => entry.classId)));
|
||||
expect(boss.partyClassIds).toEqual(trash.partyClassIds);
|
||||
expect(boss.partyRoles).toEqual(trash.partyRoles);
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
Reference in new issue
Block a user