407 lines
17 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
});
|
|
});
|