335 lines
19 KiB
TypeScript
335 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { gunzipSync, strFromU8 } from "fflate";
|
|
import {
|
|
createDungeonEncounterSnapshot,
|
|
reduceDungeonEncounter,
|
|
resetDungeonEncounterSnapshot,
|
|
validateDungeonEncounterDefinition,
|
|
} from "./dungeonEncounterRuntime";
|
|
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
|
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
|
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
|
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
|
import {
|
|
WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS,
|
|
withWowClassicEncounterContent,
|
|
} from "./wowClassicEncounterContentCatalog";
|
|
|
|
function decodeBase64(value: string): Uint8Array {
|
|
const decoded = atob(value);
|
|
const bytes = new Uint8Array(decoded.length);
|
|
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
|
return bytes;
|
|
}
|
|
|
|
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
|
readonly definitions: readonly DungeonDefinition[];
|
|
};
|
|
|
|
function adapted(id: string): DungeonDefinition {
|
|
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
|
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
|
return withWowClassicEncounterContent(definition);
|
|
}
|
|
|
|
function classicEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
|
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:classic-instance`);
|
|
if (!encounter) throw new Error(`Missing Classic encounter for ${definition.id}`);
|
|
return encounter;
|
|
}
|
|
|
|
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
|
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
|
if (!value) throw new Error(`Missing spawn group ${id}`);
|
|
return value;
|
|
}
|
|
|
|
function objectByTemplate(definition: DungeonDefinition, templateId: number) {
|
|
const value = definition.serverGameObjects.find((object) => object.source?.templateId === templateId);
|
|
if (!value) throw new Error(`Missing gameobject template ${templateId}`);
|
|
return value;
|
|
}
|
|
|
|
function killGroup(
|
|
encounter: DungeonEncounterDefinition,
|
|
snapshot: DungeonEncounterSnapshot,
|
|
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
|
now: number,
|
|
) {
|
|
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
|
return reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "tick",
|
|
now,
|
|
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
|
});
|
|
}
|
|
|
|
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
|
return definition.staticSpawns.flatMap((spawn) => {
|
|
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
|
return entry === undefined ? [] : [entry];
|
|
});
|
|
}
|
|
|
|
describe("Classic source-backed dungeon encounter content", () => {
|
|
it("pins source reviews, validates references, suppresses event bosses, and is idempotent", () => {
|
|
const cases = [
|
|
["blackfathom-deeps", []],
|
|
["uldaman", [2_748]],
|
|
["razorfen-downs", [7_355, 7_356]],
|
|
["zulfarrak", [7_273]],
|
|
] as const;
|
|
expect(WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(cases.map(([id]) => id));
|
|
for (const review of WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS) {
|
|
expect(review.fidelity).toBe("source-backed-adapted");
|
|
expect(review.sourceFiles.length).toBeGreaterThan(0);
|
|
for (const sourceFile of review.sourceFiles) {
|
|
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
|
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
|
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
|
}
|
|
expect(review.adaptations.length).toBeGreaterThan(0);
|
|
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
for (const [id, suppressedEntries] of cases) {
|
|
const definition = adapted(id);
|
|
const encounter = classicEncounter(definition);
|
|
validateDungeonEncounterDefinition(encounter);
|
|
expect(withWowClassicEncounterContent(definition)).toBe(definition);
|
|
if (suppressedEntries.length > 0) {
|
|
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
|
|
}
|
|
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
|
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
|
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
|
|
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
|
for (const spawn of spawnGroup.spawns) {
|
|
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
|
}
|
|
}
|
|
for (const rule of encounter.rules) {
|
|
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
|
for (const action of rule.actions) {
|
|
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
|
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
if (action.kind === "set-world-object-state") {
|
|
expect(objectIds.has(action.objectId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
if (action.kind === "complete-objective") {
|
|
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
for (const object of definition.serverGameObjects.filter((candidate) => (
|
|
encounter.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
|
))) {
|
|
validateDungeonWorldObject(object);
|
|
expect(object.visual?.hidden).not.toBe(true);
|
|
}
|
|
for (const entry of suppressedEntries) {
|
|
const entityIds = new Set(Object.entries(definition.entities)
|
|
.filter(([, entity]) => entity.combat?.serverEntry === entry)
|
|
.map(([entityId]) => entityId));
|
|
const objective = definition.bosses.find((boss) => definition.encounterSpawnGroups?.some(
|
|
(spawnGroup) => spawnGroup.spawns.some((spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId)),
|
|
));
|
|
expect(objective, `${id}:${entry}`).toMatchObject({ eventOnly: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
it("runs all four Blackfathom fires, opens the physical portal, and persists its checkpoint", () => {
|
|
const definition = adapted("blackfathom-deeps");
|
|
const encounter = classicEncounter(definition);
|
|
const fires = [21_118, 21_119, 21_120, 21_121].map((entry) => objectByTemplate(definition, entry));
|
|
let snapshot = createDungeonEncounterSnapshot(encounter);
|
|
fires.forEach((fire, index) => {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "interact",
|
|
objectId: fire.id,
|
|
now: index * 10,
|
|
}).snapshot;
|
|
const spawnGroup = group(definition, `blackfathom-deeps:fire-${index + 1}-pack`);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
|
|
snapshot = killGroup(encounter, snapshot, spawnGroup, index * 10 + 1).snapshot;
|
|
expect(snapshot.worldObjectStates[fire.id]).toBe("lit");
|
|
});
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100 }).snapshot;
|
|
const portal = objectByTemplate(definition, 21_117);
|
|
expect(snapshot).toMatchObject({ counters: { "completed-fires": 4 }, checkpointId: "blackfathom-fire-event" });
|
|
expect(snapshot.worldObjectStates[portal.id]).toBe("open");
|
|
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 200);
|
|
expect(reset.worldObjectStates[portal.id]).toBe("open");
|
|
expect(fires.every((fire) => reset.worldObjectStates[fire.id] === "lit")).toBe(true);
|
|
expect(reset.activeSpawnGroupIds).toEqual([]);
|
|
});
|
|
|
|
it("opens Uldaman's seal, awakens four keepers with wipe recovery, and source-gates Archaedas", () => {
|
|
const definition = adapted("uldaman");
|
|
const encounter = classicEncounter(definition);
|
|
const keystone = objectByTemplate(definition, 124_371);
|
|
const seal = objectByTemplate(definition, 124_372);
|
|
const keeperAltar = objectByTemplate(definition, 130_511);
|
|
const keeperDoor = objectByTemplate(definition, 124_367);
|
|
const archaedasAltar = objectByTemplate(definition, 133_234);
|
|
const archaedasRoom = objectByTemplate(definition, 141_869);
|
|
const vault = objectByTemplate(definition, 124_369);
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact",
|
|
objectId: keystone.id,
|
|
now: 0,
|
|
}).snapshot;
|
|
expect(snapshot.worldObjectStates[seal.id]).toBe("open");
|
|
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 1 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:stone-keeper-1"), 2).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 3 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["uldaman:stone-keeper-2"]);
|
|
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 4);
|
|
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 1 } });
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
|
|
|
for (let number = 2; number <= 4; number += 1) {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "interact",
|
|
objectId: keeperAltar.id,
|
|
now: number * 10,
|
|
}).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, `uldaman:stone-keeper-${number}`), number * 10 + 1).snapshot;
|
|
}
|
|
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 4 }, checkpointId: "uldaman-stone-keepers" });
|
|
expect(snapshot.worldObjectStates[keeperDoor.id]).toBe("open");
|
|
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 50 }).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "archaedas-active", activeSpawnGroupIds: ["uldaman:archaedas"] });
|
|
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("closed");
|
|
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 51);
|
|
expect(snapshot).toMatchObject({ phase: "active", activeSpawnGroupIds: [] });
|
|
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 52 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:archaedas"), 53).snapshot;
|
|
const objective = definition.bosses.find((boss) => boss.name === "Archaedas")!;
|
|
expect(snapshot.completedObjectiveIds).toContain(objective.id);
|
|
expect(snapshot.worldObjectStates[vault.id]).toBe("open");
|
|
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
|
});
|
|
|
|
it("recovers Razorfen's gong and runs the full timed Belnistrasz event", () => {
|
|
const definition = adapted("razorfen-downs");
|
|
const encounter = classicEncounter(definition);
|
|
const gong = objectByTemplate(definition, 148_917);
|
|
const gongGroupIds = [
|
|
"razorfen-downs:gong-fiends",
|
|
"razorfen-downs:gong-reavers",
|
|
"razorfen-downs:tutenkash",
|
|
];
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact",
|
|
objectId: gong.id,
|
|
now: 0,
|
|
}).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([gongGroupIds[0]]);
|
|
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 1 }).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "idle", counters: { "gong-round": 0 } });
|
|
for (let index = 0; index < gongGroupIds.length; index += 1) {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "interact",
|
|
objectId: gong.id,
|
|
now: 10 + index * 2,
|
|
}).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, gongGroupIds[index]), 11 + index * 2).snapshot;
|
|
}
|
|
const tutenkash = definition.bosses.find((boss) => boss.name === "Tuten'kash")!;
|
|
expect(snapshot).toMatchObject({ counters: { "gong-round": 3 } });
|
|
expect(snapshot.completedObjectiveIds).toContain(tutenkash.id);
|
|
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
|
|
|
const belnistrasz = definition.serverGameObjects.find((object) => object.id === "razorfen-downs:belnistrasz")!;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: belnistrasz.id, now: 100 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 8_100 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain("razorfen-downs:idol-wave-1");
|
|
const wiped = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 8_101);
|
|
const recovered = reduceDungeonEncounter(encounter, wiped, { kind: "tick", now: 8_101 }).snapshot;
|
|
expect(recovered).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
|
expect(recovered.worldObjectStates[belnistrasz.id]).toBe("ready");
|
|
|
|
snapshot = reduceDungeonEncounter(encounter, recovered, { kind: "interact", objectId: belnistrasz.id, now: 9_000 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 17_000 }).snapshot;
|
|
for (let wave = 2; wave <= 8; wave += 1) {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "tick",
|
|
now: snapshot.phaseStartedAt + 35_000,
|
|
}).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain(`razorfen-downs:idol-wave-${wave}`);
|
|
}
|
|
const plaguemawGroup = group(definition, "razorfen-downs:idol-wave-8");
|
|
snapshot = killGroup(encounter, snapshot, plaguemawGroup, snapshot.phaseStartedAt + 1).snapshot;
|
|
const plaguemaw = definition.bosses.find((boss) => boss.name === "Plaguemaw the Rotting")!;
|
|
expect(snapshot.completedObjectiveIds).toContain(plaguemaw.id);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
|
kind: "tick",
|
|
now: snapshot.phaseStartedAt + 55_000,
|
|
}).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
|
const fires = definition.serverGameObjects.filter((object) => [151_951, 151_952, 151_973].includes(object.source?.templateId ?? -1));
|
|
expect(fires.every((fire) => snapshot.worldObjectStates[fire.id] === "extinguished")).toBe(true);
|
|
const resetComplete = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", snapshot.phaseStartedAt + 1);
|
|
const resumedComplete = reduceDungeonEncounter(encounter, resetComplete, { kind: "tick", now: snapshot.phaseStartedAt + 1 }).snapshot;
|
|
expect(resumedComplete).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
|
});
|
|
|
|
it("runs Zul'Farrak's exact 23/24/7 pyramid and independently recovers the Gahz'rilla gong", () => {
|
|
const definition = adapted("zulfarrak");
|
|
const encounter = classicEncounter(definition);
|
|
const groups = [1, 2, 3].map((wave) => group(definition, `zulfarrak:pyramid-wave-${wave}`));
|
|
expect(groups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([23, 24, 7]);
|
|
const cages = definition.serverGameObjects.filter((object) => object.name === "Troll Cage");
|
|
const endDoor = objectByTemplate(definition, 146_084);
|
|
const weegli = definition.serverGameObjects.find((object) => object.id === "zulfarrak:weegli-gate-charge")!;
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact",
|
|
objectId: cages[0].id,
|
|
now: 0,
|
|
}).snapshot;
|
|
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "open")).toBe(true);
|
|
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
|
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
|
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "closed")).toBe(true);
|
|
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: cages[2].id, now: 2 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_002 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([groups[0].id]);
|
|
snapshot = killGroup(encounter, snapshot, groups[0], 5_003).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 15_003 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([groups[1].id]);
|
|
snapshot = killGroup(encounter, snapshot, groups[1], 15_004).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([groups[2].id]);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_004 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, groups[2], 20_005).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "pyramid-aftermath", activeSpawnGroupIds: [] });
|
|
expect(snapshot.worldObjectStates[weegli.id]).toBe("ready");
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: weegli.id, now: 20_006 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 24_006 }).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "pyramid-complete", flags: { "pyramid-complete": true }, checkpointId: "zulfarrak-pyramid" });
|
|
expect(snapshot.worldObjectStates[endDoor.id]).toBe("open");
|
|
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", 24_007);
|
|
expect(reset.worldObjectStates[endDoor.id]).toBe("open");
|
|
expect(cages.every((cage) => reset.worldObjectStates[cage.id] === "open")).toBe(true);
|
|
|
|
const gong = objectByTemplate(definition, 141_832);
|
|
snapshot = reduceDungeonEncounter(encounter, reset, { kind: "interact", objectId: gong.id, now: 25_000 }).snapshot;
|
|
const gahzrillaGroup = group(definition, "zulfarrak:gahzrilla");
|
|
expect(snapshot.activeSpawnGroupIds).toEqual([gahzrillaGroup.id]);
|
|
const gongWipe = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 25_001);
|
|
expect(gongWipe.worldObjectStates[gong.id]).toBe("ready");
|
|
snapshot = reduceDungeonEncounter(encounter, gongWipe, { kind: "interact", objectId: gong.id, now: 25_002 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, gahzrillaGroup, 25_003).snapshot;
|
|
const gahzrilla = definition.bosses.find((boss) => boss.name === "Ghaz'rilla")!;
|
|
expect(snapshot.completedObjectiveIds).toContain(gahzrilla.id);
|
|
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
|
});
|
|
});
|