Files
healer-man/src/game/wowWrathEncounterContentCatalog.test.ts
T
2026-09-09 11:21:33 -04:00

407 lines
17 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import type { DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import {
WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS,
withWowWrathEncounterContent,
} from "./wowWrathEncounterContentCatalog";
const TARGETS = [
"halls-of-reflection",
"the-oculus",
"trial-of-the-champion",
] as const;
const START_OBJECT_BY_DUNGEON = Object.freeze({
"halls-of-reflection": "halls-of-reflection:wave-start",
"the-oculus": "the-oculus:expedition-start",
"trial-of-the-champion": "trial-of-the-champion:announcer-start",
});
function adaptedDefinition(id: typeof TARGETS[number]): DungeonDefinition {
const source = DUNGEON_REGISTRY[id];
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return withWowWrathEncounterContent(source);
}
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-instance`);
if (!encounter) throw new Error(`Missing Wrath encounter for ${definition.id}`);
return encounter;
}
function activeSpawnIds(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
): readonly string[] {
const active = new Set(snapshot.activeSpawnGroupIds);
return (definition.encounterSpawnGroups ?? [])
.filter((group) => active.has(group.id))
.flatMap((group) => group.spawns.map((spawn) => spawn.id));
}
function advanceOneStep(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
): DungeonEncounterSnapshot {
if (snapshot.phase === "ready") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: START_OBJECT_BY_DUNGEON[definition.id as keyof typeof START_OBJECT_BY_DUNGEON],
now,
}).snapshot;
}
if (snapshot.phase === "escape-ready") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: "halls-of-reflection:escape-start",
now,
}).snapshot;
}
const spawnIds = activeSpawnIds(definition, snapshot);
if (spawnIds.length) {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: spawnIds, deadEntityIds: spawnIds },
}).snapshot;
}
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: Math.max(now, snapshot.phaseStartedAt + 1_000_000),
facts: { registeredEntityIds: [], deadEntityIds: [] },
}).snapshot;
}
function finishEncounter(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
initial = createDungeonEncounterSnapshot(encounter),
): { readonly snapshot: DungeonEncounterSnapshot; readonly phases: readonly string[] } {
let snapshot = initial;
const phases = [snapshot.phase];
let now = snapshot.phaseStartedAt + 1;
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
const previous = snapshot;
snapshot = advanceOneStep(definition, encounter, snapshot, now);
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
if (snapshot === previous) {
throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
}
}
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
return { snapshot, phases };
}
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
let cursor = -1;
for (const phase of expected) {
cursor = phases.indexOf(phase, cursor + 1);
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
}
}
describe("source-reviewed Wrath instance adapters", () => {
it("keeps immutable file hashes and source anchors beside every adapter", async () => {
expect(WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.length).toBeGreaterThanOrEqual(3);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const source of review.sourceFiles) {
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(createHash("sha256").update(await readFile(source.path)).digest("hex"), source.path)
.toBe(source.sha256);
}
}
});
it("validates definitions, group references, safety timeouts, and reviewed blocker geometry", () => {
for (const id of TARGETS) {
const source = DUNGEON_REGISTRY[id];
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
validateDungeonEncounterDefinition(encounter);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
for (const rule of encounter.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
}
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds.has(action.spawnGroupId), `${id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
expect(objectIds.has(action.objectId), `${id}:${action.id}`).toBe(true);
}
}
}
const reviewedBlockers = definition.serverGameObjects.filter((object) => (
object.id.includes(":reviewed-") || object.id.includes(":ice-wall-")
));
expect(reviewedBlockers.length).toBeGreaterThan(0);
for (const blocker of reviewedBlockers) {
const closed = blocker.states.find((state) => state.id === "closed");
const open = blocker.states.find((state) => state.id === "open");
expect(closed).toMatchObject({ visible: true, blocksMovement: true });
expect(open).toMatchObject({ blocksMovement: false });
}
const originalImportedObjects = definition.serverGameObjects.filter((object) => (
object.source?.spawnId !== undefined && !object.id.includes(":reviewed-")
));
const originalImportedObjectIds = new Set(originalImportedObjects.map((object) => object.id));
for (const object of source.serverGameObjects.filter((candidate) => (
candidate.source?.spawnId !== undefined && !candidate.id.includes(":reviewed-")
))) {
expect(originalImportedObjectIds.has(object.id), `${id} dropped imported object ${object.id}`).toBe(true);
}
expect(originalImportedObjects.every((object) => object.visual?.hidden === true)).toBe(true);
expect(originalImportedObjects.every((object) => object.states.every((state) => !state.blocksMovement))).toBe(true);
expect(withWowWrathEncounterContent(definition)).toBe(definition);
}
});
it("removes every unsafe static event boss and preserves exact sourced stage cardinality", () => {
const expectations = {
"halls-of-reflection": [38_112, 38_113],
"the-oculus": [27_447, 27_654, 27_655, 27_656],
"trial-of-the-champion": [30_188, 35_451, 35_569, 35_571, 35_572],
} as const;
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const staticEntries = definition.staticSpawns.map((spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry);
for (const entry of expectations[id]) expect(staticEntries).not.toContain(entry);
expect(definition.bosses.every((boss) => boss.eventOnly)).toBe(true);
const staticSpawnIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
for (const boss of definition.bosses) {
expect(staticSpawnIds.has(boss.id), `${id} left event boss ${boss.name} statically spawned`).toBe(false);
}
}
const halls = adaptedDefinition("halls-of-reflection");
expect([1, 2, 3, 4, 6, 7, 8, 9].map((wave) => (
halls.encounterSpawnGroups?.find((group) => group.id === `halls-of-reflection:wave-${String(wave).padStart(2, "0")}`)?.spawns.length
))).toEqual([3, 3, 4, 4, 5, 5, 5, 5]);
const oculus = adaptedDefinition("the-oculus");
expect(oculus.encounterSpawnGroups?.find((group) => group.id === "the-oculus:centrifuge-constructs")?.spawns).toHaveLength(10);
expect([1, 2, 3].map((wave) => (
oculus.encounterSpawnGroups?.find((group) => group.id === `the-oculus:urom-menagerie-${wave}`)?.spawns.length
))).toEqual([4, 4, 4]);
const trial = adaptedDefinition("trial-of-the-champion");
expect([1, 2, 3].map((wave) => (
trial.encounterSpawnGroups?.find((group) => group.id.startsWith(`trial-of-the-champion:minions-${wave}-`))?.spawns.length
))).toEqual([3, 3, 3]);
expect(trial.encounterSpawnGroups?.find((group) => group.id === "trial-of-the-champion:argent-soldiers")?.spawns).toHaveLength(9);
expect([1, 2, 3].map((phase) => (
trial.encounterSpawnGroups?.find((group) => group.id === `trial-of-the-champion:black-knight-phase-${phase}`)?.spawns.length
))).toEqual([1, 1, 1]);
});
it("gives every Trial event boss its pinned phase-specific combat schedule", () => {
const trial = adaptedDefinition("trial-of-the-champion");
const trialReview = WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.find(
(review) => review.dungeonId === "trial-of-the-champion",
);
expect(trialReview?.adaptations.join(" ")).toContain(
"six-second, one-charge +100% critical chance modifier",
);
const entity = (id: string) => trial.entities[id];
const specials = (id: string) => entity(id).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
for (const id of [
"wrath-event-mounted-mokra",
"wrath-event-mounted-eressea",
"wrath-event-mounted-runok",
]) {
expect(specials(id).map((attack) => attack.id)).toEqual([
"mounted-charge-63010",
"shield-breaker-68504",
"thrust-68505",
]);
expect(specials(id).map((attack) => [attack.initialCooldownMs, attack.repeatCooldownMs])).toEqual([
[[2_500, 4_000], [4_500, 6_000]],
[[5_000, 8_000], [6_000, 8_000]],
[[3_000, 5_000], [3_000, 5_000]],
]);
}
expect(specials("wrath-event-eadric").map((attack) => attack.spellId)).toEqual([
66_865,
66_935,
66_867,
]);
expect(specials("wrath-event-eadric").find((attack) => attack.id === "vengeance-66865")?.effect)
.toEqual({
kind: "aura",
aura: "vengeance",
durationMs: 0,
meleeAutoCriticalProc: {
chance: 1,
triggeredAura: "vengeance-next-melee-critical-66889",
durationMs: 6_000,
criticalChancePercent: 100,
charges: 1,
},
});
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935")?.repeatCooldownMs)
.toEqual([16_000, 16_000]);
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935"))
.toMatchObject({
school: "holy",
damageMultiplier: 0.9,
effect: { kind: "control", mechanic: "confuse", durationMs: 2_000, damageMultiplier: 0.9 },
});
expect(specials("wrath-event-eadric").find((attack) => attack.id === "hammer-of-justice-and-righteous-66863-66867"))
.toMatchObject({
name: "Hammer of Justice + Hammer of the Righteous",
target: "random-party",
initialCooldownMs: [25_000, 25_000],
repeatCooldownMs: [25_000, 25_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000, damageMultiplier: 1.3 },
});
expect(specials("wrath-event-eadric").some((attack) => attack.id === "hammer-of-justice-66863")).toBe(false);
for (const id of ["wrath-event-mounted-eressea", "wrath-event-black-knight-phase-3"]) {
expect(entity(id).combat?.attacks[0]).toMatchObject({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
school: "physical",
animation: "attack",
range: 4.5,
});
}
expect(specials("wrath-event-black-knight-phase-1").map((attack) => attack.id)).toEqual([
"plague-strike-67724",
"icy-touch-67718",
"death-respite-67745",
"obliterate-67725",
]);
expect(specials("wrath-event-black-knight-phase-1")[0]).toMatchObject({
id: "plague-strike-67724",
school: "physical",
});
expect(specials("wrath-event-black-knight-phase-2").map((attack) => attack.id)).toEqual([
"plague-strike-67724",
"icy-touch-67718",
"obliterate-67725",
"desecration-67778",
]);
expect(specials("wrath-event-black-knight-phase-2")[0]).toMatchObject({
id: "plague-strike-67724",
school: "physical",
});
expect(specials("wrath-event-black-knight-phase-3").map((attack) => ({
id: attack.id,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "death-bite-67808", initial: [2_000, 2_000], repeat: [2_000, 4_000] },
{ id: "marked-for-death-67823", initial: [1_000, 1_000], repeat: [9_000, 9_000] },
]);
expect(specials("wrath-event-black-knight-phase-3")[1].effect)
.toEqual({ kind: "target-aura", aura: "marked-for-death", durationMs: 9_000 });
});
it("reduces every complete source-ordered lifecycle without renderer state", () => {
const halls = adaptedDefinition("halls-of-reflection");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
expectOrderedPhases(hallsRun.phases, [
"wave-01-active", "wave-05-active", "wave-06-source-pause", "wave-06-active",
"wave-10-active", "general-active", "escape-ready", "escape-1-active", "escape-4-active", "complete",
]);
const oculus = adaptedDefinition("the-oculus");
const oculusRun = finishEncounter(oculus, adaptedEncounter(oculus));
expectOrderedPhases(oculusRun.phases, [
"drakos-active", "constructs-active", "varos-active", "urom-menagerie-1-active",
"urom-menagerie-3-active", "urom-active", "eregos-active", "complete",
]);
const trial = adaptedDefinition("trial-of-the-champion");
const trialRun = finishEncounter(trial, adaptedEncounter(trial));
expectOrderedPhases(trialRun.phases, [
"champion-minions-1-active", "champion-minions-3-active", "mounted-champions-active",
"foot-champions-active", "argent-soldiers-active", "eadric-active",
"black-knight-phase-1-active", "black-knight-phase-3-active", "complete",
]);
for (const [definition, result] of [
[halls, hallsRun],
[oculus, oculusRun],
[trial, trialRun],
] as const) {
expect(result.snapshot.flags["dungeon-complete"]).toBe(true);
expect(result.snapshot.completedObjectiveIds).toEqual(
expect.arrayContaining(definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id)),
);
expect(result.snapshot.activeSpawnGroupIds).toEqual([]);
}
});
it("recovers from every non-final persistent checkpoint without a soft lock", () => {
const expectedCheckpointCounts = {
"halls-of-reflection": 3,
"the-oculus": 3,
"trial-of-the-champion": 2,
} as const;
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
let snapshot = createDungeonEncounterSnapshot(encounter);
let now = 1;
const checkpointSnapshots: DungeonEncounterSnapshot[] = [];
const seenCheckpoints = new Set<string>();
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
snapshot = advanceOneStep(definition, encounter, snapshot, now);
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
if (snapshot.checkpointId && !seenCheckpoints.has(snapshot.checkpointId) && snapshot.phase !== "complete") {
seenCheckpoints.add(snapshot.checkpointId);
checkpointSnapshots.push(snapshot);
}
}
expect(checkpointSnapshots, id).toHaveLength(expectedCheckpointCounts[id]);
for (const checkpoint of checkpointSnapshots) {
const reset = resetDungeonEncounterSnapshot(encounter, checkpoint, "boss-wipe", now + 1);
expect(reset.phase).toBe("resume");
expect(reset.activeSpawnGroupIds).toEqual([]);
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
const resumed = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: now + 2 }).snapshot;
expect(resumed.phase).not.toBe("resume");
expect(
resumed.activeSpawnGroupIds.length > 0 || resumed.phase === "escape-ready",
`${id}:${checkpoint.checkpointId}`,
).toBe(true);
expect(finishEncounter(definition, encounter, resumed).snapshot.flags["dungeon-complete"]).toBe(true);
}
}
});
});