forked from phenom/healer-man
Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
| {
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
])];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
@@ -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
Reference in new issue
Block a user