Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+18 -2
View File
@@ -9,8 +9,13 @@ import { useManastormStore } from "./manastormStore";
import {
activateDungeonEncounterDefinitions,
advanceDungeonEncounters,
dispatchActiveDungeonEntityEvent,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import {
deriveDungeonEncounterCombatEvents,
type DungeonEncounterMobCursor,
} from "./dungeonEncounterCombatEvents";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
onlineSessionIsActive,
@@ -27,9 +32,11 @@ export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const activeRole = useShellStore((state) => state.activeDungeonRole);
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
const mountedResetRevision = useRef(encounterResetRevision);
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
useEffect(() => {
if (!character) return;
@@ -44,12 +51,14 @@ export function CombatBridge() {
useEffect(() => {
const definition = requireDungeonDefinition(activeDungeonId);
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? []);
}, [activeDungeonId]);
encounterMobCursor.current = {};
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
}, [activeDifficultyId, activeDungeonId]);
useEffect(() => {
if (mountedResetRevision.current === encounterResetRevision) return;
mountedResetRevision.current = encounterResetRevision;
encounterMobCursor.current = {};
useCombatStore.getState().resetEncounter();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
}, [encounterResetRevision]);
@@ -126,6 +135,13 @@ export function CombatBridge() {
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
@@ -3,9 +3,17 @@ import { applyDungeonEncounterEffect } from "./DungeonEncounterEffectBridge";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useCombatStore } from "./combatStore";
import {
clearMobRuntimeRegistry,
getMobRuntimePosition,
registerMobRuntime,
} from "./mobRuntimeRegistry";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
afterEach(() => {
clearPartyRuntimeRegistry();
clearMobRuntimeRegistry();
usePartyStore.setState({ members: [] });
});
@@ -26,4 +34,34 @@ describe("dungeon encounter effects", () => {
expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]);
expect(getPartyRuntimePosition(allyId)).toEqual([12, 3, -8]);
});
it("removes every combat and spatial actor owned by a deactivated spawn group", () => {
const dungeon = DUNGEON_REGISTRY["wailing-caverns"];
const group = dungeon.encounterSpawnGroups?.find(
(candidate) => candidate.id === "naralex-ravagers",
);
expect(group).toBeDefined();
for (const spawn of group!.spawns) {
useCombatStore.getState().registerMob(spawn.id, { name: spawn.id, maxHealth: 100 });
registerMobRuntime(spawn.id, spawn.position);
}
useCombatStore.getState().registerMob("unrelated", { name: "Unrelated", maxHealth: 100 });
applyDungeonEncounterEffect({
dungeonId: dungeon.id,
encounterId: "wailing-caverns:naralex",
effect: {
kind: "despawn-group",
actionId: "cleanup-ravagers",
spawnGroupId: group!.id,
},
}, 100);
for (const spawn of group!.spawns) {
expect(useCombatStore.getState().mobs[spawn.id]).toBeUndefined();
expect(getMobRuntimePosition(spawn.id)).toBeNull();
}
expect(useCombatStore.getState().mobs.unrelated).toBeDefined();
useCombatStore.getState().removeMob("unrelated");
});
});
+15 -1
View File
@@ -11,6 +11,8 @@ import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -33,7 +35,7 @@ export function DungeonEncounterEffectBridge() {
}
export function applyDungeonEncounterEffect(
{ effect }: DungeonEncounterEffectEnvelope,
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(),
): void {
if (effect.kind === "signal") {
@@ -49,6 +51,18 @@ export function applyDungeonEncounterEffect(
publishOnlinePartyTeleport(effect.destination, effect.actionId, now);
return;
}
if (effect.kind === "despawn-group") {
const group = dungeonDefinitionById(dungeonId)?.encounterSpawnGroups?.find(
(candidate) => candidate.id === effect.spawnGroupId,
);
if (!group) return;
const combat = useCombatStore.getState();
for (const spawn of group.spawns) {
combat.removeMob(spawn.id);
removeMobPosition(spawn.id);
}
return;
}
if (effect.kind === "loot-grant" && !grantEncounterLoot(effect.lootTableId, now)) {
useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`);
}
+6 -2
View File
@@ -57,7 +57,11 @@ export function ManastormBridge() {
}, [gameMode]);
useEffect(() => {
if (gameMode !== "manastorm" || !nextEncounterId) return;
if (
gameMode !== "manastorm"
|| (status !== "portal-ready" && status !== "transitioning")
|| !nextEncounterId
) return;
const nextEncounter = manastormEncounterById(nextEncounterId);
const packageId = nextEncounter?.assetPackageId;
if (!packageId || packageId === encounter?.assetPackageId) return;
@@ -67,7 +71,7 @@ export function ManastormBridge() {
console.warn(`Could not preload the next Manastorm package ${packageId}.`, error);
});
return () => controller.abort();
}, [encounter?.assetPackageId, gameMode, nextEncounterId]);
}, [encounter?.assetPackageId, gameMode, nextEncounterId, status]);
useEffect(() => useCombatStore.subscribe((state, previous) => {
if (
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OnlineSharedWorld } from "../app/onlineSessionTypes";
import {
activateDungeonEncounterDefinitions,
dungeonEncounterSnapshot,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import { applySharedWorld } from "./OnlineSessionBridge";
import { useGameStore } from "./store";
import {
WAILING_NARALEX_ENCOUNTER,
useWailingEncounterStore,
} from "./wailingCavernsEncounter";
const EMPTY_SHARED_WORLD = {
dungeonId: "wailing-caverns",
mobs: {},
mobTransforms: {},
partyMembers: [],
partyPositions: {},
partyRuntime: {
command: "defend",
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
},
wailing: null,
manastorm: null,
} as const satisfies OnlineSharedWorld;
describe("online shared-world hydration", () => {
afterEach(() => {
useWailingEncounterStore.getState().reset("dungeon", 0);
useGameStore.getState().activateDungeon("wailing-caverns");
});
it("accepts an authority snapshot older than a follower's local revision", () => {
activateDungeonEncounterDefinitions(
"wailing-caverns",
[WAILING_NARALEX_ENCOUNTER],
"normal",
);
useGameStore.getState().activateDungeon("wailing-caverns");
const authorityBase = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
resetDungeonEncounters("dungeon", "wailing-caverns");
const follower = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const authority = {
...authorityBase,
phase: "ready",
revision: Math.max(0, follower.revision - 1),
} as const;
expect(authority.revision).toBeLessThan(follower.revision);
applySharedWorld({ ...EMPTY_SHARED_WORLD, encounters: [authority] });
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
phase: "ready",
revision: authority.revision,
});
});
});
+2 -2
View File
@@ -87,7 +87,7 @@ function sharedWorld(): OnlineSharedWorld {
};
}
function applySharedWorld(world: OnlineSharedWorld): void {
export function applySharedWorld(world: OnlineSharedWorld): void {
useCombatStore.setState({ mobs: world.mobs });
usePartyStore.setState({
members: world.partyMembers,
@@ -103,7 +103,7 @@ function applySharedWorld(world: OnlineSharedWorld): void {
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
const activeDungeonId = useGameStore.getState().activeDungeonId;
if (world.dungeonId === activeDungeonId && world.encounters) {
importDungeonEncounterSnapshots(activeDungeonId, world.encounters);
importDungeonEncounterSnapshots(activeDungeonId, world.encounters, { authoritative: true });
}
}
@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterAction,
DungeonEncounterGuard,
DungeonEncounterTrigger,
} from "./dungeonEncounterTypes";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import type { DungeonDefinition } from "./dungeonTypes";
import {
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE,
withAscensionCustomEncounterContent,
} from "./ascensionCustomEncounterCatalog";
const CUSTOM_DUNGEON_IDS = [
"frozen-reach",
"forgotten-mine",
"tinkertech-showdown",
] as const;
function adaptedDungeon(id: typeof CUSTOM_DUNGEON_IDS[number]): DungeonDefinition {
const source = DUNGEON_REGISTRY[id];
expect(source, id).toBeDefined();
return withAscensionCustomEncounterContent(source);
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
...(definition.encounterSpawnGroups ?? []).flatMap((group) => group.spawns.map((spawn) => spawn.id)),
];
}
function expectTriggerReferences(
trigger: DungeonEncounterTrigger,
spawnIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
): void {
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
}
if (trigger.kind === "entities-dead") {
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
}
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
}
function expectGuardReferences(
guard: DungeonEncounterGuard,
groupIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
objectiveIds: ReadonlySet<string>,
): void {
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
}
function expectActionReferences(
action: DungeonEncounterAction,
groupIds: ReadonlySet<string>,
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
objectiveIds: ReadonlySet<string>,
): void {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
}
if (action.kind === "set-world-object-state") {
const object = objectsById.get(action.objectId);
expect(object, `action object ${action.objectId}`).toBeDefined();
expect(object?.states.map((state) => state.id), `object state ${action.objectId}:${action.state}`)
.toContain(action.state);
}
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
}
describe("Ascension custom encounter content", () => {
it("attaches idempotently with no dangling or duplicate static/runtime references", () => {
expect(ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => entry.dungeonId).sort())
.toEqual([...CUSTOM_DUNGEON_IDS].sort());
for (const id of CUSTOM_DUNGEON_IDS) {
const definition = adaptedDungeon(id);
expect(withAscensionCustomEncounterContent(definition)).toBe(definition);
const spawnIdList = runtimeSpawnIds(definition);
const spawnIds = new Set(spawnIdList);
expect(spawnIds.size, `${id} duplicate runtime spawn ids`).toBe(spawnIdList.length);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
const objectiveIds = new Set([
...definition.bosses.map((objective) => objective.id),
...definition.objectiveOrder.map((objective) => objective.id),
]);
for (const spawn of definition.staticSpawns) {
expect(definition.entities[spawn.entityId], `${id} static ${spawn.id}`).toBeDefined();
}
for (const group of definition.encounterSpawnGroups ?? []) {
for (const spawn of group.spawns) {
expect(definition.entities[spawn.entityId], `${id} group ${group.id}:${spawn.id}`).toBeDefined();
}
}
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
for (const encounter of definition.encounters ?? []) {
validateDungeonEncounterDefinition(encounter);
expect(encounter.dungeonId).toBe(id);
for (const rule of encounter.rules) {
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
for (const guard of rule.guards ?? []) {
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
}
for (const action of rule.actions) {
expectActionReferences(action, groupIds, objectsById, objectiveIds);
}
}
}
}
});
it("removes Frozen Reach placeholder trash and drives the Arktos-to-vengeance lifecycle", () => {
const definition = adaptedDungeon("frozen-reach");
expect(definition.staticSpawns.map((spawn) => spawn.id)).toEqual(["encounter-2168"]);
expect(definition.entities["frozen-reach-guardian"]).toBeUndefined();
expect(definition.entities["frozen-reach-caster"]).toBeUndefined();
expect(definition.entities["frozen-reach-brute"]).toBeUndefined();
expect(definition.entities["entry-97700"]).toMatchObject({
name: "Alva",
combat: { serverEntry: 97_700 },
});
expect(definition.entities["frozen-reach:arktos"]).toMatchObject({
name: "Arktos",
combat: { serverEntry: 97_701 },
});
const alvaSpellIds = definition.entities["entry-97700"].combat?.attacks
.flatMap((entry) => entry.spellId ?? []);
expect(alvaSpellIds).toEqual(expect.arrayContaining([
255_347,
255_349,
255_352,
255_358,
255_363,
255_394,
255_397,
255_410,
255_419,
255_421,
]));
const encounter = definition.encounters?.find((entry) => entry.id === "frozen-reach:alva-and-arktos");
expect(encounter?.source?.fidelity).toBe("source-backed-adapted");
const arktosGroup = definition.encounterSpawnGroups
?.find((group) => group.id === "frozen-reach:arktos-attempt");
expect(arktosGroup?.spawns).toMatchObject([{
id: "frozen-reach:arktos",
entityId: "frozen-reach:arktos",
}]);
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "tick",
now: 1,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "duo-active",
activeSpawnGroupIds: ["frozen-reach:arktos-attempt"],
});
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-death",
entityId: "frozen-reach:arktos",
now: 1_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "vengeance",
flags: { "arktos-defeated": true },
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
});
const addIds = definition.encounterSpawnGroups
?.find((group) => group.id === "frozen-reach:rebirth-alvajar")
?.spawns.map((spawn) => spawn.id) ?? [];
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "tick",
now: 2_000,
facts: { registeredEntityIds: addIds, deadEntityIds: addIds },
}).snapshot;
expect(snapshot.phase).toBe("vengeance");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
expect(reset).toMatchObject({
phase: "duo-active",
flags: { "arktos-defeated": false },
activeSpawnGroupIds: [],
});
snapshot = reduceDungeonEncounter(encounter!, reset, {
kind: "tick",
now: 3_001,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["frozen-reach:arktos-attempt"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-death",
entityId: "frozen-reach:arktos",
now: 4_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "vengeance",
flags: { "arktos-defeated": true },
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
});
});
it("runs Vek's Geospike, Resonance, and repeatable Resonite shard lifecycle", () => {
const definition = adaptedDungeon("forgotten-mine");
expect(definition.entities["entry-254272"]).toMatchObject({
name: "Geolord Vek",
combat: { serverEntry: 254_272 },
});
expect(definition.entities["forgotten-mine-guardian"].name).toBe("Forgotten Mine Stoneguard");
expect(definition.entities["forgotten-mine-caster"].name).toBe("Forgotten Mine Geomancer");
expect(definition.entities["forgotten-mine-brute"].name).toBe("Forgotten Mine Quarry Brute");
expect(definition.entities["entry-254272"].combat?.attacks.flatMap((entry) => entry.spellId ?? []))
.toEqual(expect.arrayContaining([354_000, 354_001, 354_002, 354_004, 354_013, 354_015, 354_017]));
const shards = definition.serverGameObjects.filter((object) => object.source?.templateId === 254_051);
expect(shards).toHaveLength(3);
for (const shard of shards) {
expect(shard).toMatchObject({
kind: "resource",
halfExtents: [2.25, 2.4, 2.25],
interaction: { range: 4.5, allowedStates: ["ready"] },
source: { fidelity: "source-backed-adapted", templateId: 254_051 },
});
}
const encounter = definition.encounters?.find((entry) => entry.id === "forgotten-mine:geolord-vek");
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "signal",
signalId: "forgotten-mine:geospike-cast",
now: 100,
}).snapshot;
expect(snapshot.phase).toBe("pre-resonance-geospike");
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_599 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_600 }).snapshot;
expect(snapshot.phase).toBe("boss-active");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-5069",
healthPercent: 50,
now: 4_000,
}).snapshot;
expect(snapshot.phase).toBe("resonance-active");
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "interact",
objectId: "forgotten-mine:resonite-shard-1",
now: 5_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "resonance-cleansed",
counters: { "resonite-cleanses": 1 },
worldObjectStates: { "forgotten-mine:resonite-shard-1": "consumed" },
});
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 12_999 }).snapshot;
expect(snapshot.phase).toBe("resonance-cleansed");
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 13_000 }).snapshot;
expect(snapshot.phase).toBe("resonance-recharge");
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 35_000 }).snapshot;
expect(snapshot.phase).toBe("resonance-active");
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "interact",
objectId: "forgotten-mine:resonite-shard-1",
now: 36_000,
}).snapshot;
expect(snapshot.counters["resonite-cleanses"]).toBe(2);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 37_000);
expect(reset.phase).toBe("boss-active");
expect(Object.values(reset.worldObjectStates)).toEqual(["dormant", "dormant", "dormant"]);
expect(reset.counters["resonite-cleanses"]).toBe(0);
});
it("keeps Tinkertech explicitly authored and switches Mechatron to faster overdrive with reinforcements", () => {
const definition = adaptedDungeon("tinkertech-showdown");
expect(definition.entities["entry-663012"]).toMatchObject({ name: "Mechatron", combat: { serverEntry: 663_012 } });
expect(definition.entities["tinkertech-showdown-guardian"].name).toBe("Tinkertech Guardbot");
expect(definition.entities["tinkertech-showdown-caster"].name).toBe("Tinkertech Coilcaster");
expect(definition.entities["tinkertech-showdown-brute"].name).toBe("Tinkertech Demolisher");
const review = ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.find((entry) => entry.dungeonId === definition.id)!;
expect(review.fidelity).toBe("healer-man-authored");
expect(review.exactSpellFamilies).toEqual({});
expect(review.candidateUnboundSpellFamilies).toHaveProperty("gatlingMissileBarrage");
const attacks = definition.entities["entry-663012"].combat?.attacks ?? [];
for (const spellId of [257_438, 257_459, 257_470, 257_472]) {
const normal = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-normal"));
const overdrive = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-overdrive"));
expect(normal, `normal spell ${spellId}`).toBeDefined();
expect(overdrive?.cooldownMs, `overdrive spell ${spellId}`).toBe(Math.round(normal!.cooldownMs * 0.8));
}
const encounter = definition.encounters?.find((entry) => entry.id === "tinkertech-showdown:mechatron");
expect(encounter?.source?.fidelity).toBe("healer-man-authored");
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
expect(reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-9013",
healthPercent: 36,
now: 1_000,
}).snapshot).toBe(snapshot);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-9013",
healthPercent: 35,
now: 2_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "overdrive",
flags: { overdrive: true },
activeSpawnGroupIds: ["tinkertech-showdown:overdrive-reinforcements"],
});
const reinforcements = definition.encounterSpawnGroups
?.find((group) => group.id === "tinkertech-showdown:overdrive-reinforcements");
expect(reinforcements?.spawns).toHaveLength(2);
expect(reinforcements?.spawns.every((spawn) => spawn.entityId === "tinkertech-showdown-caster"))
.toBe(true);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
expect(reset).toMatchObject({ phase: "active", flags: { overdrive: false }, activeSpawnGroupIds: [] });
});
});
File diff suppressed because it is too large. Load diff
+8
View File
@@ -185,5 +185,13 @@ describe("boss guide model", () => {
});
const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!;
expect(flowingChaos.mechanics[0].fidelityLabel).toBe("HealerMan fallback");
const theSeven = guideByDungeon.get("blackrock-depths")!.bosses.find((boss) => boss.name === "The Seven")!;
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "blackrock-depths")
?.entities[theSeven.entityId!].combat?.serverEntry).toBe(9_039);
const escape = guideByDungeon.get("halls-of-reflection")!.bosses
.find((boss) => boss.name === "Escape from the Lich King")!;
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "halls-of-reflection")
?.entities[escape.entityId!].combat?.serverEntry).toBe(36_954);
expect(escape.mechanics.length).toBeGreaterThan(0);
});
});
+58 -9
View File
@@ -1,4 +1,4 @@
import type { DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import {
normalizeEnemyAttack,
type EnemyAbilityCondition,
@@ -114,6 +114,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "aura") {
if (effect.meleeAutoCriticalProc) {
const proc = effect.meleeAutoCriticalProc;
return `Applies ${titleCase(effect.aura)} to ${target}; successful main-hand autos have a ${Math.round(proc.chance * 100)}% chance to grant ${proc.charges} charge${proc.charges === 1 ? "" : "s"} of +${proc.criticalChancePercent}% critical chance to the next main-hand auto for ${seconds(proc.durationMs)}`;
}
const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`;
}
@@ -121,7 +125,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "control") {
return `${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
const damage = effect.damageMultiplier === undefined
? ""
: `${titleCase(attack.school)} damage (${Number(effect.damageMultiplier.toFixed(2))}× boss power) and `;
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
@@ -271,18 +278,25 @@ function normalizedName(value: string): string {
function bossEntityForObjective(
dungeon: DungeonDefinition,
objectiveId: string,
objectiveName: string,
objective: DungeonBossObjective,
): readonly [string, PopulationEntityDefinition] | undefined {
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objectiveId);
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objective.id);
if (staticSpawn) {
const entity = dungeon.entities[staticSpawn.entityId];
if (entity) return [staticSpawn.entityId, entity];
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
const spawn = group.spawns.find((candidate) => candidate.id === objective.id);
if (spawn) {
const entity = dungeon.entities[spawn.entityId];
if (entity) return [spawn.entityId, entity];
}
}
for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => (
`${pack.id}:${candidate.id}` === objectiveId || candidate.id === objectiveId
`${pack.id}:${candidate.id}` === objective.id || candidate.id === objective.id
));
if (member) {
const entity = dungeon.entities[member.entityId];
@@ -290,11 +304,46 @@ function bossEntityForObjective(
}
}
const wanted = normalizedName(objectiveName);
return Object.entries(dungeon.entities).find(([, entity]) => (
const wanted = normalizedName(objective.name);
const named = Object.entries(dungeon.entities).find(([, entity]) => (
entity.kind === "boss"
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
));
if (named) return named;
if (objective.eventOnly) {
const related = Object.entries(dungeon.entities)
.filter(([, entity]) => {
const entityName = normalizedName(entity.name);
return entityName.length >= 5 && (wanted.includes(entityName) || entityName.includes(wanted));
})
.sort((left, right) => {
const leftBoss = left[1].kind === "boss" ? 1 : 0;
const rightBoss = right[1].kind === "boss" ? 1 : 0;
if (leftBoss !== rightBoss) return rightBoss - leftBoss;
const leftMechanics = left[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
const rightMechanics = right[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
return rightMechanics - leftMechanics || left[0].localeCompare(right[0]);
});
if (related[0]) return related[0];
}
// Aggregate objectives such as Blackrock Depths' "The Seven" use a
// synthetic objective id but retain the final source actor's exact position.
// That representative may be typed as a mob because the objective covers a
// multi-actor event rather than one loot-bearing boss.
for (const group of dungeon.encounterSpawnGroups ?? []) {
const exact = group.spawns.filter((candidate) => {
const dx = candidate.position[0] - objective.position[0];
const dy = candidate.position[1] - objective.position[1];
const dz = candidate.position[2] - objective.position[2];
return dx * dx + dy * dy + dz * dz <= 0.25 * 0.25;
});
const spawn = exact.find((candidate) => dungeon.entities[candidate.entityId]?.kind === "boss")
?? exact.find((candidate) => dungeon.entities[candidate.entityId] !== undefined);
if (spawn) return [spawn.entityId, dungeon.entities[spawn.entityId]];
}
return undefined;
}
function bossGuide(
@@ -325,7 +374,7 @@ export function buildDungeonBossGuide(
): DungeonBossGuide {
const representedEntityIds = new Set<string>();
const objectiveBosses = dungeon.bosses.map((objective) => {
const resolved = bossEntityForObjective(dungeon, objective.id, objective.name);
const resolved = bossEntityForObjective(dungeon, objective);
if (resolved) representedEntityIds.add(resolved[0]);
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
});
+9 -3
View File
@@ -59,12 +59,18 @@ describe("named aura combat runtime", () => {
});
it("grants learned long-term stat buffs to the full party on load", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
level: 8,
inventory: [],
equipment: {},
});
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
const playerMaxHealth = useCombatStore.getState().maxHealth;
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState();
@@ -75,7 +81,7 @@ describe("named aura combat runtime", () => {
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
}));
}
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
expect(state.maxHealth).toBe(437);
for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
}
+1 -1
View File
@@ -150,7 +150,7 @@ export interface AuraDefinition {
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
readonly utilityTags?: readonly string[];
/** Enemy control mechanics ignored while this aura is active. */
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "stun" | "silence")[];
/** Chance from 0 through 1 to reflect an incoming spell. */
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
+14
View File
@@ -237,6 +237,20 @@ describe("combat store", () => {
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana);
});
it("gives a geared level-eight Priest the expanded live-game mana pool", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
level: 8,
inventory: [],
equipment: {},
});
expect(useCombatStore.getState()).toMatchObject({
resource: 3_285,
maxResource: 3_285,
});
});
it("uses equipped spell power, stamina, and armor in player combat", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const baseMaxHealth = useCombatStore.getState().maxHealth;
+192 -41
View File
@@ -179,6 +179,7 @@ import {
type EnemyAttackDefinition,
type EnemyControlMechanic,
type EnemyDamageSchool,
type EnemyMeleeAutoCriticalProc,
} from "./enemyAttacks";
import {
creatureRuntimeBaseStats,
@@ -294,6 +295,11 @@ export interface MobAura {
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: number;
/** Persistent driver for a content-authored main-hand auto-attack proc. */
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
/** A charged modifier consumed by a successful main-hand auto attack. */
readonly nextMeleeCriticalChancePercent?: number;
readonly charges?: number;
}
export interface MobMagicResistances {
@@ -715,7 +721,10 @@ export interface CombatState {
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
return "spell";
}
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
return attack.id === DEFAULT_ENEMY_ATTACK.id
&& attack.delivery === "melee"
&& attack.animation === "attack"
&& (attack.castTimeMs ?? 0) === 0;
}
function enemyMeleeCriticalCharge(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
): MobAura | undefined {
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
return mob.auras.find((aura) => (
(aura.endsAt <= 0 || aura.endsAt > now)
&& (aura.charges ?? 0) > 0
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
));
}
function advanceEnemyMeleeAutoProcAuras(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
attackRevision: number,
landed: boolean,
consumedAuraId?: string,
): readonly MobAura[] {
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
let changed = false;
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
if (aura.id !== consumedAuraId) return [aura];
changed = true;
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
return charges > 0 ? [{ ...aura, charges }] : [];
});
for (const driver of mob.auras) {
const proc = driver.meleeAutoCriticalProc;
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
if (
chance <= 0
|| stableCombatUnit(
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
) >= chance
) {
continue;
}
const triggeredAura = proc.triggeredAura.trim();
const criticalChancePercent = Math.max(
0,
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
);
const charges = Math.max(1, Math.round(
Number.isFinite(proc.charges) ? proc.charges : 1,
));
if (!triggeredAura || criticalChancePercent <= 0) continue;
nextAuras = [
...nextAuras.filter((aura) => aura.id !== triggeredAura),
{
id: triggeredAura,
sourceAbilityId: driver.sourceAbilityId,
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
nextMeleeCriticalChancePercent: criticalChancePercent,
charges,
},
];
changed = true;
}
return changed ? nextAuras : mob.auras;
}
function resolveEnemyDamageResult(
mob: MobCombatState,
attack: EnemyAttackDefinition,
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
actorId: AggroActorId,
attackRevision: number,
now: number,
bonusCriticalChancePercent = 0,
): CombatResult {
const attackKind = enemyAttackKind(attack);
return resolveCombatResult({
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
damageSchool: attack.school,
attacker: {
level: mob.level,
criticalChancePercent: 5,
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
},
defender: combatDefenderFromCharacterStats(defender),
rules: {
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[id];
return Boolean(mob && !mob.dead);
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
});
}
@@ -6603,6 +6689,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
sourceAbilityId: attack.id,
endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
},
];
namedAuras = [...applyCombatAura(namedAuras, {
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}).auras];
}
} else if (effect.kind === "control") {
if ((effect.damageMultiplier ?? 0) > 0) {
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
formula: attack.runewakerFormula,
baselineDamage: mob.attackDamage,
}).amount
: baselineDamage;
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
});
}
}
for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
continue;
}
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
const commitMeleeAutoProc = (landed: boolean): void => {
const current = get();
const currentMob = current.mobs[hit.mob.id];
if (!currentMob) return;
const auras = advanceEnemyMeleeAutoProcAuras(
currentMob,
hit.attack,
now,
hit.attackRevision,
landed,
meleeCriticalCharge?.id,
);
if (auras === currentMob.auras) return;
set({
mobs: {
...current.mobs,
[currentMob.id]: { ...currentMob, auras },
},
});
};
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
@@ -6984,8 +7116,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
if (summon) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, summon.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: summon.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: applied > 0 ? "hit" : "absorb",
critical,
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += applied;
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, onlineActor.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: onlineActor.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: damaged,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: damaged > 0 ? "hit" : "absorb",
critical,
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += damaged;
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const currentAuras = get().auras;
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
},
resetEncounter: () => set((state) => {
resetEncounter: (options = {}) => set((state) => {
resetCombatPresentationSession();
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
const mobs = Object.fromEntries(Object.entries(state.mobs)
.filter(([, mob]) => !mob.summonedBy)
.map(([id, mob]) => [id, {
...mob,
health: mob.maxHealth,
dead: false,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
experienceGranted: false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}]));
.map(([id, mob]) => {
const preserveDefeated = mob.dead && (
options.preserveDefeated === true || preserveDefeatedIds.has(id)
);
return [id, {
...mob,
health: preserveDefeated ? 0 : mob.maxHealth,
dead: preserveDefeated,
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
looted: preserveDefeated ? mob.looted : false,
despawned: preserveDefeated ? mob.despawned : false,
diedAt: preserveDefeated ? mob.diedAt : null,
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}] as const;
}));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatEvents: [],
actors: {},
lastCombatResult: null,
feedback: nextFeedback("success", "The encounter has been reset."),
feedback: nextFeedback(
"success",
options.preserveDefeated || preserveDefeatedIds.size > 0
? "The current pull has been reset; defeated enemies remain cleared."
: "The encounter has been reset.",
),
};
}),
+38 -6
View File
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { respawnDefeatedPlayer } from "./deathRespawn";
@@ -15,13 +14,22 @@ describe("death respawn", () => {
useWailingEncounterStore.getState().reset();
});
it("atomically restores the player and encounter at the Wailing Caverns entrance", () => {
it("resets only the current pull and preserves cleared enemies and run progress", () => {
const startingResource = useCombatStore.getState().maxResource;
const checkpointSpawn = { footPosition: [4, -70, 12] as const, yaw: 0.25 };
useGameStore.getState().relocatePlayer(checkpointSpawn, true);
useGameStore.getState().updatePlayerSnapshot({ position: [10, -50, 20], yaw: 0.5, distanceDelta: 30 });
useGameStore.getState().setCameraLookActive(true);
useCombatStore.getState().registerMob("respawn-pack:a", { name: "Ravager", maxHealth: 100 });
useCombatStore.getState().registerMob("cleared-boss", { name: "Cleared Boss", maxHealth: 50, boss: true });
useCombatStore.getState().registerMob("naralex-event:ravager:1", {
name: "Event Ravager",
maxHealth: 75,
});
setMobPosition("respawn-pack:a", [11, -50, 20]);
useCombatStore.getState().damageMob("cleared-boss", 50, 900);
useCombatStore.getState().damageMob("naralex-event:ravager:1", 75, 950);
useCombatStore.getState().selectMob("respawn-pack:a");
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
@@ -30,14 +38,25 @@ describe("death respawn", () => {
);
const revisionBefore = useGameStore.getState().resetRevision;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
useWailingEncounterStore.setState({
phase: "mutanus",
phaseStartedAt: 5_000,
flags: { "dungeon-complete": true },
completedObjectiveIds: ["mutanus-spawn"],
consumedActionIds: [
"complete-mutanus-objective",
"save-mutanus-checkpoint",
"mark-wailing-complete",
],
checkpointId: "mutanus-spawn",
});
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth);
expect(useCombatStore.getState().health).toBe(0);
expect(respawnDefeatedPlayer()).toBe(true);
expect(useGameStore.getState()).toMatchObject({
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
cameraYaw: DUNGEON_MANIFEST.entrance.yaw,
playerPosition: checkpointSpawn.footPosition,
cameraYaw: checkpointSpawn.yaw,
cameraLookActive: false,
resetRevision: revisionBefore + 1,
});
@@ -45,16 +64,29 @@ describe("death respawn", () => {
health: useCombatStore.getState().maxHealth,
resource: startingResource,
selectedTargetId: null,
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
playerPosition: checkpointSpawn.footPosition,
});
expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({
health: 100,
dead: false,
engaged: false,
});
expect(useCombatStore.getState().mobs["cleared-boss"]).toMatchObject({
health: 0,
dead: true,
engaged: false,
});
expect(useCombatStore.getState().mobs["naralex-event:ravager:1"]).toMatchObject({
health: 75,
dead: false,
engaged: false,
});
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "locked",
phaseStartedAt: 0,
flags: { "dungeon-complete": true },
completedObjectiveIds: ["mutanus-spawn"],
checkpointId: "mutanus-spawn",
});
expect(respawnDefeatedPlayer()).toBe(false);
+26 -9
View File
@@ -1,23 +1,40 @@
import { useCombatStore } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { resetDungeonEncounters } from "./dungeonEncounterRuntime";
import {
activeDungeonCompletedObjectiveIds,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
/**
* Atomically returns a defeated player to the dungeon entrance and restores
* the encounter. Keeping this outside the render tree makes the behavior easy
* to verify and prevents a frame where the camera resets but combat stays dead.
* Atomically returns a defeated player to the saved run spawn and resets the
* current pull. Defeated enemies and persisted dungeon progress remain clear,
* matching a wipe instead of silently starting a brand-new dungeon.
*/
export function respawnDefeatedPlayer(): boolean {
const combat = useCombatStore.getState();
if (combat.health > 0) return false;
const dungeonId = useGameStore.getState().activeDungeonId;
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
const retrySpawnIds = new Set(
(requireDungeonDefinition(dungeonId).encounterSpawnGroups ?? [])
.flatMap((group) => group.spawns.map((spawn) => spawn.id)),
);
const preserveDefeatedIds = Object.values(combat.mobs)
.filter((mob) => (
mob.dead
&& (!retrySpawnIds.has(mob.id) || completedObjectiveIds.has(mob.id))
))
.map((mob) => mob.id);
if (typeof document !== "undefined" && document.pointerLockElement) {
document.exitPointerLock();
}
useGameStore.getState().resetAtEntrance();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
const entrance = useGameStore.getState().playerPosition;
combat.resetEncounter();
useCombatStore.getState().setPlayerPosition(entrance);
useGameStore.getState().resetAfterDefeat();
resetDungeonEncounters("boss-wipe", dungeonId);
const respawnPosition = useGameStore.getState().playerPosition;
combat.resetEncounter({ preserveDefeatedIds });
useCombatStore.getState().setPlayerPosition(respawnPosition);
return true;
}
+369 -3
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
import generated from "./generated/dungeonCampaignCatalog.json";
import manastormPackages from "./generated/manastormAssetPackages.json";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
const SOURCE_NATIVE_DUNGEON_IDS = [
"ahnkahet-old-kingdom",
@@ -77,6 +86,313 @@ describe("generated dungeon campaign catalog", () => {
}
});
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const object of dungeon.serverGameObjects) {
expect(() => validateDungeonWorldObject(object), `${dungeon.id}:${object.id}`).not.toThrow();
}
}
});
it("gives every WoW dungeon executable persisted boss progression", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
expect(wowDungeons).toHaveLength(54);
for (const dungeon of wowDungeons) {
const objectiveIds = dungeon.objectiveOrder.map((objective) => objective.id);
expect(new Set(objectiveIds).size, `${dungeon.id}:duplicate-objective-id`).toBe(objectiveIds.length);
const encounters = dungeon.encounters ?? [];
for (const encounter of encounters) {
expect(() => validateDungeonEncounterDefinition(encounter), `${dungeon.id}:${encounter.id}`).not.toThrow();
}
const allCompletedObjectiveIds = new Set(encounters.flatMap((encounter) => (
encounter.rules.flatMap((rule) => rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
)))
)));
for (const objective of dungeon.objectiveOrder) {
expect(allCompletedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
}
const progression = dungeon.encounters?.find(
(encounter) => encounter.source?.scope === "boss-progression",
) as DungeonEncounterDefinition | undefined;
const instanceCompletion = encounters.find((encounter) => (
encounter.source?.scope === "instance-orchestration"
&& encounter.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
const authority = progression ?? instanceCompletion;
expect(authority, dungeon.id).toBeDefined();
expect(authority!.source?.fidelity, dungeon.id).toBe(
dungeon.id === "tinkertech-showdown"
? "healer-man-authored"
: "source-backed-adapted",
);
if (!progression) {
expect(instanceCompletion?.source?.scope, dungeon.id).toBe("instance-orchestration");
const completedObjectiveIds = new Set(instanceCompletion!.rules.flatMap((rule) => (
rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
))
)));
for (const objective of dungeon.objectiveOrder.filter((candidate) => !candidate.optional)) {
expect(completedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
}
continue;
}
const progressionObjectiveIds = new Set(progression.rules.flatMap((rule) => (
rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
))
)));
const runtimeBosses = dungeon.objectiveOrder.filter((objective) => (
progressionObjectiveIds.has(objective.id)
));
let snapshot = createDungeonEncounterSnapshot(progression, 1_000);
const deadEntityIds: string[] = [];
for (const boss of runtimeBosses) {
deadEntityIds.push(boss.id);
snapshot = reduceDungeonEncounter(progression, snapshot, {
kind: "tick",
now: 1_000 + deadEntityIds.length * 100,
facts: {
registeredEntityIds: runtimeBosses.map((objective) => objective.id),
deadEntityIds,
},
}).snapshot;
expect(snapshot.completedObjectiveIds, `${dungeon.id}:${boss.id}`).toContain(boss.id);
}
const checkpoint = resetDungeonEncounterSnapshot(progression, snapshot, "checkpoint", 5_000);
expect(checkpoint.completedObjectiveIds, dungeon.id)
.toEqual(expect.arrayContaining(runtimeBosses.map((boss) => boss.id)));
const completesFromObjectivesAlone = progression.rules.some((rule) => (
rule.actions.some((action) => (
action.kind === "set-flag" && action.flagId === "dungeon-complete" && action.value
))
&& (rule.guards ?? []).every((guard) => guard.kind === "objective-complete")
));
if (!completesFromObjectivesAlone) {
// A full source adapter may own completion inside a separate encounter;
// its focused lifecycle suite exercises that path.
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
} else {
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
expect(snapshot.phase, dungeon.id).toBe("complete");
expect(checkpoint.flags["dungeon-complete"], dungeon.id).toBe(true);
}
}
});
it("has an instance-level source review for every campaign dungeon", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
const verifiedNoOps: string[] = [];
for (const dungeon of wowDungeons) {
const reviewed = (dungeon.encounters ?? []).filter(
(encounter) => encounter.source?.scope === "instance-orchestration",
);
expect(reviewed.length, dungeon.id).toBeGreaterThan(0);
for (const encounter of reviewed) {
expect(encounter.source?.files.length, `${dungeon.id}:${encounter.id}:files`).toBeGreaterThan(0);
expect(encounter.source?.reviewedBehavior.trim(), `${dungeon.id}:${encounter.id}:review`).not.toBe("");
if (!encounter.rules.length) {
expect(encounter.source?.fidelity, `${dungeon.id}:${encounter.id}`).toBe("source-exact");
expect(encounter.source?.adaptations, `${dungeon.id}:${encounter.id}`).toEqual([]);
expect(encounter.source?.remainingGaps, `${dungeon.id}:${encounter.id}`).toEqual([]);
verifiedNoOps.push(dungeon.id);
}
}
}
expect(wowDungeons).toHaveLength(54);
expect(verifiedNoOps.sort()).toEqual([
"ragefire-chasm",
"razorfen-kraul",
"stormwind-stockade",
]);
});
it("gates every required event-only objective behind an instance completion authority", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
for (const dungeon of wowDungeons) {
const requiredEvents = dungeon.objectiveOrder.filter(
(objective) => objective.eventOnly && !objective.optional,
);
if (!requiredEvents.length) continue;
const progression = dungeon.encounters?.find(
(encounter) => encounter.source?.scope === "boss-progression",
);
const baselineGatedObjectiveIds = new Set((progression?.rules ?? []).flatMap((rule) => (
rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
))
? (rule.guards ?? []).flatMap((guard) => (
guard.kind === "objective-complete" && guard.equals ? [guard.objectiveId] : []
))
: []
)));
const completionAuthorities = (dungeon.encounters ?? []).filter((encounter) => (
encounter.source?.scope === "instance-orchestration"
&& encounter.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
for (const objective of requiredEvents) {
expect(
baselineGatedObjectiveIds.has(objective.id) || completionAuthorities.length > 0,
`${dungeon.id}:${objective.name}`,
).toBe(true);
if (progression && completionAuthorities.length) {
const completionRules = completionAuthorities.flatMap((encounter) => (
encounter.rules.filter((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
expect(completionRules.length, `${dungeon.id}:completion-authority`).toBeGreaterThan(0);
expect(completionRules.every((rule) => (
(rule.guards ?? []).some((guard) => (
guard.kind === "objective-complete"
&& guard.objectiveId === objective.id
&& guard.equals
))
|| rule.actions.some((action) => (
action.kind === "complete-objective" && action.objectiveId === objective.id
))
)), `${dungeon.id}:${objective.name}:premature-completion`).toBe(true);
}
}
}
});
it("only uses difficulty guards that match declared dungeon variants", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
for (const dungeon of wowDungeons) {
const difficultyIds = new Set(dungeon.difficultyVariants.map((variant) => variant.id));
for (const encounter of dungeon.encounters ?? []) {
for (const rule of encounter.rules) {
for (const guard of rule.guards ?? []) {
if (guard.kind !== "difficulty") continue;
expect(guard.difficultyIds.length, `${dungeon.id}:${rule.id}`).toBeGreaterThan(0);
for (const difficultyId of guard.difficultyIds) {
expect(difficultyIds.has(difficultyId), `${dungeon.id}:${rule.id}:${difficultyId}`).toBe(true);
}
}
}
}
}
});
it("completes ordinary dungeons regardless of required boss kill order", () => {
const dungeon = requireDungeonDefinition("deadmines");
const progression = dungeon.encounters?.find(
(encounter) => encounter.id === "deadmines:boss-progression",
);
expect(progression).toBeDefined();
const required = dungeon.objectiveOrder.filter((objective) => !objective.optional && !objective.eventOnly);
expect(required.length).toBeGreaterThan(1);
const killOrder = [required.at(-1)!, ...required.slice(0, -1)];
const deadEntityIds: string[] = [];
let snapshot = createDungeonEncounterSnapshot(progression!, 1_000);
for (const boss of killOrder) {
deadEntityIds.push(boss.id);
snapshot = reduceDungeonEncounter(progression!, snapshot, {
kind: "tick",
now: 1_000 + deadEntityIds.length * 100,
facts: {
registeredEntityIds: required.map((objective) => objective.id),
deadEntityIds,
},
}).snapshot;
}
expect(snapshot.flags["dungeon-complete"]).toBe(true);
expect(snapshot.phase).toBe("complete");
});
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
const sourceRows = objects.filter((object) => (
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
));
expect(wowDungeons).toHaveLength(54);
expect(sourceRows).toHaveLength(3_943);
expect(sourceRows.filter((object) => object.source?.clientDisplayId)).toHaveLength(3_905);
expect(sourceRows.filter((object) => object.source?.clientModelPath)).toHaveLength(3_837);
expect(sourceRows.filter((object) => object.source?.clientBounds)).toHaveLength(3_547);
for (const dungeon of wowDungeons) {
expect(new Set(dungeon.serverGameObjects.map((object) => object.id)).size)
.toBe(dungeon.serverGameObjects.length);
for (const object of dungeon.serverGameObjects) {
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.source?.files.length).toBeGreaterThan(0);
if (object.source?.spawnId !== undefined) {
expect(object.source.templateId).toBeGreaterThan(0);
expect(object.source.spawnId).toBeGreaterThan(0);
expect(object.source.gameObjectType).toBeGreaterThanOrEqual(0);
expect(object.source.gameObjectType).toBeLessThanOrEqual(35);
}
if (object.visual?.hidden) {
expect(object.interaction).toBeUndefined();
expect(object.states.every((state) => !state.blocksMovement && !state.blocksSight)).toBe(true);
}
}
}
});
it("publishes a deduplicated export manifest for source-backed gameobject visuals", () => {
expect(gameObjectAssetManifest.summary).toEqual({
typedInstances: 3_943,
visualDisplayInstances: 3_905,
nonVisualInstances: 38,
resolvedModelInstances: 3_837,
boundedInstances: 3_547,
uniqueAssets: 458,
unresolvedModelInstances: 68,
unresolvedBoundInstances: 358,
unresolvedModelDisplayIds: 9,
unresolvedBoundDisplayIds: 68,
});
expect(gameObjectAssetManifest.assets).toHaveLength(458);
expect(new Set(gameObjectAssetManifest.assets.map(
(asset) => asset.sourceModelPath.toLowerCase(),
)).size).toBe(gameObjectAssetManifest.assets.length);
expect(gameObjectAssetManifest.assets.every((asset) => (
["mdl", "mdx"].includes(asset.sourceType)
&& asset.targetUrl.startsWith("/assets/game/world-objects/")
&& asset.targetUrl.endsWith(".glb")
&& asset.instances > 0
&& asset.displayIds.length > 0
&& asset.dungeons.length > 0
))).toBe(true);
expect(gameObjectAssetManifest.assets.reduce((total, asset) => total + asset.instances, 0))
.toBe(gameObjectAssetManifest.summary.resolvedModelInstances);
expect(gameObjectAssetManifest.nonVisualObjects.reduce(
(total, object) => total + object.instances,
0,
)).toBe(gameObjectAssetManifest.summary.nonVisualInstances);
expect(gameObjectAssetManifest.unresolvedModels.map((entry) => entry.displayId)).toEqual([
9158, 9159, 9160, 9161, 9162, 9163, 9236, 9467, 9468,
]);
});
it("runs Wailing Caverns with the complete server population and authored presentation", () => {
const wailing = requireDungeonDefinition("wailing-caverns");
const runtimePopulation = wailing.staticSpawns.length
@@ -101,9 +417,17 @@ describe("generated dungeon campaign catalog", () => {
const sourcePopulation = source.staticSpawns.length
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
const runtimePopulation = runtime.staticSpawns.length
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0)
+ new Set((runtime.encounterSpawnGroups ?? [])
.flatMap((group) => group.spawns.map((spawn) => spawn.id))
.filter((id) => !runtime.staticSpawns.some((spawn) => spawn.id === id))).size;
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
if (source.id === "frozen-reach") {
expect(runtime.staticSpawns.some((spawn) => spawn.id.startsWith("generated-trash-"))).toBe(false);
expect(runtime.validation.warnings.join(" ")).toMatch(/recovered Alva\/Arktos/i);
} else {
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
}
}
});
@@ -113,7 +437,11 @@ describe("generated dungeon campaign catalog", () => {
...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
const optionalBosses = generated.definitions.flatMap((dungeon) => (
dungeon.objectiveOrder.filter((objective) => "optional" in objective && objective.optional)
dungeon.objectiveOrder.filter((objective) => {
if (!("optional" in objective) || !objective.optional) return false;
const spawn = dungeon.staticSpawns.find((candidate) => candidate.id === objective.id);
return Boolean(spawn && "spawnChance" in spawn);
})
));
expect(chanceSpawns).toHaveLength(24);
@@ -355,6 +683,44 @@ describe("generated dungeon campaign catalog", () => {
}
});
it("makes every required boss objective available in every declared difficulty", () => {
for (const dungeon of generated.definitions) {
const spawns = new Map(dungeon.staticSpawns.map((spawn) => [spawn.id, spawn]));
for (const boss of dungeon.objectiveOrder) {
if (("eventOnly" in boss && boss.eventOnly) || ("optional" in boss && boss.optional)) continue;
const spawn = spawns.get(boss.id);
expect(spawn, `${dungeon.id}:${boss.name}`).toBeTruthy();
for (const difficulty of dungeon.difficultyVariants) {
expect(
(spawn!.spawnMask & difficulty.spawnMask) !== 0,
`${dungeon.id}:${boss.name}:${difficulty.id}`,
).toBe(true);
}
}
}
});
it("preserves heroic-only Epoch bosses without gating normal completion", () => {
for (const [dungeonId, bossName] of [
["the-nexus", "Frozen Commander"],
["ahnkahet-old-kingdom", "Amanitar"],
["gundrak", "Eck the Ferocious"],
] as const) {
const dungeon = generated.definitions.find((entry) => entry.id === dungeonId)!;
const boss = dungeon.objectiveOrder.find((objective) => objective.name === bossName)!;
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
expect("optional" in boss && boss.optional, `${dungeonId}:${bossName}`).toBe(true);
expect(spawn.spawnMask, `${dungeonId}:${bossName}`).toBe(2);
}
const blackMorass = generated.definitions.find((dungeon) => dungeon.id === "black-morass")!;
for (const boss of blackMorass.objectiveOrder) {
const spawn = blackMorass.staticSpawns.find((entry) => entry.id === boss.id)!;
expect("optional" in boss && boss.optional, boss.name).not.toBe(true);
expect(spawn.spawnMask, boss.name).toBe(3);
}
});
it("aligns the Deadmines source population to its packaged interior route", () => {
const deadmines = requireDungeonDefinition("deadmines");
const firstPack = deadmines.staticSpawns.find(
+24
View File
@@ -46,6 +46,30 @@ describe("dungeon combat fidelity integration", () => {
expect(friendlyResult.reason).toBeUndefined();
});
it("cycles only living enemy targets that are in range and line of sight", () => {
for (const [id, position] of [
["blocked-near", [2, 0, 0]],
["visible", [4, 0, 0]],
["too-far", [41, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id });
setMobPosition(id, position);
}
let blockVisibleTarget = false;
registerCombatSpatialProvider({
actor: (id) => ({ id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: (_origin, target) => target[0] !== 2 && (!blockVisibleTarget || target[0] !== 4),
projectGroundPoint: (point) => point,
});
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
blockVisibleTarget = true;
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBeNull();
expect(useCombatStore.getState().selectedTargetId).toBeNull();
});
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
registerTarget();
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { deriveDungeonEncounterCombatEvents } from "./dungeonEncounterCombatEvents";
describe("dungeon encounter combat event bridge", () => {
it("emits stable engage, descending health, and death edges once", () => {
const engaged = deriveDungeonEncounterCombatEvents({}, {
boss: { engaged: true, dead: false, despawned: false, health: 1_000, maxHealth: 1_000 },
}, 1);
expect(engaged.events).toEqual([{ kind: "entity-engaged", entityId: "boss", now: 1 }]);
const damaged = deriveDungeonEncounterCombatEvents(engaged.cursor, {
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
}, 2);
expect(damaged.events).toEqual([{
kind: "entity-health",
entityId: "boss",
healthPercent: 49,
now: 2,
}]);
const unchanged = deriveDungeonEncounterCombatEvents(damaged.cursor, {
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
}, 3);
expect(unchanged.events).toEqual([]);
const dead = deriveDungeonEncounterCombatEvents(unchanged.cursor, {
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
}, 4);
expect(dead.events).toEqual([{ kind: "entity-death", entityId: "boss", now: 4 }]);
expect(deriveDungeonEncounterCombatEvents(dead.cursor, {
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
}, 5).events).toEqual([]);
});
it("does not treat scripted despawn as death and permits a later respawn edge", () => {
const first = deriveDungeonEncounterCombatEvents({}, {
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
}, 1);
const despawned = deriveDungeonEncounterCombatEvents(first.cursor, {
add: { engaged: false, dead: false, despawned: true, health: 100, maxHealth: 100 },
}, 2);
expect(despawned.events).toEqual([]);
expect(despawned.cursor).toEqual({});
expect(deriveDungeonEncounterCombatEvents(despawned.cursor, {
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
}, 3).events).toEqual([{ kind: "entity-engaged", entityId: "add", now: 3 }]);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { DungeonEncounterEvent } from "./dungeonEncounterTypes";
export interface DungeonEncounterMobObservation {
readonly engaged: boolean;
readonly dead: boolean;
readonly despawned: boolean;
readonly health: number;
readonly maxHealth: number;
}
export interface DungeonEncounterMobCursorEntry {
readonly engaged: boolean;
readonly dead: boolean;
readonly healthPercent: number;
}
export type DungeonEncounterMobCursor = Readonly<Record<string, DungeonEncounterMobCursorEntry>>;
type CombatEdgeEvent = Extract<
DungeonEncounterEvent,
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
>;
export interface DungeonEncounterCombatEventBatch {
readonly cursor: DungeonEncounterMobCursor;
readonly events: readonly CombatEdgeEvent[];
}
function healthPercent(mob: DungeonEncounterMobObservation): number {
if (!Number.isFinite(mob.maxHealth) || mob.maxHealth <= 0) return 0;
return Math.max(0, Math.min(100, (Math.max(0, mob.health) / mob.maxHealth) * 100));
}
/**
* Converts mutable combat-state edges into deterministic encounter events.
* Despawned actors are deliberately omitted so scripted cleanup never looks
* like a kill and a later respawn of the same stable id can engage normally.
*/
export function deriveDungeonEncounterCombatEvents(
previous: DungeonEncounterMobCursor,
mobs: Readonly<Record<string, DungeonEncounterMobObservation>>,
now: number,
): DungeonEncounterCombatEventBatch {
const cursor: Record<string, DungeonEncounterMobCursorEntry> = {};
const events: CombatEdgeEvent[] = [];
for (const entityId of Object.keys(mobs).sort()) {
const mob = mobs[entityId];
if (mob.despawned) continue;
const percent = healthPercent(mob);
const prior = previous[entityId];
cursor[entityId] = Object.freeze({
engaged: mob.engaged,
dead: mob.dead,
healthPercent: percent,
});
if (mob.engaged && !mob.dead && !prior?.engaged) {
events.push(Object.freeze({ kind: "entity-engaged", entityId, now }));
}
if (!mob.dead && percent < (prior?.healthPercent ?? 100)) {
events.push(Object.freeze({ kind: "entity-health", entityId, healthPercent: percent, now }));
}
if (mob.dead && !prior?.dead) {
events.push(Object.freeze({ kind: "entity-death", entityId, now }));
}
}
return Object.freeze({ cursor: Object.freeze(cursor), events: Object.freeze(events) });
}
+300 -2
View File
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
activateDungeonEncounterDefinitions,
activeDungeonEncounterRevision,
activeDungeonRunIsComplete,
createDungeonEncounterSnapshot,
dispatchActiveDungeonEntityEvent,
dispatchActiveDungeonInteraction,
dispatchDungeonEncounterSignal,
drainDungeonEncounterEffects,
@@ -12,6 +14,7 @@ import {
importDungeonEncounterSnapshots,
reduceDungeonEncounter,
registerDungeonEncounterEffectHandler,
resetDungeonEncounters,
resetDungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots,
validateDungeonEncounterDefinition,
@@ -104,14 +107,32 @@ const MANAGER_B = {
{ id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" },
],
},
{
id: "observe-boss-death",
trigger: { kind: "entity-death", entityId: "boss" },
actions: [{ id: "mark-boss-dead", kind: "set-flag", flagId: "boss-dead", value: true }],
},
],
} as const satisfies DungeonEncounterDefinition;
const MANAGER_DIFFICULTY = {
id: "test:manager-difficulty",
dungeonId: "manager-difficulty",
initialPhase: "idle",
rules: [{
id: "heroic-signal",
trigger: { kind: "signal", signalId: "test:heroic" },
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
actions: [{ id: "mark-live-heroic", kind: "set-flag", flagId: "heroic", value: true }],
}],
} as const satisfies DungeonEncounterDefinition;
afterEach(() => {
activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]);
useWailingEncounterStore.getState().reset();
drainDungeonEncounterEffects("manager-a");
drainDungeonEncounterEffects("manager-b");
drainDungeonEncounterEffects("manager-difficulty");
});
describe("dungeon encounter reducer", () => {
@@ -195,6 +216,57 @@ describe("dungeon encounter reducer", () => {
});
});
it("reconstructs persisted state instead of retaining a temporary overwrite on reset", () => {
const definition = {
id: "test:persistent-reconstruction",
dungeonId: "test-dungeon",
initialPhase: "locked",
initialState: { worldObjectStates: { herald: "locked" } },
resetPolicy: { bossWipePhase: "ready" },
rules: [
{
id: "unlock-herald",
trigger: { kind: "signal", signalId: "unlock" },
actions: [{
id: "persist-herald-ready",
kind: "set-world-object-state",
objectId: "herald",
state: "ready",
persistence: "run",
}],
},
{
id: "start-attempt",
trigger: { kind: "interact", objectId: "herald" },
guards: [{ kind: "world-object-state", objectId: "herald", states: ["ready"] }],
actions: [{
id: "temporarily-activate-herald",
kind: "set-world-object-state",
objectId: "herald",
state: "active",
}],
},
],
} as const satisfies DungeonEncounterDefinition;
const unlocked = reduceDungeonEncounter(
definition,
createDungeonEncounterSnapshot(definition),
{ kind: "signal", signalId: "unlock", now: 1 },
).snapshot;
const active = reduceDungeonEncounter(
definition,
unlocked,
{ kind: "interact", objectId: "herald", now: 2 },
).snapshot;
expect(active.worldObjectStates.herald).toBe("active");
const reset = resetDungeonEncounterSnapshot(definition, active, "boss-wipe", 3);
expect(reset).toMatchObject({
phase: "ready",
worldObjectStates: { herald: "ready" },
});
});
it("rejects duplicate action ids at definition validation", () => {
expect(() => validateDungeonEncounterDefinition({
id: "test:invalid",
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
}],
})).toThrow(/duplicate action id same/);
});
it("rejects empty difficulty guards at definition validation", () => {
expect(() => validateDungeonEncounterDefinition({
id: "test:invalid-difficulty",
dungeonId: "test-dungeon",
initialPhase: "idle",
rules: [{
id: "empty-difficulty",
trigger: { kind: "tick" },
guards: [{ kind: "difficulty", difficultyIds: [] }],
actions: [{ id: "should-not-fire", kind: "set-phase", phase: "invalid" }],
}],
})).toThrow(/empty difficulty guard/);
});
it("evaluates difficulty guards from ephemeral runtime context", () => {
const definition = {
id: "test:difficulty",
dungeonId: "test-dungeon",
initialPhase: "idle",
rules: [{
id: "heroic-branch",
trigger: { kind: "signal", signalId: "start" },
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
actions: [{ id: "mark-heroic", kind: "set-flag", flagId: "heroic", value: true }],
}],
} as const satisfies DungeonEncounterDefinition;
const initial = createDungeonEncounterSnapshot(definition);
const event = { kind: "signal", signalId: "start", now: 1 } as const;
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "normal" }).snapshot).toBe(initial);
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "heroic" }).snapshot.flags.heroic).toBe(true);
});
it("never turns an entities-dead safety timeout into boss or dungeon credit", () => {
const completion = {
id: "test:timeout-completion",
dungeonId: "test-dungeon",
initialPhase: "boss-active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "boss-cleared",
trigger: { kind: "entities-dead", entityIds: ["boss"], timeoutMs: 1_000 },
actions: [
{ id: "complete-timeout-boss", kind: "complete-objective", objectiveId: "boss", persistence: "run" },
{ id: "checkpoint-timeout-boss", kind: "set-checkpoint", checkpointId: "boss", persistence: "run" },
{ id: "complete-timeout-dungeon", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
],
}],
} as const satisfies DungeonEncounterDefinition;
const initial = createDungeonEncounterSnapshot(completion, 0);
const idled = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: ["boss"], deadEntityIds: [] },
}).snapshot;
const missing = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: [], deadEntityIds: [] },
}).snapshot;
expect(idled).toBe(initial);
expect(missing).toBe(initial);
const killed = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: ["boss"], deadEntityIds: ["boss"] },
}).snapshot;
expect(killed.completedObjectiveIds).toEqual(["boss"]);
expect(killed.flags["dungeon-complete"]).toBe(true);
const recovery = {
id: "test:timeout-recovery",
dungeonId: "test-dungeon",
initialPhase: "wave-active",
rules: [{
id: "missing-wave-recovery",
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
actions: [
{ id: "despawn-missing-wave", kind: "deactivate-spawn-group", spawnGroupId: "missing-wave" },
{ id: "leave-missing-wave", kind: "set-phase", phase: "recovered" },
],
}],
} as const satisfies DungeonEncounterDefinition;
const recovered = reduceDungeonEncounter(
recovery,
createDungeonEncounterSnapshot(recovery, 0),
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
).snapshot;
expect(recovered.phase).toBe("recovered");
const surrogateProgress = {
id: "test:timeout-surrogate-progress",
dungeonId: "test-dungeon",
initialPhase: "wave-active",
initialState: { flags: { "escort-complete": false } },
rules: [{
id: "missing-wave-cannot-persist",
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
actions: [
{ id: "leave-missing-persistent-wave", kind: "set-phase", phase: "recovered" },
{
id: "persist-fake-escort-credit",
kind: "set-flag",
flagId: "escort-complete",
value: true,
persistence: "run",
},
],
}],
} as const satisfies DungeonEncounterDefinition;
const surrogateInitial = createDungeonEncounterSnapshot(surrogateProgress, 0);
const refused = reduceDungeonEncounter(
surrogateProgress,
surrogateInitial,
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
).snapshot;
expect(refused).toBe(surrogateInitial);
});
});
describe("active dungeon encounter manager", () => {
it("requires every declared completion authority before finishing a dungeon", () => {
const baseline = {
id: "test:completion-baseline",
dungeonId: "completion-dungeon",
initialPhase: "active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "complete-baseline",
trigger: { kind: "signal", signalId: "baseline-complete" },
actions: [{
id: "mark-baseline-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
}],
}],
} as const satisfies DungeonEncounterDefinition;
const instance = {
id: "test:completion-instance",
dungeonId: "completion-dungeon",
initialPhase: "active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "complete-instance",
trigger: { kind: "signal", signalId: "instance-complete" },
actions: [{
id: "mark-instance-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
}],
}],
} as const satisfies DungeonEncounterDefinition;
activateDungeonEncounterDefinitions("completion-dungeon", [baseline, instance]);
expect(activeDungeonRunIsComplete()).toBe(false);
dispatchDungeonEncounterSignal("baseline-complete", 1);
expect(activeDungeonRunIsComplete()).toBe(false);
dispatchDungeonEncounterSignal("instance-complete", 2);
expect(activeDungeonRunIsComplete()).toBe(true);
});
it("isolates shared signals to the active dungeon", () => {
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
dispatchDungeonEncounterSignal("test:shared", 1_000);
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1);
});
it("broadcasts live combat entity edges to the active dungeon", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
expect(dispatchActiveDungeonEntityEvent({
kind: "entity-death",
entityId: "boss",
now: 2_500,
})).toEqual([]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.flags["boss-dead"]).toBe(true);
const managerBRevision = dungeonEncounterSnapshot(MANAGER_B.id)?.revision;
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_600 });
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.revision).toBe(managerBRevision);
});
it("updates difficulty context when an existing dungeon provider is reactivated", () => {
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "normal");
dispatchDungeonEncounterSignal("test:heroic", 1);
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).not.toBe(true);
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "heroic");
dispatchDungeonEncounterSignal("test:heroic", 2);
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).toBe(true);
});
it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
const subscriber = vi.fn();
const effectHandler = vi.fn();
const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber);
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
actionId: "switch-cue",
signalId: "test:switch-opened",
});
expect(effectHandler).toHaveBeenCalled();
expect(effectHandler).toHaveBeenCalledTimes(3);
expect(subscriber).toHaveBeenCalled();
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
const exported = exportDungeonEncounterSnapshots("manager-b");
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
const olderAuthority = {
...hydrated,
phase: "authority-restored",
revision: Math.max(0, hydrated.revision - 1),
};
expect(importDungeonEncounterSnapshots("manager-b", [olderAuthority])).toEqual([]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
expect(importDungeonEncounterSnapshots(
"manager-b",
[olderAuthority],
{ authoritative: true },
)).toEqual([MANAGER_B.id]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("authority-restored");
unregisterEffect();
unsubscribe();
});
it("keeps effects drainable without a handler and clears queued effects on reset", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 4_000)).toHaveLength(3);
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 4_100)).toHaveLength(3);
resetDungeonEncounters("dungeon", "manager-b");
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
});
it("discards undrained effects when the active dungeon changes", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 5_000)).toHaveLength(3);
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
});
});
+205 -69
View File
@@ -20,6 +20,15 @@ function unique(values: readonly string[]): readonly string[] {
return Object.freeze([...new Set(values)].sort());
}
function orderedUnique(values: readonly string[]): readonly string[] {
const seen = new Set<string>();
return Object.freeze(values.filter((value) => {
if (seen.has(value)) return false;
seen.add(value);
return true;
}));
}
export function createDungeonEncounterSnapshot(
definition: DungeonEncounterDefinition,
now = 0,
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
validateStableId(definition.id, "Encounter");
validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`);
validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`);
if (definition.source) {
if (!definition.source.files.length || definition.source.files.some((file) => !file.trim())) {
throw new Error(`Encounter ${definition.id} source review must name at least one source file`);
}
if (!definition.source.reviewedBehavior.trim()) {
throw new Error(`Encounter ${definition.id} source review must describe the reviewed behavior`);
}
if (definition.source.fidelity === "source-exact"
&& (definition.source.adaptations.length || definition.source.remainingGaps.length)) {
throw new Error(`Source-exact encounter ${definition.id} cannot retain adaptations or gaps`);
}
}
const ruleIds = new Set<string>();
const actionIds = new Set<string>();
for (const rule of definition.rules) {
validateStableId(rule.id, `Encounter ${definition.id} rule`);
if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`);
ruleIds.add(rule.id);
for (const guard of rule.guards ?? []) {
if (guard.kind === "difficulty") {
if (!guard.difficultyIds.length) {
throw new Error(`Encounter ${definition.id} rule ${rule.id} has an empty difficulty guard`);
}
for (const difficultyId of guard.difficultyIds) {
validateStableId(
difficultyId,
`Encounter ${definition.id} rule ${rule.id} difficulty`,
);
}
}
}
for (const action of rule.actions) {
validateStableId(action.id, `Encounter ${definition.id} action`);
if (actionIds.has(action.id)) {
@@ -72,8 +106,19 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
}
}
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
export interface DungeonEncounterRuntimeContext {
readonly difficultyId?: string;
}
function guardMatches(
snapshot: DungeonEncounterSnapshot,
guard: DungeonEncounterGuard,
context: DungeonEncounterRuntimeContext,
): boolean {
if (guard.kind === "phase") return guard.phases.includes(snapshot.phase);
if (guard.kind === "difficulty") {
return context.difficultyId !== undefined && guard.difficultyIds.includes(context.difficultyId);
}
if (guard.kind === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals;
if (guard.kind === "objective-complete") {
return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals;
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
return current <= guard.value;
}
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
function triggerMatches(
snapshot: DungeonEncounterSnapshot,
trigger: DungeonEncounterRule["trigger"],
event: DungeonEncounterEvent,
): boolean {
if (trigger.kind === "tick") return event.kind === "tick";
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId;
): DungeonEncounterTriggerMatch {
if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false;
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false;
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") {
return event.kind === trigger.kind && event.entityId === trigger.entityId;
return event.kind === trigger.kind && event.entityId === trigger.entityId ? "event" : false;
}
if (trigger.kind === "entity-health") {
return event.kind === "entity-health"
&& event.entityId === trigger.entityId
&& event.healthPercent <= trigger.atOrBelowPercent;
&& event.healthPercent <= trigger.atOrBelowPercent
? "event"
: false;
}
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId ? "event" : false;
if (trigger.kind === "elapsed") {
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs;
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs ? "event" : false;
}
if (event.kind !== "tick") return false;
const registered = new Set(event.facts?.registeredEntityIds ?? []);
const dead = new Set(event.facts?.deadEntityIds ?? []);
const allDead = trigger.entityIds.length > 0
&& trigger.entityIds.every((id) => registered.has(id) && dead.has(id));
if (allDead) return "event";
const hasAuthoritativeFacts = event.facts?.registeredEntityIds !== undefined
&& event.facts.deadEntityIds !== undefined;
const hasRegisteredLivingTarget = trigger.entityIds.some((id) => (
registered.has(id) && !dead.has(id)
));
const timedOut = trigger.timeoutMs !== undefined
&& hasAuthoritativeFacts
&& !hasRegisteredLivingTarget
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs;
return allDead || timedOut;
return timedOut ? "timeout" : false;
}
function ruleCanAdvanceOnMissingEntityTimeout(rule: DungeonEncounterRule): boolean {
return !rule.actions.some((action) => (
action.persistence === "checkpoint"
|| action.persistence === "run"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
|| action.kind === "grant-loot"
));
}
function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean {
@@ -193,7 +260,7 @@ function applyAction(
}
return {
snapshot: consume
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) }
? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
: next,
effect: actionEffect(action),
};
@@ -203,6 +270,7 @@ export function reduceDungeonEncounter(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
context: DungeonEncounterRuntimeContext = {},
): DungeonEncounterReduction {
if (snapshot.encounterId !== definition.id) {
throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${definition.id}`);
@@ -215,8 +283,10 @@ export function reduceDungeonEncounter(
.sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index);
for (const { rule } of rules) {
if (ruleAlreadyConsumed(next, rule)) continue;
if (!triggerMatches(next, rule.trigger, event)) continue;
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && rule.guards?.length) continue;
const triggerMatch = triggerMatches(next, rule.trigger, event);
if (!triggerMatch) continue;
if (triggerMatch === "timeout" && !ruleCanAdvanceOnMissingEntityTimeout(rule)) continue;
if (!rule.guards?.every((guard) => guardMatches(next, guard, context)) && rule.guards?.length) continue;
let fired = false;
for (const action of rule.actions) {
const applied = applyAction(next, action, event.now, rule.once ?? true);
@@ -246,44 +316,54 @@ function retainedActionIds(
return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id)));
}
function retainedStateKeys(definition: DungeonEncounterDefinition): {
readonly flags: ReadonlySet<string>;
readonly counters: ReadonlySet<string>;
readonly worldObjects: ReadonlySet<string>;
readonly transitions: ReadonlySet<string>;
readonly objectives: ReadonlySet<string>;
readonly checkpoints: boolean;
function reconstructedPersistentState(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
initial: DungeonEncounterSnapshot,
): {
readonly flags: Readonly<Record<string, boolean>>;
readonly counters: Readonly<Record<string, number>>;
readonly worldObjectStates: Readonly<Record<string, string>>;
readonly transitionStates: Readonly<Record<string, boolean>>;
readonly completedObjectiveIds: readonly string[];
readonly checkpointId: string | null;
} {
const actions = definition.rules.flatMap((rule) => rule.actions)
const actionsById = new Map(definition.rules
.flatMap((rule) => rule.actions)
.map((action) => [action.id, action] as const));
const actions = snapshot.consumedActionIds
.map((id) => actionsById.get(id))
.filter((action): action is DungeonEncounterAction => action !== undefined)
.filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
return {
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)),
counters: new Set(actions
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter")
.map((action) => action.counterId)),
worldObjects: new Set(actions
.filter((action) => action.kind === "set-world-object-state")
.map((action) => action.objectId)),
transitions: new Set(actions
.filter((action) => action.kind === "set-transition-active")
.map((action) => action.transitionId)),
objectives: new Set(actions
.filter((action) => action.kind === "complete-objective")
.map((action) => action.objectiveId)),
checkpoints: actions.some((action) => action.kind === "set-checkpoint"),
};
}
function retainedRecord<T>(
initial: Readonly<Record<string, T>>,
current: Readonly<Record<string, T>>,
keys: ReadonlySet<string>,
): Readonly<Record<string, T>> {
const retained: Record<string, T> = { ...initial };
for (const key of keys) {
if (current[key] !== undefined) retained[key] = current[key];
const flags = { ...initial.flags };
const counters = { ...initial.counters };
const worldObjectStates = { ...initial.worldObjectStates };
const transitionStates = { ...initial.transitionStates };
const completedObjectiveIds = new Set<string>();
let checkpointId: string | null = null;
for (const action of actions) {
if (action.kind === "set-flag") flags[action.flagId] = action.value;
else if (action.kind === "set-counter") counters[action.counterId] = action.value;
else if (action.kind === "increment-counter") {
counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1);
} else if (action.kind === "set-world-object-state") {
worldObjectStates[action.objectId] = action.state;
} else if (action.kind === "set-transition-active") {
transitionStates[action.transitionId] = action.active;
} else if (action.kind === "complete-objective") {
completedObjectiveIds.add(action.objectiveId);
} else if (action.kind === "set-checkpoint") {
checkpointId = action.checkpointId;
}
}
return Object.freeze(retained);
return {
flags: Object.freeze(flags),
counters: Object.freeze(counters),
worldObjectStates: Object.freeze(worldObjectStates),
transitionStates: Object.freeze(transitionStates),
completedObjectiveIds: Object.freeze([...completedObjectiveIds].sort()),
checkpointId,
};
}
export function resetDungeonEncounterSnapshot(
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
const phase = scope === "boss-wipe"
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
const retained = retainedStateKeys(definition);
const retained = reconstructedPersistentState(definition, snapshot, initial);
return Object.freeze({
...initial,
phase,
phaseStartedAt: finiteTime(now),
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags),
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters),
worldObjectStates: retainedRecord(
initial.worldObjectStates,
snapshot.worldObjectStates,
retained.worldObjects,
),
transitionStates: retainedRecord(
initial.transitionStates,
snapshot.transitionStates,
retained.transitions,
),
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
.filter((id) => retained.objectives.has(id))),
flags: retained.flags,
counters: retained.counters,
worldObjectStates: retained.worldObjectStates,
transitionStates: retained.transitionStates,
completedObjectiveIds: retained.completedObjectiveIds,
consumedActionIds: retainedActionIds(definition, snapshot, scope),
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
checkpointId: retained.checkpointId,
});
}
@@ -330,6 +401,8 @@ export interface DungeonEncounterProvider {
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
/** Hydrates authoritative online/save state without involving renderer state. */
readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void;
/** Updates ephemeral run context without polluting serialized snapshots. */
readonly setContext?: (context: DungeonEncounterRuntimeContext) => void;
}
const providers = new Map<string, DungeonEncounterProvider>();
@@ -337,6 +410,14 @@ const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
let activeDungeonId: string | null = null;
function clearPendingDungeonEncounterEffects(dungeonId?: string): void {
for (let index = pendingEffects.length - 1; index >= 0; index -= 1) {
if (!dungeonId || pendingEffects[index].dungeonId === dungeonId) {
pendingEffects.splice(index, 1);
}
}
}
function queueProviderEffects(
provider: DungeonEncounterProvider,
effects: readonly DungeonEncounterEffect[],
@@ -347,8 +428,11 @@ function queueProviderEffects(
encounterId: provider.encounterId,
effect,
});
pendingEffects.push(value);
for (const handler of effectHandlers) handler(value);
if (effectHandlers.size) {
for (const handler of effectHandlers) handler(value);
} else {
pendingEffects.push(value);
}
}
return effects;
}
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
return Object.freeze(effects);
}
export function dispatchActiveDungeonEntityEvent(
event: Extract<
DungeonEncounterEvent,
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
>,
): readonly DungeonEncounterEffect[] {
if (!activeDungeonId) return [];
const effects: DungeonEncounterEffect[] = [];
for (const provider of providers.values()) {
if (provider.dungeonId !== activeDungeonId) continue;
effects.push(...queueProviderEffects(provider, provider.dispatch(event)));
}
notifyEncounterSubscribers();
return Object.freeze(effects);
}
export function advanceDungeonEncounters(
dungeonId: string,
event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>,
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
dungeonId?: string,
now = 0,
): void {
clearPendingDungeonEncounterEffects(dungeonId);
for (const provider of providers.values()) {
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
}
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
export function activateDungeonEncounterDefinitions(
dungeonId: string,
definitions: readonly DungeonEncounterDefinition[],
difficultyId?: string,
): void {
if (activeDungeonId && activeDungeonId !== dungeonId) {
clearPendingDungeonEncounterEffects(activeDungeonId);
}
activeDungeonId = dungeonId;
for (const definition of definitions) {
validateDungeonEncounterDefinition(definition);
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id);
if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId });
continue;
}
let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId };
const provider: DungeonEncounterProvider = {
definition,
encounterId: definition.id,
dungeonId,
getSnapshot: () => snapshot,
dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event);
const result = reduceDungeonEncounter(definition, snapshot, event, context);
snapshot = result.snapshot;
return result.effects;
},
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
}
snapshot = cloneEncounterSnapshot(value);
},
setContext: (value) => {
context = Object.freeze({ ...value });
},
};
providers.set(definition.id, provider);
}
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
transitionStates: Object.freeze({ ...value.transitionStates }),
activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
completedObjectiveIds: unique(value.completedObjectiveIds),
consumedActionIds: unique(value.consumedActionIds),
consumedActionIds: orderedUnique(value.consumedActionIds),
revision: Math.max(0, Math.trunc(value.revision)),
});
}
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
export function importDungeonEncounterSnapshots(
dungeonId: string,
snapshots: readonly DungeonEncounterSnapshot[],
options: { readonly authoritative?: boolean } = {},
): readonly string[] {
const imported: string[] = [];
for (const raw of snapshots) {
const provider = providers.get(raw.encounterId);
if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue;
const snapshot = cloneEncounterSnapshot(raw);
if (snapshot.revision < provider.getSnapshot().revision) continue;
if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
provider.replaceSnapshot(snapshot);
imported.push(snapshot.encounterId);
}
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
return Object.freeze(drained);
}
/** Immediate application hook; effects also remain drainable for frame-bound consumers. */
/** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */
export function registerDungeonEncounterEffectHandler(
handler: (value: DungeonEncounterEffectEnvelope) => void,
): () => void {
@@ -598,3 +709,28 @@ export function activeDungeonEncounterSpawnGroupIds(): readonly string[] {
return Object.freeze([...new Set(exportDungeonEncounterSnapshots()
.flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort());
}
export function activeDungeonCompletedObjectiveIds(dungeonId = activeDungeonId): readonly string[] {
return unique(exportDungeonEncounterSnapshots(dungeonId)
.flatMap((snapshot) => snapshot.completedObjectiveIds));
}
function definitionControlsDungeonCompletion(definition: DungeonEncounterDefinition): boolean {
return definition.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)));
}
export function activeDungeonRunIsComplete(dungeonId = activeDungeonId): boolean {
if (!dungeonId) return false;
const completionProviders = [...providers.values()].filter((provider) => (
provider.dungeonId === dungeonId
&& definitionControlsDungeonCompletion(provider.definition)
));
return completionProviders.length > 0
&& completionProviders.every(
(provider) => provider.getSnapshot().flags["dungeon-complete"] === true,
);
}
+24 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "./dungeonEncounterSpawns";
const spawn = (id: string, spawnMask: number) => ({
id,
@@ -20,4 +23,24 @@ describe("encounter spawn groups", () => {
{ id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] },
], ["wave-a"], 2).map((entry) => entry.id)).toEqual(["two"]);
});
it("suppresses completed static and roaming boss runtime ids on checkpoint reload", () => {
const result = suppressCompletedDungeonPopulation(
[spawn("static-boss", 1), spawn("trash", 1)],
[{
id: "boss-patrol",
name: "Boss Patrol",
speed: 1,
formationSource: "database-solo",
waypoints: [[0, 0, 0], [1, 0, 0]],
members: [
{ id: "boss", entityId: "test-mob", role: "leader", formationOffset: [0, 0, 0] },
{ id: "escort", entityId: "test-mob", role: "follower", formationOffset: [1, 0, 0] },
],
}],
["static-boss", "boss-patrol:boss"],
);
expect(result.staticSpawns.map((entry) => entry.id)).toEqual(["trash"]);
expect(result.roamingPacks[0].members.map((member) => member.id)).toEqual(["escort"]);
});
});
+26 -1
View File
@@ -1,4 +1,8 @@
import { spawnMatchesDifficulty, type StaticMobSpawnDefinition } from "./mobPopulation";
import {
spawnMatchesDifficulty,
type MobPackDefinition,
type StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
export function activeEncounterSpawnGroupSpawns(
@@ -20,3 +24,24 @@ export function activeEncounterSpawnGroupSpawns(
return true;
});
}
export function suppressCompletedDungeonPopulation(
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
completedObjectiveIds: readonly string[],
): {
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
readonly roamingPacks: readonly MobPackDefinition[];
} {
if (!completedObjectiveIds.length) return { staticSpawns, roamingPacks };
const completed = new Set(completedObjectiveIds);
return {
staticSpawns: staticSpawns.filter((spawn) => !completed.has(spawn.id)),
roamingPacks: roamingPacks.flatMap((pack) => {
const members = pack.members.filter(
(member) => !completed.has(`${pack.id}:${member.id}`),
);
return members.length ? [{ ...pack, members }] : [];
}),
};
}
+31 -2
View File
@@ -1,6 +1,8 @@
import type { Vector3Tuple } from "./dungeonTypes";
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 1;
// Version 2 preserves consumed action execution order so durable state can be
// reconstructed without reviving attempt-scoped object/flag overwrites.
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 2;
export type DungeonEncounterResetScope = "boss-wipe" | "checkpoint" | "dungeon";
export type DungeonEncounterValue = string | number | boolean;
@@ -52,12 +54,17 @@ export type DungeonEncounterTrigger =
| {
readonly kind: "entities-dead";
readonly entityIds: readonly string[];
/** A source-authored safety timeout prevents an encounter from soft locking. */
/**
* Missing-actor recovery window. It may advance presentation-only rules
* when no listed target is registered alive, but can never award an
* objective, checkpoint, loot, or dungeon completion.
*/
readonly timeoutMs?: number;
};
export type DungeonEncounterGuard =
| { readonly kind: "phase"; readonly phases: readonly string[] }
| { readonly kind: "difficulty"; readonly difficultyIds: readonly string[] }
| { readonly kind: "flag"; readonly flagId: string; readonly equals: boolean }
| {
readonly kind: "counter";
@@ -116,12 +123,34 @@ export interface DungeonEncounterResetPolicy {
readonly checkpointPhase?: string;
}
export type DungeonEncounterFidelity =
| "source-exact"
| "source-backed-adapted"
| "healer-man-authored";
export type DungeonEncounterScope = "boss-progression" | "instance-orchestration";
/**
* Review record kept beside executable encounter data. Adapted behavior must
* remain explicit so a safe approximation is never mistaken for a finished
* port of the original instance script.
*/
export interface DungeonEncounterSourceReview {
readonly fidelity: DungeonEncounterFidelity;
readonly scope: DungeonEncounterScope;
readonly files: readonly string[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
export interface DungeonEncounterDefinition {
readonly id: string;
readonly dungeonId: string;
readonly initialPhase: string;
readonly initialState?: DungeonEncounterInitialState;
readonly resetPolicy?: DungeonEncounterResetPolicy;
readonly source?: DungeonEncounterSourceReview;
readonly rules: readonly DungeonEncounterRule[];
}
+89 -1
View File
@@ -7,6 +7,18 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate";
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
import { withWowWrathEncounterContent } from "./wowWrathEncounterContentCatalog";
import { withWowClassicEncounterContent } from "./wowClassicEncounterContentCatalog";
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
import { withWowWrathEncounterBatchTwoContent } from "./wowWrathEncounterBatchTwoCatalog";
import { withWowWrathEncounterBatchThreeContent } from "./wowWrathEncounterBatchThreeCatalog";
import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCatalog";
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -60,6 +72,45 @@ function enrichAuthoredDefinition(
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
);
const authoredObjectiveByName = new Map(
authored.objectiveOrder.map((objective) => [normalizedName(objective.name), objective.id]),
);
const generatedObjectiveAliases = new Map(
generated.objectiveOrder.map((objective) => [
objective.id,
authoredObjectiveByName.get(normalizedName(objective.name)) ?? objective.id,
]),
);
const authoredId = (id: string): string => generatedObjectiveAliases.get(id) ?? id;
const remappedGeneratedEncounters = (generated.encounters ?? []).map((encounter) => (
encounter.source?.scope !== "boss-progression"
? encounter
: {
...encounter,
rules: encounter.rules.map((rule) => ({
...rule,
trigger: rule.trigger.kind === "entities-dead"
? { ...rule.trigger, entityIds: rule.trigger.entityIds.map(authoredId) }
: rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged"
? { ...rule.trigger, entityId: authoredId(rule.trigger.entityId) }
: rule.trigger,
guards: rule.guards?.map((guard) => (
guard.kind === "objective-complete"
? { ...guard, objectiveId: authoredId(guard.objectiveId) }
: guard
)),
actions: rule.actions.map((action) => {
if (action.kind === "complete-objective") {
return { ...action, objectiveId: authoredId(action.objectiveId) };
}
if (action.kind === "set-checkpoint") {
return { ...action, checkpointId: authoredId(action.checkpointId) };
}
return action;
}),
})),
}
));
return {
...authored,
entities: {
@@ -76,6 +127,15 @@ function enrichAuthoredDefinition(
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
)),
],
encounters: [
...remappedGeneratedEncounters,
...(authored.encounters ?? []),
],
encounterSpawnGroups: [
...(generated.encounterSpawnGroups ?? []),
...(authored.encounterSpawnGroups ?? []),
],
serverGameObjects: generated.serverGameObjects,
};
}
@@ -88,6 +148,31 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
throw new Error(`Default dungeon id mismatch: ${authoredWailingCaverns.id}`);
}
const DUNGEON_CONTENT_ADAPTERS: readonly (
(definition: DungeonDefinition) => DungeonDefinition
)[] = Object.freeze([
withWowEncounterContent,
withWowClassicEncounterContent,
withWowClassicEncounterBatchTwo,
withWowClassicEncounterBatchThree,
withWowVerifiedNoOpInstanceContent,
withWowWrathEncounterContent,
withWowWrathEncounterBatchTwoContent,
withWowWrathEncounterBatchThreeContent,
withWowProgressionObjectContent,
withWowTbcEncounterContent,
withWowTbcEncounterBatchTwoContent,
withAscensionCustomEncounterContent,
withRunewakerEncounterContent,
]);
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
return DUNGEON_CONTENT_ADAPTERS.reduce(
(current, applyContent) => applyContent(current),
definition,
);
}
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
@@ -99,7 +184,10 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries.map(
([id, definition]) => [id, withRunewakerEncounterContent(definition)] as const,
([id, definition]) => [
id,
applyDungeonContentAdapters(definition),
] as const,
)),
);
+13
View File
@@ -40,4 +40,17 @@ describe("dungeon run checkpoints", () => {
savedAt: 1,
})).toBeNull();
});
it("fails closed on legacy checkpoints whose sorted action history cannot be reconstructed", () => {
const currentSnapshot = createDungeonEncounterSnapshot(definition);
expect(normalizeDungeonRunCheckpoint({
version: 1,
dungeonId: "test",
difficultyId: "normal",
checkpointId: null,
spawn: { footPosition: [1, 2, 3], yaw: 0 },
encounters: [{ ...currentSnapshot, schemaVersion: 1 }],
savedAt: 1,
})).toBeNull();
});
});
+4 -1
View File
@@ -2,7 +2,10 @@ import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
export const DUNGEON_RUN_CHECKPOINT_VERSION = 1;
// Version 1 snapshots sorted consumed action ids and cannot be migrated
// reliably when multiple durable actions write the same state slot. Failing
// those active-run checkpoints closed is safer than resuming corrupted doors.
export const DUNGEON_RUN_CHECKPOINT_VERSION = 2;
export interface DungeonRunCheckpoint {
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
+11 -3
View File
@@ -12,10 +12,12 @@ import { useManastormAdminStore } from "./manastormAdminStore";
import type { GmReviewTarget } from "./gmContentReview";
import {
activateDungeonEncounterDefinitions,
exportDungeonEncounterSnapshots,
importDungeonEncounterSnapshots,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { dungeonContentIsUsable } from "../content/contentManager";
export interface DungeonSessionOptions {
readonly gameMode?: GameMode;
@@ -39,13 +41,17 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId)) {
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
return false;
}
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
activateDungeonEncounterDefinitions(dungeonId, definition.encounters ?? []);
activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
difficultyId ?? definition.defaultDifficultyId,
);
resetDungeonEncounters("dungeon", dungeonId);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
@@ -91,7 +97,7 @@ export function activateDungeonSession(
gmReviewTarget: options.gmReviewTarget,
});
if (options.encounterSnapshots?.length) {
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots);
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
}
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
return true;
@@ -103,11 +109,13 @@ export function activateDungeonSession(
*/
export function restartActiveDungeonSession(): boolean {
const game = useGameStore.getState();
const encounterSnapshots = exportDungeonEncounterSnapshots(game.activeDungeonId);
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
gameMode: game.gameMode,
spawn: game.activeSpawn,
gmTestMode: game.gmTestMode,
gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true,
encounterSnapshots,
});
}
+29 -6
View File
@@ -7,12 +7,16 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
import { useManastormAdminStore } from "./manastormAdminStore";
import { dungeonEncounterSnapshot } from "./dungeonEncounterRuntime";
import {
dungeonEncounterSnapshot,
importDungeonEncounterSnapshots,
} from "./dungeonEncounterRuntime";
import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter";
describe("dungeon session asset eviction", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
useGameStore.getState().activateDungeon("wailing-caverns");
});
@@ -35,11 +39,26 @@ describe("dungeon session asset eviction", () => {
expect(clear).not.toHaveBeenCalled();
});
it("restarts the active dungeon in place while preserving its party", () => {
const active = dungeonDefinitionById("deadmines")!;
useGameStore.getState().activateDungeon(active.id);
it("does not activate a direct production session before its dungeon pack is usable", () => {
vi.stubEnv("DEV", false);
useGameStore.getState().activateDungeon("wailing-caverns");
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
expect(activateDungeonSession("deadmines")).toBe(false);
expect(useGameStore.getState().activeDungeonId).toBe("wailing-caverns");
expect(clear).not.toHaveBeenCalled();
});
it("restarts the active dungeon in place while preserving its party and encounter state", () => {
const active = dungeonDefinitionById("wailing-caverns")!;
expect(activateDungeonSession(active.id)).toBe(true);
usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
const members = usePartyStore.getState().members.map((member) => member.id);
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const progressed = { ...current, phase: "ready", revision: current.revision + 1 } as const;
expect(importDungeonEncounterSnapshots(active.id, [progressed])).toEqual([
WAILING_NARALEX_ENCOUNTER.id,
]);
const revision = useGameStore.getState().sessionRevision;
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
@@ -48,6 +67,10 @@ describe("dungeon session asset eviction", () => {
expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
phase: "ready",
revision: progressed.revision,
});
});
it("applies the published dungeon group anchor at the session boundary", () => {
@@ -59,9 +82,9 @@ describe("dungeon session asset eviction", () => {
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
});
it("hydrates a safe encounter checkpoint after the dungeon reset", () => {
it("hydrates a same-process checkpoint even when the reset revision is newer", () => {
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const saved = { ...current, phase: "ready", revision: current.revision + 10 } as const;
const saved = { ...current, phase: "ready" } as const;
expect(activateDungeonSession("wailing-caverns", "normal", {
encounterSnapshots: [saved],
})).toBe(true);
+42
View File
@@ -7,6 +7,20 @@ export type DungeonWorldObjectKind =
| "switch"
| "key"
| "chest"
| "resource"
| "generic"
| "chair"
| "spell-focus"
| "text"
| "trap"
| "transport"
| "camera"
| "ritual"
| "mailbox"
| "fishing-hole"
| "aura-generator"
| "spellcaster"
| "destructible"
| "npc"
| "quest-object"
| "trigger";
@@ -21,6 +35,14 @@ export interface DungeonWorldObjectSource {
readonly files: readonly string[];
readonly templateId?: number;
readonly spawnId?: number;
readonly gameObjectType?: number;
readonly clientDisplayId?: number;
readonly clientModelPath?: string;
/** Unscaled client x/y/z display bounds retained for asset/collision review. */
readonly clientBounds?: {
readonly min: MobVector3;
readonly max: MobVector3;
};
readonly note?: string;
}
@@ -45,6 +67,8 @@ export interface DungeonWorldObjectInteraction {
export interface DungeonWorldObjectVisual {
/** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */
readonly assetUrl?: string;
/** Source-adapted records stay hidden until their client display is reviewed. */
readonly hidden?: boolean;
readonly color?: string;
readonly emissive?: string;
readonly scale?: MobVector3;
@@ -111,6 +135,24 @@ export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition)
if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) {
throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`);
}
if (object.visual?.assetUrl && object.visual.hidden) {
throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`);
}
if (object.source?.gameObjectType !== undefined && (
!Number.isInteger(object.source.gameObjectType)
|| object.source.gameObjectType < 0
|| object.source.gameObjectType > 35
)) {
throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`);
}
const clientBounds = object.source?.clientBounds;
if (clientBounds && (
!clientBounds.min.every(Number.isFinite)
|| !clientBounds.max.every(Number.isFinite)
|| clientBounds.min.some((value, axis) => value >= clientBounds.max[axis])
)) {
throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`);
}
}
export function nearestDungeonWorldInteraction(
+16 -1
View File
@@ -20,7 +20,19 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
/**
* A persistent enemy aura that procs a short-lived, charged critical-strike
* modifier from successful main-hand auto attacks.
*/
export interface EnemyMeleeAutoCriticalProc {
readonly chance: number;
readonly triggeredAura: string;
readonly durationMs: number;
readonly criticalChancePercent: number;
readonly charges: number;
}
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
@@ -45,6 +57,7 @@ export type EnemyAbilityEffect =
readonly aura: string;
readonly durationMs: number;
readonly magnitude?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
}
| {
readonly kind: "target-aura";
@@ -55,6 +68,8 @@ export type EnemyAbilityEffect =
readonly kind: "control";
readonly mechanic: EnemyControlMechanic;
readonly durationMs: number;
/** Optional damage resolved against the same targets as the control. */
readonly damageMultiplier?: number;
}
| { readonly kind: "transform"; readonly form: string }
| {
+162
View File
@@ -250,6 +250,168 @@ describe("server-backed enemy ability runtime", () => {
}));
});
it("resolves compound enemy damage and control against the same target", () => {
useCombatStore.getState().registerMob("compound-hammer", {
name: "Compound Hammer",
maxHealth: 1_000,
attacks: [{
id: "hammer-pair",
name: "Hammer Pair",
delivery: "projectile",
target: "primary",
school: "holy",
animation: "cast",
castTimeMs: 0,
range: 20,
cooldownMs: 25_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [25_000, 25_000],
damageMultiplier: 1.3,
effect: {
kind: "control",
mechanic: "stun",
durationMs: 2_500,
damageMultiplier: 1.3,
},
}],
});
setMobPosition("compound-hammer", [2, 0, 0]);
useCombatStore.getState().damageMob("compound-hammer", 1, 1_000);
const healthBefore = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
expect(useCombatStore.getState().health).toBeLessThan(healthBefore);
expect(useCombatStore.getState()).toMatchObject({
controlMechanic: "stun",
controlledUntil: 3_500,
});
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: "compound-hammer",
targetId: PLAYER_AGGRO_ID,
expiresAt: 3_500,
}));
});
it("arms, limits, and consumes a next-melee critical proc only from main-hand autos", () => {
useCombatStore.getState().registerMob("melee-proc-boss-1", {
name: "Melee Proc Boss",
level: 20,
maxHealth: 1_000,
damageMultiplier: 0.05,
attacks: [
{
id: "vengeance-driver",
name: "Vengeance",
delivery: "area",
target: "self",
school: "holy",
animation: "cast",
castTimeMs: 0,
range: 0,
cooldownMs: 1,
initialCooldownMs: [0, 0],
repeatCooldownMs: [1, 1],
maximumCasts: 1,
damageMultiplier: 0,
effect: {
kind: "aura",
aura: "vengeance",
durationMs: 0,
meleeAutoCriticalProc: {
chance: 1,
triggeredAura: "vengeance-next-melee-critical",
durationMs: 6_000,
criticalChancePercent: 100,
charges: 1,
},
},
},
{
id: "melee-special",
name: "Melee Special",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 5,
cooldownMs: 1_000,
initialCooldownMs: [500, 500],
repeatCooldownMs: [1_000, 1_000],
damageMultiplier: 1,
},
{
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 4.5,
cooldownMs: 2_000,
initialCooldownMs: [1_000, 1_000],
repeatCooldownMs: [2_000, 2_000],
damageMultiplier: 1,
},
],
});
setMobPosition("melee-proc-boss-1", [2, 0, 0]);
useCombatStore.getState().damageMob("melee-proc-boss-1", 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
useCombatStore.getState().advanceMobCombat(1_500, [0, 0, 0]);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.map((aura) => aura.id))
.toEqual(["vengeance"]);
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(5);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual({
id: "vengeance-next-melee-critical",
sourceAbilityId: "vengeance-driver",
endsAt: 8_000,
nextMeleeCriticalChancePercent: 100,
charges: 1,
});
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 8_000, charges: 1 }),
);
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
outcome: "crit",
critical: true,
landed: true,
});
expect(useCombatStore.getState().lastCombatResult?.damageBeforeMitigation)
.toBe((useCombatStore.getState().lastCombatResult?.rawDamage ?? 0) * 2);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 10_000, charges: 1 }),
);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
"melee-proc-boss-1": {
...state.mobs["melee-proc-boss-1"],
auras: state.mobs["melee-proc-boss-1"].auras.map((aura) => (
aura.id === "vengeance" ? { ...aura, meleeAutoCriticalProc: undefined } : aura
)),
},
},
}));
useCombatStore.getState().advanceMobCombat(5_000, [0, 0, 0]);
useCombatStore.getState().advanceMobCombat(6_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.some(
(aura) => aura.id === "vengeance-next-melee-critical",
)).toBe(false);
});
it("honors encounter-state conditions before selecting an ability", () => {
useWailingEncounterStore.getState().reset();
useCombatStore.getState().registerMob("event-caster", {
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI Line truncated
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+13 -3
View File
@@ -6,17 +6,27 @@ import {
} from "./manastormStageAssets";
let initialized = false;
const generatedPackages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
export const GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT = registry.packages.length;
/** Resolves a runtime stage package to its independently downloadable content pack. */
export function generatedManastormContentPackId(assetPackageId: string): string | null {
const assetPackage = generatedPackages.find((candidate) => candidate.id === assetPackageId);
const assetUrl = assetPackage?.visual[0]?.url
?? assetPackage?.collision[0]?.url
?? assetPackage?.navigation.url;
const directory = /^\/assets\/game\/manastorm\/([^/]+)\//.exec(assetUrl ?? "")?.[1];
return directory ? `manastorm:${directory}` : null;
}
export function registerGeneratedManastormStageAssets(): readonly string[] {
const packages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
if (initialized) return Object.freeze(packages.map((assetPackage) => assetPackage.id));
if (initialized) return Object.freeze(generatedPackages.map((assetPackage) => assetPackage.id));
if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) {
throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`);
}
const registered: string[] = [];
for (const assetPackage of packages) {
for (const assetPackage of generatedPackages) {
registerManastormStageAssetPackage(assetPackage);
registered.push(assetPackage.id);
}
+7
View File
@@ -11,6 +11,7 @@ import {
preloadManastormStage,
releaseActiveManastormStage,
} from "./manastormStageLoader";
import { generatedManastormContentPackId } from "./manastormAssetRegistry";
const CHECKSUM = "a".repeat(64);
@@ -64,6 +65,12 @@ describe("Manastorm stage asset packages", () => {
]));
});
it("maps generated stages to the content pack needed by the download prompt", () => {
expect(generatedManastormContentPackId("manastorm:assets:43"))
.toBe("manastorm:43-wailingcaverns");
expect(generatedManastormContentPackId("missing-package")).toBeNull();
});
it("preloads one package, activates the stage, and releases the previous package", async () => {
registerManastormStageAssetPackage(assetPackage("first"));
registerManastormStageAssetPackage(assetPackage("second"));
@@ -0,0 +1,36 @@
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
import { describe, expect, it } from "vitest";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
function navigationScene(): Group {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
0, 0, 0,
2, 0, 0,
0, 0, 2,
], 3));
const scene = new Group();
scene.add(new Mesh(geometry));
return scene;
}
describe("party navigation asset cache", () => {
it("reuses the graph prepared during expedition loading", () => {
const first = preparePartyNavigationGraph("/navigation/cache-test.glb", navigationScene(), []);
const second = preparePartyNavigationGraph("/navigation/cache-test.glb", new Group(), []);
expect(second).toBe(first);
expect(first.sourceTriangleCount).toBe(1);
});
it("keeps navigation variants with different authored links separate", () => {
const first = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), []);
const second = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), [{
id: "ledge",
start: [0, 0, 0],
end: [2, 0, 2],
bidirectional: true,
authoredReason: "Test link",
}]);
expect(second).not.toBe(first);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { BufferGeometry, type Group, type Mesh } from "three";
import type { DungeonNavigationLink } from "./dungeonTypes";
import {
buildPartyNavigationGraph,
type PartyNavigationGraph,
} from "./partyNavigation";
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>();
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
const links = offMeshLinks.map((link) => [
link.id,
link.start,
link.end,
link.bidirectional,
]);
return `${url}:${JSON.stringify(links)}`;
}
function navigationGeometry(scene: Group): BufferGeometry {
scene.updateMatrixWorld(true);
let sourceMesh: Mesh | null = null;
let largestVertexCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
if (count > largestVertexCount) {
sourceMesh = mesh;
largestVertexCount = count;
}
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
export function preparePartyNavigationGraph(
url: string,
scene: Group,
offMeshLinks: readonly DungeonNavigationLink[],
): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks);
const cached = graphCache.get(cacheKey);
if (cached) {
graphCache.delete(cacheKey);
graphCache.set(cacheKey, cached);
return cached;
}
const geometry = navigationGeometry(scene);
let graph: PartyNavigationGraph;
try {
graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks,
});
} finally {
geometry.dispose();
}
graphCache.set(cacheKey, graph);
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
const oldest = graphCache.keys().next().value as string | undefined;
if (oldest) graphCache.delete(oldest);
}
return graph;
}
+13 -6
View File
@@ -29,6 +29,7 @@ import {
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import type { ActiveAura, DispelCategory } from "./combatAuras";
import {
@@ -121,13 +122,19 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : [];
}
const game = useGameStore.getState();
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional);
if (game.activeDungeonId !== "wailing-caverns") return objectives;
const phase = useWailingEncounterStore.getState().phase;
return phase === "mutanus" || phase === "complete"
? objectives
: objectives.filter((objective) => objective.id !== "mutanus-spawn");
.filter((objective) => !objective.optional && !completed.has(objective.id));
const wailingPhase = game.activeDungeonId === "wailing-caverns"
? useWailingEncounterStore.getState().phase
: null;
return objectives.filter((objective) => (
!objective.eventOnly
|| Boolean(mobs[objective.id])
|| (objective.id === "mutanus-spawn"
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
));
}
export function resetPartyRuntimeClock(): void {
+21
View File
@@ -70,6 +70,8 @@ export interface GameState {
setAssetStatus: (status: AssetStatus, message?: string | null) => void;
updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void;
relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void;
/** Returns the party to its saved run spawn without requesting a full dungeon reset. */
resetAfterDefeat: () => void;
resetAtActiveSpawn: () => void;
resetAtEntrance: () => void;
}
@@ -227,6 +229,25 @@ export const useGameStore = create<GameState>((set) => ({
relocationRevision: state.relocationRevision + 1,
};
}),
resetAfterDefeat: () => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
overlay: null,
paused: false,
mapOpen: false,
cameraLookActive: false,
playerPosition: state.activeSpawn.footPosition,
playerNavigationPosition: state.activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: state.activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
distanceTravelled: 0,
hasMoved: false,
actionLayer: "primary",
resetRevision: state.resetRevision + 1,
relocationRevision: state.relocationRevision + 1,
};
}),
resetAtActiveSpawn: () => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog";
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiArtUrls, talentUiNodesForTree } from "./talentUiCatalog";
describe("talent UI catalog shards", () => {
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => {
@@ -17,4 +17,13 @@ describe("talent UI catalog shards", () => {
expect(talentUiNodesForTree(catalog, tree.id).every((node) => node.treeId === tree.id)).toBe(true);
}
});
it("returns each Priest talent artwork URL once", async () => {
const catalog = await loadTalentUiCatalog("priest");
const urls = talentUiArtUrls(catalog);
expect(loadedTalentUiCatalog("priest")).toBe(catalog);
expect(urls).toHaveLength(new Set(urls).size);
expect(urls).toContain("/assets/ui/talents/backgrounds/talent_priest1.png");
expect(urls).toContain("/assets/ui/talents/icons/spell_holy_powerwordshield.png");
});
});
+13
View File
@@ -50,6 +50,7 @@ interface TalentUiCatalogModule {
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
const loadedCatalogs = new Map<ClassId, TalentUiCatalog>();
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
const cached = loadCache.get(classId);
@@ -61,6 +62,7 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
}
loadedCatalogs.set(classId, module.default);
return module.default;
}).catch((error: unknown) => {
loadCache.delete(classId);
@@ -70,6 +72,10 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
return pending;
}
export function loadedTalentUiCatalog(classId: ClassId): TalentUiCatalog | null {
return loadedCatalogs.get(classId) ?? null;
}
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
const normalizedRank = Math.min(
node.maxRank,
@@ -91,3 +97,10 @@ export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string):
.filter((node) => node.treeId === treeId)
.sort((left, right) => left.row - right.row || left.index - right.index);
}
export function talentUiArtUrls(catalog: TalentUiCatalog): readonly string[] {
return [...new Set([
...catalog.trees.flatMap((tree) => [tree.icon, tree.background]),
...catalog.nodes.map((node) => node.icon),
])];
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import { requireDungeonDefinition } from "./dungeonRegistry";
import type { DungeonDifficultyVariant, DungeonSourceRecipe } from "./dungeonTypes";
const recipeModules = import.meta.glob<DungeonSourceRecipe>(
"../../dungeon-pipeline/recipes/*.json",
{ eager: true, import: "default" },
);
const TBC_DUNGEON_IDS = campaign.dungeons
.filter((dungeon) => dungeon.source === "burning-crusade")
.map((dungeon) => dungeon.slug);
function requireRecipe(dungeonId: string): DungeonSourceRecipe {
const path = `../../dungeon-pipeline/recipes/${dungeonId}.json`;
const recipe = recipeModules[path];
if (!recipe) throw new Error(`Missing Burning Crusade recipe: ${path}`);
return recipe;
}
function expectSelectableDifficultyContract(
label: string,
variants: readonly DungeonDifficultyVariant[],
): void {
const normal = variants.find((variant) => variant.id === "normal");
const heroic = variants.find((variant) => variant.id === "heroic");
expect(normal, `${label}:normal`).toMatchObject({
difficulty: "normal",
spawnMask: 1,
});
expect(heroic, `${label}:heroic`).toMatchObject({
difficulty: "heroic",
spawnMask: 2,
levelRange: [70, 70],
});
expect(new Set(variants.map((variant) => variant.id)).size, `${label}:unique-ids`)
.toBe(variants.length);
}
describe("Burning Crusade difficulty contract", () => {
it("advertises selectable normal and level-70 heroic variants in every recipe and compiled definition", () => {
expect(TBC_DUNGEON_IDS).toHaveLength(14);
for (const dungeonId of TBC_DUNGEON_IDS) {
const recipe = requireRecipe(dungeonId);
const definition = requireDungeonDefinition(dungeonId);
expect(recipe.slug, dungeonId).toBe(dungeonId);
expect(recipe.expansion, dungeonId).toBe("burning-crusade");
expectSelectableDifficultyContract(`${dungeonId}:recipe`, recipe.difficultyVariants);
expectSelectableDifficultyContract(`${dungeonId}:runtime`, definition.difficultyVariants);
}
});
it("only uses encounter difficulty guards that players can select", () => {
for (const dungeonId of TBC_DUNGEON_IDS) {
const definition = requireDungeonDefinition(dungeonId);
const selectableDifficultyIds = new Set(
definition.difficultyVariants.map((variant) => variant.id),
);
for (const encounter of definition.encounters ?? []) {
for (const rule of encounter.rules) {
for (const guard of rule.guards ?? []) {
if (guard.kind !== "difficulty") continue;
expect(guard.difficultyIds.length, `${dungeonId}:${encounter.id}:${rule.id}`)
.toBeGreaterThan(0);
for (const difficultyId of guard.difficultyIds) {
expect(
selectableDifficultyIds.has(difficultyId),
`${dungeonId}:${encounter.id}:${rule.id}:${difficultyId}`,
).toBe(true);
}
}
}
}
}
});
});
+41 -1
View File
@@ -114,12 +114,50 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
dungeonId: "wailing-caverns",
initialPhase: "locked",
resetPolicy: { bossWipePhase: "locked", checkpointPhase: "locked" },
source: {
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
files: [
"src/game/generated/azerothCoreReference.json",
"dungeon-pipeline/fixtures/wailing-caverns/source-snapshot.json",
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/instance_wailing_caverns.cpp",
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/wailing_caverns.h",
],
reviewedBehavior: "The four Fanglords unlock the Disciple of Naralex event, which advances through ravager, serpent, cleansing, and nightmare stages before summoning Mutanus and completing the dungeon.",
adaptations: [
"Disciple movement is deterministic waypoint interpolation using source positions and reviewed stage durations instead of server-side escort motion packets.",
"Wave safety timeouts prevent a missing or unreachable summon from permanently soft locking the event.",
],
remainingGaps: [
"Port the complete source dialogue/emote sequence and every waypoint pause trigger.",
"Bind the Disciple interaction to a reviewed source-backed NPC/gossip presentation instead of the current encounter signal adapter.",
"Record full traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest evidence.",
],
},
rules: [
{
id: "fanglords-defeated",
trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS },
guards: [{ kind: "phase", phases: ["locked"] }],
actions: [{ id: "unlock-naralex", kind: "set-phase", phase: "ready" }],
actions: [
{
id: "record-fanglords-complete",
kind: "set-flag",
flagId: "fanglords-complete",
value: true,
persistence: "run",
},
{ id: "unlock-naralex", kind: "set-phase", phase: "ready" },
],
},
{
id: "restore-ready-after-checkpoint",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["locked"] },
{ kind: "flag", flagId: "fanglords-complete", equals: true },
],
actions: [{ id: "restore-ready-phase", kind: "set-phase", phase: "ready" }],
},
{
id: "start-escort",
@@ -196,6 +234,8 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
actions: [
{ id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" },
{ id: "complete-mutanus-objective", kind: "complete-objective", objectiveId: "mutanus-spawn", persistence: "run" },
{ id: "save-mutanus-checkpoint", kind: "set-checkpoint", checkpointId: "mutanus-spawn", persistence: "checkpoint" },
{ id: "mark-wailing-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
],
},
],
+26
View File
@@ -136,5 +136,31 @@ describe("AzerothCore-backed Wailing Caverns parity slice", () => {
expect(useWailingEncounterStore.getState().phase).toBe("mutanus");
useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504);
expect(useWailingEncounterStore.getState().phase).toBe("complete");
expect(useWailingEncounterStore.getState().flags["dungeon-complete"]).toBe(true);
expect(useWailingEncounterStore.getState().checkpointId).toBe("mutanus-spawn");
});
it("restores the Naralex unlock after a checkpoint reload suppresses the cleared Fanglords", () => {
useWailingEncounterStore.getState().advance({
"lady-anacondra-spawn": deadMob,
"lord-cobrahn-spawn": deadMob,
"lord-pythas-spawn": deadMob,
"lord-serpentis-spawn": deadMob,
}, 1_000);
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "ready",
flags: { "fanglords-complete": true },
});
useWailingEncounterStore.getState().reset("checkpoint", 2_000);
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "locked",
flags: { "fanglords-complete": true },
});
// A loaded checkpoint intentionally has no live Fanglord runtime ids: the
// progression provider has already suppressed those completed bosses.
useWailingEncounterStore.getState().advance({}, 2_100);
expect(useWailingEncounterStore.getState().phase).toBe("ready");
});
});
@@ -0,0 +1,515 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
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 { withWowEncounterContent } from "./wowEncounterContentCatalog";
import {
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS,
withWowClassicEncounterBatchThree,
} from "./wowClassicEncounterBatchThreeCatalog";
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 original(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return definition;
}
function preadapted(id: string): DungeonDefinition {
return withWowEncounterContent(original(id));
}
function adapted(id: string): DungeonDefinition {
return withWowClassicEncounterBatchThree(preadapted(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-three`);
if (!value) throw new Error(`Missing Classic batch-three encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing encounter group ${id}`);
return value;
}
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
return matches[0];
}
function killGroup(
scripted: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
}
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 encounter batch three", () => {
it("pins sources, validates references, suppresses event actors, remains idempotent, and preserves Solakar", () => {
const cases = [
["gnomeregan", [7_998]],
["dire-maul", [11_496]],
["blackrock-spire", [9_816, 10_316, 10_429, 10_264]],
] as const;
expect(WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId))
.toEqual(cases.map(([id]) => id));
const checkedPaths = new Set<string>();
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.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);
if (!checkedPaths.has(sourceFile.path)) {
expect(createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex"), sourceFile.path)
.toBe(sourceFile.sha256);
checkedPaths.add(sourceFile.path);
}
}
}
const priorBRS = preadapted("blackrock-spire");
const priorSolakarEncounter = priorBRS.encounters?.find((value) => value.id === "blackrock-spire:wow-instance");
const priorSolakarGroups = priorBRS.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar"));
const priorFatherFlame = priorBRS.serverGameObjects.find((value) => value.source?.templateId === 175_245);
expect(priorSolakarEncounter).toBeDefined();
expect(priorSolakarGroups).toHaveLength(6);
expect(priorFatherFlame).toBeDefined();
for (const [id, suppressed] of cases) {
const definition = adapted(id);
const scripted = encounter(definition);
validateDungeonEncounterDefinition(scripted);
expect(scripted.source?.scope).toBe("instance-orchestration");
expect(withWowClassicEncounterBatchThree(definition)).toBe(definition);
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressed]));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
]);
const groupIds = 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((objective) => objective.id));
expect(objectIds.size).toBe(definition.serverGameObjects.length);
expect(groupIds.size).toBe(definition.encounterSpawnGroups?.length ?? 0);
const actionIds = new Set<string>();
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
for (const spawn of spawnGroup.spawns) expect(definition.entities[spawn.entityId], `${id}:${spawn.id}`).toBeDefined();
}
for (const rule of scripted.rules) {
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
}
for (const action of rule.actions) {
expect(actionIds.has(action.id), `${id}:duplicate action ${action.id}`).toBe(false);
actionIds.add(action.id);
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "complete-objective") {
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
}
}
const sourceSpawnIds = definition.serverGameObjects.flatMap((object) => (
object.source?.spawnId === undefined ? [] : [object.source.spawnId]
));
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
for (const object of definition.serverGameObjects.filter((candidate) => (
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
))) {
validateDungeonWorldObject(object);
expect(object.visual?.hidden).not.toBe(true);
}
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
}
const brs = adapted("blackrock-spire");
expect(brs.encounters?.find((value) => value.id === "blackrock-spire:wow-instance")).toEqual(priorSolakarEncounter);
expect(brs.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar")))
.toEqual(priorSolakarGroups);
expect(brs.serverGameObjects.find((value) => value.source?.templateId === 175_245)).toEqual(priorFatherFlame);
});
it("ports Gyth and Urok's pinned combat schedules instead of leaving event bosses basic-only", () => {
const definition = adapted("blackrock-spire");
const byEntry = (entry: number) => Object.values(definition.entities)
.find((entity) => entity.combat?.serverEntry === entry)!;
const gyth = byEntry(10_339);
const urok = byEntry(10_584);
expect(gyth.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "corrosive-acid-16359", spellId: 16_359, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "freeze-16350", spellId: 16_350, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "flame-breath-16390", spellId: 16_390, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "knock-away-10101", spellId: 10_101, initial: [12_000, 18_000], repeat: [14_000, 20_000] },
]);
expect(gyth.combat?.mechanicIds).toEqual([
"corrosive-acid-16359",
"freeze-16350",
"flame-breath-16390",
"knock-away-10101",
]);
expect(gyth.combat?.attacks.find((attack) => attack.id === "corrosive-acid-16359"))
.toMatchObject({
target: "nearby-party",
school: "nature",
range: 20,
radius: 20,
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(gyth.combat?.attacks.find((attack) => attack.id === "freeze-16350"))
.toMatchObject({
target: "nearby-party",
school: "frost",
range: 20,
radius: 20,
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 },
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(gyth.combat?.attacks.find((attack) => attack.id === "flame-breath-16390"))
.toMatchObject({
target: "nearby-party",
school: "fire",
range: 20,
radius: 20,
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(urok.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "rend-16509", spellId: 16_509, initial: [17_000, 20_000], repeat: [8_000, 10_000] },
{ id: "strike-15580", spellId: 15_580, initial: [10_000, 12_000], repeat: [8_000, 10_000] },
{ id: "intimidating-roar-16508", spellId: 16_508, initial: [25_000, 30_000], repeat: [40_000, 45_000] },
]);
expect(urok.combat?.attacks.find((attack) => attack.id === "intimidating-roar-16508"))
.toMatchObject({
target: "nearby-party",
range: 8,
radius: 8,
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 },
telegraph: { shape: "circle", anchor: "source", radius: 8 },
});
const review = WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
candidate.dungeonId === "blackrock-spire"
))!;
expect(review.remainingGaps.join(" ")).toContain("Freeze periodic damage");
expect(review.remainingGaps.join(" ")).toContain("split primary-root/nearby-fear targeting");
});
it("ports Crystal Fang's exact death summon while keeping the source-faithful rare boss melee-only", () => {
const definition = adapted("blackrock-spire");
const scripted = encounter(definition);
const crystalFang = definition.staticSpawns.find((spawn) => (
definition.entities[spawn.entityId]?.combat?.serverEntry === 10_376
))!;
const spiderling = group(definition, "blackrock-spire:batch-three:crystal-fang-spiderling");
expect(crystalFang.spawnChance).toBe(0.15);
expect(spiderling.spawns).toHaveLength(1);
expect(spiderling.spawns[0]).toMatchObject({
entityId: expect.stringContaining("entry-10375"),
position: crystalFang.position,
});
const snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "entity-death",
entityId: crystalFang.id,
now: 1,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(spiderling.id);
});
it("runs Gnomeregan's exact Emi group counts, cave-ins, retry, doors, and persistent Grubbis gate", () => {
const definition = adapted("gnomeregan");
const scripted = encounter(definition);
const groups = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"]
.map((name) => group(definition, `gnomeregan:batch-three:${name}`));
expect(groups.map((value) => value.spawns.length)).toEqual([10, 4, 5, 5, 2]);
expect(groups[2].spawns.map((spawn) => spawn.position)).toEqual(groups[3].spawns.map((spawn) => spawn.position));
const emi = definition.serverGameObjects.find((object) => object.id === "gnomeregan:batch-three:emi-shortfuse")!;
const caveOne = objectBySpawn(definition, 6_447);
const caveTwo = objectBySpawn(definition, 17_633);
const workshop = objectBySpawn(definition, 12_003);
const finalDoor = objectBySpawn(definition, 6_910);
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: workshop.id, now: 0 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: finalDoor.id, now: 1 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[0].id);
snapshot = killGroup(scripted, snapshot, groups[0], 3);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 4);
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[emi.id]).toBe("ready");
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("closed");
expect(snapshot.worldObjectStates[workshop.id]).toBe("open");
expect(snapshot.worldObjectStates[finalDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 10 }).snapshot;
snapshot = killGroup(scripted, snapshot, groups[0], 11);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_011 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[1].id);
snapshot = killGroup(scripted, snapshot, groups[1], 5_012);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 45_012 }).snapshot;
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(groups[2].id);
snapshot = killGroup(scripted, snapshot, groups[2], 45_013);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 48_013 }).snapshot;
snapshot = killGroup(scripted, snapshot, groups[3], 48_014);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 108_014 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[4].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "gnomeregan:batch-three:grubbis",
now: 108_015,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
expect(snapshot.worldObjectStates[emi.id]).toBe("complete");
expect(snapshot.checkpointId).toBe("gnomeregan-grubbis");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 108_016);
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
const fleet = definition.encounters?.find((value) => value.id === "gnomeregan:boss-progression")!;
const completion = fleet.rules.find((rule) => rule.id === "all-required-bosses-defeated")!;
expect(completion.guards).toContainEqual({
kind: "objective-complete",
objectiveId: "gnomeregan:batch-three:grubbis",
equals: true,
});
});
it("runs Dire Maul's pylons, Immol'thar recovery, east-wing wall, doors, and six tribute tiers", () => {
const definition = adapted("dire-maul");
const scripted = encounter(definition);
const pylons = [29_742, 29_743, 29_744, 29_745, 29_746].map((id) => objectBySpawn(definition, id));
const forcefield = objectBySpawn(definition, 29_747);
const courtyard = objectBySpawn(definition, 29_750);
const inner = objectBySpawn(definition, 29_756);
const conservatory = objectBySpawn(definition, 268_275);
const wall = objectBySpawn(definition, 268_276);
const ironbark = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:old-ironbark")!;
const tribute = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:gordok-tribute")!;
const immol = group(definition, "dire-maul:batch-three:immolthar");
const alzzin = group(definition, "dire-maul:batch-three:alzzin");
let snapshot = createDungeonEncounterSnapshot(scripted);
pylons.forEach((pylon, index) => {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pylon.id, now: index }).snapshot;
});
expect(snapshot.counters["pylons-off"]).toBe(5);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 10 }).snapshot;
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 11);
expect(snapshot.activeSpawnGroupIds).not.toContain(immol.id);
expect(snapshot.counters["pylons-off"]).toBe(5);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 12 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: immol.spawns[0].id, now: 13 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain(immol.spawns[0].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248429", now: 20 }).snapshot;
expect(snapshot.worldObjectStates[ironbark.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: ironbark.id, now: 21 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_021 }).snapshot;
expect(snapshot.worldObjectStates[conservatory.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 51, now: 7_022 }).snapshot;
expect(snapshot.worldObjectStates[wall.id]).toBe("closed");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 50, now: 7_023 }).snapshot;
expect(snapshot.worldObjectStates[wall.id]).toBe("open");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 7_024);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_025 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: alzzin.spawns[0].id, now: 7_026 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("dire-maul:batch-three:alzzin");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: courtyard.id, now: 8_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: inner.id, now: 8_001 }).snapshot;
expect(snapshot.worldObjectStates[courtyard.id]).toBe("open");
expect(snapshot.worldObjectStates[inner.id]).toBe("open");
for (const id of ["creature-248079", "creature-248080"]) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: id, now: snapshot.revision + 8_010 }).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248076", now: 9_000 }).snapshot;
expect(snapshot.counters["tribute-penalties"]).toBe(2);
expect(snapshot.worldObjectStates[tribute.id]).toBe("tier-4");
let perfect = createDungeonEncounterSnapshot(scripted);
perfect = reduceDungeonEncounter(scripted, perfect, { kind: "entity-death", entityId: "creature-248076", now: 1 }).snapshot;
expect(perfect.worldObjectStates[tribute.id]).toBe("tier-6");
const optionalIds = new Set(["creature-248077", "creature-248079", "creature-248080", "creature-248081", "creature-248082"]);
expect(definition.bosses.filter((boss) => optionalIds.has(boss.id)).every((boss) => boss.optional)).toBe(true);
const fleetCompletion = definition.encounters?.find((value) => value.id === "dire-maul:boss-progression")
?.rules.find((rule) => rule.id === "all-required-bosses-defeated");
expect(fleetCompletion?.guards?.some((guard) => guard.kind === "objective-complete" && optionalIds.has(guard.objectiveId)))
.toBe(false);
});
it("runs Blackrock Spire's runes, Emberseer reset, exact stadium, Drakkisath doors, and Urok retry", () => {
const definition = adapted("blackrock-spire");
const scripted = encounter(definition);
const hallRunes = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824].map((id) => objectBySpawn(definition, id));
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((id) => objectBySpawn(definition, id));
const emberIn = objectBySpawn(definition, 87_839);
const emberOut = objectBySpawn(definition, 87_841);
const emberDoors = objectBySpawn(definition, 87_842);
const emberTrigger = definition.serverGameObjects.find((object) => object.id === "blackrock-spire:batch-three:emberseer-trigger")!;
const incarcerators = group(definition, "blackrock-spire:batch-three:incarcerators");
const emberseer = group(definition, "blackrock-spire:batch-three:emberseer");
const runeMobs = [
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
["creature-137872", "creature-137896", "creature-137898"],
["creature-137870", "creature-137886", "creature-137929"],
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
];
let snapshot = createDungeonEncounterSnapshot(scripted);
runeMobs.forEach((ids, index) => {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: index + 1,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
});
expect(snapshot.counters["dragonspire-runes"]).toBe(7);
expect(hallRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "inactive")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 8 }).snapshot;
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
expect(snapshot.worldObjectStates[emberDoors.id]).toBe("open");
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 10 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_010 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(incarcerators.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2_011);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_012 }).snapshot;
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).not.toContain(incarcerators.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 3_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
snapshot = killGroup(scripted, snapshot, incarcerators, 5_001);
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "growing")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 30_001 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(emberseer.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: emberseer.spawns[0].id, now: 30_002 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
expect(snapshot.worldObjectStates[emberOut.id]).toBe("open");
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "released")).toBe(true);
const stadiumEntry = objectBySpawn(definition, 87_845);
const stadiumWaveDoor = objectBySpawn(definition, 87_843);
const stadiumExit = objectBySpawn(definition, 87_844);
const waves = Array.from({ length: 6 }, (_, index) => group(definition, `blackrock-spire:batch-three:stadium-wave-${index + 1}`));
expect(waves.map((value) => value.spawns.length)).toEqual([4, 3, 4, 4, 5, 5]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: stadiumEntry.id, now: 31_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 42_000 }).snapshot;
for (let index = 0; index < waves.length; index += 1) {
expect(snapshot.activeSpawnGroupIds).toContain(waves[index].id);
snapshot = killGroup(scripted, snapshot, waves[index], snapshot.phaseStartedAt + 1);
if (index < waves.length - 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 13_000 }).snapshot;
}
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 18_000 }).snapshot;
const gyth = group(definition, "blackrock-spire:batch-three:gyth");
const rend = group(definition, "blackrock-spire:batch-three:rend");
expect(snapshot.activeSpawnGroupIds).toContain(gyth.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 26, now: snapshot.phaseStartedAt + 1 }).snapshot;
expect(snapshot.activeSpawnGroupIds).not.toContain(rend.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 25, now: snapshot.phaseStartedAt + 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(rend.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: gyth.spawns[0].id, now: snapshot.phaseStartedAt + 3 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: rend.spawns[0].id, now: snapshot.phaseStartedAt + 4 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 5 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
expect(snapshot.worldObjectStates[stadiumEntry.id]).toBe("complete");
expect(snapshot.worldObjectStates[stadiumWaveDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[stadiumExit.id]).toBe("open");
const drakkDoors = [objectBySpawn(definition, 87_854), objectBySpawn(definition, 87_855)];
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-137971", now: snapshot.phaseStartedAt + 6 }).snapshot;
expect(drakkDoors.every((door) => snapshot.worldObjectStates[door.id] === "open")).toBe(true);
const pile = objectBySpawn(definition, 99_791);
const urok = group(definition, "blackrock-spire:batch-three:urok");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 7 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(urok.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", snapshot.phaseStartedAt + 8);
expect(snapshot.worldObjectStates[pile.id]).toBe("ready");
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 9 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: urok.spawns[0].id, now: snapshot.phaseStartedAt + 10 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("blackrock-spire:batch-three:urok-doomhowl");
expect(snapshot.worldObjectStates[pile.id]).toBe("complete");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,482 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
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_BATCH_TWO_REVIEWS,
withWowClassicEncounterBatchTwo,
} from "./wowClassicEncounterBatchTwoCatalog";
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 original(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return definition;
}
function adapted(id: string): DungeonDefinition {
return withWowClassicEncounterBatchTwo(original(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-two`);
if (!value) throw new Error(`Missing Classic batch-two encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing encounter group ${id}`);
return value;
}
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
return matches[0];
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
) {
const entityIds = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: entityIds, deadEntityIds: entityIds },
}).snapshot;
}
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 encounter batch two", () => {
it("pins exact source hashes, validates every reference, suppresses event actors, and stays idempotent", () => {
const cases = [
["sunken-temple", [8_580, 8_443, 5_709]],
["blackrock-depths", [9_035, 9_038, 9_040, 9_037, 9_036, 9_034, 9_039]],
["scholomance", [1_853]],
["stratholme", [10_440]],
] as const;
expect(WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId))
.toEqual(cases.map(([id]) => id));
const verifiedPaths = new Set<string>();
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.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);
if (!verifiedPaths.has(sourceFile.path)) {
const actualHash = createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex");
expect(actualHash, sourceFile.path).toBe(sourceFile.sha256);
verifiedPaths.add(sourceFile.path);
}
}
}
for (const [id, suppressedEntries] of cases) {
const definition = adapted(id);
const scripted = encounter(definition);
validateDungeonEncounterDefinition(scripted);
expect(withWowClassicEncounterBatchTwo(definition)).toBe(definition);
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
]);
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((objective) => objective.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 scripted.rules) {
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${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") {
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "complete-objective") {
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
}
}
const sourceSpawnIds = definition.serverGameObjects
.flatMap((object) => object.source?.spawnId === undefined ? [] : [object.source.spawnId]);
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
for (const object of definition.serverGameObjects.filter((candidate) => (
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
))) {
validateDungeonWorldObject(object);
expect(object.visual?.hidden).not.toBe(true);
}
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
}
});
it("runs Sunken Temple's ordered statues, ritual, defender gate, and Eranikus recovery", () => {
const definition = adapted("sunken-temple");
const scripted = encounter(definition);
const statues = [27_898, 27_899, 28_111, 28_112, 28_113, 28_114]
.map((spawnId) => objectBySpawn(definition, spawnId));
const forcefield = objectBySpawn(definition, 268_932);
const altar = objectBySpawn(definition, 11_922);
const flames = [47_561, 47_562, 47_563, 47_564].map((spawnId) => objectBySpawn(definition, spawnId));
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: statues[1].id, now: 1 }).snapshot;
expect(snapshot.counters["statue-progress"]).toBe(0);
for (let index = 0; index < statues.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "interact",
objectId: statues[index].id,
now: index + 2,
}).snapshot;
}
expect(snapshot).toMatchObject({ phase: "atal-alarion-active", counters: { "statue-progress": 6 } });
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 20);
expect(snapshot).toMatchObject({ phase: "recovery", counters: { "statue-progress": 6 }, activeSpawnGroupIds: [] });
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:atal-alarion"), 21);
const atalAlarion = definition.bosses.find((objective) => objective.name === "Atal'alarion")!;
expect(snapshot.completedObjectiveIds).toContain(atalAlarion.id);
const defenderIds = ["creature-39843", "creature-39844", "creature-39845", "creature-39846", "creature-34522", "creature-39847"];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 22,
facts: { registeredEntityIds: defenderIds, deadEntityIds: defenderIds },
}).snapshot;
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 30 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:hakkar-pack-1");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 31);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 31 }).snapshot;
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[altar.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 40 }).snapshot;
for (let index = 0; index < 4; index += 1) {
const ritualGroup = group(definition, `sunken-temple:hakkar-pack-${index + 1}`);
snapshot = killGroup(scripted, snapshot, ritualGroup, 41 + index * 2);
expect(snapshot.worldObjectStates[flames[index].id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "interact",
objectId: flames[index].id,
now: 42 + index * 2,
}).snapshot;
}
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:avatar-of-hakkar");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:avatar-of-hakkar"), 60);
const avatar = definition.bosses.find((objective) => objective.name === "Avatar of Hakkar")!;
expect(snapshot.completedObjectiveIds).toContain(avatar.id);
expect(flames.every((flame) => snapshot.worldObjectStates[flame.id] === "doused")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-39737", now: 70 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 71);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 71 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:shade-of-eranikus"), 72);
const eranikus = definition.bosses.find((objective) => objective.name === "Shade of Eranikus")!;
expect(snapshot.completedObjectiveIds).toContain(eranikus.id);
expect(snapshot.checkpointId).toBe("sunken-temple-eranikus");
});
it("runs BRD's deterministic arena, twelve-coffer vault, and exact seven-stage tomb with retry", () => {
const definition = adapted("blackrock-depths");
const scripted = encounter(definition);
const arenaGate4 = objectBySpawn(definition, 15_309);
const arenaGate3 = objectBySpawn(definition, 15_295);
const arenaGroups = [
group(definition, "blackrock-depths:ring-wave-1"),
group(definition, "blackrock-depths:ring-wave-2"),
group(definition, "blackrock-depths:ring-boss"),
];
expect(arenaGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([3, 3, 1]);
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "interact",
objectId: arenaGate4.id,
now: 0,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_001);
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("ready");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: arenaGate4.id, now: 6_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 11_000 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[0], 11_001);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 16_001 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[1], 16_002);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 21_002 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[2], 21_003);
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:ring-of-law-gorosh");
expect(snapshot.worldObjectStates[arenaGate3.id]).toBe("open");
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("open");
const coffers = [15_306, 15_329, 15_330, 15_331, 15_363, 15_364, 15_365, 15_536, 17_904, 35_801, 35_864, 39_924]
.map((spawnId) => objectBySpawn(definition, spawnId));
for (let index = 0; index < 6; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 30 + index }).snapshot;
}
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 40);
expect(snapshot.counters["opened-coffers"]).toBe(6);
expect(coffers.slice(0, 6).every((coffer) => snapshot.worldObjectStates[coffer.id] === "open")).toBe(true);
for (let index = 6; index < coffers.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 41 + index }).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 60 }).snapshot;
const watchman = group(definition, "blackrock-depths:vault-watchman");
expect(snapshot.activeSpawnGroupIds).toContain(watchman.id);
snapshot = killGroup(scripted, snapshot, watchman, 61);
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:watchman-doomgrip");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 43_094).id]).toBe("open");
const marker = definition.serverGameObjects.find((object) => object.id === "blackrock-depths:doomrel-marker")!;
const tombGroups = Array.from({ length: 7 }, (_, index) => group(definition, `blackrock-depths:tomb-spectral-${index + 1}`));
expect(tombGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1, 1, 1, 1, 1]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 100 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1_100 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(tombGroups[0].id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1_101);
expect(snapshot).toMatchObject({ phase: "idle", counters: { "tomb-kills": 0 }, activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[marker.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 2_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 3_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: tombGroups[0].spawns[0].id,
now: 3_001,
}).snapshot;
for (let index = 1; index < tombGroups.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: snapshot.phaseStartedAt + 30_000,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: tombGroups[index].spawns[0].id,
now: snapshot.phaseStartedAt + 1,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 2 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:tomb-of-seven");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 67_872).id]).toBe("available");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 15_334).id]).toBe("open");
});
it("gates Scholomance completion on six minibosses and a wipe-safe Gandling reveal", () => {
const definition = adapted("scholomance");
const scripted = encounter(definition);
const brazier = objectBySpawn(definition, 43_208);
const kirtonosGate = objectBySpawn(definition, 18_892);
const gandlingEntrance = objectBySpawn(definition, 43_201);
const progression = definition.encounters?.find((candidate) => candidate.id === "scholomance:boss-progression")!;
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "interact",
objectId: brazier.id,
now: 0,
}).snapshot;
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1 }).snapshot;
expect(snapshot.worldObjectStates[brazier.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: brazier.id, now: 2 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_002 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:kirtonos");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "scholomance:kirtonos-the-herald",
now: 5_003,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("scholomance:kirtonos-the-herald");
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("open");
const miniBossIds = ["creature-48867", "creature-48925", "creature-48946", "creature-48863", "creature-48857", "creature-48854"];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 6_000,
facts: { registeredEntityIds: miniBossIds, deadEntityIds: miniBossIds },
}).snapshot;
expect(snapshot.phase).toBe("gandling-reveal");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_001);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_001 }).snapshot;
expect(snapshot).toMatchObject({ phase: "gandling-ready", flags: { "gandling-unlocked": true } });
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:gandling");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-engaged",
entityId: "creature-247104",
now: 6_002,
}).snapshot;
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_003);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_003 }).snapshot;
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "creature-247104",
now: 6_004,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-247104");
const fleetRequired = definition.bosses.filter((objective) => !objective.optional && !objective.eventOnly);
for (const objective of fleetRequired) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: objective.id,
now: snapshot.revision + 10_000,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20_000 }).snapshot;
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
});
it("persists and resumes Stratholme's exact 3-to-13-to-1-to-33-to-5 slaughter chain", () => {
const definition = adapted("stratholme");
const scripted = encounter(definition);
const acolytes = Array.from({ length: 3 }, (_, index) => group(definition, `stratholme:ziggurat-${index + 1}-acolyte`));
const zigguratBosses = ["creature-54238", "creature-54239", "creature-54240"];
const zigguratDoors = [11_415, 11_414, 11_416].map((spawnId) => objectBySpawn(definition, spawnId));
const mindless = group(definition, "stratholme:mindless-undead");
const guards = group(definition, "stratholme:black-guards");
expect(acolytes.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1]);
expect(mindless.spawns).toHaveLength(33);
expect(guards.spawns).toHaveLength(5);
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: zigguratBosses[0], now: 1 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2);
expect(snapshot.worldObjectStates[zigguratDoors[0].id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: acolytes[0].spawns[0].id,
now: 3,
}).snapshot;
for (let index = 1; index < 3; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: zigguratBosses[index],
now: 4 + index * 2,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: acolytes[index].spawns[0].id,
now: 5 + index * 2,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
expect(snapshot.flags["ziggurats-complete"]).toBe(true);
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_911).id]).toBe("open");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_908).id]).toBe("open");
const slaughterIds = [
"creature-53969", "creature-54002", "creature-54018", "creature-54019",
"creature-54020", "creature-54021", "creature-54022", "creature-54026", "creature-54027",
"creature-54039", "creature-54040", "creature-54041", "creature-54050",
];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 21,
facts: { registeredEntityIds: slaughterIds, deadEntityIds: slaughterIds },
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 22);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 22 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "stratholme:ramstein-the-gorger",
now: 23,
}).snapshot;
expect(snapshot.phase).toBe("mindless-delay");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "checkpoint", 24);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 24 }).snapshot;
expect(snapshot.phase).toBe("mindless-delay");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_024 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(mindless.id);
snapshot = killGroup(scripted, snapshot, mindless, 5_025);
expect(snapshot.phase).toBe("black-guard-delay");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_026);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_026 }).snapshot;
expect(snapshot.phase).toBe("black-guard-delay");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_026 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(guards.id);
expect(snapshot.worldObjectStates[objectBySpawn(definition, 11_808).id]).toBe("open");
snapshot = killGroup(scripted, snapshot, guards, 25_027);
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:baron-rivendare");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-engaged",
entityId: "creature-54241",
now: 25_028,
}).snapshot;
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 25_029);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_029 }).snapshot;
expect(snapshot).toMatchObject({ phase: "baron-ready", flags: { "guards-defeated": true } });
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "creature-54241",
now: 25_030,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-54241");
expect(snapshot.checkpointId).toBe("creature-54241");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,334 @@
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");
});
});
File diff suppressed because it is too large. Load diff
+32 -10
View File
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import {
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
} from "./wowDungeonContentExceptions";
import {
createMobAnimationTransform,
getMobAnimationPhase,
@@ -15,14 +19,8 @@ import {
const publicRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../public");
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
const basicOnlyBossExceptions = new Set([
"wailing-caverns:kresh",
"wailing-caverns:deviate-faerie-dragon",
"blackrock-spire:entry-9218",
"blackrock-spire:entry-9219",
"blackrock-spire:entry-10376",
]);
const noTrashExceptions = new Set(["trial-of-the-champion"]);
const basicOnlyBossExceptions = new Set(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key }) => key));
const noTrashExceptions = new Set(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key }) => key));
const animationCache = new Map<string, Promise<readonly { readonly name: string }[]>>();
async function readAnimations(url: string): Promise<readonly { readonly name: string }[]> {
@@ -54,9 +52,23 @@ async function readAnimations(url: string): Promise<readonly { readonly name: st
}
describe("WoW dungeon combat and animation coverage", () => {
it("gives all 54 campaign dungeons complete executable populations", async () => {
it("gives all 54 campaign dungeons runtime-spawnable combat populations", async () => {
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
expect(dungeons).toHaveLength(54);
expect(basicOnlyBossExceptions.size).toBe(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length);
expect(noTrashExceptions.size).toBe(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length);
const actualBasicOnlyBosses = dungeons.flatMap((dungeon) => Object.entries(dungeon.entities)
.filter(([, entity]) => (
entity.kind === "boss"
&& !(entity.combat?.attacks ?? []).some((attack) => attack.id !== "basic-attack")
))
.map(([entityId]) => `${dungeon.id}:${entityId}`))
.sort();
expect(actualBasicOnlyBosses).toEqual([...basicOnlyBossExceptions].sort());
expect(dungeons
.filter((dungeon) => !Object.values(dungeon.entities).some((entity) => entity.kind === "mob"))
.map((dungeon) => dungeon.id)
.sort()).toEqual([...noTrashExceptions].sort());
for (const dungeon of dungeons) {
const entities = Object.values(dungeon.entities);
@@ -68,7 +80,9 @@ describe("WoW dungeon combat and animation coverage", () => {
expect(mobs.length, dungeon.id + ": mobs").toBeGreaterThan(0);
}
expect(
dungeon.staticSpawns.length + dungeon.roamingPacks.length,
dungeon.staticSpawns.length
+ dungeon.roamingPacks.length
+ (dungeon.encounterSpawnGroups?.reduce((count, group) => count + group.spawns.length, 0) ?? 0),
dungeon.id + ": population",
).toBeGreaterThan(0);
@@ -86,6 +100,14 @@ describe("WoW dungeon combat and animation coverage", () => {
).toBeDefined();
}
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
for (const spawn of group.spawns) {
expect(
dungeon.entities[spawn.entityId],
dungeon.id + ":" + group.id + ":" + spawn.id,
).toBeDefined();
}
}
for (const entity of entities) {
const key = dungeon.id + ":" + entity.id;
+35
View File
@@ -0,0 +1,35 @@
export interface WowDungeonContentException {
readonly key: string;
readonly reason: string;
}
/** Exact runtime entity keys for source-verified bosses whose only attack is melee. */
export const WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([
Object.freeze({
key: "wailing-caverns:kresh",
reason: "Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
}),
Object.freeze({
key: "wailing-caverns:deviate-faerie-dragon",
reason: "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
}),
Object.freeze({
key: "gnomeregan:wow-classic-batch-three-entry-7361",
reason: "Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast.",
}),
Object.freeze({
key: "blackrock-spire:entry-9218",
reason: "Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
}),
Object.freeze({
key: "blackrock-spire:entry-9219",
reason: "Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
}),
Object.freeze({
key: "blackrock-spire:entry-10376",
reason: "Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter.",
}),
]);
/** Exact dungeon keys allowed to contain no mob-kind combat actors. */
export const WOW_DUNGEON_NO_TRASH_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([]);
+352
View File
@@ -0,0 +1,352 @@
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([]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,243 @@
import { describe, expect, it } from "vitest";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import { WOW_PROGRESSION_OBJECT_REVIEWS } from "./wowProgressionObjectContentCatalog";
function encounter(dungeonId: string, suffix: string) {
const definition = DUNGEON_REGISTRY[dungeonId];
const result = definition.encounters?.find((candidate) => candidate.id === `${dungeonId}:${suffix}`);
if (!result) throw new Error(`Missing ${dungeonId}:${suffix}`);
return result;
}
function actorId(dungeonId: string, entry: number): string {
const definition = DUNGEON_REGISTRY[dungeonId];
const staticSpawn = definition.staticSpawns.find(
(spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry === entry,
);
if (staticSpawn) return staticSpawn.id;
for (const pack of definition.roamingPacks) {
const member = pack.members.find(
(candidate) => definition.entities[candidate.entityId]?.combat?.serverEntry === entry,
);
if (member) return `${pack.id}:${member.id}`;
}
throw new Error(`${dungeonId} is missing entry ${entry}`);
}
describe("source-backed progression objects", () => {
it("pins source hashes and keeps every adaptation and remaining gap explicit", () => {
expect(WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => review.dungeonId)).toEqual([
"deadmines",
"shadowfang-keep",
"maraudon",
"mechanar",
"steamvault",
"shadow-labyrinth",
"scarlet-monastery",
]);
for (const review of WOW_PROGRESSION_OBJECT_REVIEWS) {
expect(review.files.length).toBeGreaterThan(0);
expect(review.files.every((file) => /sha256 [a-f0-9]{64}/.test(file))).toBe(true);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
const runtime = DUNGEON_REGISTRY[review.dungeonId].encounters?.find(
(candidate) => candidate.source?.reviewedBehavior === review.reviewedBehavior,
);
expect(runtime).toBeDefined();
validateDungeonEncounterDefinition(runtime!);
}
});
it("runs Deadmines factory, foundry, and cannon breach transitions", () => {
const dungeon = DUNGEON_REGISTRY.deadmines;
const definition = encounter("deadmines", "progression-objects");
const factory = dungeon.serverGameObjects.find((object) => object.source?.templateId === 13_965)!;
const foundry = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_399)!;
const ironClad = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_397)!;
const foundryLever = dungeon.serverGameObjects.find((object) => object.source?.templateId === 101_834)!;
const cannon = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_398)!;
let snapshot = createDungeonEncounterSnapshot(definition);
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("deadmines", 644),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[factory.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "interact",
objectId: foundryLever.id,
now: 2,
}).snapshot;
expect(snapshot.worldObjectStates[foundry.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "interact",
objectId: cannon.id,
now: 3,
}).snapshot;
expect(snapshot.worldObjectStates[ironClad.id]).toBe("open");
expect(snapshot.worldObjectStates[cannon.id]).toBe("used");
expect(snapshot.activeSpawnGroupIds).toContain("deadmines:cannon-reinforcements");
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === "deadmines:cannon-reinforcements")?.spawns).toHaveLength(2);
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
expect(reset.worldObjectStates[factory.id]).toBe("open");
expect(reset.worldObjectStates[foundry.id]).toBe("open");
expect(reset.worldObjectStates[ironClad.id]).toBe("open");
expect(reset.activeSpawnGroupIds).not.toContain("deadmines:cannon-reinforcements");
});
it("opens each Shadowfang Keep progression door at its source milestone", () => {
const dungeon = DUNGEON_REGISTRY["shadowfang-keep"];
const definition = encounter("shadowfang-keep", "progression-objects");
const courtyard = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_895)!;
const sorcerer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_972)!;
const arugal = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_971)!;
let snapshot = createDungeonEncounterSnapshot(definition);
for (const [entry, objectId, now] of [
[3_914, courtyard.id, 1],
[4_274, sorcerer.id, 2],
[3_927, arugal.id, 3],
] as const) {
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("shadowfang-keep", entry),
now,
}).snapshot;
expect(snapshot.worldObjectStates[objectId]).toBe("open");
}
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "checkpoint", 4);
expect(reset.worldObjectStates[courtyard.id]).toBe("open");
expect(reset.worldObjectStates[sorcerer.id]).toBe("open");
expect(reset.worldObjectStates[arugal.id]).toBe("open");
});
it("opens Maraudon's Corruption Spewer when Noxxion dies", () => {
const dungeon = DUNGEON_REGISTRY.maraudon;
const definition = encounter("maraudon", "progression-objects");
const spewer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 178_570)!;
let snapshot = createDungeonEncounterSnapshot(definition);
expect(snapshot.worldObjectStates[spewer.id]).toBe("closed");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("maraudon", 13_282),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[spewer.id]).toBe("open");
expect(resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2).worldObjectStates[spewer.id]).toBe("open");
});
it("opens Mechanar passage doors and resets Sepethrea's room door safely", () => {
const dungeon = DUNGEON_REGISTRY.mechanar;
const definition = encounter("mechanar", "progression-objects");
let snapshot = createDungeonEncounterSnapshot(definition);
const ironDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_632)!;
const gyroDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_322)!;
const sepethreaDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_449)!;
expect(ironDoor.states.find((state) => state.id === "closed")?.blocksMovement).toBe(true);
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("mechanar", 19_710),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("mechanar", 19_218),
now: 2,
}).snapshot;
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-engaged",
entityId: actorId("mechanar", 19_221),
now: 3,
}).snapshot;
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("open");
});
it("requires both Steamvault boss panels before opening the main chamber", () => {
const dungeon = DUNGEON_REGISTRY.steamvault;
const definition = encounter("steamvault", "access-panels");
let snapshot = createDungeonEncounterSnapshot(definition);
const hydro = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_125)!;
const mek = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_126)!;
const door = dungeon.serverGameObjects.find((object) => object.source?.templateId === 183_049)!;
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("steamvault", 17_797),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[hydro.id]).toBe("unlocked");
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: hydro.id, now: 2 }).snapshot;
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
expect(snapshot.worldObjectStates[door.id]).toBe("closed");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("steamvault", 17_796),
now: 4,
}).snapshot;
expect(snapshot.worldObjectStates[mek.id]).toBe("unlocked");
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: mek.id, now: 5 }).snapshot;
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 6 }).snapshot;
expect(snapshot.worldObjectStates[door.id]).toBe("open");
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 7);
expect(reset.worldObjectStates[door.id]).toBe("open");
expect(reset.worldObjectStates[hydro.id]).toBe("used");
expect(reset.worldObjectStates[mek.id]).toBe("used");
});
it("holds Hellmaw out of static population until all common ritualists die", () => {
const dungeon = DUNGEON_REGISTRY["shadow-labyrinth"];
const definition = encounter("shadow-labyrinth", "progression-objects");
const ritualists = dungeon.staticSpawns
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_794 && (spawn.spawnMask ?? 3) === 3)
.map((spawn) => spawn.id);
const hellmawGroup = dungeon.encounterSpawnGroups?.find(
(group) => group.id === "shadow-labyrinth:hellmaw",
);
expect(ritualists).toHaveLength(18);
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_731)).toBe(false);
expect(hellmawGroup?.spawns).toHaveLength(1);
const result = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
kind: "tick",
now: 1,
facts: { registeredEntityIds: ritualists, deadEntityIds: ritualists },
});
expect(result.snapshot.activeSpawnGroupIds).toContain("shadow-labyrinth:hellmaw");
});
it("gates Whitemane behind Mograine and restores her attempt after a wipe", () => {
const dungeon = DUNGEON_REGISTRY["scarlet-monastery"];
const definition = encounter("scarlet-monastery", "cathedral-pair");
const groupId = "scarlet-monastery:whitemane";
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 3_977)).toBe(false);
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === groupId)?.spawns).toHaveLength(1);
let snapshot = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
kind: "entity-death",
entityId: actorId("scarlet-monastery", 3_976),
now: 1,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2);
expect(snapshot.activeSpawnGroupIds).not.toContain(groupId);
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
});
});
@@ -0,0 +1,784 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
} from "./dungeonTypes";
import type {
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "./dungeonEncounterTypes";
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
import type { StaticMobSpawnDefinition } from "./mobPopulation";
interface ProgressionSourceReview {
readonly dungeonId: string;
readonly files: readonly string[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
const WORLD_SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-world";
export const WOW_PROGRESSION_OBJECT_REVIEWS: readonly ProgressionSourceReview[] = Object.freeze([
{
dungeonId: "deadmines",
files: [
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/instance_deadmines.cpp (sha256 c13065819443a70ec431b921edd2e32bcd6e0ec66c4c84c3f2e932e9fb1cbbc9; saved Rhahk'Zor and cannon state lines 31-95)`,
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/deadmines.h (sha256 a510bb170ac09b78c2f095742aa668c1aac8bf7ba8b457f6923807f37ae98045; encounter and gameobject entries lines 25-45)`,
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; Rhahk'Zor rows 4777-4780, cannon rows 17399 and 43337-43343, lever rows 38147-38150)`,
],
reviewedBehavior: "Rhahk'Zor opens the Factory Door; the foundry lever opens the Foundry Door; the Defias Cannon breaches the Iron Clad Door and summons two Defias Squallshapers, with the nearby inside lever retaining its database-linked door control.",
adaptations: [
"The source cannon requires a Gunpowder spell hit; Healer Man exposes the cannon as a direct one-shot interaction because inventory quest-item spell casting is not part of the dungeon runtime.",
"The cannon's custom animation and Mr. Smite dialogue are collapsed into the immediate breach and exact two-add reinforcement spawn.",
"Source transforms and client bounds drive procedural doors, levers, and cannon collision while their native legacy models await export.",
],
remainingGaps: [
"Heavy Door 1, Heavy Door 2, and the Mast Room Door are still left nonblocking because the reviewed source only persists their generic client/core state and does not bind an instance progression event.",
"Cannon smoke, timed dialogue, native door/lever animation, and the original gunpowder item requirement remain unported presentation or inventory mechanics.",
],
},
{
dungeonId: "shadowfang-keep",
files: [
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/instance_shadowfang_keep.cpp (sha256 12d7568b35b30a7d2630de46729d4ffaa0d58de9c345c6c9143037bf88b59e07; saved courtyard, Fenrus, and Nandos door state lines 76-111)`,
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/shadowfang_keep.h (sha256 daa0ac298fe8c23c4e9353be898bec0fa6b7cdd9c35e821ef880aaa8600419fd; encounter and gameobject entries lines 25-48)`,
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; prisoner escort/unlock rows 7933-7945 and 40678-40693)`,
],
reviewedBehavior: "The courtyard route opens after the Rethilgore/prisoner section, Fenrus opens the Sorcerer Door, and Wolf Master Nandos opens the final Arugal Door.",
adaptations: [
"The two faction-specific prisoner escorts are collapsed into an immediate courtyard-door transition when Rethilgore dies, avoiding a mandatory faction/gossip branch that the current runtime cannot represent safely.",
"The exact imported door transforms and display bounds provide procedural blockers while native legacy door animations await export.",
],
remainingGaps: [
"Deathstalker Adamant and Sorcerer Ashcrombe dialogue, waypoint movement, spell visuals, faction-specific gossip, and despawn presentation remain unported.",
"The seasonal Apothecary Hummel event is intentionally outside the ordinary dungeon route and has not been made a required objective.",
],
},
{
dungeonId: "maraudon",
files: [
`${SOURCE_ROOT}/Kalimdor/Maraudon/instance_maraudon.cpp (sha256 edc4b2c0ba055f9953186bda5009ac990e1b18d206ae8d9aa60dcbf24f259d09; saved Noxxion state and spewer transition lines 31-79)`,
`${SOURCE_ROOT}/Kalimdor/Maraudon/maraudon.h (sha256 81939372ae6bb38d879cda349992c1895e7281f5364ddca4e0fc235140e6cbfa; encounter and Corruption Spewer entries lines 20-33)`,
],
reviewedBehavior: "Defeating Noxxion permanently opens the Corruption Spewer route transition.",
adaptations: [
"The imported transform and display bounds drive a conservative procedural barrier that disappears when opened; the legacy spewer animation and particles await native model export.",
],
remainingGaps: [
"Corruption spray/particle presentation, the native gameobject animation, optional quest interactions, and Celebras's Scepter shortcut are not part of this mandatory route gate.",
],
},
{
dungeonId: "mechanar",
files: [
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/instance_mechanar.cpp (sha256 6dac8f562fe69dd5ec06ef94ffea5399ae08c9b502eec3fa8790390a0fde90b7; DoorData lines 22-27)`,
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/mechanar.h (sha256 9c5baf35ca4b6126901d39278a289a42efce0b3d6c56d1ac1231cd8ddb6d9283; data and gameobject entries lines 29-52)`,
],
reviewedBehavior: "Gatewatcher Iron-Hand and Gatewatcher Gyro-Kill each open their bound Mo'arg passage door; Sepethrea's room door closes while she is engaged and opens after her defeat.",
adaptations: [
"The exact imported door transforms and display bounds drive procedural blockers while the three native MDX door models await export.",
"Door motion is represented by visible/blocked and hidden/unblocked states rather than the client door animation.",
],
remainingGaps: [
"Pathaleon's bridge trash death counter, bridge dialogue, Cache of the Legion rules, and elevator operation are not yet ported.",
],
},
{
dungeonId: "steamvault",
files: [
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/instance_steam_vault.cpp (sha256 55b1bd5fd6a01233ca18a35eb9b0c098bbb996cfd220d8f4c3e51f992e3c60cf; access panels lines 36-91 and boss state lines 154-174)`,
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/steam_vault.h (sha256 94d142e428a796cf4c0d36ec6b9b7fe70f059790d6378bbb55c7803bf9cda393; object entries lines 31-50)`,
],
reviewedBehavior: "Hydromancer Thespia and Mekgineer Steamrigger each unlock their nearby access panel; using both panels opens the Main Chambers door to Warlord Kalithresh.",
adaptations: [
"Source positions identify both panels and the main door, with compact procedural consoles and a procedural collision blocker until their client models are exported.",
"The original door-controller creature's movement and emote are collapsed into the immediate two-panel door transition.",
],
remainingGaps: [
"Door-controller presentation, distiller reset coupling, access-panel animation, and native object visuals remain unported.",
],
},
{
dungeonId: "shadow-labyrinth",
files: [
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/instance_shadow_labyrinth.cpp (sha256 b51d655ee5918d0bcb89b4ada5d9af752c8c0395032d99e4148b9edaaf34a834; ritualist release lines 58-89 and DoorData lines 24-29)`,
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/shadow_labyrinth.h (sha256 11f6bf85abe38fab9d4182325cc6a4693e3ea724e49f211355756345a7d44046; ritualist, Hellmaw, and door entries lines 28-44)`,
],
reviewedBehavior: "The common Cabal Ritualists keep Ambassador Hellmaw banished; Blackheart and Vorpil open the Refectory and Screaming Hall passage doors on defeat.",
adaptations: [
"Hellmaw is removed from the unsafe static spawn and appears only after the 18 normal/common ritualists die.",
"Heroic-only duplicate ritualists do not delay release because the encounter reducer has no active-difficulty guard; they remain ordinary combat population.",
"The exact imported door transforms and bounds use procedural blockers until native door models are exported.",
],
remainingGaps: [
"Hellmaw's banish channel presentation, heroic-only ritualist contribution, Blackheart room aggro choreography, and native door animations remain unported.",
],
},
{
dungeonId: "scarlet-monastery",
files: [
`${SOURCE_ROOT}/EasternKingdoms/ScarletMonastery/instance_scarlet_monastery.cpp (sha256 1bada7d72e38197e6d8d202d39940a27b69e6dbbf795a53e97744227aa8ca01e; cathedral pair and doors lines 38-69 and 132-197)`,
],
reviewedBehavior: "Commander Mograine gates High Inquisitor Whitemane behind the High Inquisitor's Door as one linked cathedral encounter.",
adaptations: [
"Whitemane is removed from the unsafe static spawn, the imported inner door opens on Mograine's first defeat, and Whitemane then spawns at her exact database position.",
"The source resurrection duet is represented as two ordered lives because the current encounter action vocabulary cannot revive an existing combat actor.",
"The Chapel Door is left open; its source Ashbringer event is optional external narrative content and must not block the ordinary cathedral route.",
],
remainingGaps: [
"Whitemane's sleep/resurrection of Mograine, Mograine's post-resurrection phase, cathedral-wide social aggro, Ashbringer scene, and native door animation remain unported.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
function entityEntry(definition: DungeonDefinition, entityId: string): number | undefined {
return definition.entities[entityId]?.combat?.serverEntry;
}
function staticSpawnForEntry(definition: DungeonDefinition, entry: number): StaticMobSpawnDefinition {
const spawn = definition.staticSpawns.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
if (!spawn) throw new Error(`${definition.id} is missing static creature entry ${entry}`);
return spawn;
}
function actorIdForEntry(definition: DungeonDefinition, entry: number): string {
const staticSpawn = definition.staticSpawns.find(
(candidate) => entityEntry(definition, candidate.entityId) === entry,
);
if (staticSpawn) return staticSpawn.id;
for (const pack of definition.roamingPacks) {
const member = pack.members.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
if (member) return `${pack.id}:${member.id}`;
}
throw new Error(`${definition.id} is missing runtime actor for creature entry ${entry}`);
}
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
const actorIds = new Set<string>();
for (const spawn of definition.staticSpawns) {
if (entityEntry(definition, spawn.entityId) === entry) actorIds.add(spawn.id);
}
for (const pack of definition.roamingPacks) {
for (const member of pack.members) {
if (entityEntry(definition, member.entityId) === entry) actorIds.add(`${pack.id}:${member.id}`);
}
}
const objective = definition.bosses.find((candidate) => actorIds.has(candidate.id));
if (!objective) throw new Error(`${definition.id} is missing boss objective for creature entry ${entry}`);
return objective;
}
function sourceObject(definition: DungeonDefinition, templateId: number): DungeonWorldObjectDefinition {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
if (!object) throw new Error(`${definition.id} is missing source gameobject template ${templateId}`);
return object;
}
function reviewedDoor(
definition: DungeonDefinition,
templateId: number,
initialState: "closed" | "open",
halfExtents?: readonly [number, number, number],
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
return Object.freeze({
...source,
kind: "door",
...(halfExtents ? { halfExtents } : {}),
initialState,
states: Object.freeze([
{
id: "closed",
label: "Closed",
visible: true,
blocksMovement: true,
blocksSight: true,
interactionEnabled: false,
},
{
id: "open",
label: "Open",
visible: false,
blocksMovement: false,
blocksSight: false,
interactionEnabled: false,
},
]),
visual: Object.freeze({ color: "#544a3f", emissive: "#130f0a" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "Source transform and client bounds are retained. A procedural blocker provides reviewed gameplay collision until the native legacy door model is exported.",
}),
});
}
function reviewedPanel(
definition: DungeonDefinition,
templateId: number,
signalId: string,
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
return Object.freeze({
...source,
kind: "switch",
halfExtents: [0.85, 1.1, 0.45] as const,
initialState: "locked",
states: Object.freeze([
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "unlocked", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
]),
interaction: Object.freeze({
prompt: "Activate access panel",
range: 3,
signalId,
allowedStates: ["unlocked"],
oneShot: true,
}),
visual: Object.freeze({ color: "#2f6d75", emissive: "#173f46" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "Source placement identifies the console. Compact procedural geometry supplies a readable interaction target until its native legacy model is exported.",
}),
});
}
function reviewedInteractable(
definition: DungeonDefinition,
templateId: number,
options: {
readonly signalId: string;
readonly prompt: string;
readonly kind?: DungeonWorldObjectDefinition["kind"];
readonly initialState?: "ready" | "locked";
readonly range?: number;
},
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
const initialState = options.initialState ?? "ready";
return Object.freeze({
...source,
kind: options.kind ?? "switch",
initialState,
states: Object.freeze([
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
]),
interaction: Object.freeze({
prompt: options.prompt,
range: options.range ?? 3.2,
signalId: options.signalId,
allowedStates: ["ready"],
oneShot: true,
}),
visual: Object.freeze({ color: "#786344", emissive: "#2b2114" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "The source transform and client display bounds are retained. Procedural geometry supplies a readable interaction target until the native legacy model is exported.",
}),
});
}
function replaceObjects(
definition: DungeonDefinition,
replacements: readonly DungeonWorldObjectDefinition[],
): readonly DungeonWorldObjectDefinition[] {
const byId = new Map(replacements.map((object) => [object.id, object] as const));
return Object.freeze(definition.serverGameObjects.map((object) => byId.get(object.id) ?? object));
}
function sourceReview(definition: DungeonDefinition): DungeonEncounterDefinition["source"] {
const review = REVIEW_BY_DUNGEON.get(definition.id);
if (!review) throw new Error(`Missing progression-object source review for ${definition.id}`);
return Object.freeze({
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
files: review.files,
reviewedBehavior: review.reviewedBehavior,
adaptations: review.adaptations,
remainingGaps: review.remainingGaps,
});
}
function appendWarning(definition: DungeonDefinition, warning: string): DungeonDefinition["validation"] {
return Object.freeze({
...definition.validation,
warnings: Object.freeze([...new Set([...definition.validation.warnings, warning])]),
});
}
function buildDeadmines(definition: DungeonDefinition): DungeonDefinition {
const rhahkZor = actorIdForEntry(definition, 644);
const squallshaperSource = staticSpawnForEntry(definition, 1_732);
const factoryDoor = reviewedDoor(definition, 13_965, "closed");
const foundryDoor = reviewedDoor(definition, 16_399, "closed");
const ironCladDoor = reviewedDoor(definition, 16_397, "closed");
const factoryLever = reviewedInteractable(definition, 101_831, {
signalId: "deadmines:factory-lever",
prompt: "Use the factory lever",
initialState: "locked",
});
const ironCladLever = reviewedInteractable(definition, 101_833, {
signalId: "deadmines:iron-clad-lever",
prompt: "Use the iron-clad lever",
});
const foundryLever = reviewedInteractable(definition, 101_834, {
signalId: "deadmines:foundry-lever",
prompt: "Use the foundry lever",
});
const cannon = reviewedInteractable(definition, 16_398, {
signalId: "deadmines:fire-cannon",
prompt: "Load and fire the Defias Cannon",
kind: "quest-object",
range: 4.5,
});
const cannonReinforcements: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "deadmines:cannon-reinforcements",
spawns: Object.freeze([
{
id: "deadmines:cannon-squallshaper-one",
entityId: squallshaperSource.entityId,
position: [-709.2917, 9.0828, -100.7318] as const,
yaw: -1.58,
spawnMask: 1,
},
{
id: "deadmines:cannon-squallshaper-two",
entityId: squallshaperSource.entityId,
position: [-708.6717, 9.0028, -90.2018] as const,
yaw: -1.74,
spawnMask: 1,
},
]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "rhahk-zor-opens-factory",
trigger: { kind: "entity-death", entityId: rhahkZor },
actions: [
{ id: "open-factory-door", kind: "set-world-object-state", objectId: factoryDoor.id, state: "open", persistence: "run" },
{ id: "mark-factory-lever-used", kind: "set-world-object-state", objectId: factoryLever.id, state: "used", persistence: "run" },
],
},
{
id: "foundry-lever-opens-door",
trigger: { kind: "interact", objectId: foundryLever.id },
guards: [{ kind: "world-object-state", objectId: foundryLever.id, states: ["ready"] }],
actions: [
{ id: "open-foundry-door", kind: "set-world-object-state", objectId: foundryDoor.id, state: "open", persistence: "run" },
{ id: "mark-foundry-lever-used", kind: "set-world-object-state", objectId: foundryLever.id, state: "used", persistence: "run" },
],
},
{
id: "iron-clad-lever-opens-door",
trigger: { kind: "interact", objectId: ironCladLever.id },
guards: [{ kind: "world-object-state", objectId: ironCladLever.id, states: ["ready"] }],
actions: [
{ id: "lever-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
{ id: "mark-iron-clad-lever-used", kind: "set-world-object-state", objectId: ironCladLever.id, state: "used", persistence: "run" },
],
},
{
id: "cannon-breaches-iron-clad-door",
trigger: { kind: "interact", objectId: cannon.id },
guards: [{ kind: "world-object-state", objectId: cannon.id, states: ["ready"] }],
actions: [
{ id: "mark-defias-cannon-used", kind: "set-world-object-state", objectId: cannon.id, state: "used", persistence: "run" },
{ id: "cannon-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
{ id: "spawn-cannon-reinforcements", kind: "activate-spawn-group", spawnGroupId: cannonReinforcements.id },
],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "deadmines:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[factoryDoor.id]: "closed",
[foundryDoor.id]: "closed",
[ironCladDoor.id]: "closed",
[factoryLever.id]: "locked",
[foundryLever.id]: "ready",
[ironCladLever.id]: "ready",
[cannon.id]: "ready",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), cannonReinforcements]),
serverGameObjects: replaceObjects(definition, [
factoryDoor,
foundryDoor,
ironCladDoor,
factoryLever,
foundryLever,
ironCladLever,
cannon,
]),
validation: appendWarning(definition, "Deadmines now runs the source Rhahk'Zor, foundry-lever, and cannon/iron-clad transitions; generic heavy and mast doors remain explicitly nonblocking."),
});
}
function buildShadowfangKeep(definition: DungeonDefinition): DungeonDefinition {
const rethilgore = actorIdForEntry(definition, 3_914);
const fenrus = actorIdForEntry(definition, 4_274);
const nandos = actorIdForEntry(definition, 3_927);
const courtyardDoor = reviewedDoor(definition, 18_895, "closed");
const sorcererDoor = reviewedDoor(definition, 18_972, "closed");
const arugalDoor = reviewedDoor(definition, 18_971, "closed");
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "rethilgore-section-opens-courtyard",
trigger: { kind: "entity-death", entityId: rethilgore },
actions: [{ id: "open-courtyard-door", kind: "set-world-object-state", objectId: courtyardDoor.id, state: "open", persistence: "run" }],
},
{
id: "fenrus-opens-sorcerer-door",
trigger: { kind: "entity-death", entityId: fenrus },
actions: [{ id: "open-sorcerer-door", kind: "set-world-object-state", objectId: sorcererDoor.id, state: "open", persistence: "run" }],
},
{
id: "nandos-opens-arugal-door",
trigger: { kind: "entity-death", entityId: nandos },
actions: [{ id: "open-arugal-door", kind: "set-world-object-state", objectId: arugalDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "shadowfang-keep:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[courtyardDoor.id]: "closed",
[sorcererDoor.id]: "closed",
[arugalDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [courtyardDoor, sorcererDoor, arugalDoor]),
validation: appendWarning(definition, "Shadowfang Keep now binds all three source progression doors; the faction-specific prisoner escort is an explicit immediate post-Rethilgore adaptation."),
});
}
function buildMaraudon(definition: DungeonDefinition): DungeonDefinition {
const noxxion = actorIdForEntry(definition, 13_282);
const corruptionSpewer = reviewedDoor(definition, 178_570, "closed", [1.2, 7.7, 13.9]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "noxxion-opens-corruption-spewer",
trigger: { kind: "entity-death", entityId: noxxion },
actions: [{
id: "open-corruption-spewer",
kind: "set-world-object-state",
objectId: corruptionSpewer.id,
state: "open",
persistence: "run",
}],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "maraudon:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: { worldObjectStates: { [corruptionSpewer.id]: "closed" } },
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [corruptionSpewer]),
validation: appendWarning(definition, "Maraudon's source Noxxion-to-Corruption-Spewer route gate is executable; native spewer presentation remains an explicit asset adaptation."),
});
}
function buildMechanar(definition: DungeonDefinition): DungeonDefinition {
const ironHand = actorIdForEntry(definition, 19_710);
const gyroKill = actorIdForEntry(definition, 19_218);
const sepethrea = actorIdForEntry(definition, 19_221);
const ironDoor = reviewedDoor(definition, 184_632, "closed", [4.65, 7.98, 1.1]);
const gyroDoor = reviewedDoor(definition, 184_322, "closed", [4.65, 7.98, 1.1]);
const sepethreaDoor = reviewedDoor(definition, 184_449, "open", [4.65, 7.98, 1.1]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "iron-hand-opens-moarg-one",
trigger: { kind: "entity-death", entityId: ironHand },
actions: [{ id: "open-moarg-one", kind: "set-world-object-state", objectId: ironDoor.id, state: "open", persistence: "run" }],
},
{
id: "gyro-kill-opens-moarg-two",
trigger: { kind: "entity-death", entityId: gyroKill },
actions: [{ id: "open-moarg-two", kind: "set-world-object-state", objectId: gyroDoor.id, state: "open", persistence: "run" }],
},
{
id: "sepethrea-closes-room",
trigger: { kind: "entity-engaged", entityId: sepethrea },
actions: [{ id: "close-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "closed" }],
},
{
id: "sepethrea-opens-room",
trigger: { kind: "entity-death", entityId: sepethrea },
actions: [{ id: "open-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "mechanar:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[ironDoor.id]: "closed",
[gyroDoor.id]: "closed",
[sepethreaDoor.id]: "open",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [ironDoor, gyroDoor, sepethreaDoor]),
validation: appendWarning(definition, "Mechanar source doors are executable; bridge scripting, elevator operation, and native door assets remain documented adaptations."),
});
}
function buildSteamvault(definition: DungeonDefinition): DungeonDefinition {
const thespia = actorIdForEntry(definition, 17_797);
const steamrigger = actorIdForEntry(definition, 17_796);
const hydroPanel = reviewedPanel(definition, 184_125, "steamvault:hydromancer-panel");
const mekPanel = reviewedPanel(definition, 184_126, "steamvault:mekgineer-panel");
const mainDoor = reviewedDoor(definition, 183_049, "closed", [7.2, 5.95, 0.9]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "thespia-unlocks-panel",
trigger: { kind: "entity-death", entityId: thespia },
actions: [{ id: "unlock-hydromancer-panel", kind: "set-world-object-state", objectId: hydroPanel.id, state: "unlocked", persistence: "run" }],
},
{
id: "steamrigger-unlocks-panel",
trigger: { kind: "entity-death", entityId: steamrigger },
actions: [{ id: "unlock-mekgineer-panel", kind: "set-world-object-state", objectId: mekPanel.id, state: "unlocked", persistence: "run" }],
},
{
id: "use-hydromancer-panel",
trigger: { kind: "interact", objectId: hydroPanel.id },
guards: [{ kind: "world-object-state", objectId: hydroPanel.id, states: ["unlocked"] }],
actions: [
{ id: "mark-hydromancer-panel-used", kind: "set-flag", flagId: "hydromancer-panel-used", value: true, persistence: "run" },
{ id: "set-hydromancer-panel-used", kind: "set-world-object-state", objectId: hydroPanel.id, state: "used", persistence: "run" },
],
},
{
id: "use-mekgineer-panel",
trigger: { kind: "interact", objectId: mekPanel.id },
guards: [{ kind: "world-object-state", objectId: mekPanel.id, states: ["unlocked"] }],
actions: [
{ id: "mark-mekgineer-panel-used", kind: "set-flag", flagId: "mekgineer-panel-used", value: true, persistence: "run" },
{ id: "set-mekgineer-panel-used", kind: "set-world-object-state", objectId: mekPanel.id, state: "used", persistence: "run" },
],
},
{
id: "both-panels-open-main-door",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "hydromancer-panel-used", equals: true },
{ kind: "flag", flagId: "mekgineer-panel-used", equals: true },
],
actions: [{ id: "open-main-chambers-door", kind: "set-world-object-state", objectId: mainDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "steamvault:access-panels",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
flags: { "hydromancer-panel-used": false, "mekgineer-panel-used": false },
worldObjectStates: {
[hydroPanel.id]: "locked",
[mekPanel.id]: "locked",
[mainDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [hydroPanel, mekPanel, mainDoor]),
validation: appendWarning(definition, "Steamvault's two boss-gated access panels and Kalithresh door are executable with documented procedural presentation."),
});
}
function buildShadowLabyrinth(definition: DungeonDefinition): DungeonDefinition {
const hellmawObjective = objectiveForEntry(definition, 18_731);
const hellmawSpawn = staticSpawnForEntry(definition, 18_731);
const ritualists = definition.staticSpawns
.filter((spawn) => entityEntry(definition, spawn.entityId) === 18_794 && (spawn.spawnMask ?? 3) === 3)
.map((spawn) => spawn.id);
if (ritualists.length !== 18) {
throw new Error(`shadow-labyrinth expected 18 common Cabal Ritualists, found ${ritualists.length}`);
}
const blackheart = actorIdForEntry(definition, 18_667);
const vorpil = actorIdForEntry(definition, 18_732);
const refectoryDoor = reviewedDoor(definition, 183_296, "closed", [3.5, 6, 0.8]);
const screamingHallDoor = reviewedDoor(definition, 183_295, "closed", [3.5, 6, 0.8]);
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "shadow-labyrinth:hellmaw",
spawns: Object.freeze([hellmawSpawn]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "release-hellmaw",
trigger: { kind: "entities-dead", entityIds: ritualists },
actions: [
{ id: "spawn-released-hellmaw", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "enter-hellmaw-active", kind: "set-phase", phase: "hellmaw-active" },
],
},
{
id: "blackheart-opens-refectory",
trigger: { kind: "entity-death", entityId: blackheart },
actions: [{ id: "open-refectory-door", kind: "set-world-object-state", objectId: refectoryDoor.id, state: "open", persistence: "run" }],
},
{
id: "vorpil-opens-screaming-hall",
trigger: { kind: "entity-death", entityId: vorpil },
actions: [{ id: "open-screaming-hall-door", kind: "set-world-object-state", objectId: screamingHallDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "shadow-labyrinth:progression-objects",
dungeonId: definition.id,
initialPhase: "ritualists-active",
initialState: {
worldObjectStates: {
[refectoryDoor.id]: "closed",
[screamingHallDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "ritualists-active", checkpointPhase: "ritualists-active" },
source: sourceReview(definition),
rules,
});
const eventObjective = Object.freeze({ ...hellmawObjective, eventOnly: true });
return Object.freeze({
...definition,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== hellmawSpawn.id)),
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
serverGameObjects: replaceObjects(definition, [refectoryDoor, screamingHallDoor]),
validation: appendWarning(definition, "Shadow Labyrinth now gates Hellmaw on the common ritualists and binds both passage doors; heroic ritualist gating and presentation remain documented adaptations."),
});
}
function buildScarletMonastery(definition: DungeonDefinition): DungeonDefinition {
const mograine = actorIdForEntry(definition, 3_976);
const whitemaneObjective = objectiveForEntry(definition, 3_977);
const whitemaneSpawn = staticSpawnForEntry(definition, 3_977);
const chapelDoor = reviewedDoor(definition, 104_591, "open", [4.3, 5.5, 0.8]);
const inquisitorDoor = reviewedDoor(definition, 104_600, "closed", [3.6, 4.6, 0.8]);
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "scarlet-monastery:whitemane",
spawns: Object.freeze([whitemaneSpawn]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "mograine-releases-whitemane",
trigger: { kind: "entity-death", entityId: mograine },
actions: [
{ id: "remember-mograine-defeat", kind: "set-flag", flagId: "mograine-defeated", value: true, persistence: "run" },
{ id: "open-inquisitor-door", kind: "set-world-object-state", objectId: inquisitorDoor.id, state: "open", persistence: "run" },
{ id: "spawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "enter-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
],
},
{
id: "resume-whitemane-after-wipe",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "mograine-defeated", equals: true },
{ kind: "objective-complete", objectiveId: whitemaneObjective.id, equals: false },
],
actions: [
{ id: "respawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "resume-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "scarlet-monastery:cathedral-pair",
dungeonId: definition.id,
initialPhase: "mograine-active",
initialState: {
flags: { "mograine-defeated": false },
worldObjectStates: { [chapelDoor.id]: "open", [inquisitorDoor.id]: "closed" },
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(definition),
rules,
});
const eventObjective = Object.freeze({ ...whitemaneObjective, eventOnly: true });
return Object.freeze({
...definition,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== whitemaneSpawn.id)),
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
serverGameObjects: replaceObjects(definition, [chapelDoor, inquisitorDoor]),
validation: appendWarning(definition, "Scarlet Monastery now gates Whitemane behind Mograine and the source inner door; resurrection remains a documented two-stage adaptation."),
});
}
export function withWowProgressionObjectContent(definition: DungeonDefinition): DungeonDefinition {
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:progression-objects`)
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:access-panels`)
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:cathedral-pair`)) {
return definition;
}
if (definition.id === "deadmines") return buildDeadmines(definition);
if (definition.id === "shadowfang-keep") return buildShadowfangKeep(definition);
if (definition.id === "maraudon") return buildMaraudon(definition);
if (definition.id === "mechanar") return buildMechanar(definition);
if (definition.id === "steamvault") return buildSteamvault(definition);
if (definition.id === "shadow-labyrinth") return buildShadowLabyrinth(definition);
if (definition.id === "scarlet-monastery") return buildScarletMonastery(definition);
return definition;
}
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
import {
WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS,
withWowTbcEncounterBatchTwoContent,
} from "./wowTbcEncounterBatchTwoCatalog";
const TARGETS = [
"underbog",
"slave-pens",
"mana-tombs",
"auchenai-crypts",
"magisters-terrace",
] as const;
const EXPECTED_SOURCE_HASHES = Object.freeze({
underbog: [
"89a7406ee90f7a068a9ca0e3c4d8b9f8995325a704bbf9e15dfa75a17c78deca",
"57aa7cbbdeef9b2bf6bf911e99a118f8b8daa51b9d71e305c673c15b0d101dca",
"c10c6e02bc9af2e9c8c876f77cda8abf295f79c3240e789315afd156511a25d9",
"28a3e2b836b349b2104d187fdf8b424455ded2b067f221b209b636c509933ac3",
"875f6aadf0a44d3119de5cc5751fba232d156ece30bfa5e522bb24f4c005884d",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"5109b88955dfd4f718e581ca88f0f9ec478fc64cdf0904cea07e17157bd68ccb",
],
"slave-pens": [
"c1dc38b81d430d16b7a55ff128bb8989fa8ef487bc033540cb7cd5bf5d2f2967",
"1e83c465f3cf74797474ee51e3b8a7f6682ede63ba9b2fdd18841ce1b4bf91e8",
"09c7f29871b5f2e429c5f643db6f645f0c33a163b143e48e4806ac7231d174d6",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"mana-tombs": [
"f243c4ee42d272202023b0c81762221b96e62029006fcfbafbcd5d2bf7b40c7d",
"dbb6971658836e2137e6c7a590ce13a790b3d75f79a97edc84e6c8ac8624de1b",
"490d328811925cb815949e55ba88919512ae11fed6ce7e0c243947a6014a342c",
"f9407bd388db7434746b6f650ae98772933ef47908fb484e19a8460a16e0e783",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"auchenai-crypts": [
"5a5192f9f89726bf0d73a9b92fccd5e684c47ef22a53465a1ccc6153dabf490c",
"ee1e867c866a9c023d1257f90c99925975a321a67f2303e83a8373e2da3315e0",
"970edcd88536186ea675f84ef28cdb3f99b19554f289c5fbb1b42e895d13c8be",
"6e34fbe51bed87ca4495a8e0b006017ea2de52673d00d6d5148b00e138ebf752",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"magisters-terrace": [
"8aa316ceaa914b64e88fe420a0f37537bf016bf5635f8106bf32f82b3eb25728",
"43ff46dc05da63bfc4ed49cc5fce8451656857cce544f4ed146736285d4f626d",
"458daa44853b6a94a058ab8ccfe08ae4c8ccd34a0387cb766544e12bcbf45dee",
"9f5b4a05eb6b6e2acc361f764fd1e472ec255bb9a32369c16e7cc8303e5693e2",
"25884cccec0c8bf123c73294bbcdcaee7f6da9a7a0a4af8b3e4d79b66c6b3024",
"ed4a734dcdf8b7e186dd4ceeb0ee7d890784aa18edfeab5acf03180a8da1d47f",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
],
});
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: typeof TARGETS[number]): DungeonDefinition {
const generated = campaign.definitions.find((definition) => definition.id === id);
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
const baseline = withWowTbcEncounterContent(withWowProgressionObjectContent(withWowEncounterContent(generated)));
return withWowTbcEncounterBatchTwoContent(baseline);
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-batch-two-instance`);
if (!value) throw new Error(`Missing TBC batch-two encounter for ${definition.id}`);
return value;
}
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 objectIdForSpawn(definition: DungeonDefinition, spawnId: number): string {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId);
if (!object) throw new Error(`${definition.id} missing object spawn ${spawnId}`);
return object.id;
}
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
}
function entityForEntry(definition: DungeonDefinition, entry: number) {
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
return entity;
}
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
if (!spawn) throw new Error(`${definition.id} missing static boss entry ${entry}`);
return spawn.id;
}
function dispatch(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
difficultyId?: "normal" | "heroic",
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
difficultyId?: "normal" | "heroic",
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return dispatch(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}, difficultyId);
}
describe("source-backed adapted TBC encounter batch two", () => {
it("pins every reviewed source hash/anchor and publishes explicit adaptations and gaps", () => {
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const source of review.sourceFiles) {
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
.toBe(source.sha256);
}
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
}
});
it("gives each reviewed event actor its source schedule and keeps Frozen Core passive", () => {
const auchenai = adapted("auchenai-crypts");
const avatar = entityForEntry(auchenai, 18_478);
expect(avatar.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_391 },
{
id: "mortal-strike-16856",
name: "Mortal Strike",
spellId: 16_856,
target: "primary",
school: "physical",
initialCooldownMs: [8_500, 16_800],
repeatCooldownMs: [10_900, 24_100],
damageMultiplier: 2,
source: { entry: 18_478, rowId: 0, eventType: 0, actionType: 11, targetType: 2 },
},
{
id: "sunder-armor-16145",
name: "Sunder Armor",
spellId: 16_145,
target: "primary",
initialCooldownMs: [6_500, 11_500],
repeatCooldownMs: [6_200, 15_700],
effect: { kind: "target-aura", aura: "sunder-armor", durationMs: 30_000 },
source: { entry: 18_478, rowId: 1, eventType: 0, actionType: 11, targetType: 2 },
},
]);
const manaTombs = adapted("mana-tombs");
const yor = entityForEntry(manaTombs, 22_930);
expect(yor.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 2_000 },
{
id: "double-breath-38361",
name: "Double Breath",
spellId: 38_361,
target: "nearby-party",
school: "fire",
range: 5,
initialCooldownMs: [25_500, 30_500],
repeatCooldownMs: [10_000, 20_000],
telegraph: { shape: "cone", anchor: "source", length: 20 },
},
{
id: "stomp-36405",
name: "Stomp",
spellId: 36_405,
target: "nearby-party",
school: "physical",
initialCooldownMs: [12_000, 18_000],
repeatCooldownMs: [14_000, 24_000],
},
]);
const slavePens = adapted("slave-pens");
const ahune = entityForEntry(slavePens, 25_740);
const frozenCore = entityForEntry(slavePens, 25_865);
expect(ahune).toMatchObject({ kind: "boss", hasLoot: true });
expect(ahune.combat?.attacks).toMatchObject([
{
id: "ahunes-shield-45954",
name: "Ahune's Shield",
spellId: 45_954,
target: "self",
castTimeMs: 0,
initialCooldownMs: [4, 4],
maximumCasts: 1,
effect: { kind: "aura", aura: "ahunes-shield", durationMs: 0, magnitude: 0.75 },
},
{
id: "ahune-spanky-hands-46146",
name: "[PH] Ahune Spanky Hands",
spellId: 46_146,
target: "self",
castTimeMs: 0,
initialCooldownMs: [4, 4],
maximumCasts: 1,
},
]);
expect(frozenCore).toMatchObject({ kind: "mob", hasLoot: false });
expect(frozenCore.combat?.healthMultiplier).toBe(4.3);
expect(frozenCore.combat?.attacks).toMatchObject([
{
id: "ice-spear-control-aura-46371",
name: "Ice Spear Control Aura",
spellId: 46_371,
target: "self",
castTimeMs: 0,
maximumCasts: 1,
},
{
id: "synch-health-46430",
name: "Synch Health",
spellId: 46_430,
target: "self",
castTimeMs: 0,
initialCooldownMs: [3_000, 3_000],
repeatCooldownMs: [3_000, 3_000],
effect: { kind: "encounter-event", eventId: "ahune-synch-health" },
},
]);
for (const entity of [avatar, yor, ahune, frozenCore]) {
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
expect(entity.combat?.mechanicIds).toEqual(
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
);
}
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.find((review) => review.dungeonId === "slave-pens")
?.remainingGaps.join(" ")).toContain("proc-driven Cold Slap");
});
it("validates definitions, stable action ids, references, timeouts, and wrapper idempotency", () => {
for (const id of TARGETS) {
const definition = adapted(id);
const value = encounter(definition);
validateDungeonEncounterDefinition(value);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groups = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
const objects = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
const spawns = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
]);
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
expect(new Set(actionIds).size, id).toBe(actionIds.length);
for (const rule of value.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
rule.trigger.entityIds.forEach((entityId) => expect(spawns.has(entityId), entityId).toBe(true));
}
if (rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health" || rule.trigger.kind === "entity-death") {
expect(spawns.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "interact") expect(objects.has(rule.trigger.objectId), rule.id).toBe(true);
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groups.has(action.spawnGroupId), action.id).toBe(true);
}
if (action.kind === "set-world-object-state") expect(objects.has(action.objectId), action.id).toBe(true);
}
}
expect(withWowTbcEncounterBatchTwoContent(definition)).toBe(definition);
}
});
it("suppresses event-only/static duplicates and preserves source order and cardinality", () => {
const underbog = adapted("underbog");
expect(underbog.staticSpawns.some((spawn) => [18_105, 17_827].includes(entryForSpawn(underbog, spawn.id) ?? -1))).toBe(false);
expect(group(underbog, "underbog:ghazan-platform").spawns).toHaveLength(1);
expect(group(underbog, "underbog:claw").spawns).toHaveLength(1);
expect(underbog.bosses.find((boss) => boss.name === "Ghaz'an")).toMatchObject({ eventOnly: true });
const slavePens = adapted("slave-pens");
expect(slavePens.staticSpawns.some((spawn) => entryForSpawn(slavePens, spawn.id) === 17_893)).toBe(false);
expect(group(slavePens, "slave-pens:naturalist-attackers").spawns).toHaveLength(3);
expect(group(slavePens, "slave-pens:ahune-coldwaves").spawns).toHaveLength(2);
expect(slavePens.objectiveOrder.at(-1)).toMatchObject({ id: "slave-pens:ahune", optional: true, eventOnly: true });
const manaTombs = adapted("mana-tombs");
expect(group(manaTombs, "mana-tombs:pandemonius-room-pull").spawns).toHaveLength(12);
expect(group(manaTombs, "mana-tombs:shaheen-wave-1").spawns).toHaveLength(4);
expect(group(manaTombs, "mana-tombs:shaheen-wave-2").spawns).toHaveLength(4);
expect(group(manaTombs, "mana-tombs:shaheen-wave-3").spawns).toHaveLength(1);
expect(manaTombs.staticSpawns.some((spawn) => /^creature-833(0[9]|1[0-9]|21)$/.test(spawn.id))).toBe(false);
expect(manaTombs.objectiveOrder.at(-1)).toMatchObject({ id: "mana-tombs:yor", optional: true, eventOnly: true });
const auchenai = adapted("auchenai-crypts");
expect(group(auchenai, "auchenai-crypts:stolen-soul").spawns).toHaveLength(1);
expect(group(auchenai, "auchenai-crypts:avatar").spawns).toHaveLength(1);
const magisters = adapted("magisters-terrace");
expect(magisters.staticSpawns.some((spawn) => entryForSpawn(magisters, spawn.id) === 24_560)).toBe(false);
expect(group(magisters, "magisters-terrace:selin-crystals").spawns).toHaveLength(5);
expect(group(magisters, "magisters-terrace:delrissa-party").spawns).toHaveLength(5);
expect(group(magisters, "magisters-terrace:kael-spheres").spawns).toHaveLength(3);
expect(magisters.objectiveOrder.map((boss) => boss.name)).toEqual([
"Selin Fireheart", "Vexallus", "Priestess Delrissa", "Kael'thas Sunstrider",
]);
});
it("replaces only reviewed GO rows by exact source spawnId with visible procedural states", () => {
const expected = Object.freeze({
underbog: [],
"slave-pens": [50_458, 1_267],
"mana-tombs": [43_146, 2_134_514],
"auchenai-crypts": [],
"magisters-terrace": [99_800, 99_802, 99_807, 99_808, 99_809, 99_813],
} as const);
for (const id of TARGETS) {
const definition = adapted(id);
for (const spawnId of expected[id]) {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.source?.files.length).toBeGreaterThan(0);
expect(object.visual?.hidden).not.toBe(true);
}
}
const magisters = adapted("magisters-terrace");
for (const spawnId of [99_802, 99_807, 99_808, 99_809, 99_813]) {
const door = magisters.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
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: false,
blocksMovement: false,
blocksSight: false,
});
}
});
it("runs Underbog difficulty branches, Ghaz'an release, Claw rescue, and wipe retry", () => {
const definition = adapted("underbog");
const value = encounter(definition);
const hungarfen = bossIdForEntry(definition, 17_770);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "normal");
normal = dispatch(value, normal, { kind: "tick", now: 9_999 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual([]);
normal = dispatch(value, normal, { kind: "tick", now: 10_000 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-normal"]);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "heroic");
heroic = dispatch(value, heroic, { kind: "tick", now: 3_000 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-heroic"]);
heroic = resetDungeonEncounterSnapshot(value, heroic, "boss-wipe", 3_001);
heroic = dispatch(value, heroic, { kind: "tick", now: 3_002 }, "heroic");
expect(heroic).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
let snapshot = dispatch(value, heroic, { kind: "interact", objectId: "underbog:ghazan-platform-trigger", now: 4_000 });
expect(snapshot.activeSpawnGroupIds).toEqual(["underbog:ghazan-platform"]);
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: "creature-138392", now: 4_001 });
expect(snapshot.completedObjectiveIds).toContain("creature-138392");
expect(snapshot.flags["ghazan-complete"]).toBe(true);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 4_002);
expect(reset.worldObjectStates["underbog:ghazan-platform-trigger"]).toBe("complete");
snapshot = dispatch(value, reset, { kind: "tick", now: 4_003 });
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 17_826), now: 4_004 });
expect(snapshot.activeSpawnGroupIds).toContain("underbog:claw");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "creature-138499", healthPercent: 20, now: 4_005 });
expect(snapshot.flags["claw-rescued"]).toBe(true);
expect(snapshot.activeSpawnGroupIds).not.toContain("underbog:claw");
expect(snapshot.worldObjectStates["underbog:rescued-claw"]).toBe("complete");
});
it("runs Naturalist and Ahune as finite, persistent, retryable Slave Pens events", () => {
const definition = adapted("slave-pens");
const value = encounter(definition);
const cage = objectIdForSpawn(definition, 50_458);
const iceStone = objectIdForSpawn(definition, 1_267);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: cage, now: 0 });
snapshot = killGroup(value, snapshot, group(definition, "slave-pens:naturalist-attackers"), 1);
expect(snapshot.phase).toBe("naturalist-handoff");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_441 });
expect(snapshot.flags["naturalist-complete"]).toBe(true);
expect(snapshot.worldObjectStates[cage]).toBe("complete");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 4_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_000 });
expect(snapshot.activeSpawnGroupIds).toEqual(["slave-pens:ahune"]);
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 14_001);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_002 });
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
expect(snapshot.flags["naturalist-complete"]).toBe(true);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 15_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 29_000 });
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-hailstone");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 37_000 });
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-coldwaves");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 123_000 });
expect(snapshot.phase).toBe("ahune-core");
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:frozen-core");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "slave-pens:frozen-core").spawns[0].id, now: 123_001 });
expect(snapshot).toMatchObject({ phase: "ready", flags: { "ahune-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.completedObjectiveIds).toContain("slave-pens:ahune");
});
it("runs Mana-Tombs room pull, Sha'heen ambushes, and heroic-only Yor", () => {
const definition = adapted("mana-tombs");
const value = encounter(definition);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 18_341), now: 0 });
expect(snapshot.activeSpawnGroupIds).toEqual(["mana-tombs:pandemonius-room-pull"]);
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 1);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 2 }, "normal");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const panel = objectIdForSpawn(definition, 43_146);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: panel, now: 3 });
for (let index = 1; index <= 3; index += 1) {
snapshot = killGroup(value, snapshot, group(definition, `mana-tombs:shaheen-wave-${index}`), 3 + index);
}
expect(snapshot.flags["shaheen-complete"]).toBe(true);
expect(snapshot.worldObjectStates[panel]).toBe("complete");
const persisted = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 7);
expect(persisted.flags["shaheen-complete"]).toBe(true);
const stasis = objectIdForSpawn(definition, 2_134_514);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "tick", now: 0 }, "normal");
expect(normal.worldObjectStates[stasis]).toBe("hidden");
normal = dispatch(value, normal, { kind: "interact", objectId: stasis, now: 1 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual([]);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "tick", now: 0 }, "heroic");
expect(heroic.worldObjectStates[stasis]).toBe("ready");
heroic = dispatch(value, heroic, { kind: "interact", objectId: stasis, now: 1 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["mana-tombs:yor"]);
heroic = dispatch(value, heroic, { kind: "entity-death", entityId: group(definition, "mana-tombs:yor").spawns[0].id, now: 2 }, "heroic");
expect(heroic.flags["yor-complete"]).toBe(true);
expect(heroic.completedObjectiveIds).toContain("mana-tombs:yor");
});
it("runs Maladaar's finite soul/avatar transition and keeps the avatar independently killable", () => {
const definition = adapted("auchenai-crypts");
const value = encounter(definition);
const maladaar = bossIdForEntry(definition, 18_373);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: maladaar, now: 0 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:stolen-soul");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: maladaar, healthPercent: 25, now: 25_001 });
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: maladaar, now: 25_002 });
expect(snapshot.activeSpawnGroupIds).not.toContain("auchenai-crypts:stolen-soul");
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "auchenai-crypts:avatar").spawns[0].id, now: 25_003 });
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 25_004);
expect(dispatch(value, reset, { kind: "tick", now: 25_005 }).phase).toBe("ready");
});
it("runs Magisters' exact doors, difficulty branches, Delrissa recovery, and reachable completion", () => {
const definition = adapted("magisters-terrace");
const value = encounter(definition);
const selin = bossIdForEntry(definition, 24_723);
const vexallus = bossIdForEntry(definition, 24_744);
const kael = bossIdForEntry(definition, 24_664);
const selinPassage = objectIdForSpawn(definition, 99_802);
const selinRoom = objectIdForSpawn(definition, 99_809);
const vexPassage = objectIdForSpawn(definition, 99_813);
const delrissaPassage = objectIdForSpawn(definition, 99_807);
const kaelRoom = objectIdForSpawn(definition, 99_808);
const orb = objectIdForSpawn(definition, 99_800);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-normal"]);
expect(group(definition, normal.activeSpawnGroupIds[0]).spawns).toHaveLength(1);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-heroic"]);
expect(group(definition, heroic.activeSpawnGroupIds[0]).spawns).toHaveLength(2);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: selin, now: 0 });
expect(snapshot.worldObjectStates[selinRoom]).toBe("closed");
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:selin-crystals");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: selin, now: 1 });
expect(snapshot.worldObjectStates[selinPassage]).toBe("open");
expect(snapshot.worldObjectStates[selinRoom]).toBe("open");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: vexallus, now: 2 });
expect(snapshot.worldObjectStates[vexPassage]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:delrissa-party");
const prematureKael = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 2.5 });
expect(prematureKael.flags["dungeon-complete"]).not.toBe(true);
expect(prematureKael.completedObjectiveIds).not.toContain("creature-96966");
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 4 });
expect(snapshot.phase).toBe("delrissa-active");
expect(snapshot.activeSpawnGroupIds).toEqual(["magisters-terrace:delrissa-party"]);
snapshot = killGroup(value, snapshot, group(definition, "magisters-terrace:delrissa-party"), 5);
expect(snapshot.completedObjectiveIds).toContain("creature-96966");
expect(snapshot.worldObjectStates[delrissaPassage]).toBe("open");
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kael, now: 6 });
expect(snapshot.worldObjectStates[kaelRoom]).toBe("closed");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 15_006 });
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-phoenix");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: kael, healthPercent: 50, now: 15_007 });
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-spheres");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 15_008 });
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
expect(snapshot.worldObjectStates[kaelRoom]).toBe("open");
expect(snapshot.worldObjectStates[orb]).toBe("unlocked");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 15_009);
const resumed = dispatch(value, reset, { kind: "tick", now: 15_010 });
expect(resumed.phase).toBe("complete");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,518 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import {
WOW_TBC_ENCOUNTER_CONTENT_REVIEWS,
withWowTbcEncounterContent,
} from "./wowTbcEncounterContentCatalog";
const TARGETS = [
"blood-furnace",
"shattered-halls",
"hellfire-ramparts",
"botanica",
"sethekk-halls",
] as const;
const EXPECTED_SOURCE_HASHES = Object.freeze({
"blood-furnace": [
"ec11055881a2cb1a01c299ab9999f91f732ddefe2ed79e3b817734179694e398",
"d1c55db2400e0dd5563f5832028dce474ad55ff0fc21471442bee7af3a10750e",
"3ddeb8493dd32d30f76ca1612ee768d78783842cc3d187c48cc66861b92e0e36",
],
"shattered-halls": [
"8e1e6178a4c36d5bfd245529625df928a3c1e9178562bf7c03daf29cee725dbc",
"8edd2a23afb4d6db7c72d63b16d86a860c68bd6bee2dc21ece7d09e307092dfd",
"1d4eae585fc0084c50e5d8b6caf1502643cc2f5739b42e79d668439afdf7341a",
"6fee37fc2e07515aa887155701c83bb0383ba8a41bb5549e90da4a83e95dd94e",
"c4c2e6724b0d7ca9daa659a2f4e64df53e1eae67e913fe901ceb74865d29f850",
],
"hellfire-ramparts": [
"0b70541937db116dfd1d9eba75c0f9b662679edf5f61ca52c88d3198319ebb45",
"32fa87c630d4512b62b4f7fb7f22ea82b8e4d93c9cfd5bd17e452e0b6245357c",
"5365a041c9bacdb8c1a46f25b8ed90f2a8ff06e1925f7e8a34cd77ee1ea8df93",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
botanica: [
"58e3f612984d30d765d4ebe98016e530629d7cd5a66499b3ee1c541f16cb378f",
"062c751d365cb5d205c47a5dca93c64fa46cfda7629456e8e58096dfe8aeaa22",
"b350507ced2b94f6a9178db0102176c98e130cfe5b844ce11f649b62f94cae83",
"a428f3f216e1e91a93339b837c9e1049f4d0eca4fa735312c574fcc388971dc7",
"484bebc3e3211ab624bb933fd0b59bb1e9f6c2891b66d36c318dac8df7705399",
],
"sethekk-halls": [
"223f84da402f20cfb5d99cbdee8b1bdf4cd22bf2f74eed1516cb078b9759250f",
"2d44ec532e3d62d804cf0b4c26304cc9ad12b38f626ef5876e5e02b0b99e5bb8",
"93ecd4af480331bc4026d24431c6db9288a17f9502966795e4318615eec774ca",
"b15531207673a2b908e263acb6e5c3360810ffc682cc93f6cadaaad67f01a703",
"73fdb3c48d6a1763600a1a3293639849f6a61cc465e1c186958c1322e3003f3f",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
});
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: typeof TARGETS[number]): DungeonDefinition {
const generated = campaign.definitions.find((definition) => definition.id === id);
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
const baseline = withWowProgressionObjectContent(withWowEncounterContent(generated));
return withWowTbcEncounterContent(baseline);
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-instance`);
if (!value) throw new Error(`Missing TBC encounter for ${definition.id}`);
return value;
}
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 objectIdForTemplate(definition: DungeonDefinition, templateId: number): string {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
if (!object) throw new Error(`${definition.id} missing object template ${templateId}`);
return object.id;
}
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
}
function entityForEntry(definition: DungeonDefinition, entry: number) {
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
return entity;
}
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
if (!spawn) throw new Error(`${definition.id} missing boss entry ${entry}`);
return spawn.id;
}
function dispatch(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return dispatch(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
});
}
describe("source-backed adapted TBC dungeon orchestration", () => {
it("pins reviewed source hashes, symbol/line anchors, and explicit unsupported behavior", () => {
expect(WOW_TBC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_TBC_ENCOUNTER_CONTENT_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
expect(review.remainingGaps.join(" ")).toMatch(/remain|require|need|unported|incomplete/i);
for (const source of review.sourceFiles) {
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
.toBe(source.sha256);
}
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
}
});
it("gives the event-only Ramparts and Sethekk bosses their pinned source combat schedules", () => {
const ramparts = adapted("hellfire-ramparts");
const vazruden = entityForEntry(ramparts, 17_537);
const nazan = entityForEntry(ramparts, 17_536);
const sethekk = adapted("sethekk-halls");
const anzu = entityForEntry(sethekk, 23_035);
expect(vazruden.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_300 },
{
id: "revenge-19130",
name: "Revenge",
spellId: 19_130,
target: "primary",
school: "physical",
initialCooldownMs: [4_000, 4_000],
repeatCooldownMs: [6_000, 6_000],
},
]);
expect(nazan.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 2_000 },
{
id: "fireball-33793",
name: "Fireball",
spellId: 33_793,
target: "random-party",
school: "fire",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [4_000, 6_000],
},
{
id: "cone-of-fire-30926",
name: "Cone of Fire",
spellId: 30_926,
target: "nearby-party",
school: "fire",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [12_000, 12_000],
},
]);
expect(anzu.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_400 },
{
id: "paralyzing-screech-40184",
name: "Paralyzing Screech",
spellId: 40_184,
target: "nearby-party",
school: "shadow",
initialCooldownMs: [14_000, 14_000],
repeatCooldownMs: [23_000, 23_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
},
{
id: "spell-bomb-40303",
name: "Spell Bomb",
spellId: 40_303,
target: "random-party",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [16_000, 24_500],
effect: { kind: "target-aura", aura: "spell-bomb", durationMs: 8_000 },
},
{
id: "cyclone-of-feathers-40321",
name: "Cyclone of Feathers",
spellId: 40_321,
target: "random-party",
school: "shadow",
initialCooldownMs: [8_000, 8_000],
repeatCooldownMs: [22_000, 27_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
},
]);
for (const entity of [vazruden, nazan, anzu]) {
expect(entity.kind).toBe("boss");
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
expect(entity.combat?.mechanicIds).toEqual(
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
);
}
});
it("validates every encounter, stable action id, spawn/object reference, and safety timeout", () => {
for (const id of TARGETS) {
const definition = adapted(id);
const value = encounter(definition);
validateDungeonEncounterDefinition(value);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
const objectIds = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
]);
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
expect(new Set(actionIds).size, id).toBe(actionIds.length);
for (const rule of value.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
for (const entityId of rule.trigger.entityIds) expect(spawnIds.has(entityId), `${id}:${rule.id}:${entityId}`).toBe(true);
}
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(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);
}
}
}
expect(withWowTbcEncounterContent(definition)).toBe(definition);
}
});
it("exposes collision only for source-reviewed imported objects", () => {
const reviewedTemplates = Object.freeze({
"blood-furnace": [181_811, 181_812, 181_822, 181_819, 181_823, 181_766, 181_821, 181_818, 181_820, 181_817, 181_982],
"shattered-halls": [182_539, 182_540],
"hellfire-ramparts": [185_168],
botanica: [],
"sethekk-halls": [183_398, 187_372],
} as const);
for (const id of TARGETS) {
const definition = adapted(id);
for (const templateId of reviewedTemplates[id]) {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId)!;
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.visual?.hidden).not.toBe(true);
expect(object.source?.files.length).toBeGreaterThan(0);
}
const allowed = new Set<number>(reviewedTemplates[id]);
const untouchedImports = definition.serverGameObjects.filter((object) => (
object.source?.spawnId !== undefined && !allowed.has(object.source.templateId ?? -1)
));
expect(untouchedImports.every((object) => object.visual?.hidden === true)).toBe(true);
expect(untouchedImports.every((object) => object.states.every((state) => !state.blocksMovement && !state.blocksSight))).toBe(true);
}
for (const [id, templateId] of [
["blood-furnace", 181_811],
["shattered-halls", 182_539],
["sethekk-halls", 183_398],
] as const) {
const definition = adapted(id);
const object = definition.serverGameObjects.find((candidate) => candidate.id === objectIdForTemplate(definition, templateId))!;
expect(object.states.find((state) => state.id === "closed")).toMatchObject({
visible: true,
blocksMovement: true,
blocksSight: true,
});
expect(object.states.find((state) => state.id === "open")).toMatchObject({
visible: false,
blocksMovement: false,
blocksSight: false,
});
}
});
it("removes unsafe static event actors and preserves finite source-shaped cardinality", () => {
const blood = adapted("blood-furnace");
expect(blood.staticSpawns.some((spawn) => entryForSpawn(blood, spawn.id) === 17_380)).toBe(false);
expect([1, 2, 3, 4].map((index) => group(blood, `blood-furnace:broggok-cell-${index}`).spawns.length).sort()).toEqual([3, 4, 5, 6]);
expect(group(blood, "blood-furnace:broggok").spawns).toHaveLength(1);
expect(blood.staticSpawns.some((spawn) => (
entryForSpawn(blood, spawn.id) === 17_398
&& spawn.position[0] < -400
&& spawn.position[2] >= 75
&& spawn.position[2] <= 125
))).toBe(false);
const shattered = adapted("shattered-halls");
expect(shattered.staticSpawns.some((spawn) => entryForSpawn(shattered, spawn.id) === 16_807)).toBe(false);
expect(group(shattered, "shattered-halls:nethekurse-converts").spawns).toHaveLength(4);
expect(group(shattered, "shattered-halls:nethekurse").spawns).toHaveLength(1);
expect(group(shattered, "shattered-halls:kargath-reinforcements").spawns).toHaveLength(7);
const ramparts = adapted("hellfire-ramparts");
expect(ramparts.staticSpawns.some((spawn) => [17_536, 17_537].includes(entryForSpawn(ramparts, spawn.id) ?? -1))).toBe(false);
expect(group(ramparts, "hellfire-ramparts:vazruden-sentries").spawns).toHaveLength(2);
expect(group(ramparts, "hellfire-ramparts:vazruden-and-nazan").spawns).toHaveLength(2);
const botanica = adapted("botanica");
expect(group(botanica, "botanica:freywinn-frayers").spawns).toHaveLength(3);
expect(group(botanica, "botanica:laj-thorns").spawns).toHaveLength(2);
expect(group(botanica, "botanica:warp-saplings").spawns).toHaveLength(6);
const sethekk = adapted("sethekk-halls");
expect([90, 50, 10].map((threshold) => group(sethekk, `sethekk-halls:syth-elementals-${threshold}`).spawns.length)).toEqual([4, 4, 4]);
expect(group(sethekk, "sethekk-halls:anzu").spawns).toHaveLength(1);
expect([66, 33].map((threshold) => group(sethekk, `sethekk-halls:anzu-broods-${threshold}`).spawns.length)).toEqual([5, 5]);
expect(sethekk.bosses.find((boss) => boss.id === "sethekk-halls:anzu")).toMatchObject({ optional: true, eventOnly: true });
});
it("runs Blood Furnace cells and recovers the Broggok checkpoint without duplication", () => {
const definition = adapted("blood-furnace");
const value = encounter(definition);
const leverId = objectIdForTemplate(definition, 181_982);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: leverId, now: 1 });
expect(snapshot.phase).toBe("cell-1-active");
for (let index = 1; index <= 4; index += 1) {
snapshot = killGroup(value, snapshot, group(definition, `blood-furnace:broggok-cell-${index}`), index + 1);
}
expect(snapshot).toMatchObject({ phase: "broggok-active", activeSpawnGroupIds: ["blood-furnace:broggok"] });
snapshot = killGroup(value, snapshot, group(definition, "blood-furnace:broggok"), 7);
expect(snapshot.phase).toBe("post-broggok");
expect(snapshot.completedObjectiveIds).toContain(group(definition, "blood-furnace:broggok").spawns[0].id);
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 8);
expect(reset).toMatchObject({ phase: "resume", activeSpawnGroupIds: [] });
const resumed = dispatch(value, reset, { kind: "tick", now: 9 });
expect(resumed.phase).toBe("post-broggok");
expect(resumed.activeSpawnGroupIds).toEqual([]);
});
it("runs Nethekurse's convert gate, wipe recovery, and bounded Kargath adds", () => {
const definition = adapted("shattered-halls");
const value = encounter(definition);
const entrance = objectIdForTemplate(definition, 182_539);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: entrance, now: 1 });
expect(snapshot.phase).toBe("nethekurse-converts-active");
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 2);
expect(snapshot.phase).toBe("nethekurse-active");
const wiped = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
const ready = dispatch(value, wiped, { kind: "tick", now: 4 });
expect(ready).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
snapshot = dispatch(value, ready, { kind: "interact", objectId: entrance, now: 5 });
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 6);
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse"), 7);
expect(snapshot.phase).toBe("post-nethekurse");
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 182_540)]).toBe("open");
const kargathId = bossIdForEntry(definition, 16_808);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kargathId, now: 8 });
expect(snapshot.activeSpawnGroupIds).toContain("shattered-halls:kargath-reinforcements");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kargathId, now: 9 });
expect(snapshot.activeSpawnGroupIds).not.toContain("shattered-halls:kargath-reinforcements");
});
it("makes the previously missing Ramparts finale the only completion path and recovers it", () => {
const definition = adapted("hellfire-ramparts");
const value = encounter(definition);
const progression = definition.encounters?.find((candidate) => candidate.id === "hellfire-ramparts:boss-progression")!;
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
expect(definition.bosses.at(-1)).toMatchObject({ id: "hellfire-ramparts:vazruden-and-nazan", eventOnly: true });
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_306), now: 1 });
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_308), now: 2 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3 });
expect(snapshot.phase).toBe("ready");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "hellfire-ramparts:vazruden-herald", now: 4 });
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 5);
expect(snapshot.phase).toBe("herald-descent");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_005 });
expect(snapshot.phase).toBe("bosses-active");
const interrupted = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3_006);
const retryReady = dispatch(value, interrupted, { kind: "tick", now: 3_007 });
expect(retryReady).toMatchObject({
phase: "ready",
worldObjectStates: { "hellfire-ramparts:vazruden-herald": "ready" },
});
snapshot = dispatch(value, retryReady, {
kind: "interact",
objectId: "hellfire-ramparts:vazruden-herald",
now: 3_008,
});
expect(snapshot.phase).toBe("sentries-active");
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 3_009);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 6_009 });
expect(snapshot.phase).toBe("bosses-active");
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-and-nazan"), 6_010);
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 185_168)]).toBe("unlocked");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 6_011);
const resumed = dispatch(value, reset, { kind: "tick", now: 6_012 });
expect(resumed).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
});
it("runs each Botanica summon once and resets an interrupted finite cycle", () => {
const definition = adapted("botanica");
const value = encounter(definition);
const cases = [
[17_975, "freywinn", "botanica:freywinn-frayers", 30_000],
[17_980, "laj", "botanica:laj-thorns", 20_000],
[17_977, "warp-splinter", "botanica:warp-saplings", 40_000],
] as const;
let snapshot = createDungeonEncounterSnapshot(value);
let now = 1;
for (const [entry, prefix, groupId, delay] of cases) {
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now });
expect(snapshot.phase).toBe(`${prefix}-wait`);
if (prefix === "freywinn") {
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", now + 1);
snapshot = dispatch(value, reset, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now: now + 2 });
now += 2;
}
snapshot = dispatch(value, snapshot, { kind: "tick", now: now + delay });
expect(snapshot.activeSpawnGroupIds).toEqual([groupId]);
snapshot = killGroup(value, snapshot, group(definition, groupId), now + delay + 1);
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
now += delay + 2;
}
});
it("runs Syth thresholds, Ikiss objects, and optional Anzu lifecycle with recovery", () => {
const definition = adapted("sethekk-halls");
const value = encounter(definition);
let snapshot = createDungeonEncounterSnapshot(value);
const sythId = bossIdForEntry(definition, 18_472);
for (const [threshold, health] of [[90, 89], [50, 49], [10, 9]] as const) {
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: sythId, healthPercent: health, now: 100 - health });
const wave = group(definition, `sethekk-halls:syth-elementals-${threshold}`);
expect(snapshot.activeSpawnGroupIds).toContain(wave.id);
snapshot = killGroup(value, snapshot, wave, 101 - health);
expect(snapshot.activeSpawnGroupIds).not.toContain(wave.id);
}
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 18_473), now: 100 });
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 183_398)]).toBe("open");
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 187_372)]).toBe("unlocked");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 101 });
expect(snapshot.phase).toBe("anzu-intro");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 102);
snapshot = dispatch(value, reset, { kind: "tick", now: 103 });
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 104 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 16_104 });
expect(snapshot.activeSpawnGroupIds).toEqual(["sethekk-halls:anzu"]);
for (const [threshold, health] of [[66, 65], [33, 32]] as const) {
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "sethekk-halls:anzu", healthPercent: health, now: 17_000 - health });
const broods = group(definition, `sethekk-halls:anzu-broods-${threshold}`);
expect(snapshot.activeSpawnGroupIds).toContain(broods.id);
snapshot = killGroup(value, snapshot, broods, 17_001 - health);
}
snapshot = killGroup(value, snapshot, group(definition, "sethekk-halls:anzu"), 18_000);
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [], flags: { "anzu-complete": true } });
expect(snapshot.completedObjectiveIds).toContain("sethekk-halls:anzu");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import { validateDungeonEncounterDefinition } from "./dungeonEncounterRuntime";
import type { DungeonDefinition } from "./dungeonTypes";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS,
withWowVerifiedNoOpInstanceContent,
} from "./wowVerifiedNoOpInstanceCatalog";
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 generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
describe("verified no-op WoW instance scripts", () => {
it("matches every pinned source file by SHA-256", async () => {
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
const source = await readFile(new URL(`../../${review.sourceFile}`, import.meta.url));
expect(createHash("sha256").update(source).digest("hex"), review.dungeonId).toBe(review.sha256);
}
});
it("adds a source-exact empty orchestration review without altering population", () => {
expect(WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.map((review) => review.dungeonId)).toEqual([
"stormwind-stockade",
"razorfen-kraul",
"ragefire-chasm",
]);
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
const source = generatedDefinitions.find((definition) => definition.id === review.dungeonId);
if (!source) throw new Error(`Missing generated dungeon ${review.dungeonId}`);
const adapted = withWowVerifiedNoOpInstanceContent(source);
const encounter = adapted.encounters?.find(
(candidate) => candidate.id === `${review.dungeonId}:source-reviewed-instance`,
);
expect(encounter).toBeDefined();
validateDungeonEncounterDefinition(encounter!);
expect(encounter?.source).toMatchObject({
fidelity: "source-exact",
scope: "instance-orchestration",
adaptations: [],
remainingGaps: [],
});
expect(encounter?.rules).toEqual([]);
expect(adapted.entities).toBe(source.entities);
expect(adapted.staticSpawns).toBe(source.staticSpawns);
expect(adapted.roamingPacks).toBe(source.roamingPacks);
expect(withWowVerifiedNoOpInstanceContent(adapted)).toBe(adapted);
}
});
});
@@ -0,0 +1,75 @@
import type { DungeonDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
export interface WowVerifiedNoOpInstanceReview {
readonly dungeonId: "stormwind-stockade" | "razorfen-kraul" | "ragefire-chasm";
readonly sourceFile: string;
readonly sha256: string;
readonly reviewedBehavior: string;
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
/**
* These pinned instance scripts only register an empty InstanceScript. Keeping
* that fact executable prevents the production audit from inventing doors,
* waves, or gates that the authoritative source does not define.
*/
export const WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS: readonly WowVerifiedNoOpInstanceReview[] = Object.freeze([
{
dungeonId: "stormwind-stockade",
sourceFile: `${SOURCE_ROOT}/EasternKingdoms/TheStockade/instance_the_stockade.cpp`,
sha256: "5e86a879ea7919840567607e6fbc1b6df2c50581f23574295430b46701f54a49",
reviewedBehavior: "The pinned Stockade instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
{
dungeonId: "razorfen-kraul",
sourceFile: `${SOURCE_ROOT}/Kalimdor/RazorfenKraul/instance_razorfen_kraul.cpp`,
sha256: "50309cf599020ca404d3230f134919349177a98aa572bf61132dd0e14504da54",
reviewedBehavior: "The pinned Razorfen Kraul instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
{
dungeonId: "ragefire-chasm",
sourceFile: `${SOURCE_ROOT}/Kalimdor/RagefireChasm/instance_ragefire_chasm.cpp`,
sha256: "294d043e5e6f02f3fbe82f369129659617da30727e048ffa4a333237e58a22f6",
reviewedBehavior: "The pinned Ragefire Chasm instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
]);
function reviewedNoOpEncounter(
definition: DungeonDefinition,
review: WowVerifiedNoOpInstanceReview,
): DungeonEncounterDefinition {
return Object.freeze({
id: `${definition.id}:source-reviewed-instance`,
dungeonId: definition.id,
initialPhase: "active",
source: Object.freeze({
fidelity: "source-exact",
scope: "instance-orchestration",
files: Object.freeze([review.sourceFile]),
reviewedBehavior: review.reviewedBehavior,
adaptations: Object.freeze([]),
remainingGaps: Object.freeze([]),
}),
rules: Object.freeze([]),
});
}
export function withWowVerifiedNoOpInstanceContent(
definition: DungeonDefinition,
): DungeonDefinition {
const review = WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.find(
(candidate) => candidate.dungeonId === definition.id,
);
if (!review) return definition;
const encounterId = `${definition.id}:source-reviewed-instance`;
if (definition.encounters?.some((encounter) => encounter.id === encounterId)) return definition;
return Object.freeze({
...definition,
encounters: Object.freeze([
...(definition.encounters ?? []),
reviewedNoOpEncounter(definition, review),
]),
});
}
@@ -0,0 +1,668 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS,
withWowWrathEncounterBatchThreeContent,
type WowWrathEncounterBatchThreeDungeonId,
} from "./wowWrathEncounterBatchThreeCatalog";
const TARGETS = [
"utgarde-keep",
"the-nexus",
"draktharon-keep",
"azjol-nerub",
"halls-of-lightning",
"ahnkahet-old-kingdom",
"forge-of-souls",
] as const satisfies readonly WowWrathEncounterBatchThreeDungeonId[];
const REVIEWED_TEMPLATES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
"utgarde-keep": [186_756, 186_694],
"the-nexus": [188_526, 188_527, 188_528],
"draktharon-keep": [189_299, 189_300, 189_301, 189_302],
"azjol-nerub": [192_395, 192_396, 192_397, 192_398],
"halls-of-lightning": [191_325, 191_326],
"ahnkahet-old-kingdom": [192_236, 193_093, 193_094, 193_564],
"forge-of-souls": [],
});
const SUPPRESSED_ENTRIES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
"utgarde-keep": [24_200, 24_201, 23_954],
"the-nexus": [26_731, 26_763, 26_723],
"draktharon-keep": [26_631, 26_632],
"azjol-nerub": [28_684, 28_921, 29_120, 28_729, 28_730, 28_731],
"halls-of-lightning": [28_546],
"ahnkahet-old-kingdom": [29_309, 29_308, 29_310, 29_311],
"forge-of-souls": [36_497],
});
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 generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
function sourceDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
const source = generatedDefinitions.find((definition) => definition.id === id);
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return source;
}
function adaptedDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
return withWowWrathEncounterBatchThreeContent(sourceDefinition(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-batch-three-instance`);
if (!value) throw new Error(`Missing batch-three encounter for ${definition.id}`);
return value;
}
function normalized(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
const fragment = normalized(nameFragment);
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
return value;
}
function group(definition: DungeonDefinition, id: string) {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
return value;
}
function send(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
actorIds: readonly string[],
now: number,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
return send(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: actorIds, deadEntityIds: actorIds },
}, difficultyId);
}
function assertRecoverable(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
const completed = snapshot.completedObjectiveIds;
const checkpoint = snapshot.checkpointId;
expect(checkpoint).not.toBeNull();
let recovered = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", now);
expect(recovered.phase).toBe("resume");
recovered = send(definition, recovered, { kind: "tick", now: now + 1 }, difficultyId);
expect(recovered.phase).not.toBe("resume");
expect(recovered.completedObjectiveIds).toEqual(completed);
expect(recovered.checkpointId).toBe(checkpoint);
return recovered;
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
];
}
describe("Wrath encounter batch three source contract", () => {
it("pins every reviewed AzerothCore source file by SHA-256", async () => {
expect(WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const sourceFile of review.sourceFiles) {
expect(sourceFile.anchors.length).toBeGreaterThan(0);
const bytes = await readFile(sourceFile.path);
expect(createHash("sha256").update(bytes).digest("hex"), sourceFile.path).toBe(sourceFile.sha256);
}
}
});
it("is idempotent and keeps all definitions, references, and source-row replacements valid", () => {
for (const id of TARGETS) {
const source = sourceDefinition(id);
const adapted = adaptedDefinition(id);
const controller = encounter(adapted);
expect(withWowWrathEncounterBatchThreeContent(adapted)).toBe(adapted);
expect(controller.source?.scope).toBe("instance-orchestration");
validateDungeonEncounterDefinition(controller);
adapted.serverGameObjects.forEach(validateDungeonWorldObject);
const objectiveIds = adapted.bosses.map((boss) => boss.id);
expect(new Set(objectiveIds).size, `${id} objective ids`).toBe(objectiveIds.length);
const orderedObjectiveIds = adapted.objectiveOrder.map((boss) => boss.id);
expect(new Set(orderedObjectiveIds).size, `${id} objective-order ids`).toBe(orderedObjectiveIds.length);
const spawnIds = runtimeSpawnIds(adapted);
expect(new Set(spawnIds).size, `${id} runtime spawn ids`).toBe(spawnIds.length);
const objectIds = adapted.serverGameObjects.map((object) => object.id);
expect(new Set(objectIds).size, `${id} object ids`).toBe(objectIds.length);
const sourceObjectSpawnIds = adapted.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined);
expect(new Set(sourceObjectSpawnIds).size, `${id} source object rows`).toBe(sourceObjectSpawnIds.length);
for (const templateId of REVIEWED_TEMPLATES[id]) {
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
const adaptedRows = adapted.serverGameObjects.filter((object) => object.source?.templateId === templateId);
expect(adaptedRows.length, `${id} template ${templateId}`).toBe(originalRows.length);
expect(adaptedRows.map((object) => object.source?.spawnId)).toEqual(originalRows.map((object) => object.source?.spawnId));
expect(adaptedRows.every((object) => object.source?.fidelity === "source-backed-adapted")).toBe(true);
expect(adaptedRows.some((object) => originalRows.some((original) => original.id === object.id))).toBe(false);
}
const remainingEntries = new Set(adapted.staticSpawns.map((spawn) => adapted.entities[spawn.entityId]?.combat?.serverEntry));
for (const entry of SUPPRESSED_ENTRIES[id]) expect(remainingEntries.has(entry), `${id} static entry ${entry}`).toBe(false);
const spawnGroupIds = new Set((adapted.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id));
const worldObjectIds = new Set(adapted.serverGameObjects.map((object) => object.id));
const validObjectiveIds = new Set(adapted.bosses.map((boss) => boss.id));
const validEntityIds = new Set(runtimeSpawnIds(adapted));
for (const rule of controller.rules) {
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(validEntityIds.has(rule.trigger.entityId), `${id} trigger entity ${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
rule.trigger.entityIds.forEach((entityId) => expect(validEntityIds.has(entityId), `${id} dead-set entity ${entityId}`).toBe(true));
}
if (rule.trigger.kind === "interact") expect(worldObjectIds.has(rule.trigger.objectId), `${id} interaction ${rule.trigger.objectId}`).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} action group ${action.spawnGroupId}`).toBe(true);
}
if (action.kind === "set-world-object-state") expect(worldObjectIds.has(action.objectId), `${id} action object ${action.objectId}`).toBe(true);
if (action.kind === "complete-objective") expect(validObjectiveIds.has(action.objectiveId), `${id} action objective ${action.objectiveId}`).toBe(true);
}
}
}
});
});
describe("Wrath encounter batch three lifecycle", () => {
it("runs Utgarde Keep's order-safe duo ghosts and timed Ingvar resurrection with wipe recovery", () => {
const dungeon = adaptedDefinition("utgarde-keep");
const controller = encounter(dungeon);
const keleseth = objective(dungeon, "Keleseth");
const duo = objective(dungeon, "Skarvold");
const ingvar = objective(dungeon, "Ingvar");
const skarvald = group(dungeon, "utgarde-keep:skarvald");
const dalronn = group(dungeon, "utgarde-keep:dalronn");
const skarvaldGhost = group(dungeon, "utgarde-keep:skarvald-ghost");
const dalronnGhost = group(dungeon, "utgarde-keep:dalronn-ghost");
const ingvarLiving = group(dungeon, "utgarde-keep:ingvar-living");
const ingvarUndead = group(dungeon, "utgarde-keep:ingvar-undead");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keleseth.id, now: ++now });
expect(snapshot.phase).toBe("duo-active");
expect(snapshot.activeSpawnGroupIds).toEqual(expect.arrayContaining([skarvald.id, dalronn.id]));
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: ++now });
expect(snapshot.flags["dalronn-dead"]).toBe(true);
expect(snapshot.activeSpawnGroupIds).toContain(dalronnGhost.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(duo.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(skarvaldGhost.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(dalronnGhost.id);
expect(snapshot.phase).toBe("ingvar-living-active");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarLiving.spawns[0].id, now: ++now });
expect(snapshot.phase).toBe("ingvar-resurrection");
const resurrectionStarted = now;
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 19_999 });
expect(snapshot.phase).toBe("ingvar-resurrection");
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_000 });
expect(snapshot.phase).toBe("ingvar-undead-active");
expect(snapshot.activeSpawnGroupIds).toContain(ingvarUndead.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarUndead.spawns[0].id, now: resurrectionStarted + 20_001 });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([keleseth.id, duo.id, ingvar.id]));
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-one"]).toBe("open");
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-two"]).toBe("open");
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_002 });
expect(snapshot.phase).toBe("complete");
let alternate = createDungeonEncounterSnapshot(controller, 0);
alternate = send(controller, alternate, { kind: "entity-death", entityId: keleseth.id, now: 1 });
alternate = send(controller, alternate, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: 2 });
expect(alternate.activeSpawnGroupIds).toContain(skarvaldGhost.id);
alternate = send(controller, alternate, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: 3 });
expect(alternate.completedObjectiveIds).toContain(duo.id);
});
it("keeps the source combat schedules on Utgarde Keep's ghost and undead replacement actors", () => {
const dungeon = adaptedDefinition("utgarde-keep");
const entityForGroup = (groupId: string) => {
const entityId = group(dungeon, groupId).spawns[0].entityId;
return dungeon.entities[entityId];
};
const attacksForGroup = (groupId: string) => {
return entityForGroup(groupId).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
};
expect(entityForGroup("utgarde-keep:dalronn-ghost").combat?.attacks[0]).toMatchObject({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
school: "physical",
animation: "attack",
range: 4.5,
});
expect(attacksForGroup("utgarde-keep:skarvald-ghost").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "charge-43651", spellId: 43_651, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
{ id: "stone-strike-48583", spellId: 48_583, initial: [10_000, 10_000], repeat: [5_000, 10_000] },
]);
expect(attacksForGroup("utgarde-keep:dalronn-ghost").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "shadow-bolt-43649", spellId: 43_649, initial: [1_000, 1_000], repeat: [2_000, 2_000] },
{ id: "debilitate-43650", spellId: 43_650, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
]);
expect(attacksForGroup("utgarde-keep:dalronn-ghost")[1].effect)
.toEqual({ kind: "target-aura", aura: "debilitate", durationMs: 8_000 });
expect(attacksForGroup("utgarde-keep:ingvar-undead").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "dreadful-roar-42729", spellId: 42_729, initial: [15_000, 15_000], repeat: [15_000, 20_000] },
{ id: "woe-strike-42730", spellId: 42_730, initial: [2_000, 2_000], repeat: [3_000, 7_000] },
{ id: "dark-smash-42723", spellId: 42_723, initial: [5_000, 5_000], repeat: [9_000, 11_000] },
{ id: "shadow-axe-42749", spellId: 42_749, initial: [10_000, 10_000], repeat: [35_000, 35_000] },
]);
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
attack.id === "woe-strike-42730"
))?.school).toBe("physical");
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
attack.id === "shadow-axe-42749"
))?.school).toBe("physical");
const review = WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
candidate.dungeonId === "utgarde-keep"
))!;
expect(review.remainingGaps.join(" ")).toContain("Woe Strike's damage-on-heal reaction");
expect(review.remainingGaps.join(" ")).toContain("Shadow Axe's outbound/returning actor");
});
it("runs Nexus spheres, normal Telestra split, Keristrasza release, and checkpoint recovery", () => {
const dungeon = adaptedDefinition("the-nexus");
const controller = encounter(dungeon);
const anomalus = objective(dungeon, "Anomalus");
const ormorok = objective(dungeon, "Ormorok");
const telestra = objective(dungeon, "Telestra");
const keristrasza = objective(dungeon, "Keristrasza");
const opening = group(dungeon, "the-nexus:telestra-opening");
const anomalusOpening = group(dungeon, "the-nexus:anomalus-opening");
const anomalusRift = group(dungeon, "the-nexus:anomalus-empowered-rift");
const anomalusFinal = group(dungeon, "the-nexus:anomalus-final");
const clones = group(dungeon, "the-nexus:telestra-first-clones");
const normalFinal = group(dungeon, "the-nexus:telestra-normal-final");
const keristraszaActor = group(dungeon, "the-nexus:keristrasza");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: anomalusOpening.spawns[0].id, healthPercent: 51, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(anomalusRift.id);
snapshot = killGroup(controller, snapshot, anomalusRift.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.activeSpawnGroupIds).toContain(anomalusFinal.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: anomalusFinal.spawns[0].id, now: ++now });
expect(snapshot.worldObjectStates["the-nexus:reviewed-anomalus-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ormorok.id, now: ++now });
expect(snapshot.worldObjectStates["the-nexus:reviewed-ormorok-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: ++now });
expect(snapshot.phase).toBe("telestra-first-clones");
snapshot = killGroup(controller, snapshot, clones.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.phase).toBe("telestra-normal-final");
expect(snapshot.activeSpawnGroupIds).toContain(normalFinal.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: normalFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(telestra.id);
expect(snapshot.worldObjectStates["the-nexus:reviewed-telestra-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
for (const sphere of [
"the-nexus:reviewed-telestra-sphere",
"the-nexus:reviewed-anomalus-sphere",
"the-nexus:reviewed-ormorok-sphere",
]) snapshot = send(controller, snapshot, { kind: "interact", objectId: sphere, now: ++now });
expect(snapshot.counters["spheres-used"]).toBe(3);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(keristraszaActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keristraszaActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(keristrasza.id);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
it("selects Telestra's second split only on heroic", () => {
const dungeon = adaptedDefinition("the-nexus");
const controller = encounter(dungeon);
const opening = group(dungeon, "the-nexus:telestra-opening");
const firstClones = group(dungeon, "the-nexus:telestra-first-clones");
const middle = group(dungeon, "the-nexus:telestra-heroic-middle");
const secondClones = group(dungeon, "the-nexus:telestra-second-clones");
const final = group(dungeon, "the-nexus:telestra-heroic-final");
let snapshot = createDungeonEncounterSnapshot(controller, 0);
snapshot = send(controller, snapshot, { kind: "tick", now: 1 }, "heroic");
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: 2 }, "heroic");
snapshot = killGroup(controller, snapshot, firstClones.spawns.map((spawn) => spawn.id), 3, "heroic");
expect(snapshot.phase).toBe("telestra-heroic-middle");
expect(snapshot.activeSpawnGroupIds).toContain(middle.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: middle.spawns[0].id, healthPercent: 11, now: 4 }, "heroic");
expect(snapshot.activeSpawnGroupIds).toContain(secondClones.id);
snapshot = killGroup(controller, snapshot, secondClones.spawns.map((spawn) => spawn.id), 5, "heroic");
expect(snapshot.phase).toBe("telestra-heroic-final");
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
});
it("runs Drak'Tharon's four crystal waves and Tharon'ja stage with each checkpoint recoverable", () => {
const dungeon = adaptedDefinition("draktharon-keep");
const controller = encounter(dungeon);
const trollgore = objective(dungeon, "Trollgore");
const novos = objective(dungeon, "Novos");
const dred = objective(dungeon, "King Dred");
const tharon = objective(dungeon, "Tharon'ja");
const novosActor = group(dungeon, "draktharon-keep:novos");
const tharonOpening = group(dungeon, "draktharon-keep:tharon-opening");
const tharonFlesh = group(dungeon, "draktharon-keep:tharon-flesh");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: trollgore.id, now: ++now });
expect(snapshot.phase).toBe("novos-handler-1");
snapshot = assertRecoverable(controller, snapshot, ++now);
for (let waveIndex = 1; waveIndex <= 4; waveIndex += 1) {
const wave = group(dungeon, `draktharon-keep:novos-handler-wave-${waveIndex}`);
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.worldObjectStates[`draktharon-keep:reviewed-novos-crystal-${waveIndex}`]).toBe("complete");
}
expect(snapshot.phase).toBe("novos-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: novosActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(novos.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.phase).toBe("dred-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dred.id, now: ++now });
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(tharonOpening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: tharonOpening.spawns[0].id, healthPercent: 55, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(tharonFlesh.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: tharonFlesh.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([trollgore.id, novos.id, dred.id, tharon.id]));
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
});
function advanceAzjolToAnubarak(difficultyId: "normal" | "heroic") {
const dungeon = adaptedDefinition("azjol-nerub");
const controller = encounter(dungeon);
const krik = objective(dungeon, "Krik'thir");
const hadronox = objective(dungeon, "Hadronox");
const watchers = group(dungeon, "azjol-nerub:krikthir-watchers");
const krikActor = group(dungeon, "azjol-nerub:krikthir");
const hadronoxActor = group(dungeon, "azjol-nerub:hadronox");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now }, difficultyId);
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), ++now, difficultyId);
expect(snapshot.activeSpawnGroupIds).toContain(krikActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: krikActor.spawns[0].id, now: ++now }, difficultyId);
expect(snapshot.completedObjectiveIds).toContain(krik.id);
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
expect(snapshot.worldObjectStates["azjol-nerub:reviewed-krikthir-passage"]).toBe("open");
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:hadronox-start", now: ++now }, difficultyId);
for (let waveIndex = 1; waveIndex <= 3; waveIndex += 1) {
const wave = group(dungeon, `azjol-nerub:crusher-wave-${waveIndex}`);
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now, difficultyId);
}
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: hadronoxActor.spawns[0].id, now: ++now }, difficultyId);
expect(snapshot.completedObjectiveIds).toContain(hadronox.id);
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:anubarak-start", now: ++now }, difficultyId);
expect(snapshot.phase).toBe("anubarak-stage-1");
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("closed");
}
return { dungeon, controller, snapshot, now };
}
describe("Wrath encounter batch three route gates", () => {
it("runs Azjol-Nerub's normal watcher, crusher, and three-submerge route", () => {
const prepared = advanceAzjolToAnubarak("normal");
const { dungeon, controller } = prepared;
const anub = objective(dungeon, "Anub'arak");
let { snapshot, now } = prepared;
const thresholds = [75, 50, 25] as const;
for (let index = 0; index < thresholds.length; index += 1) {
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
const adds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
snapshot = send(controller, snapshot, {
kind: "entity-health",
entityId: stage.spawns[0].id,
healthPercent: thresholds[index],
now: ++now,
});
expect(snapshot.activeSpawnGroupIds).toContain(adds.id);
expect(adds.spawns.length).toBe([7, 8, 13][index]);
snapshot = killGroup(controller, snapshot, adds.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.phase).toBe(`anubarak-stage-${index + 2}`);
}
const finalStage = group(dungeon, "azjol-nerub:anubarak-stage-4");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: finalStage.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(anub.id);
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("open");
}
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
it("selects every larger Anub'arak intermission on heroic", () => {
const prepared = advanceAzjolToAnubarak("heroic");
const { dungeon, controller } = prepared;
let { snapshot, now } = prepared;
const thresholds = [75, 50, 25] as const;
for (let index = 0; index < thresholds.length; index += 1) {
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
const heroicAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-heroic`);
const normalAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
snapshot = send(controller, snapshot, {
kind: "entity-health",
entityId: stage.spawns[0].id,
healthPercent: thresholds[index],
now: ++now,
}, "heroic");
expect(snapshot.activeSpawnGroupIds).toContain(heroicAdds.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(normalAdds.id);
expect(heroicAdds.spawns.length).toBe([8, 10, 15][index]);
expect(heroicAdds.spawns.every((spawn) => spawn.spawnMask === 2)).toBe(true);
snapshot = killGroup(controller, snapshot, heroicAdds.spawns.map((spawn) => spawn.id), ++now, "heroic");
}
expect(snapshot.phase).toBe("anubarak-stage-4");
});
it("runs Halls of Lightning's timed Ionar split and mandatory watcher checkpoint", () => {
const dungeon = adaptedDefinition("halls-of-lightning");
const controller = encounter(dungeon);
const bjarngrim = objective(dungeon, "Bjarngrim");
const volkhan = objective(dungeon, "Volkhan");
const ionar = objective(dungeon, "Ionar");
const loken = objective(dungeon, "Loken");
const opening = group(dungeon, "halls-of-lightning:ionar-opening");
const sparks = group(dungeon, "halls-of-lightning:ionar-sparks");
const final = group(dungeon, "halls-of-lightning:ionar-final");
const watchers = group(dungeon, "halls-of-lightning:awakened-watchers");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: bjarngrim.id, now: ++now });
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.phase).toBe("volkhan-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volkhan.id, now: ++now });
expect(snapshot.worldObjectStates["halls-of-lightning:reviewed-volkhan-door"]).toBe("open");
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 50, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(sparks.id);
const splitStarted = now;
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 19_999 });
expect(snapshot.phase).toBe("ionar-sparks-active");
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_000 });
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: final.spawns[0].id, now: splitStarted + 20_001 });
expect(snapshot.completedObjectiveIds).toContain(ionar.id);
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_002);
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), splitStarted + 20_003);
expect(snapshot.flags["watchers-cleared"]).toBe(true);
expect(snapshot.phase).toBe("loken-active");
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_004);
expect(snapshot.phase).toBe("loken-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: loken.id, now: splitStarted + 20_005 });
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_006 });
expect(snapshot.phase).toBe("complete");
});
it("runs Ahn'kahet's spheres, guardian, ritual, insanity stages, and recovers each boss checkpoint", () => {
const dungeon = adaptedDefinition("ahnkahet-old-kingdom");
const controller = encounter(dungeon);
const nadox = objective(dungeon, "Nadox");
const taldaram = objective(dungeon, "Taldaram");
const jedoga = objective(dungeon, "Jedoga");
const volazj = objective(dungeon, "Volazj");
const nadoxOpening = group(dungeon, "ahnkahet-old-kingdom:nadox-opening");
const nadoxGuardian = group(dungeon, "ahnkahet-old-kingdom:nadox-guardian");
const nadoxFinal = group(dungeon, "ahnkahet-old-kingdom:nadox-final");
const taldaramActor = group(dungeon, "ahnkahet-old-kingdom:taldaram");
const initiates = group(dungeon, "ahnkahet-old-kingdom:jedoga-initiates");
const jedogaOpening = group(dungeon, "ahnkahet-old-kingdom:jedoga-opening");
const volunteer = group(dungeon, "ahnkahet-old-kingdom:jedoga-volunteer");
const jedogaFinal = group(dungeon, "ahnkahet-old-kingdom:jedoga-final");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
for (let sphereIndex = 1; sphereIndex <= 2; sphereIndex += 1) {
snapshot = send(controller, snapshot, { kind: "interact", objectId: `ahnkahet-old-kingdom:reviewed-sphere-${sphereIndex}`, now: ++now });
}
expect(snapshot.counters["spheres-used"]).toBe(2);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: nadoxOpening.spawns[0].id, healthPercent: 50, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(nadoxGuardian.id);
snapshot = killGroup(controller, snapshot, nadoxGuardian.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: nadoxFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(nadox.id);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
expect(snapshot.worldObjectStates["ahnkahet-old-kingdom:reviewed-taldaram-platform"]).toBe("open");
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: taldaramActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(taldaram.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(initiates.id);
snapshot = killGroup(controller, snapshot, initiates.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: jedogaOpening.spawns[0].id, healthPercent: 55, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(volunteer.id);
snapshot = killGroup(controller, snapshot, volunteer.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: jedogaFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(jedoga.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
for (const [index, threshold] of [66, 33].entries()) {
const stage = group(dungeon, `ahnkahet-old-kingdom:volazj-stage-${index + 1}`);
const visages = group(dungeon, `ahnkahet-old-kingdom:volazj-visages-${index + 1}`);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: stage.spawns[0].id, healthPercent: threshold, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(visages.id);
snapshot = killGroup(controller, snapshot, visages.spawns.map((spawn) => spawn.id), ++now);
}
const volazjFinal = group(dungeon, "ahnkahet-old-kingdom:volazj-stage-3");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volazjFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([nadox.id, taldaram.id, jedoga.id, volazj.id]));
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
const amanitarEntry = dungeon.staticSpawns
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 30_258);
expect(amanitarEntry).toHaveLength(1);
expect(amanitarEntry[0].spawnMask).toBe(2);
});
it("keeps Forge of Souls doorless while staging Bronjahm's 35-percent Soulstorm", () => {
const dungeon = adaptedDefinition("forge-of-souls");
const controller = encounter(dungeon);
const bronjahm = objective(dungeon, "Bronjahm");
const devourer = objective(dungeon, "Devourer");
const opening = group(dungeon, "forge-of-souls:bronjahm-opening");
const soulstorm = group(dungeon, "forge-of-souls:bronjahm-soulstorm");
let snapshot = createDungeonEncounterSnapshot(controller, 0);
snapshot = send(controller, snapshot, { kind: "tick", now: 1 });
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 35, now: 2 });
expect(snapshot.activeSpawnGroupIds).toContain(soulstorm.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: soulstorm.spawns[0].id, now: 3 });
expect(snapshot.completedObjectiveIds).toContain(bronjahm.id);
snapshot = assertRecoverable(controller, snapshot, 4);
expect(snapshot.phase).toBe("devourer-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: devourer.id, now: 5 });
snapshot = send(controller, snapshot, { kind: "tick", now: 6 });
expect(snapshot.phase).toBe("complete");
expect(REVIEWED_TEMPLATES["forge-of-souls"]).toEqual([]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,673 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterGuard,
DungeonEncounterSnapshot,
DungeonEncounterTrigger,
} from "./dungeonEncounterTypes";
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS,
withWowWrathEncounterBatchTwoContent,
} from "./wowWrathEncounterBatchTwoCatalog";
const TARGETS = [
"halls-of-stone",
"pit-of-saron",
"utgarde-pinnacle",
"gundrak",
] as const;
type TargetId = typeof TARGETS[number];
const REVIEWED_TEMPLATES: Readonly<Record<TargetId, readonly number[]>> = Object.freeze({
"halls-of-stone": [193_907, 193_906, 191_296],
"pit-of-saron": [201_885, 201_848],
"utgarde-pinnacle": [191_745, 188_593, 192_173, 192_174],
gundrak: [
192_518, 192_519, 192_520,
192_564, 192_565, 192_567, 192_566,
193_188, 192_633, 192_632, 192_569,
192_568, 193_208, 193_209,
],
});
const EVENT_BOSS_NAMES: Readonly<Record<TargetId, readonly string[]>> = Object.freeze({
"halls-of-stone": ["Tribunal of Ages"],
"pit-of-saron": ["Tyrannus"],
"utgarde-pinnacle": ["Svala", "Gortok", "Skadi"],
gundrak: ["Eck"],
});
const FINAL_BOSS_NAME: Readonly<Record<TargetId, string>> = Object.freeze({
"halls-of-stone": "Sjonnir",
"pit-of-saron": "Tyrannus",
"utgarde-pinnacle": "Ymiron",
gundrak: "Gal'darah",
});
const EXPECTED_NONFINAL_CHECKPOINTS: Readonly<Record<TargetId, number>> = Object.freeze({
"halls-of-stone": 3,
"pit-of-saron": 3,
"utgarde-pinnacle": 3,
gundrak: 4,
});
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 generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
function sourceDefinition(id: TargetId): DungeonDefinition {
const source = generatedDefinitions.find((definition) => definition.id === id);
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return source;
}
function adaptedDefinition(id: TargetId): DungeonDefinition {
return withWowWrathEncounterBatchTwoContent(sourceDefinition(id));
}
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((candidate) => (
candidate.id === `${definition.id}:wrath-batch-two-instance`
));
if (!encounter) throw new Error(`Missing batch-two encounter for ${definition.id}`);
return encounter;
}
function normalized(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
const fragment = normalized(nameFragment);
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
return value;
}
function group(definition: DungeonDefinition, id: string) {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
return value;
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => (
pack.members.map((member) => `${pack.id}:${member.id}`)
)),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => (
spawnGroup.spawns.map((spawn) => spawn.id)
)),
];
}
function activeSpawnIds(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
ignoredGroupIds: ReadonlySet<string> = new Set(),
): readonly string[] {
const active = new Set(snapshot.activeSpawnGroupIds);
return (definition.encounterSpawnGroups ?? [])
.filter((spawnGroup) => active.has(spawnGroup.id) && !ignoredGroupIds.has(spawnGroup.id))
.flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id));
}
function killIds(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
ids: readonly string[],
now: number,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
}
function interact(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
objectId: string,
now: number,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId, now }).snapshot;
}
function incompleteObjective(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
names: readonly string[],
): DungeonBossObjective | undefined {
return names
.map((name) => objective(definition, name))
.find((candidate) => !snapshot.completedObjectiveIds.includes(candidate.id));
}
function advanceOneStep(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
): DungeonEncounterSnapshot {
if (snapshot.phase === "resume") {
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (definition.id === "halls-of-stone") {
if (snapshot.phase === "tribunal-ready") {
const remaining = incompleteObjective(definition, snapshot, ["Krystallus", "Maiden"]);
return remaining
? killIds(encounter, snapshot, [remaining.id], now)
: interact(encounter, snapshot, "halls-of-stone:reviewed-tribunal-console", now);
}
if (snapshot.phase === "sjonnir-ready") {
return killIds(encounter, snapshot, [objective(definition, "Sjonnir").id], now);
}
}
if (definition.id === "pit-of-saron") {
if (snapshot.phase === "progression") {
const remaining = incompleteObjective(definition, snapshot, ["Garfrost", "Ick"]);
return remaining
? killIds(encounter, snapshot, [remaining.id], now)
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (snapshot.phase === "gauntlet-ready") {
return interact(encounter, snapshot, "pit-of-saron:gauntlet-start", now);
}
if (snapshot.phase === "tunnel-ready") {
return interact(encounter, snapshot, "pit-of-saron:tunnel-start", now);
}
}
if (definition.id === "utgarde-pinnacle") {
if (snapshot.phase === "svala-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:svala-start", now);
}
if (snapshot.phase === "svala-phase-one-active") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "entity-health",
entityId: "utgarde-pinnacle:svala-phase-one-actor",
healthPercent: 50,
now,
}).snapshot;
}
if (snapshot.phase === "gortok-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:reviewed-stasis-sphere", now);
}
if (snapshot.phase === "skadi-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:skadi-start", now);
}
const harpoonByPhase: Readonly<Record<string, string>> = {
"harpoon-one-ready": "utgarde-pinnacle:harpoon-1",
"harpoon-two-ready": "utgarde-pinnacle:harpoon-2",
"harpoon-three-ready": "utgarde-pinnacle:harpoon-3",
};
const harpoon = harpoonByPhase[snapshot.phase];
if (harpoon) return interact(encounter, snapshot, harpoon, now);
if (snapshot.phase === "ymiron-ready") {
return killIds(encounter, snapshot, [objective(definition, "Ymiron").id], now);
}
}
if (definition.id === "gundrak") {
if (snapshot.phase === "altar-progression") {
const remaining = incompleteObjective(
definition,
snapshot,
["Slad'ran", "Moorabi", "Drakkari Colossus"],
);
if (remaining) return killIds(encounter, snapshot, [remaining.id], now);
const altar = definition.serverGameObjects.find((object) => (
object.id.startsWith("gundrak:reviewed-altar-")
&& snapshot.worldObjectStates[object.id] === "ready"
));
return altar
? interact(encounter, snapshot, altar.id, now)
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (snapshot.phase === "bridge-activating") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: Math.max(now, snapshot.phaseStartedAt + 5_000),
}).snapshot;
}
if (snapshot.phase === "gal-darah-ready") {
return killIds(encounter, snapshot, [objective(definition, "Gal'darah").id], now);
}
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
const spawnIds = activeSpawnIds(definition, snapshot);
if (spawnIds.length) return killIds(encounter, snapshot, spawnIds, now);
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
interface FinishedEncounter {
readonly snapshot: DungeonEncounterSnapshot;
readonly phases: readonly string[];
readonly checkpoints: readonly DungeonEncounterSnapshot[];
}
function finishEncounter(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
initial = createDungeonEncounterSnapshot(encounter),
): FinishedEncounter {
let snapshot = initial;
const phases = [snapshot.phase];
const checkpoints: DungeonEncounterSnapshot[] = [];
const seenCheckpoints = new Set<string>();
const finalObjectiveId = objective(definition, FINAL_BOSS_NAME[definition.id as TargetId]).id;
let now = snapshot.phaseStartedAt + 1;
for (let step = 0; step < 100 && 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.checkpointId
&& snapshot.checkpointId !== finalObjectiveId
&& !seenCheckpoints.has(snapshot.checkpointId)) {
seenCheckpoints.add(snapshot.checkpointId);
checkpoints.push(snapshot);
}
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, checkpoints };
}
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);
}
}
function expectTriggerReferences(
trigger: DungeonEncounterTrigger,
spawnIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
): void {
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
}
if (trigger.kind === "entities-dead") {
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
}
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
}
function expectGuardReferences(
guard: DungeonEncounterGuard,
groupIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
objectiveIds: ReadonlySet<string>,
): void {
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
}
function expectActionReferences(
action: DungeonEncounterAction,
groupIds: ReadonlySet<string>,
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
objectiveIds: ReadonlySet<string>,
): void {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
}
if (action.kind === "set-world-object-state") {
const object = objectsById.get(action.objectId);
expect(object, `action object ${action.objectId}`).toBeDefined();
expect(object?.states.map((state) => state.id), `${action.objectId}:${action.state}`)
.toContain(action.state);
}
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
}
describe("source-reviewed Wrath encounter batch two", () => {
it("pins every reviewed AzerothCore source file by actual SHA-256 and line/symbol anchors", async () => {
expect(WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
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(source.anchors.every((anchor) => /lines? \d+/i.test(anchor))).toBe(true);
const digest = createHash("sha256").update(await readFile(source.path)).digest("hex");
expect(digest, source.path).toBe(source.sha256);
}
}
});
it("attaches idempotently with unique rows, valid references, and finite event groups", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
expect(withWowWrathEncounterBatchTwoContent(definition)).toBe(definition);
expect(encounter.source).toMatchObject({
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
});
validateDungeonEncounterDefinition(encounter);
const spawnIdList = runtimeSpawnIds(definition);
expect(new Set(spawnIdList).size, `${id} duplicate runtime spawn id`).toBe(spawnIdList.length);
const groupIdList = (definition.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id);
expect(new Set(groupIdList).size, `${id} duplicate group id`).toBe(groupIdList.length);
const objectIdList = definition.serverGameObjects.map((object) => object.id);
expect(new Set(objectIdList).size, `${id} duplicate object id`).toBe(objectIdList.length);
const encounterIdList = (definition.encounters ?? []).map((candidate) => candidate.id);
expect(new Set(encounterIdList).size, `${id} duplicate encounter id`).toBe(encounterIdList.length);
const sourceSpawnIds = definition.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined);
expect(new Set(sourceSpawnIds).size, `${id} duplicate source gameobject row`).toBe(sourceSpawnIds.length);
const spawnIds = new Set(spawnIdList);
const groupIds = new Set(groupIdList);
const objectIds = new Set(objectIdList);
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
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], `${spawnGroup.id}:${spawn.id}`).toBeDefined();
}
}
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
for (const rule of encounter.rules) {
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
for (const guard of rule.guards ?? []) {
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
}
for (const action of rule.actions) {
expectActionReferences(action, groupIds, objectsById, objectiveIds);
}
}
const batchGroups = (definition.encounterSpawnGroups ?? [])
.filter((spawnGroup) => spawnGroup.id.startsWith(`${id}:`));
for (const spawnGroup of batchGroups) {
const completion = encounter.rules.find((rule) => (
rule.trigger.kind === "entities-dead"
&& rule.trigger.entityIds.length === spawnGroup.spawns.length
&& rule.trigger.entityIds.every((spawnId) => spawnGroup.spawns.some((spawn) => spawn.id === spawnId))
));
const isHealthGatedSvala = spawnGroup.id === "utgarde-pinnacle:svala-phase-one";
expect(Boolean(completion) || isHealthGatedSvala, `${id} finite completion for ${spawnGroup.id}`).toBe(true);
if (completion?.trigger.kind === "entities-dead") {
expect(completion.trigger.timeoutMs, `${id} timeout for ${spawnGroup.id}`).toBeGreaterThan(0);
}
}
}
});
it("replaces each reviewed imported object by spawnId without dropping or duplicating source rows", () => {
for (const id of TARGETS) {
const source = sourceDefinition(id);
const definition = adaptedDefinition(id);
const sourceRowIds = source.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined)
.sort((left, right) => left - right);
const adaptedRowIds = definition.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined)
.sort((left, right) => left - right);
expect(adaptedRowIds, `${id} source-row inventory`).toEqual(sourceRowIds);
for (const templateId of REVIEWED_TEMPLATES[id]) {
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
expect(originalRows, `${id} template ${templateId}`).toHaveLength(1);
const original = originalRows[0];
expect(original.source?.spawnId).toEqual(expect.any(Number));
const replacements = definition.serverGameObjects.filter((object) => (
object.source?.spawnId === original.source?.spawnId
));
expect(replacements, `${id} spawn ${original.source?.spawnId}`).toHaveLength(1);
const replacement = replacements[0];
expect(replacement.id).not.toBe(original.id);
expect(replacement.id).toContain(":reviewed-");
expect(replacement.position).toEqual(original.position);
expect(replacement.halfExtents).toEqual(original.halfExtents);
expect(replacement.yaw).toEqual(original.yaw);
expect(replacement.visual?.hidden).not.toBe(true);
expect(replacement.source?.fidelity).toBe("source-backed-adapted");
}
}
expect(adaptedDefinition("halls-of-stone").serverGameObjects
.find((object) => object.id === "halls-of-stone:reviewed-tribunal-console")?.interaction)
.toBeDefined();
expect(adaptedDefinition("utgarde-pinnacle").serverGameObjects
.find((object) => object.id === "utgarde-pinnacle:reviewed-stasis-sphere")?.interaction)
.toBeDefined();
expect(adaptedDefinition("gundrak").serverGameObjects
.filter((object) => object.id.startsWith("gundrak:reviewed-altar-")).every((object) => object.interaction))
.toBe(true);
});
it("suppresses only source-gated event objectives and preserves exact finite cardinalities", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const staticIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
for (const name of EVENT_BOSS_NAMES[id]) {
const eventBoss = objective(definition, name);
expect(eventBoss.eventOnly, `${id}:${eventBoss.name}`).toBe(true);
expect(staticIds, `${id}:${eventBoss.name}`).not.toContain(eventBoss.id);
}
}
const halls = adaptedDefinition("halls-of-stone");
expect([
group(halls, "halls-of-stone:tribunal-protectors").spawns.length,
group(halls, "halls-of-stone:tribunal-stormcallers").spawns.length,
group(halls, "halls-of-stone:tribunal-custodian").spawns.length,
]).toEqual([3, 2, 1]);
expect(incompleteObjective(halls, createDungeonEncounterSnapshot(adaptedEncounter(halls)), ["Krystallus", "Maiden"]))
.toBeDefined();
const pit = adaptedDefinition("pit-of-saron");
expect(group(pit, "pit-of-saron:first-ambush").spawns).toHaveLength(10);
expect(group(pit, "pit-of-saron:second-ambush-normal").spawns).toHaveLength(6);
expect(group(pit, "pit-of-saron:tyrannus").spawns).toHaveLength(1);
expect(pit.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
objective(pit, "Garfrost").id,
objective(pit, "Ick").id,
]));
const pinnacle = adaptedDefinition("utgarde-pinnacle");
expect(group(pinnacle, "utgarde-pinnacle:svala-ritual-channelers").spawns).toHaveLength(3);
expect(group(pinnacle, "utgarde-pinnacle:skadi-first-wave").spawns).toHaveLength(13);
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-one").spawns).toHaveLength(3);
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-two").spawns).toHaveLength(3);
expect(pinnacle.staticSpawns.map((spawn) => spawn.id)).toContain(objective(pinnacle, "Ymiron").id);
const gundrak = adaptedDefinition("gundrak");
expect(group(gundrak, "gundrak:eck").spawns).toHaveLength(1);
expect(group(gundrak, "gundrak:eck").spawns[0].spawnMask).toBe(2);
expect(objective(gundrak, "Eck").optional).toBe(true);
expect(gundrak.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
objective(gundrak, "Slad'ran").id,
objective(gundrak, "Moorabi").id,
objective(gundrak, "Drakkari Colossus").id,
objective(gundrak, "Gal'darah").id,
]));
});
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
const halls = adaptedDefinition("halls-of-stone");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
expectOrderedPhases(hallsRun.phases, [
"tribunal-protectors-active",
"tribunal-stormcallers-active",
"tribunal-custodian-active",
"sjonnir-ready",
"complete",
]);
expect(hallsRun.snapshot.worldObjectStates["halls-of-stone:reviewed-sjonnir-door"]).toBe("open");
const pit = adaptedDefinition("pit-of-saron");
const pitRun = finishEncounter(pit, adaptedEncounter(pit));
expectOrderedPhases(pitRun.phases, [
"gauntlet-ready",
"first-ambush-active",
"second-ambush-active",
"tunnel-ready",
"tyrannus-active",
"complete",
]);
expect(pitRun.snapshot.flags["gauntlet-complete"]).toBe(true);
expect(pitRun.snapshot.worldObjectStates["pit-of-saron:reviewed-portcullis"]).toBe("open");
const pinnacle = adaptedDefinition("utgarde-pinnacle");
const pinnacleRun = finishEncounter(pinnacle, adaptedEncounter(pinnacle));
expectOrderedPhases(pinnacleRun.phases, [
"svala-phase-one-active",
"svala-ritual-active",
"svala-phase-two-active",
"gortok-ready",
"stasis-worgen-active",
"stasis-furbolg-active",
"gortok-active",
"skadi-ready",
"skadi-first-wave-active",
"harpoon-one-ready",
"skadi-reinforcement-one-active",
"harpoon-two-ready",
"skadi-reinforcement-two-active",
"harpoon-three-ready",
"skadi-ground-active",
"ymiron-ready",
"complete",
]);
expect(pinnacleRun.snapshot.counters["harpoon-hits"]).toBe(3);
expect(pinnacleRun.snapshot.worldObjectStates["utgarde-pinnacle:reviewed-ymiron-door"]).toBe("open");
const gundrak = adaptedDefinition("gundrak");
const gundrakRun = finishEncounter(gundrak, adaptedEncounter(gundrak));
expectOrderedPhases(gundrakRun.phases, [
"altar-progression",
"bridge-activating",
"gal-darah-ready",
"complete",
]);
expect(gundrakRun.snapshot.counters["keys-in"]).toBe(3);
expect(gundrakRun.snapshot.flags["bridge-open"]).toBe(true);
expect(gundrakRun.snapshot.worldObjectStates["gundrak:reviewed-bridge"]).toBe("open");
expect(gundrakRun.snapshot.completedObjectiveIds).not.toContain(objective(gundrak, "Eck").id);
expect(gundrakRun.snapshot.activeSpawnGroupIds).toContain("gundrak:eck");
for (const [definition, result] of [
[halls, hallsRun],
[pit, pitRun],
[pinnacle, pinnacleRun],
[gundrak, gundrakRun],
] as const) {
expect(result.snapshot.flags["dungeon-complete"], definition.id).toBe(true);
expect(result.snapshot.completedObjectiveIds, definition.id).toEqual(expect.arrayContaining(
definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id),
));
}
});
it("recovers from both wipe scopes at every nonfinal checkpoint, including optional Eck", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
const run = finishEncounter(definition, encounter);
expect(run.checkpoints, id).toHaveLength(EXPECTED_NONFINAL_CHECKPOINTS[id]);
for (const checkpoint of run.checkpoints) {
for (const scope of ["boss-wipe", "checkpoint"] as const) {
const reset = resetDungeonEncounterSnapshot(
encounter,
checkpoint,
scope,
checkpoint.phaseStartedAt + 10_000,
);
expect(reset.phase, `${id}:${checkpoint.checkpointId}:${scope}`).toBe("resume");
expect(reset.activeSpawnGroupIds).toEqual([]);
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
expect(reset.checkpointId).toBe(checkpoint.checkpointId);
expect(finishEncounter(definition, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
}
}
}
const gundrak = adaptedDefinition("gundrak");
const encounter = adaptedEncounter(gundrak);
let eckCheckpoint = killIds(
encounter,
createDungeonEncounterSnapshot(encounter),
[objective(gundrak, "Moorabi").id],
1,
);
eckCheckpoint = killIds(
encounter,
eckCheckpoint,
group(gundrak, "gundrak:eck").spawns.map((spawn) => spawn.id),
2,
);
expect(eckCheckpoint.checkpointId).toBe(objective(gundrak, "Eck").id);
for (const scope of ["boss-wipe", "checkpoint"] as const) {
const reset = resetDungeonEncounterSnapshot(encounter, eckCheckpoint, scope, 3);
expect(reset.completedObjectiveIds).toContain(objective(gundrak, "Eck").id);
expect(finishEncounter(gundrak, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
}
});
it("keeps completion order-safe when a static final boss is reported dead before its event gate", () => {
for (const id of ["halls-of-stone", "utgarde-pinnacle", "gundrak"] as const) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
const finalBoss = objective(definition, FINAL_BOSS_NAME[id]);
const premature = killIds(encounter, createDungeonEncounterSnapshot(encounter), [finalBoss.id], 1);
expect(premature.flags["dungeon-complete"], id).toBe(false);
const completed = finishEncounter(definition, encounter, premature).snapshot;
expect(completed.flags["dungeon-complete"], id).toBe(true);
if (id === "gundrak") expect(completed.flags["bridge-open"]).toBe(true);
}
const pit = adaptedDefinition("pit-of-saron");
const pitEncounter = adaptedEncounter(pit);
const tyrannusGroup = group(pit, "pit-of-saron:tyrannus");
const prematureTyrannus = killIds(
pitEncounter,
createDungeonEncounterSnapshot(pitEncounter),
tyrannusGroup.spawns.map((spawn) => spawn.id),
1,
);
expect(prematureTyrannus.completedObjectiveIds).not.toContain(objective(pit, "Tyrannus").id);
expect(prematureTyrannus.phase).toBe("progression");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,406 @@
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);
}
}
});
});
File diff suppressed because it is too large. Load diff