353 lines
19 KiB
TypeScript
353 lines
19 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { gunzipSync, strFromU8 } from "fflate";
|
|
import {
|
|
createDungeonEncounterSnapshot,
|
|
reduceDungeonEncounter,
|
|
resetDungeonEncounterSnapshot,
|
|
validateDungeonEncounterDefinition,
|
|
} from "./dungeonEncounterRuntime";
|
|
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
|
|
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
|
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
|
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
|
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
|
import {
|
|
WOW_ENCOUNTER_CONTENT_REVIEWS,
|
|
WOW_ENCOUNTER_NEXT_BATCH,
|
|
withWowEncounterContent,
|
|
} from "./wowEncounterContentCatalog";
|
|
|
|
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 withWowEncounterContent(definition);
|
|
}
|
|
|
|
function wowEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
|
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:wow-instance`);
|
|
if (!encounter) throw new Error(`Missing WoW 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 staticEntries(definition: DungeonDefinition): readonly number[] {
|
|
return definition.staticSpawns.flatMap((spawn) => {
|
|
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
|
return entry === undefined ? [] : [entry];
|
|
});
|
|
}
|
|
|
|
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 },
|
|
});
|
|
}
|
|
|
|
describe("source-backed WoW encounter content", () => {
|
|
it("removes every adapted event boss from static population and validates its encounter", () => {
|
|
const cases = [
|
|
["black-morass", [17_879, 17_880, 17_881]],
|
|
["violet-hold", [29_315, 29_316, 31_134]],
|
|
["culling-of-stratholme", [26_529, 26_530, 26_532, 26_533]],
|
|
["escape-from-durnholde", [17_848, 17_862, 18_096]],
|
|
["arcatraz", [20_912]],
|
|
["blackrock-spire", [10_264]],
|
|
] as const;
|
|
for (const [id, entries] of cases) {
|
|
const definition = adapted(id);
|
|
const encounter = wowEncounter(definition);
|
|
validateDungeonEncounterDefinition(encounter);
|
|
expect(encounter.source?.fidelity).toBe("source-backed-adapted");
|
|
expect(encounter.source?.scope).toBe("instance-orchestration");
|
|
const remainingStaticEntries = staticEntries(definition);
|
|
for (const entry of entries) {
|
|
expect(remainingStaticEntries, `${id} static entry ${entry}`).not.toContain(entry);
|
|
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 ${entry}`).toMatchObject({ eventOnly: true });
|
|
}
|
|
expect(withWowEncounterContent(definition)).toBe(definition);
|
|
}
|
|
expect(staticEntries(adapted("violet-hold"))).not.toContain(29_395);
|
|
expect(WOW_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(expect.arrayContaining(cases.map(([id]) => id)));
|
|
expect(WOW_ENCOUNTER_NEXT_BATCH).toEqual([]);
|
|
});
|
|
|
|
it("resumes Black Morass at rift 7 and 13 after run-persistent boss checkpoints", () => {
|
|
const definition = adapted("black-morass");
|
|
const encounter = wowEncounter(definition);
|
|
let snapshot = reduceDungeonEncounter(
|
|
encounter,
|
|
createDungeonEncounterSnapshot(encounter),
|
|
{ kind: "interact", objectId: "black-morass:medivh-defense-start", now: 0 },
|
|
).snapshot;
|
|
let now = 0;
|
|
|
|
for (let rift = 1; rift <= 6; rift += 1) {
|
|
now = snapshot.phaseStartedAt + (rift === 1 ? 9_000 : 7_000);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
|
|
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
|
|
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
|
|
}
|
|
const deja = definition.bosses.find((boss) => boss.name === "Chrono Lord Deja")!;
|
|
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja" });
|
|
expect(snapshot.completedObjectiveIds).toContain(deja.id);
|
|
|
|
now = snapshot.phaseStartedAt + 156_000;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-07"]);
|
|
const resetAfterDeja = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", now + 1);
|
|
expect(resetAfterDeja.activeSpawnGroupIds).toEqual([]);
|
|
expect(resetAfterDeja.completedObjectiveIds).toEqual([deja.id]);
|
|
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], resetAfterDeja.activeSpawnGroupIds, 1)).toEqual([]);
|
|
snapshot = reduceDungeonEncounter(encounter, resetAfterDeja, { kind: "tick", now: now + 1 }).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja", counters: { rift: 6 } });
|
|
|
|
for (let rift = 7; rift <= 12; rift += 1) {
|
|
now = snapshot.phaseStartedAt + (rift === 7 ? 156_000 : 7_000);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
|
|
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
|
|
}
|
|
const temporus = definition.bosses.find((boss) => boss.name === "Temporus")!;
|
|
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus" });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([deja.id, temporus.id]));
|
|
|
|
now = snapshot.phaseStartedAt + 156_000;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-13"]);
|
|
const resetAfterTemporus = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", now + 1);
|
|
expect(resetAfterTemporus.activeSpawnGroupIds).toEqual([]);
|
|
snapshot = reduceDungeonEncounter(encounter, resetAfterTemporus, { kind: "tick", now: now + 1 }).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus", counters: { rift: 12 } });
|
|
});
|
|
|
|
it("makes reviewed Violet Hold doors visible and physically stateful", () => {
|
|
const definition = adapted("violet-hold");
|
|
const reviewedTemplates = new Set([191_723, 191_564, 191_563, 191_562, 191_606]);
|
|
const doors = definition.serverGameObjects.filter((object) => reviewedTemplates.has(object.source?.templateId ?? -1));
|
|
expect(doors.length).toBeGreaterThan(0);
|
|
for (const door of doors) {
|
|
validateDungeonWorldObject(door);
|
|
expect(door.states.find((state) => state.id === "closed")).toMatchObject({
|
|
visible: true,
|
|
blocksMovement: true,
|
|
blocksSight: true,
|
|
});
|
|
expect(door.states.find((state) => state.id === "open")).toMatchObject({
|
|
visible: true,
|
|
blocksMovement: false,
|
|
blocksSight: false,
|
|
});
|
|
expect(door.visual?.hidden).not.toBe(true);
|
|
}
|
|
});
|
|
|
|
it("runs all 18 Violet Hold waves and persists its three objectives", () => {
|
|
const definition = adapted("violet-hold");
|
|
const encounter = wowEncounter(definition);
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact", objectId: "violet-hold:sinclari-start", now: 0,
|
|
}).snapshot;
|
|
let now = 0;
|
|
for (let wave = 1; wave <= 18; wave += 1) {
|
|
const id = `violet-hold:wave-${String(wave).padStart(2, "0")}`;
|
|
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
|
const delay = wave === 1 ? 19_000 : wave === 7 || wave === 13 ? 35_000 : 3_000;
|
|
now = snapshot.phaseStartedAt + delay;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
}
|
|
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
|
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
|
now += 1;
|
|
}
|
|
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
|
|
});
|
|
|
|
it("persists Black Morass completion and deactivates the final group", () => {
|
|
const definition = adapted("black-morass");
|
|
const encounter = wowEncounter(definition);
|
|
const aeonus = definition.bosses.find((boss) => boss.name === "Aeonus")!;
|
|
const finalGroup = group(definition, "black-morass:rift-18");
|
|
const base = createDungeonEncounterSnapshot(encounter, 1_000);
|
|
const readyToClear = {
|
|
...base,
|
|
phase: "rift-18-active",
|
|
activeSpawnGroupIds: [finalGroup.id],
|
|
} as DungeonEncounterSnapshot;
|
|
const cleared = killGroup(encounter, readyToClear, finalGroup, 2_000);
|
|
expect(cleared.snapshot).toMatchObject({
|
|
phase: "complete",
|
|
flags: { "dungeon-complete": true },
|
|
activeSpawnGroupIds: [],
|
|
});
|
|
expect(cleared.snapshot.completedObjectiveIds).toContain(aeonus.id);
|
|
expect(cleared.effects).toContainEqual({
|
|
kind: "despawn-group",
|
|
actionId: "deactivate-rift-18",
|
|
spawnGroupId: finalGroup.id,
|
|
});
|
|
const reset = resetDungeonEncounterSnapshot(encounter, cleared.snapshot, "boss-wipe", 3_000);
|
|
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 3_000 }).snapshot;
|
|
expect(recovered).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
|
|
expect(recovered.completedObjectiveIds).toContain(aeonus.id);
|
|
});
|
|
|
|
it("runs Culling's city, rift, Epoch, and Mal'Ganis groups to completion", () => {
|
|
const definition = adapted("culling-of-stratholme");
|
|
const encounter = wowEncounter(definition);
|
|
const sequence = [
|
|
...Array.from({ length: 10 }, (_, index) => `culling-city-${String(index + 1).padStart(2, "0")}`),
|
|
"culling-town-01", "culling-town-02", "culling-town-03", "culling-epoch", "culling-malganis",
|
|
];
|
|
const delays = new Map<string, number>([[sequence[0], 10_000], ["culling-town-01", 5_000], ["culling-epoch", 32_000], ["culling-malganis", 5_000]]);
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact", objectId: "culling-of-stratholme:arthas-start", now: 0,
|
|
}).snapshot;
|
|
let now = 0;
|
|
for (const id of sequence) {
|
|
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
|
now = snapshot.phaseStartedAt + (delays.get(id) ?? 0);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
}
|
|
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
|
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
|
now += 1;
|
|
}
|
|
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
|
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
|
|
});
|
|
|
|
it("uses five imported Durnholde barrels before Drake, then gates the Thrall sequence", () => {
|
|
const definition = adapted("escape-from-durnholde");
|
|
const encounter = wowEncounter(definition);
|
|
const barrels = definition.serverGameObjects.filter((object) => object.source?.templateId === 182_589);
|
|
expect(barrels.length).toBeGreaterThanOrEqual(5);
|
|
expect(barrels.every((object) => object.interaction?.prompt === "Plant an incendiary bomb")).toBe(true);
|
|
let snapshot = createDungeonEncounterSnapshot(encounter);
|
|
barrels.slice(0, 5).forEach((barrel, index) => {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: barrel.id, now: index }).snapshot;
|
|
});
|
|
expect(snapshot.counters.barrels).toBe(5);
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5 }).snapshot;
|
|
expect(snapshot.phase).toBe("durnholde-drake-wait");
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_005 }).snapshot;
|
|
const drakeGroup = group(definition, "durnholde-drake");
|
|
snapshot = killGroup(encounter, snapshot, drakeGroup, 12_006).snapshot;
|
|
const drake = definition.bosses.find((boss) => boss.name === "Lieutenant Drake")!;
|
|
expect(snapshot).toMatchObject({ phase: "durnholde-await-thrall", checkpointId: drake.id });
|
|
const prisonDoor = definition.serverGameObjects.find((object) => object.source?.templateId === 184_393)!;
|
|
expect(snapshot.worldObjectStates[prisonDoor.id]).toBe("open");
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: "escape-from-durnholde:thrall-escort", now: 12_007 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_007 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toContain("durnholde-skarloc");
|
|
|
|
const reset = resetDungeonEncounterSnapshot(encounter, killGroup(encounter, snapshot, group(definition, "durnholde-skarloc"), 12_008).snapshot, "boss-wipe", 12_009);
|
|
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 12_009 }).snapshot;
|
|
expect(recovered.phase).toBe("durnholde-barn-wait");
|
|
expect(recovered.activeSpawnGroupIds).toEqual([]);
|
|
|
|
snapshot = recovered;
|
|
let now = 12_009;
|
|
const remainder = [
|
|
["durnholde-barn", 3_000],
|
|
["durnholde-church", 3_000],
|
|
["durnholde-epoch-wave-1", 3_000],
|
|
["durnholde-epoch-wave-2", 0],
|
|
["durnholde-epoch-wave-3", 0],
|
|
["durnholde-epoch", 8_000],
|
|
] as const;
|
|
for (const [id, delay] of remainder) {
|
|
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
|
now = snapshot.phaseStartedAt + delay;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
}
|
|
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
|
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
|
now += 1;
|
|
}
|
|
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
|
});
|
|
|
|
it("opens reviewed Arcatraz pods in order before spawning Skyriss", () => {
|
|
const definition = adapted("arcatraz");
|
|
const encounter = wowEncounter(definition);
|
|
const pods = [183_961, 183_963, 183_964, 183_962, 183_965].map((templateId) => (
|
|
definition.serverGameObjects.find((object) => object.source?.templateId === templateId)!
|
|
));
|
|
for (const pod of pods) {
|
|
validateDungeonWorldObject(pod);
|
|
expect(pod.states.find((state) => state.id === "closed")).toMatchObject({ blocksMovement: true, blocksSight: true, visible: true });
|
|
expect(pod.states.find((state) => state.id === "open")).toMatchObject({ blocksMovement: false, blocksSight: false, visible: true });
|
|
expect(pod.visual?.hidden).not.toBe(true);
|
|
}
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact", objectId: "arcatraz:warden-start", now: 0,
|
|
}).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-alpha"), 5_001).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 10_001 }).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_001 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-delta"), 20_002).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 28_002 }).snapshot;
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-gamma"), 28_003).snapshot;
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 40_003 }).snapshot;
|
|
expect(snapshot.activeSpawnGroupIds).toEqual(["arcatraz-skyriss"]);
|
|
expect(pods.every((pod) => snapshot.worldObjectStates[pod.id] === "open")).toBe(true);
|
|
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-skyriss"), 40_004).snapshot;
|
|
expect(snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
|
|
});
|
|
|
|
it("keeps Solakar waves overlapping and removes surviving adds when Solakar dies", () => {
|
|
const definition = adapted("blackrock-spire");
|
|
const encounter = wowEncounter(definition);
|
|
const fatherFlame = definition.serverGameObjects.find((object) => object.source?.templateId === 175_245)!;
|
|
expect(fatherFlame.interaction?.prompt).toBe("Activate Father Flame");
|
|
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
|
kind: "interact", objectId: fatherFlame.id, now: 0,
|
|
}).snapshot;
|
|
for (const now of [500, 30_500, 60_500, 90_500, 120_500, 150_500]) {
|
|
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
|
}
|
|
expect(snapshot.activeSpawnGroupIds).toHaveLength(6);
|
|
expect(snapshot.activeSpawnGroupIds).toContain("blackrock-spire:solakar");
|
|
const solakarGroup = group(definition, "blackrock-spire:solakar");
|
|
const cleared = killGroup(encounter, snapshot, solakarGroup, 150_501);
|
|
expect(cleared.snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
|
|
expect(cleared.effects.filter((effect) => effect.kind === "despawn-group")).toHaveLength(6);
|
|
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], cleared.snapshot.activeSpawnGroupIds, 1)).toEqual([]);
|
|
});
|
|
});
|