Release Healer Man 0.1.22

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

No files matched your search

+21 -4
View File
@@ -6,8 +6,14 @@ import { useManastormAdminStore } from "./game/manastormAdminStore";
import { useAuthoritativeDualScreenSync, useForcedThorDisplays } from "./platform/useThorDualScreen";
import { LoginScreen } from "./ui/LoginScreen";
import { OnlineGroupCoordinator } from "./app/OnlineGroupCoordinator";
import { preloadTalentGameplayResources } from "./ui/talentGameplayResources";
import {
getLoadedGameRuntime,
loadGameRuntime,
preloadSharedGameplayResources,
} from "./ui/gameplayResources";
const GameRuntime = lazy(() => import("./GameRuntime"));
const GameRuntime = lazy(loadGameRuntime);
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
const CharacterSelectScreen = lazy(() => import("./ui/CharacterSelectScreen").then((module) => ({ default: module.CharacterSelectScreen })));
const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module) => ({ default: module.ClassHallScreen })));
@@ -32,7 +38,11 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
if (definition.mode === "conquest") return [...BASE_CLASS_IDS, character.classId];
return [character.classId, ...(character.secondaryClassId ? [character.secondaryClassId] : [])];
}, [character]);
const talentClassId = character && !character.classId.startsWith("rom-")
? character.classId
: null;
const requestKey = classIds.join(":");
const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled);
const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState("");
@@ -40,13 +50,17 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
if (!requestKey) return;
let active = true;
setFailedKey("");
void preloadAbilityCatalogsForClasses(classIds).then(() => {
void Promise.all([
preloadAbilityCatalogsForClasses(classIds),
talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
preloadSharedGameplayResources(originalMediaEnabled),
]).then(() => {
if (active) setLoadedKey(requestKey);
}).catch(() => {
if (active) setFailedKey(requestKey);
});
return () => { active = false; };
}, [classIds, requestKey]);
}, [classIds, originalMediaEnabled, requestKey, talentClassId]);
if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) {
@@ -61,6 +75,7 @@ export default function App() {
const phase = useShellStore((state) => state.phase);
const session = useShellStore((state) => state.session);
const refreshManastormConfig = useManastormAdminStore((state) => state.refresh);
const PreloadedGameRuntime = getLoadedGameRuntime();
useEffect(() => {
void refreshManastormConfig(session);
@@ -77,7 +92,9 @@ export default function App() {
else if (phase === "arenas") screen = <Suspense fallback={<ShellTransition />}><ArenaSelectScreen /></Suspense>;
else if (phase === "arena-game") screen = <Suspense fallback={<ShellTransition />}><ArenaRuntime /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><GmAdminScreen /></Suspense>;
else if (phase === "game") screen = <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
else if (phase === "game") screen = PreloadedGameRuntime
? <PreloadedGameRuntime />
: <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>;
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
const DUNGEON_ACTIVITY: OnlineGroupActivity = {
id: "activity-dungeon-1",
type: "dungeon",
contentId: "deadmines",
fillWithAi: true,
partySize: 5,
startingLevel: null,
startedAt: 1,
};
function entryDependencies(enterDungeon: () => boolean) {
return {
installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon),
installManastormRuntime: vi.fn(async () => undefined),
enterManastorm: vi.fn(() => true),
};
}
describe("online group activity entry", () => {
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"healer",
consumeActivity,
dependencies,
)).resolves.toBe(false);
expect(dependencies.installDungeonRuntime).toHaveBeenCalledOnce();
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer");
expect(consumeActivity).not.toHaveBeenCalled();
});
it("can retry the same dungeon activity and consumes it only after entry succeeds", async () => {
const consumeActivity = vi.fn();
const enterDungeon = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const dependencies = entryDependencies(enterDungeon);
await enterOnlineGroupActivity(DUNGEON_ACTIVITY, "tank", consumeActivity, dependencies);
expect(consumeActivity).not.toHaveBeenCalled();
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"tank",
consumeActivity,
dependencies,
)).resolves.toBe(true);
expect(dependencies.enterDungeon).toHaveBeenCalledTimes(2);
expect(consumeActivity).toHaveBeenCalledOnce();
expect(consumeActivity).toHaveBeenCalledWith(DUNGEON_ACTIVITY.id);
});
it("does not consume the activity when loading the dungeon runtime throws", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => true);
dependencies.installDungeonRuntime.mockRejectedValueOnce(new Error("runtime unavailable"));
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"damage",
consumeActivity,
dependencies,
)).rejects.toThrow("runtime unavailable");
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
});
+59 -22
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useOnlineGroupStore } from "./onlineGroupStore";
import {
@@ -6,11 +6,56 @@ import {
defaultPartyRoleForClass,
type PartyRole,
} from "../game/partyRoles";
import { isManastormPartySize } from "../game/manastormProgress";
import { isManastormPartySize, type ManastormPartySize } from "../game/manastormProgress";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { useContentManagerSnapshot } from "../content/useContentManager";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole) => boolean;
readonly installManastormRuntime: () => Promise<void>;
readonly enterManastorm: (role?: PartyRole, partySize?: ManastormPartySize, startingLevel?: number) => boolean;
}
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role),
installManastormRuntime: async () => {
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
},
enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm(
role,
partySize,
startingLevel,
),
};
export async function enterOnlineGroupActivity(
activity: OnlineGroupActivity,
role: PartyRole,
consumeActivity: (activityId: string) => void,
dependencies: OnlineGroupActivityEntryDependencies = DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES,
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role);
} else {
await dependencies.installManastormRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
entered = dependencies.enterManastorm(role, partySize, activity.startingLevel ?? undefined);
}
if (entered) consumeActivity(activity.id);
return entered;
}
export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
@@ -21,6 +66,8 @@ export function OnlineGroupCoordinator() {
const updateMember = useOnlineGroupStore((state) => state.updateMember);
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
const reset = useOnlineGroupStore((state) => state.reset);
const { installedRevision } = useContentManagerSnapshot();
const pendingActivityIds = useRef(new Set<string>());
useEffect(() => {
if (!session || session.kind !== "account" || !session.accessToken) {
@@ -60,26 +107,16 @@ export function OnlineGroupCoordinator() {
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return;
consumeActivity(activity.id);
void (async () => {
if (activity.type === "dungeon") {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
return;
}
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
useShellStore.getState().enterManastorm(
member.role ?? undefined,
partySize,
activity.startingLevel ?? undefined,
);
})().catch((error) => {
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
});
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
if (pendingActivityIds.current.has(activity.id)) return;
pendingActivityIds.current.add(activity.id);
void enterOnlineGroupActivity(activity, member.role, consumeActivity)
.catch((error) => {
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
})
.finally(() => {
pendingActivityIds.current.delete(activity.id);
});
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
return null;
}
+5
View File
@@ -11,6 +11,7 @@ import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
import { dungeonContentIsUsable } from "../content/contentManager";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
@@ -22,6 +23,10 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
useShellStore.setState({ notice: "That dungeon is not available yet." });
return false;
}
if (!dungeonContentIsUsable(dungeonId)) {
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
return false;
}
const { session, selectedCharacterId, characters } = useShellStore.getState();
if (!session || !selectedCharacterId) {
useShellStore.setState({ notice: "Select or create a character first." });
+16 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import { useShellStore } from "./shellStore";
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
@@ -79,6 +79,10 @@ function prepareSelectedCharacter(): void {
}
describe("shell store", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
useShellStore.setState({
phase: "login",
@@ -167,6 +171,17 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42);
});
it("rejects a shipped dungeon whose production content pack is not usable", () => {
vi.stubEnv("DEV", false);
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
useShellStore.getState().openDungeons();
expect(useShellStore.getState().enterDungeon("deadmines", "healer")).toBe(false);
expect(useShellStore.getState().phase).toBe("dungeons");
expect(useShellStore.getState().notice).toMatch(/downloads/i);
});
it("returns from gameplay to the main menu with the same character selected", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true);
+13
View File
@@ -8,6 +8,7 @@ import {
contentPackIsBundled,
contentPackIsInstalled,
contentPackIsUsable,
dungeonContentIsUsable,
getContentManagerSnapshot,
installContentPack,
removeContentPack,
@@ -81,6 +82,7 @@ function contentManifest(version: string, objectName: string, bytes: Uint8Array)
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
describe("browser content packs", () => {
@@ -122,6 +124,7 @@ describe("browser content packs", () => {
manifest = contentManifest("content-two", "updated.glb", updatedBytes);
await checkForContentUpdates();
expect(contentPackIsUsable("characters:test")).toBe(false);
expect(contentPackHasUpdate("characters:test")).toBe(true);
corruptUpdate = true;
@@ -147,4 +150,14 @@ describe("browser content packs", () => {
await expect(removeContentPack("core:ui")).resolves.toBeUndefined();
expect(contentPackIsUsable("core:ui")).toBe(true);
});
it("uses source-served dungeons in development and the content gate in shipped builds", () => {
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
vi.stubEnv("DEV", true);
expect(dungeonContentIsUsable("deadmines")).toBe(true);
vi.stubEnv("DEV", false);
expect(dungeonContentIsUsable("deadmines")).toBe(false);
expect(dungeonContentIsUsable("wailing-caverns")).toBe(true);
});
});
+235 -23
View File
@@ -9,7 +9,7 @@ import {
} from "./contentManifest";
import { NativeContentStorage } from "./nativeContentStorage";
const CONTENT_STATE_KEY = "healer-man.content-state.v1";
export const CONTENT_STATE_KEY = "healer-man.content-state.v2";
const PRODUCTION_ORIGIN = "https://iwanttoheal.phenomrom.com";
const STATIC_ANDROID_BUNDLED_PACKS = new Set([
"core:ui",
@@ -30,6 +30,8 @@ const STATIC_BROWSER_BUNDLED_PACKS = new Set([
export const BROWSER_CONTENT_CACHE_NAME = "healer-man-content-v1";
export const BROWSER_CONTENT_WORKER_URL = "/content-cache-sw.js";
const BROWSER_CONTENT_SHA256_HEADER = "x-healer-man-content-sha256";
const BROWSER_CONTENT_BYTES_HEADER = "x-healer-man-content-bytes";
export interface InstalledContentFile {
readonly packId: string;
@@ -39,9 +41,20 @@ export interface InstalledContentFile {
readonly extension: string;
}
interface InstalledContentFileIdentity {
readonly sha256: string;
readonly bytes: number;
}
interface InstalledContentPackReceipt {
readonly manifestVersion: string;
readonly dependencies: readonly string[];
readonly files: Readonly<Record<string, InstalledContentFileIdentity>>;
}
interface PersistedContentState {
readonly version: 1;
readonly packVersions: Readonly<Record<string, string>>;
readonly version: 2;
readonly packs: Readonly<Record<string, InstalledContentPackReceipt>>;
readonly files: Readonly<Record<string, InstalledContentFile>>;
}
@@ -57,7 +70,7 @@ export interface ContentManagerSnapshot {
readonly error: string;
}
const EMPTY_STATE: PersistedContentState = { version: 1, packVersions: {}, files: {} };
const EMPTY_STATE: PersistedContentState = { version: 2, packs: {}, files: {} };
let installedState = readInstalledState();
let snapshot: ContentManagerSnapshot = {
revision: 0,
@@ -72,16 +85,69 @@ let snapshot: ContentManagerSnapshot = {
};
const listeners = new Set<() => void>();
let initialization: Promise<void> | null = null;
let installedStorageValidation: Promise<void> | null = null;
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
const validatedInstalledFiles = new Set<string>();
function safeLocalStorage(): Storage | null {
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isSha256(value: unknown): value is string {
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
}
function isByteCount(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function readInstalledState(): PersistedContentState {
try {
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as Partial<PersistedContentState> | null;
if (!value || value.version !== 1 || !value.files || !value.packVersions) return EMPTY_STATE;
return { version: 1, files: value.files, packVersions: value.packVersions };
const value = JSON.parse(safeLocalStorage()?.getItem(CONTENT_STATE_KEY) ?? "null") as unknown;
if (!isRecord(value) || value.version !== 2 || !isRecord(value.files) || !isRecord(value.packs)) {
return EMPTY_STATE;
}
const files: Record<string, InstalledContentFile> = {};
for (const [logicalPath, entry] of Object.entries(value.files)) {
if (!logicalPath.startsWith("/")
|| !isRecord(entry)
|| typeof entry.packId !== "string"
|| !isSha256(entry.sha256)
|| !isByteCount(entry.bytes)
|| typeof entry.fileUri !== "string"
|| !entry.fileUri
|| typeof entry.extension !== "string"
|| !entry.extension.startsWith(".")) return EMPTY_STATE;
files[logicalPath] = entry as unknown as InstalledContentFile;
}
const packs: Record<string, InstalledContentPackReceipt> = {};
for (const [packId, receipt] of Object.entries(value.packs)) {
if (!packId
|| !isRecord(receipt)
|| typeof receipt.manifestVersion !== "string"
|| !receipt.manifestVersion
|| !Array.isArray(receipt.dependencies)
|| !receipt.dependencies.every((dependency) => typeof dependency === "string")
|| !isRecord(receipt.files)) return EMPTY_STATE;
const receiptFiles: Record<string, InstalledContentFileIdentity> = {};
for (const [logicalPath, identity] of Object.entries(receipt.files)) {
if (!logicalPath.startsWith("/")
|| !isRecord(identity)
|| !isSha256(identity.sha256)
|| !isByteCount(identity.bytes)) return EMPTY_STATE;
receiptFiles[logicalPath] = { sha256: identity.sha256, bytes: identity.bytes };
}
packs[packId] = {
manifestVersion: receipt.manifestVersion,
dependencies: [...receipt.dependencies],
files: receiptFiles,
};
}
return { version: 2, files, packs };
} catch {
return EMPTY_STATE;
}
@@ -116,6 +182,60 @@ function browserCacheStorage(): CacheStorage | null {
return typeof caches === "undefined" ? null : caches;
}
async function validateInstalledContentStorage(): Promise<void> {
if (!installedStorageValidation) {
installedStorageValidation = (async () => {
const validFiles: Record<string, InstalledContentFile> = {};
if (Capacitor.isNativePlatform()) {
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
try {
const result = await NativeContentStorage.has({
sha256: entry.sha256,
extension: entry.extension,
bytes: entry.bytes,
});
if (!result.exists) continue;
validFiles[logicalPath] = {
...entry,
fileUri: result.fileUri || entry.fileUri,
};
} catch {
// A file that cannot be verified must not make its pack launchable.
}
}
} else {
try {
const storage = browserCacheStorage();
const active = storage ? await storage.open(BROWSER_CONTENT_CACHE_NAME) : null;
if (active) {
for (const [logicalPath, entry] of Object.entries(installedState.files)) {
const cached = await active.match(browserCacheRequest(logicalPath));
if (cached?.headers.get(BROWSER_CONTENT_SHA256_HEADER) === entry.sha256
&& cached.headers.get(BROWSER_CONTENT_BYTES_HEADER) === String(entry.bytes)) {
validFiles[logicalPath] = entry;
}
}
}
} catch {
// Cache access failure is handled as unavailable content, never as a valid install.
}
}
validatedInstalledFiles.clear();
Object.keys(validFiles).forEach((logicalPath) => validatedInstalledFiles.add(logicalPath));
const filesChanged = Object.keys(validFiles).length !== Object.keys(installedState.files).length
|| Object.entries(validFiles).some(([logicalPath, entry]) => (
installedState.files[logicalPath]?.fileUri !== entry.fileUri
));
installedState = { ...installedState, files: validFiles };
installedStorageValidationComplete = true;
if (filesChanged) persistInstalledState();
publish({ installedRevision: snapshot.installedRevision + 1 });
})();
}
await installedStorageValidation;
}
async function registerBrowserContentWorker(): Promise<void> {
if (Capacitor.isNativePlatform()
|| import.meta.env.DEV
@@ -137,6 +257,19 @@ function extensionForEntry(entry: ContentManifestFile): string {
return match?.[0].split("?")[0].toLowerCase() ?? ".bin";
}
function packReceiptForManifest(manifest: ContentManifest, packId: string): InstalledContentPackReceipt {
const pack = manifest.packs.find((candidate) => candidate.id === packId);
if (!pack) throw new Error(`Unknown content pack: ${packId}`);
return {
manifestVersion: manifest.version,
dependencies: [...pack.dependencies],
files: Object.fromEntries(filesForContentPack(manifest, packId).map(([logicalPath, entry]) => [
logicalPath,
{ sha256: entry.sha256, bytes: entry.bytes },
])),
};
}
function bundledFiles(): Readonly<Record<string, { readonly sha256: string }>> {
if (snapshot.bundledManifest) return snapshot.bundledManifest.files;
if (!snapshot.manifest) return {};
@@ -166,7 +299,9 @@ export function resolveContentUrl(logicalUrl: string): string {
let pathname = logicalUrl;
try { pathname = new URL(logicalUrl, "https://local.invalid").pathname; } catch { /* preserve original */ }
const installed = installedState.files[pathname];
return installed?.fileUri ? Capacitor.convertFileSrc(installed.fileUri) : logicalUrl;
return installed?.fileUri && validatedInstalledFiles.has(pathname)
? Capacitor.convertFileSrc(installed.fileUri)
: logicalUrl;
}
export function contentPackIsBundled(packId: string): boolean {
@@ -176,20 +311,66 @@ export function contentPackIsBundled(packId: string): boolean {
}
export function contentPackIsInstalled(packId: string): boolean {
return contentPackIsBundled(packId) || Boolean(installedState.packVersions[packId]);
return contentPackIsBundled(packId) || Boolean(installedState.packs[packId]);
}
function matchingReceiptFiles(
packId: string,
receipt: InstalledContentPackReceipt,
expectedFiles: Readonly<Record<string, InstalledContentFileIdentity>>,
): boolean {
const expectedEntries = Object.entries(expectedFiles);
if (Object.keys(receipt.files).length !== expectedEntries.length) return false;
return expectedEntries.every(([logicalPath, expected]) => {
const recorded = receipt.files[logicalPath];
const installed = installedState.files[logicalPath];
return recorded?.sha256 === expected.sha256
&& recorded.bytes === expected.bytes
&& installed?.packId === packId
&& installed.sha256 === expected.sha256
&& installed.bytes === expected.bytes
&& validatedInstalledFiles.has(logicalPath);
});
}
function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<string>): boolean {
if (contentPackIsBundled(packId)) return true;
if (!installedStorageValidationComplete || visiting.has(packId)) return false;
const receipt = installedState.packs[packId];
if (!receipt) return false;
const manifestPack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
if (snapshot.manifest && !manifestPack) return false;
const dependencies = manifestPack?.dependencies ?? receipt.dependencies;
if (dependencies.length !== receipt.dependencies.length
|| dependencies.some((dependency, index) => receipt.dependencies[index] !== dependency)) return false;
const expectedFiles = manifestPack
? Object.fromEntries(filesForContentPack(snapshot.manifest as ContentManifest, packId).map(([logicalPath, entry]) => [
logicalPath,
{ sha256: entry.sha256, bytes: entry.bytes },
]))
: receipt.files;
if (!matchingReceiptFiles(packId, receipt, expectedFiles)) return false;
const nextVisiting = new Set(visiting);
nextVisiting.add(packId);
return dependencies.every((dependency) => contentPackIsUsableInternal(dependency, nextVisiting));
}
export function contentPackIsUsable(packId: string): boolean {
if (contentPackIsBundled(packId)) return true;
if (!installedState.packVersions[packId]) return false;
const pack = snapshot.manifest?.packs.find((candidate) => candidate.id === packId);
return pack?.dependencies.every((dependency) => contentPackIsUsable(dependency)) ?? true;
return contentPackIsUsableInternal(packId, new Set());
}
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
export function dungeonContentIsUsable(dungeonId: string): boolean {
return import.meta.env.DEV || contentPackIsUsable(`dungeon:${dungeonId}`);
}
export function contentPackHasUpdate(packId: string): boolean {
if (!snapshot.manifest || !contentPackIsInstalled(packId)) return false;
const pack = snapshot.manifest.packs.find((candidate) => candidate.id === packId);
return changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
return Boolean(!contentPackIsBundled(packId) && !contentPackIsUsable(packId))
|| changedFilesForPack(snapshot.manifest, packId, activeFileIdentities()).length > 0
|| Boolean(!contentPackIsBundled(packId)
&& pack?.dependencies.some((dependency) => (
!contentPackIsUsable(dependency) || contentPackHasUpdate(dependency)
@@ -224,7 +405,11 @@ async function loadBundledManifest(): Promise<void> {
export function initializeContentSystem(): Promise<void> {
if (!initialization) {
initialization = (async () => {
await Promise.all([loadBundledManifest(), registerBrowserContentWorker()]);
await Promise.all([
loadBundledManifest(),
registerBrowserContentWorker(),
validateInstalledContentStorage(),
]);
if (typeof navigator === "undefined" || navigator.onLine !== false) void checkForContentUpdates();
})();
}
@@ -268,6 +453,8 @@ async function installBrowserContentPack(
const headers = new Headers();
const contentType = response.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
headers.set(BROWSER_CONTENT_SHA256_HEADER, entry.sha256);
headers.set(BROWSER_CONTENT_BYTES_HEADER, String(entry.bytes));
await staging.put(browserCacheRequest(logicalPath), new Response(bytes, {
headers,
status: 200,
@@ -289,6 +476,7 @@ async function installBrowserContentPack(
if (installed.packId !== packId || latestPaths.has(logicalPath)) continue;
await active.delete(browserCacheRequest(logicalPath));
delete pendingFiles[logicalPath];
validatedInstalledFiles.delete(logicalPath);
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
pendingFiles[logicalPath] = {
@@ -298,11 +486,15 @@ async function installBrowserContentPack(
extension: extensionForEntry(entry),
fileUri: absoluteContentUrl(logicalPath),
};
validatedInstalledFiles.add(logicalPath);
}
installedState = {
version: 1,
version: 2,
files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
persistInstalledState();
publish({
@@ -321,6 +513,7 @@ async function installBrowserContentPack(
}
export async function installContentPack(packId: string): Promise<void> {
await validateInstalledContentStorage();
const manifest = snapshot.manifest ?? await checkForContentUpdates();
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
if (!appVersionMeetsMinimum(import.meta.env.VITE_APP_VERSION, manifest.minimumAppVersion)) {
@@ -380,12 +573,30 @@ export async function installContentPack(packId: string): Promise<void> {
const latestPaths = new Set(filesForContentPack(manifest, packId).map(([logicalPath]) => logicalPath));
for (const [logicalPath, installed] of Object.entries(pendingFiles)) {
if (installed.packId === packId && !latestPaths.has(logicalPath)) delete pendingFiles[logicalPath];
if (installed.packId === packId && !latestPaths.has(logicalPath)) {
delete pendingFiles[logicalPath];
validatedInstalledFiles.delete(logicalPath);
}
}
for (const [logicalPath, entry] of filesForContentPack(manifest, packId)) {
const installed = pendingFiles[logicalPath];
if (!installed) throw new Error(`Verified content is missing after installation: ${logicalPath}`);
pendingFiles[logicalPath] = {
...installed,
packId,
sha256: entry.sha256,
bytes: entry.bytes,
extension: extensionForEntry(entry),
};
validatedInstalledFiles.add(logicalPath);
}
installedState = {
version: 1,
version: 2,
files: pendingFiles,
packVersions: { ...installedState.packVersions, [packId]: manifest.version },
packs: {
...installedState.packs,
[packId]: packReceiptForManifest(manifest, packId),
},
};
persistInstalledState();
publish({
@@ -424,9 +635,10 @@ export async function removeContentPack(packId: string): Promise<void> {
delete remainingFiles[logicalPath];
}
}
const packVersions = { ...installedState.packVersions };
delete packVersions[packId];
installedState = { version: 1, files: remainingFiles, packVersions };
targetFiles.forEach(([logicalPath]) => validatedInstalledFiles.delete(logicalPath));
const packs = { ...installedState.packs };
delete packs[packId];
installedState = { version: 2, files: remainingFiles, packs };
persistInstalledState();
publish({ error: "", installedRevision: snapshot.installedRevision + 1 });
}
@@ -0,0 +1,195 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./nativeContentStorage", () => ({ NativeContentStorage: {} }));
const STATE_KEY = "healer-man.content-state.v2";
const ENVIRONMENT_PATH = "/assets/game/manastorm/test/visual.glb";
const CREATURE_PATH = "/assets/game/creatures/shared/test.glb";
const ENVIRONMENT_HASH = "a".repeat(64);
const CREATURE_HASH = "b".repeat(64);
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number { return this.values.size; }
clear(): void { this.values.clear(); }
getItem(key: string): string | null { return this.values.get(key) ?? null; }
key(index: number): string | null { return [...this.values.keys()][index] ?? null; }
removeItem(key: string): void { this.values.delete(key); }
setItem(key: string, value: string): void { this.values.set(key, value); }
}
class MemoryCache {
readonly responses = new Map<string, Response>();
async match(request: RequestInfo | URL): Promise<Response | undefined> {
return this.responses.get(this.key(request))?.clone();
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.responses.set(this.key(request), response.clone());
}
async delete(request: RequestInfo | URL): Promise<boolean> {
return this.responses.delete(this.key(request));
}
private key(request: RequestInfo | URL): string {
if (request instanceof Request) return request.url;
return new URL(String(request), "http://localhost").toString();
}
}
class MemoryCacheStorage {
readonly stores = new Map<string, MemoryCache>();
async open(name: string): Promise<Cache> {
const cache = this.stores.get(name) ?? new MemoryCache();
this.stores.set(name, cache);
return cache as unknown as Cache;
}
async delete(name: string): Promise<boolean> {
return this.stores.delete(name);
}
}
function installedState() {
return {
version: 2,
packs: {
"dungeon:test": {
manifestVersion: "content-one",
dependencies: ["manastorm:test", "shared:creatures"],
files: {},
},
"manastorm:test": {
manifestVersion: "content-one",
dependencies: [],
files: { [ENVIRONMENT_PATH]: { sha256: ENVIRONMENT_HASH, bytes: 11 } },
},
"shared:creatures": {
manifestVersion: "content-one",
dependencies: [],
files: { [CREATURE_PATH]: { sha256: CREATURE_HASH, bytes: 12 } },
},
},
files: {
[ENVIRONMENT_PATH]: {
packId: "manastorm:test",
sha256: ENVIRONMENT_HASH,
bytes: 11,
fileUri: `http://localhost${ENVIRONMENT_PATH}`,
extension: ".glb",
},
[CREATURE_PATH]: {
packId: "shared:creatures",
sha256: CREATURE_HASH,
bytes: 12,
fileUri: `http://localhost${CREATURE_PATH}`,
extension: ".glb",
},
},
};
}
async function loadOfflineManager(
state: unknown,
cachedPaths: readonly string[],
) {
vi.resetModules();
vi.stubEnv("DEV", false);
vi.stubEnv("VITE_CONTENT_MODE", "bundled");
const localStorage = new MemoryStorage();
localStorage.setItem(STATE_KEY, JSON.stringify(state));
vi.stubGlobal("window", { localStorage, location: { origin: "http://localhost" } });
vi.stubGlobal("navigator", { onLine: false });
const cacheStorage = new MemoryCacheStorage();
vi.stubGlobal("caches", cacheStorage as unknown as CacheStorage);
const { Capacitor } = await import("@capacitor/core");
vi.spyOn(Capacitor, "isNativePlatform").mockReturnValue(false);
const manager = await import("./contentManager");
const active = await cacheStorage.open(manager.BROWSER_CONTENT_CACHE_NAME);
for (const logicalPath of cachedPaths) {
const entry = installedState().files[logicalPath as keyof ReturnType<typeof installedState>["files"]];
await active.put(new Request(`http://localhost${logicalPath}`), new Response("cached", {
headers: {
"x-healer-man-content-sha256": entry.sha256,
"x-healer-man-content-bytes": String(entry.bytes),
},
}));
}
return { localStorage, manager };
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.resetModules();
});
describe("persisted content readiness", () => {
it("restores a complete dependency-only dungeon closure offline after verifying storage", async () => {
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
expect(manager.dungeonContentIsUsable("test")).toBe(false);
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(true);
});
it("rejects a persisted install when a required cached file is missing", async () => {
const { localStorage, manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH]);
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(false);
expect(JSON.parse(localStorage.getItem(STATE_KEY) ?? "null").files[CREATURE_PATH]).toBeUndefined();
});
it("rejects a missing dependency receipt", async () => {
const state = installedState();
delete (state.packs as Partial<typeof state.packs>)["shared:creatures"];
const { manager } = await loadOfflineManager(state, [ENVIRONMENT_PATH, CREATURE_PATH]);
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(false);
});
it("rejects a receipt whose dependency topology is stale", async () => {
const { manager } = await loadOfflineManager(installedState(), [ENVIRONMENT_PATH, CREATURE_PATH]);
await manager.initializeContentSystem();
expect(manager.dungeonContentIsUsable("test")).toBe(true);
vi.stubGlobal("fetch", vi.fn(async () => Response.json({
schemaVersion: 1,
version: "content-two",
createdAt: "2026-09-08T00:00:00.000Z",
minimumAppVersion: "0.1.0",
packs: [
{
id: "dungeon:test",
label: "Test",
bytes: 0,
fileCount: 0,
bundledOnAndroid: false,
dependencies: ["manastorm:new"],
},
],
files: {},
})));
await manager.checkForContentUpdates();
expect(manager.contentPackIsUsable("dungeon:test")).toBe(false);
expect(manager.contentPackHasUpdate("dungeon:test")).toBe(true);
});
it("does not accept a legacy packVersions marker as an install receipt", async () => {
const { manager } = await loadOfflineManager({
version: 2,
packVersions: { "dungeon:test": "content-one" },
files: {},
}, []);
await manager.initializeContentSystem();
expect(manager.contentPackIsInstalled("dungeon:test")).toBe(false);
expect(manager.dungeonContentIsUsable("test")).toBe(false);
});
});
+18 -2
View File
@@ -9,8 +9,13 @@ import { useManastormStore } from "./manastormStore";
import {
activateDungeonEncounterDefinitions,
advanceDungeonEncounters,
dispatchActiveDungeonEntityEvent,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import {
deriveDungeonEncounterCombatEvents,
type DungeonEncounterMobCursor,
} from "./dungeonEncounterCombatEvents";
import { requireDungeonDefinition } from "./dungeonRegistry";
import {
onlineSessionIsActive,
@@ -27,9 +32,11 @@ export function CombatBridge() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
const activeRole = useShellStore((state) => state.activeDungeonRole);
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
const mountedResetRevision = useRef(encounterResetRevision);
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
useEffect(() => {
if (!character) return;
@@ -44,12 +51,14 @@ export function CombatBridge() {
useEffect(() => {
const definition = requireDungeonDefinition(activeDungeonId);
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? []);
}, [activeDungeonId]);
encounterMobCursor.current = {};
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
}, [activeDifficultyId, activeDungeonId]);
useEffect(() => {
if (mountedResetRevision.current === encounterResetRevision) return;
mountedResetRevision.current = encounterResetRevision;
encounterMobCursor.current = {};
useCombatStore.getState().resetEncounter();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
}, [encounterResetRevision]);
@@ -126,6 +135,13 @@ export function CombatBridge() {
useCombatStore.getState().synchronizeActors();
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
const mobs = useCombatStore.getState().mobs;
const combatEvents = deriveDungeonEncounterCombatEvents(
encounterMobCursor.current,
mobs,
epochNow,
);
encounterMobCursor.current = combatEvents.cursor;
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
const registeredEntityIds = Object.keys(mobs);
advanceDungeonEncounters(game.activeDungeonId, {
kind: "tick",
@@ -3,9 +3,17 @@ import { applyDungeonEncounterEffect } from "./DungeonEncounterEffectBridge";
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useCombatStore } from "./combatStore";
import {
clearMobRuntimeRegistry,
getMobRuntimePosition,
registerMobRuntime,
} from "./mobRuntimeRegistry";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
afterEach(() => {
clearPartyRuntimeRegistry();
clearMobRuntimeRegistry();
usePartyStore.setState({ members: [] });
});
@@ -26,4 +34,34 @@ describe("dungeon encounter effects", () => {
expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]);
expect(getPartyRuntimePosition(allyId)).toEqual([12, 3, -8]);
});
it("removes every combat and spatial actor owned by a deactivated spawn group", () => {
const dungeon = DUNGEON_REGISTRY["wailing-caverns"];
const group = dungeon.encounterSpawnGroups?.find(
(candidate) => candidate.id === "naralex-ravagers",
);
expect(group).toBeDefined();
for (const spawn of group!.spawns) {
useCombatStore.getState().registerMob(spawn.id, { name: spawn.id, maxHealth: 100 });
registerMobRuntime(spawn.id, spawn.position);
}
useCombatStore.getState().registerMob("unrelated", { name: "Unrelated", maxHealth: 100 });
applyDungeonEncounterEffect({
dungeonId: dungeon.id,
encounterId: "wailing-caverns:naralex",
effect: {
kind: "despawn-group",
actionId: "cleanup-ravagers",
spawnGroupId: group!.id,
},
}, 100);
for (const spawn of group!.spawns) {
expect(useCombatStore.getState().mobs[spawn.id]).toBeUndefined();
expect(getMobRuntimePosition(spawn.id)).toBeNull();
}
expect(useCombatStore.getState().mobs.unrelated).toBeDefined();
useCombatStore.getState().removeMob("unrelated");
});
});
+15 -1
View File
@@ -11,6 +11,8 @@ import { useCombatStore } from "./combatStore";
import { useGameStore } from "./store";
import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
import { dungeonDefinitionById } from "./dungeonRegistry";
import { removeMobPosition } from "./mobRuntimeRegistry";
function grantEncounterLoot(lootTableId: string, now: number): boolean {
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
@@ -33,7 +35,7 @@ export function DungeonEncounterEffectBridge() {
}
export function applyDungeonEncounterEffect(
{ effect }: DungeonEncounterEffectEnvelope,
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
now = Date.now(),
): void {
if (effect.kind === "signal") {
@@ -49,6 +51,18 @@ export function applyDungeonEncounterEffect(
publishOnlinePartyTeleport(effect.destination, effect.actionId, now);
return;
}
if (effect.kind === "despawn-group") {
const group = dungeonDefinitionById(dungeonId)?.encounterSpawnGroups?.find(
(candidate) => candidate.id === effect.spawnGroupId,
);
if (!group) return;
const combat = useCombatStore.getState();
for (const spawn of group.spawns) {
combat.removeMob(spawn.id);
removeMobPosition(spawn.id);
}
return;
}
if (effect.kind === "loot-grant" && !grantEncounterLoot(effect.lootTableId, now)) {
useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`);
}
+6 -2
View File
@@ -57,7 +57,11 @@ export function ManastormBridge() {
}, [gameMode]);
useEffect(() => {
if (gameMode !== "manastorm" || !nextEncounterId) return;
if (
gameMode !== "manastorm"
|| (status !== "portal-ready" && status !== "transitioning")
|| !nextEncounterId
) return;
const nextEncounter = manastormEncounterById(nextEncounterId);
const packageId = nextEncounter?.assetPackageId;
if (!packageId || packageId === encounter?.assetPackageId) return;
@@ -67,7 +71,7 @@ export function ManastormBridge() {
console.warn(`Could not preload the next Manastorm package ${packageId}.`, error);
});
return () => controller.abort();
}, [encounter?.assetPackageId, gameMode, nextEncounterId]);
}, [encounter?.assetPackageId, gameMode, nextEncounterId, status]);
useEffect(() => useCombatStore.subscribe((state, previous) => {
if (
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from "vitest";
import type { OnlineSharedWorld } from "../app/onlineSessionTypes";
import {
activateDungeonEncounterDefinitions,
dungeonEncounterSnapshot,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import { applySharedWorld } from "./OnlineSessionBridge";
import { useGameStore } from "./store";
import {
WAILING_NARALEX_ENCOUNTER,
useWailingEncounterStore,
} from "./wailingCavernsEncounter";
const EMPTY_SHARED_WORLD = {
dungeonId: "wailing-caverns",
mobs: {},
mobTransforms: {},
partyMembers: [],
partyPositions: {},
partyRuntime: {
command: "defend",
activeMobId: null,
objectiveBossId: null,
memberTargetIds: {},
},
wailing: null,
manastorm: null,
} as const satisfies OnlineSharedWorld;
describe("online shared-world hydration", () => {
afterEach(() => {
useWailingEncounterStore.getState().reset("dungeon", 0);
useGameStore.getState().activateDungeon("wailing-caverns");
});
it("accepts an authority snapshot older than a follower's local revision", () => {
activateDungeonEncounterDefinitions(
"wailing-caverns",
[WAILING_NARALEX_ENCOUNTER],
"normal",
);
useGameStore.getState().activateDungeon("wailing-caverns");
const authorityBase = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
resetDungeonEncounters("dungeon", "wailing-caverns");
const follower = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const authority = {
...authorityBase,
phase: "ready",
revision: Math.max(0, follower.revision - 1),
} as const;
expect(authority.revision).toBeLessThan(follower.revision);
applySharedWorld({ ...EMPTY_SHARED_WORLD, encounters: [authority] });
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
phase: "ready",
revision: authority.revision,
});
});
});
+2 -2
View File
@@ -87,7 +87,7 @@ function sharedWorld(): OnlineSharedWorld {
};
}
function applySharedWorld(world: OnlineSharedWorld): void {
export function applySharedWorld(world: OnlineSharedWorld): void {
useCombatStore.setState({ mobs: world.mobs });
usePartyStore.setState({
members: world.partyMembers,
@@ -103,7 +103,7 @@ function applySharedWorld(world: OnlineSharedWorld): void {
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
const activeDungeonId = useGameStore.getState().activeDungeonId;
if (world.dungeonId === activeDungeonId && world.encounters) {
importDungeonEncounterSnapshots(activeDungeonId, world.encounters);
importDungeonEncounterSnapshots(activeDungeonId, world.encounters, { authoritative: true });
}
}
@@ -0,0 +1,340 @@
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterAction,
DungeonEncounterGuard,
DungeonEncounterTrigger,
} from "./dungeonEncounterTypes";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import type { DungeonDefinition } from "./dungeonTypes";
import {
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE,
withAscensionCustomEncounterContent,
} from "./ascensionCustomEncounterCatalog";
const CUSTOM_DUNGEON_IDS = [
"frozen-reach",
"forgotten-mine",
"tinkertech-showdown",
] as const;
function adaptedDungeon(id: typeof CUSTOM_DUNGEON_IDS[number]): DungeonDefinition {
const source = DUNGEON_REGISTRY[id];
expect(source, id).toBeDefined();
return withAscensionCustomEncounterContent(source);
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
...(definition.encounterSpawnGroups ?? []).flatMap((group) => group.spawns.map((spawn) => spawn.id)),
];
}
function expectTriggerReferences(
trigger: DungeonEncounterTrigger,
spawnIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
): void {
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
}
if (trigger.kind === "entities-dead") {
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
}
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
}
function expectGuardReferences(
guard: DungeonEncounterGuard,
groupIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
objectiveIds: ReadonlySet<string>,
): void {
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
}
function expectActionReferences(
action: DungeonEncounterAction,
groupIds: ReadonlySet<string>,
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
objectiveIds: ReadonlySet<string>,
): void {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
}
if (action.kind === "set-world-object-state") {
const object = objectsById.get(action.objectId);
expect(object, `action object ${action.objectId}`).toBeDefined();
expect(object?.states.map((state) => state.id), `object state ${action.objectId}:${action.state}`)
.toContain(action.state);
}
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
}
describe("Ascension custom encounter content", () => {
it("attaches idempotently with no dangling or duplicate static/runtime references", () => {
expect(ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => entry.dungeonId).sort())
.toEqual([...CUSTOM_DUNGEON_IDS].sort());
for (const id of CUSTOM_DUNGEON_IDS) {
const definition = adaptedDungeon(id);
expect(withAscensionCustomEncounterContent(definition)).toBe(definition);
const spawnIdList = runtimeSpawnIds(definition);
const spawnIds = new Set(spawnIdList);
expect(spawnIds.size, `${id} duplicate runtime spawn ids`).toBe(spawnIdList.length);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
const objectiveIds = new Set([
...definition.bosses.map((objective) => objective.id),
...definition.objectiveOrder.map((objective) => objective.id),
]);
for (const spawn of definition.staticSpawns) {
expect(definition.entities[spawn.entityId], `${id} static ${spawn.id}`).toBeDefined();
}
for (const group of definition.encounterSpawnGroups ?? []) {
for (const spawn of group.spawns) {
expect(definition.entities[spawn.entityId], `${id} group ${group.id}:${spawn.id}`).toBeDefined();
}
}
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
for (const encounter of definition.encounters ?? []) {
validateDungeonEncounterDefinition(encounter);
expect(encounter.dungeonId).toBe(id);
for (const rule of encounter.rules) {
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
for (const guard of rule.guards ?? []) {
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
}
for (const action of rule.actions) {
expectActionReferences(action, groupIds, objectsById, objectiveIds);
}
}
}
}
});
it("removes Frozen Reach placeholder trash and drives the Arktos-to-vengeance lifecycle", () => {
const definition = adaptedDungeon("frozen-reach");
expect(definition.staticSpawns.map((spawn) => spawn.id)).toEqual(["encounter-2168"]);
expect(definition.entities["frozen-reach-guardian"]).toBeUndefined();
expect(definition.entities["frozen-reach-caster"]).toBeUndefined();
expect(definition.entities["frozen-reach-brute"]).toBeUndefined();
expect(definition.entities["entry-97700"]).toMatchObject({
name: "Alva",
combat: { serverEntry: 97_700 },
});
expect(definition.entities["frozen-reach:arktos"]).toMatchObject({
name: "Arktos",
combat: { serverEntry: 97_701 },
});
const alvaSpellIds = definition.entities["entry-97700"].combat?.attacks
.flatMap((entry) => entry.spellId ?? []);
expect(alvaSpellIds).toEqual(expect.arrayContaining([
255_347,
255_349,
255_352,
255_358,
255_363,
255_394,
255_397,
255_410,
255_419,
255_421,
]));
const encounter = definition.encounters?.find((entry) => entry.id === "frozen-reach:alva-and-arktos");
expect(encounter?.source?.fidelity).toBe("source-backed-adapted");
const arktosGroup = definition.encounterSpawnGroups
?.find((group) => group.id === "frozen-reach:arktos-attempt");
expect(arktosGroup?.spawns).toMatchObject([{
id: "frozen-reach:arktos",
entityId: "frozen-reach:arktos",
}]);
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "tick",
now: 1,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "duo-active",
activeSpawnGroupIds: ["frozen-reach:arktos-attempt"],
});
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-death",
entityId: "frozen-reach:arktos",
now: 1_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "vengeance",
flags: { "arktos-defeated": true },
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
});
const addIds = definition.encounterSpawnGroups
?.find((group) => group.id === "frozen-reach:rebirth-alvajar")
?.spawns.map((spawn) => spawn.id) ?? [];
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "tick",
now: 2_000,
facts: { registeredEntityIds: addIds, deadEntityIds: addIds },
}).snapshot;
expect(snapshot.phase).toBe("vengeance");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
expect(reset).toMatchObject({
phase: "duo-active",
flags: { "arktos-defeated": false },
activeSpawnGroupIds: [],
});
snapshot = reduceDungeonEncounter(encounter!, reset, {
kind: "tick",
now: 3_001,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["frozen-reach:arktos-attempt"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-death",
entityId: "frozen-reach:arktos",
now: 4_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "vengeance",
flags: { "arktos-defeated": true },
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
});
});
it("runs Vek's Geospike, Resonance, and repeatable Resonite shard lifecycle", () => {
const definition = adaptedDungeon("forgotten-mine");
expect(definition.entities["entry-254272"]).toMatchObject({
name: "Geolord Vek",
combat: { serverEntry: 254_272 },
});
expect(definition.entities["forgotten-mine-guardian"].name).toBe("Forgotten Mine Stoneguard");
expect(definition.entities["forgotten-mine-caster"].name).toBe("Forgotten Mine Geomancer");
expect(definition.entities["forgotten-mine-brute"].name).toBe("Forgotten Mine Quarry Brute");
expect(definition.entities["entry-254272"].combat?.attacks.flatMap((entry) => entry.spellId ?? []))
.toEqual(expect.arrayContaining([354_000, 354_001, 354_002, 354_004, 354_013, 354_015, 354_017]));
const shards = definition.serverGameObjects.filter((object) => object.source?.templateId === 254_051);
expect(shards).toHaveLength(3);
for (const shard of shards) {
expect(shard).toMatchObject({
kind: "resource",
halfExtents: [2.25, 2.4, 2.25],
interaction: { range: 4.5, allowedStates: ["ready"] },
source: { fidelity: "source-backed-adapted", templateId: 254_051 },
});
}
const encounter = definition.encounters?.find((entry) => entry.id === "forgotten-mine:geolord-vek");
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "signal",
signalId: "forgotten-mine:geospike-cast",
now: 100,
}).snapshot;
expect(snapshot.phase).toBe("pre-resonance-geospike");
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_599 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_600 }).snapshot;
expect(snapshot.phase).toBe("boss-active");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-5069",
healthPercent: 50,
now: 4_000,
}).snapshot;
expect(snapshot.phase).toBe("resonance-active");
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "interact",
objectId: "forgotten-mine:resonite-shard-1",
now: 5_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "resonance-cleansed",
counters: { "resonite-cleanses": 1 },
worldObjectStates: { "forgotten-mine:resonite-shard-1": "consumed" },
});
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 12_999 }).snapshot;
expect(snapshot.phase).toBe("resonance-cleansed");
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 13_000 }).snapshot;
expect(snapshot.phase).toBe("resonance-recharge");
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 35_000 }).snapshot;
expect(snapshot.phase).toBe("resonance-active");
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "interact",
objectId: "forgotten-mine:resonite-shard-1",
now: 36_000,
}).snapshot;
expect(snapshot.counters["resonite-cleanses"]).toBe(2);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 37_000);
expect(reset.phase).toBe("boss-active");
expect(Object.values(reset.worldObjectStates)).toEqual(["dormant", "dormant", "dormant"]);
expect(reset.counters["resonite-cleanses"]).toBe(0);
});
it("keeps Tinkertech explicitly authored and switches Mechatron to faster overdrive with reinforcements", () => {
const definition = adaptedDungeon("tinkertech-showdown");
expect(definition.entities["entry-663012"]).toMatchObject({ name: "Mechatron", combat: { serverEntry: 663_012 } });
expect(definition.entities["tinkertech-showdown-guardian"].name).toBe("Tinkertech Guardbot");
expect(definition.entities["tinkertech-showdown-caster"].name).toBe("Tinkertech Coilcaster");
expect(definition.entities["tinkertech-showdown-brute"].name).toBe("Tinkertech Demolisher");
const review = ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.find((entry) => entry.dungeonId === definition.id)!;
expect(review.fidelity).toBe("healer-man-authored");
expect(review.exactSpellFamilies).toEqual({});
expect(review.candidateUnboundSpellFamilies).toHaveProperty("gatlingMissileBarrage");
const attacks = definition.entities["entry-663012"].combat?.attacks ?? [];
for (const spellId of [257_438, 257_459, 257_470, 257_472]) {
const normal = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-normal"));
const overdrive = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-overdrive"));
expect(normal, `normal spell ${spellId}`).toBeDefined();
expect(overdrive?.cooldownMs, `overdrive spell ${spellId}`).toBe(Math.round(normal!.cooldownMs * 0.8));
}
const encounter = definition.encounters?.find((entry) => entry.id === "tinkertech-showdown:mechatron");
expect(encounter?.source?.fidelity).toBe("healer-man-authored");
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
expect(reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-9013",
healthPercent: 36,
now: 1_000,
}).snapshot).toBe(snapshot);
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
kind: "entity-health",
entityId: "encounter-9013",
healthPercent: 35,
now: 2_000,
}).snapshot;
expect(snapshot).toMatchObject({
phase: "overdrive",
flags: { overdrive: true },
activeSpawnGroupIds: ["tinkertech-showdown:overdrive-reinforcements"],
});
const reinforcements = definition.encounterSpawnGroups
?.find((group) => group.id === "tinkertech-showdown:overdrive-reinforcements");
expect(reinforcements?.spawns).toHaveLength(2);
expect(reinforcements?.spawns.every((spawn) => spawn.entityId === "tinkertech-showdown-caster"))
.toBe(true);
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
expect(reset).toMatchObject({ phase: "active", flags: { overdrive: false }, activeSpawnGroupIds: [] });
});
});
File diff suppressed because it is too large. Load diff
+8
View File
@@ -185,5 +185,13 @@ describe("boss guide model", () => {
});
const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!;
expect(flowingChaos.mechanics[0].fidelityLabel).toBe("HealerMan fallback");
const theSeven = guideByDungeon.get("blackrock-depths")!.bosses.find((boss) => boss.name === "The Seven")!;
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "blackrock-depths")
?.entities[theSeven.entityId!].combat?.serverEntry).toBe(9_039);
const escape = guideByDungeon.get("halls-of-reflection")!.bosses
.find((boss) => boss.name === "Escape from the Lich King")!;
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "halls-of-reflection")
?.entities[escape.entityId!].combat?.serverEntry).toBe(36_954);
expect(escape.mechanics.length).toBeGreaterThan(0);
});
});
+58 -9
View File
@@ -1,4 +1,4 @@
import type { DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
import {
normalizeEnemyAttack,
type EnemyAbilityCondition,
@@ -114,6 +114,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "aura") {
if (effect.meleeAutoCriticalProc) {
const proc = effect.meleeAutoCriticalProc;
return `Applies ${titleCase(effect.aura)} to ${target}; successful main-hand autos have a ${Math.round(proc.chance * 100)}% chance to grant ${proc.charges} charge${proc.charges === 1 ? "" : "s"} of +${proc.criticalChancePercent}% critical chance to the next main-hand auto for ${seconds(proc.durationMs)}`;
}
const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`;
}
@@ -121,7 +125,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "control") {
return `${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
const damage = effect.damageMultiplier === undefined
? ""
: `${titleCase(attack.school)} damage (${Number(effect.damageMultiplier.toFixed(2))}× boss power) and `;
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
}
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
@@ -271,18 +278,25 @@ function normalizedName(value: string): string {
function bossEntityForObjective(
dungeon: DungeonDefinition,
objectiveId: string,
objectiveName: string,
objective: DungeonBossObjective,
): readonly [string, PopulationEntityDefinition] | undefined {
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objectiveId);
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objective.id);
if (staticSpawn) {
const entity = dungeon.entities[staticSpawn.entityId];
if (entity) return [staticSpawn.entityId, entity];
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
const spawn = group.spawns.find((candidate) => candidate.id === objective.id);
if (spawn) {
const entity = dungeon.entities[spawn.entityId];
if (entity) return [spawn.entityId, entity];
}
}
for (const pack of dungeon.roamingPacks) {
const member = pack.members.find((candidate) => (
`${pack.id}:${candidate.id}` === objectiveId || candidate.id === objectiveId
`${pack.id}:${candidate.id}` === objective.id || candidate.id === objective.id
));
if (member) {
const entity = dungeon.entities[member.entityId];
@@ -290,11 +304,46 @@ function bossEntityForObjective(
}
}
const wanted = normalizedName(objectiveName);
return Object.entries(dungeon.entities).find(([, entity]) => (
const wanted = normalizedName(objective.name);
const named = Object.entries(dungeon.entities).find(([, entity]) => (
entity.kind === "boss"
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
));
if (named) return named;
if (objective.eventOnly) {
const related = Object.entries(dungeon.entities)
.filter(([, entity]) => {
const entityName = normalizedName(entity.name);
return entityName.length >= 5 && (wanted.includes(entityName) || entityName.includes(wanted));
})
.sort((left, right) => {
const leftBoss = left[1].kind === "boss" ? 1 : 0;
const rightBoss = right[1].kind === "boss" ? 1 : 0;
if (leftBoss !== rightBoss) return rightBoss - leftBoss;
const leftMechanics = left[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
const rightMechanics = right[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
return rightMechanics - leftMechanics || left[0].localeCompare(right[0]);
});
if (related[0]) return related[0];
}
// Aggregate objectives such as Blackrock Depths' "The Seven" use a
// synthetic objective id but retain the final source actor's exact position.
// That representative may be typed as a mob because the objective covers a
// multi-actor event rather than one loot-bearing boss.
for (const group of dungeon.encounterSpawnGroups ?? []) {
const exact = group.spawns.filter((candidate) => {
const dx = candidate.position[0] - objective.position[0];
const dy = candidate.position[1] - objective.position[1];
const dz = candidate.position[2] - objective.position[2];
return dx * dx + dy * dy + dz * dz <= 0.25 * 0.25;
});
const spawn = exact.find((candidate) => dungeon.entities[candidate.entityId]?.kind === "boss")
?? exact.find((candidate) => dungeon.entities[candidate.entityId] !== undefined);
if (spawn) return [spawn.entityId, dungeon.entities[spawn.entityId]];
}
return undefined;
}
function bossGuide(
@@ -325,7 +374,7 @@ export function buildDungeonBossGuide(
): DungeonBossGuide {
const representedEntityIds = new Set<string>();
const objectiveBosses = dungeon.bosses.map((objective) => {
const resolved = bossEntityForObjective(dungeon, objective.id, objective.name);
const resolved = bossEntityForObjective(dungeon, objective);
if (resolved) representedEntityIds.add(resolved[0]);
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
});
+9 -3
View File
@@ -59,12 +59,18 @@ describe("named aura combat runtime", () => {
});
it("grants learned long-term stat buffs to the full party on load", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
level: 8,
inventory: [],
equipment: {},
});
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
useCombatStore.getState().synchronizeActors();
const playerMaxHealth = useCombatStore.getState().maxHealth;
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState();
@@ -75,7 +81,7 @@ describe("named aura combat runtime", () => {
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
}));
}
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
expect(state.maxHealth).toBe(437);
for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
}
+1 -1
View File
@@ -150,7 +150,7 @@ export interface AuraDefinition {
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
readonly utilityTags?: readonly string[];
/** Enemy control mechanics ignored while this aura is active. */
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "stun" | "silence")[];
/** Chance from 0 through 1 to reflect an incoming spell. */
readonly reflectSpellChance?: number;
/** Portion of incoming target damage transferred to the aura source. */
+14
View File
@@ -237,6 +237,20 @@ describe("combat store", () => {
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana);
});
it("gives a geared level-eight Priest the expanded live-game mana pool", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
level: 8,
inventory: [],
equipment: {},
});
expect(useCombatStore.getState()).toMatchObject({
resource: 3_285,
maxResource: 3_285,
});
});
it("uses equipped spell power, stamina, and armor in player combat", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
const baseMaxHealth = useCombatStore.getState().maxHealth;
+192 -41
View File
@@ -179,6 +179,7 @@ import {
type EnemyAttackDefinition,
type EnemyControlMechanic,
type EnemyDamageSchool,
type EnemyMeleeAutoCriticalProc,
} from "./enemyAttacks";
import {
creatureRuntimeBaseStats,
@@ -294,6 +295,11 @@ export interface MobAura {
readonly sourceAbilityId: string;
readonly endsAt: number;
readonly magnitude?: number;
/** Persistent driver for a content-authored main-hand auto-attack proc. */
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
/** A charged modifier consumed by a successful main-hand auto attack. */
readonly nextMeleeCriticalChancePercent?: number;
readonly charges?: number;
}
export interface MobMagicResistances {
@@ -715,7 +721,10 @@ export interface CombatState {
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
tick: (deltaSeconds: number, now?: number) => void;
resetEncounter: () => void;
resetEncounter: (options?: {
readonly preserveDefeated?: boolean;
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
return "spell";
}
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
return attack.id === DEFAULT_ENEMY_ATTACK.id
&& attack.delivery === "melee"
&& attack.animation === "attack"
&& (attack.castTimeMs ?? 0) === 0;
}
function enemyMeleeCriticalCharge(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
): MobAura | undefined {
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
return mob.auras.find((aura) => (
(aura.endsAt <= 0 || aura.endsAt > now)
&& (aura.charges ?? 0) > 0
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
));
}
function advanceEnemyMeleeAutoProcAuras(
mob: MobCombatState,
attack: EnemyAttackDefinition,
now: number,
attackRevision: number,
landed: boolean,
consumedAuraId?: string,
): readonly MobAura[] {
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
let changed = false;
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
if (aura.id !== consumedAuraId) return [aura];
changed = true;
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
return charges > 0 ? [{ ...aura, charges }] : [];
});
for (const driver of mob.auras) {
const proc = driver.meleeAutoCriticalProc;
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
if (
chance <= 0
|| stableCombatUnit(
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
) >= chance
) {
continue;
}
const triggeredAura = proc.triggeredAura.trim();
const criticalChancePercent = Math.max(
0,
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
);
const charges = Math.max(1, Math.round(
Number.isFinite(proc.charges) ? proc.charges : 1,
));
if (!triggeredAura || criticalChancePercent <= 0) continue;
nextAuras = [
...nextAuras.filter((aura) => aura.id !== triggeredAura),
{
id: triggeredAura,
sourceAbilityId: driver.sourceAbilityId,
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
nextMeleeCriticalChancePercent: criticalChancePercent,
charges,
},
];
changed = true;
}
return changed ? nextAuras : mob.auras;
}
function resolveEnemyDamageResult(
mob: MobCombatState,
attack: EnemyAttackDefinition,
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
actorId: AggroActorId,
attackRevision: number,
now: number,
bonusCriticalChancePercent = 0,
): CombatResult {
const attackKind = enemyAttackKind(attack);
return resolveCombatResult({
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
damageSchool: attack.school,
attacker: {
level: mob.level,
criticalChancePercent: 5,
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
},
defender: combatDefenderFromCharacterStats(defender),
rules: {
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
const mob = state.mobs[id];
return Boolean(mob && !mob.dead);
const position = getMobPosition(id);
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
});
}
@@ -6603,6 +6689,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
sourceAbilityId: attack.id,
endsAt,
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
...(effect.meleeAutoCriticalProc === undefined
? {}
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
},
];
namedAuras = [...applyCombatAura(namedAuras, {
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}).auras];
}
} else if (effect.kind === "control") {
if ((effect.damageMultiplier ?? 0) > 0) {
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
const attackDamage = attack.runewakerFormula
? adaptRunewakerEnemyDamage({
formula: attack.runewakerFormula,
baselineDamage: mob.attackDamage,
}).amount
: baselineDamage;
for (const actorId of targets) {
incomingHits.push({
actorId,
amount: attackDamage,
mob,
attack,
attackRevision,
});
}
}
for (const actorId of targets) {
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
continue;
}
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
const commitMeleeAutoProc = (landed: boolean): void => {
const current = get();
const currentMob = current.mobs[hit.mob.id];
if (!currentMob) return;
const auras = advanceEnemyMeleeAutoProcAuras(
currentMob,
hit.attack,
now,
hit.attackRevision,
landed,
meleeCriticalCharge?.id,
);
if (auras === currentMob.auras) return;
set({
mobs: {
...current.mobs,
[currentMob.id]: { ...currentMob, auras },
},
});
};
if (hit.actorId === PLAYER_AGGRO_ID) {
const current = get();
const defender = applyAuraStats(
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
@@ -6984,8 +7116,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
if (summon) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, summon.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: summon.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: applied,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: applied > 0 ? "hit" : "absorb",
critical,
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += applied;
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
if (onlineActor) {
const current = get();
const critical = bonusCriticalChancePercent > 0;
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
commitMeleeAutoProc(true);
const adjusted = resolveAuraDamageValue(
hit.amount,
incomingAmount,
aurasForEntity(current.auras, onlineActor.id),
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
);
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
targetActorId: onlineActor.id,
abilityId: hit.attack.id,
school: hit.attack.school,
rawAmount: hit.amount,
rawAmount: incomingAmount,
effectiveAmount: damaged,
overheal: 0,
absorbed: absorbed.absorbed,
critical: false,
outcome: damaged > 0 ? "hit" : "absorb",
critical,
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
}),
});
appliedDamage += damaged;
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
hit.actorId,
hit.attackRevision,
now,
bonusCriticalChancePercent,
);
commitMeleeAutoProc(combatResult.landed);
const currentAuras = get().auras;
const auraAdjustedDamage = resolveAuraDamageValue(
combatResult.finalDamage,
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
},
resetEncounter: () => set((state) => {
resetEncounter: (options = {}) => set((state) => {
resetCombatPresentationSession();
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
for (const mob of Object.values(state.mobs)) {
if (mob.summonedBy) removeMobPosition(mob.id);
}
const mobs = Object.fromEntries(Object.entries(state.mobs)
.filter(([, mob]) => !mob.summonedBy)
.map(([id, mob]) => [id, {
...mob,
health: mob.maxHealth,
dead: false,
pendingLoot: [],
looted: false,
despawned: false,
diedAt: null,
corpseDespawnAt: null,
experienceGranted: false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}]));
.map(([id, mob]) => {
const preserveDefeated = mob.dead && (
options.preserveDefeated === true || preserveDefeatedIds.has(id)
);
return [id, {
...mob,
health: preserveDefeated ? 0 : mob.maxHealth,
dead: preserveDefeated,
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
looted: preserveDefeated ? mob.looted : false,
despawned: preserveDefeated ? mob.despawned : false,
diedAt: preserveDefeated ? mob.diedAt : null,
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
statuses: [],
controlDiminishingReturns: {},
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
auras: [],
threatByActor: {},
targetActorId: null,
forcedTarget: null,
lastDamageAt: 0,
engaged: false,
combatPhase: "idle" as const,
homePosition: null,
nextAttackAt: 0,
abilityReadyAt: {},
abilityCastCounts: {},
activeCast: null,
schoolLockedUntil: {},
nextWoundAnimationAt: 0,
}] as const;
}));
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
const maxHealth = auraAdjustedPlayerMaxHealth(
state.classId,
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
combatEvents: [],
actors: {},
lastCombatResult: null,
feedback: nextFeedback("success", "The encounter has been reset."),
feedback: nextFeedback(
"success",
options.preserveDefeated || preserveDefeatedIds.size > 0
? "The current pull has been reset; defeated enemies remain cleared."
: "The encounter has been reset.",
),
};
}),
+38 -6
View File
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { useCombatStore } from "./combatStore";
import { respawnDefeatedPlayer } from "./deathRespawn";
@@ -15,13 +14,22 @@ describe("death respawn", () => {
useWailingEncounterStore.getState().reset();
});
it("atomically restores the player and encounter at the Wailing Caverns entrance", () => {
it("resets only the current pull and preserves cleared enemies and run progress", () => {
const startingResource = useCombatStore.getState().maxResource;
const checkpointSpawn = { footPosition: [4, -70, 12] as const, yaw: 0.25 };
useGameStore.getState().relocatePlayer(checkpointSpawn, true);
useGameStore.getState().updatePlayerSnapshot({ position: [10, -50, 20], yaw: 0.5, distanceDelta: 30 });
useGameStore.getState().setCameraLookActive(true);
useCombatStore.getState().registerMob("respawn-pack:a", { name: "Ravager", maxHealth: 100 });
useCombatStore.getState().registerMob("cleared-boss", { name: "Cleared Boss", maxHealth: 50, boss: true });
useCombatStore.getState().registerMob("naralex-event:ravager:1", {
name: "Event Ravager",
maxHealth: 75,
});
setMobPosition("respawn-pack:a", [11, -50, 20]);
useCombatStore.getState().damageMob("cleared-boss", 50, 900);
useCombatStore.getState().damageMob("naralex-event:ravager:1", 75, 950);
useCombatStore.getState().selectMob("respawn-pack:a");
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
@@ -30,14 +38,25 @@ describe("death respawn", () => {
);
const revisionBefore = useGameStore.getState().resetRevision;
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
useWailingEncounterStore.setState({
phase: "mutanus",
phaseStartedAt: 5_000,
flags: { "dungeon-complete": true },
completedObjectiveIds: ["mutanus-spawn"],
consumedActionIds: [
"complete-mutanus-objective",
"save-mutanus-checkpoint",
"mark-wailing-complete",
],
checkpointId: "mutanus-spawn",
});
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth);
expect(useCombatStore.getState().health).toBe(0);
expect(respawnDefeatedPlayer()).toBe(true);
expect(useGameStore.getState()).toMatchObject({
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
cameraYaw: DUNGEON_MANIFEST.entrance.yaw,
playerPosition: checkpointSpawn.footPosition,
cameraYaw: checkpointSpawn.yaw,
cameraLookActive: false,
resetRevision: revisionBefore + 1,
});
@@ -45,16 +64,29 @@ describe("death respawn", () => {
health: useCombatStore.getState().maxHealth,
resource: startingResource,
selectedTargetId: null,
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
playerPosition: checkpointSpawn.footPosition,
});
expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({
health: 100,
dead: false,
engaged: false,
});
expect(useCombatStore.getState().mobs["cleared-boss"]).toMatchObject({
health: 0,
dead: true,
engaged: false,
});
expect(useCombatStore.getState().mobs["naralex-event:ravager:1"]).toMatchObject({
health: 75,
dead: false,
engaged: false,
});
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "locked",
phaseStartedAt: 0,
flags: { "dungeon-complete": true },
completedObjectiveIds: ["mutanus-spawn"],
checkpointId: "mutanus-spawn",
});
expect(respawnDefeatedPlayer()).toBe(false);
+26 -9
View File
@@ -1,23 +1,40 @@
import { useCombatStore } from "./combatStore";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { useGameStore } from "./store";
import { resetDungeonEncounters } from "./dungeonEncounterRuntime";
import {
activeDungeonCompletedObjectiveIds,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
/**
* Atomically returns a defeated player to the dungeon entrance and restores
* the encounter. Keeping this outside the render tree makes the behavior easy
* to verify and prevents a frame where the camera resets but combat stays dead.
* Atomically returns a defeated player to the saved run spawn and resets the
* current pull. Defeated enemies and persisted dungeon progress remain clear,
* matching a wipe instead of silently starting a brand-new dungeon.
*/
export function respawnDefeatedPlayer(): boolean {
const combat = useCombatStore.getState();
if (combat.health > 0) return false;
const dungeonId = useGameStore.getState().activeDungeonId;
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
const retrySpawnIds = new Set(
(requireDungeonDefinition(dungeonId).encounterSpawnGroups ?? [])
.flatMap((group) => group.spawns.map((spawn) => spawn.id)),
);
const preserveDefeatedIds = Object.values(combat.mobs)
.filter((mob) => (
mob.dead
&& (!retrySpawnIds.has(mob.id) || completedObjectiveIds.has(mob.id))
))
.map((mob) => mob.id);
if (typeof document !== "undefined" && document.pointerLockElement) {
document.exitPointerLock();
}
useGameStore.getState().resetAtEntrance();
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
const entrance = useGameStore.getState().playerPosition;
combat.resetEncounter();
useCombatStore.getState().setPlayerPosition(entrance);
useGameStore.getState().resetAfterDefeat();
resetDungeonEncounters("boss-wipe", dungeonId);
const respawnPosition = useGameStore.getState().playerPosition;
combat.resetEncounter({ preserveDefeatedIds });
useCombatStore.getState().setPlayerPosition(respawnPosition);
return true;
}
+369 -3
View File
@@ -1,9 +1,18 @@
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
import generated from "./generated/dungeonCampaignCatalog.json";
import manastormPackages from "./generated/manastormAssetPackages.json";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
const SOURCE_NATIVE_DUNGEON_IDS = [
"ahnkahet-old-kingdom",
@@ -77,6 +86,313 @@ describe("generated dungeon campaign catalog", () => {
}
});
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
for (const dungeon of DUNGEON_DEFINITIONS) {
for (const object of dungeon.serverGameObjects) {
expect(() => validateDungeonWorldObject(object), `${dungeon.id}:${object.id}`).not.toThrow();
}
}
});
it("gives every WoW dungeon executable persisted boss progression", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
expect(wowDungeons).toHaveLength(54);
for (const dungeon of wowDungeons) {
const objectiveIds = dungeon.objectiveOrder.map((objective) => objective.id);
expect(new Set(objectiveIds).size, `${dungeon.id}:duplicate-objective-id`).toBe(objectiveIds.length);
const encounters = dungeon.encounters ?? [];
for (const encounter of encounters) {
expect(() => validateDungeonEncounterDefinition(encounter), `${dungeon.id}:${encounter.id}`).not.toThrow();
}
const allCompletedObjectiveIds = new Set(encounters.flatMap((encounter) => (
encounter.rules.flatMap((rule) => rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
)))
)));
for (const objective of dungeon.objectiveOrder) {
expect(allCompletedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
}
const progression = dungeon.encounters?.find(
(encounter) => encounter.source?.scope === "boss-progression",
) as DungeonEncounterDefinition | undefined;
const instanceCompletion = encounters.find((encounter) => (
encounter.source?.scope === "instance-orchestration"
&& encounter.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
const authority = progression ?? instanceCompletion;
expect(authority, dungeon.id).toBeDefined();
expect(authority!.source?.fidelity, dungeon.id).toBe(
dungeon.id === "tinkertech-showdown"
? "healer-man-authored"
: "source-backed-adapted",
);
if (!progression) {
expect(instanceCompletion?.source?.scope, dungeon.id).toBe("instance-orchestration");
const completedObjectiveIds = new Set(instanceCompletion!.rules.flatMap((rule) => (
rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
))
)));
for (const objective of dungeon.objectiveOrder.filter((candidate) => !candidate.optional)) {
expect(completedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
}
continue;
}
const progressionObjectiveIds = new Set(progression.rules.flatMap((rule) => (
rule.actions.flatMap((action) => (
action.kind === "complete-objective" ? [action.objectiveId] : []
))
)));
const runtimeBosses = dungeon.objectiveOrder.filter((objective) => (
progressionObjectiveIds.has(objective.id)
));
let snapshot = createDungeonEncounterSnapshot(progression, 1_000);
const deadEntityIds: string[] = [];
for (const boss of runtimeBosses) {
deadEntityIds.push(boss.id);
snapshot = reduceDungeonEncounter(progression, snapshot, {
kind: "tick",
now: 1_000 + deadEntityIds.length * 100,
facts: {
registeredEntityIds: runtimeBosses.map((objective) => objective.id),
deadEntityIds,
},
}).snapshot;
expect(snapshot.completedObjectiveIds, `${dungeon.id}:${boss.id}`).toContain(boss.id);
}
const checkpoint = resetDungeonEncounterSnapshot(progression, snapshot, "checkpoint", 5_000);
expect(checkpoint.completedObjectiveIds, dungeon.id)
.toEqual(expect.arrayContaining(runtimeBosses.map((boss) => boss.id)));
const completesFromObjectivesAlone = progression.rules.some((rule) => (
rule.actions.some((action) => (
action.kind === "set-flag" && action.flagId === "dungeon-complete" && action.value
))
&& (rule.guards ?? []).every((guard) => guard.kind === "objective-complete")
));
if (!completesFromObjectivesAlone) {
// A full source adapter may own completion inside a separate encounter;
// its focused lifecycle suite exercises that path.
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
} else {
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
expect(snapshot.phase, dungeon.id).toBe("complete");
expect(checkpoint.flags["dungeon-complete"], dungeon.id).toBe(true);
}
}
});
it("has an instance-level source review for every campaign dungeon", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
const verifiedNoOps: string[] = [];
for (const dungeon of wowDungeons) {
const reviewed = (dungeon.encounters ?? []).filter(
(encounter) => encounter.source?.scope === "instance-orchestration",
);
expect(reviewed.length, dungeon.id).toBeGreaterThan(0);
for (const encounter of reviewed) {
expect(encounter.source?.files.length, `${dungeon.id}:${encounter.id}:files`).toBeGreaterThan(0);
expect(encounter.source?.reviewedBehavior.trim(), `${dungeon.id}:${encounter.id}:review`).not.toBe("");
if (!encounter.rules.length) {
expect(encounter.source?.fidelity, `${dungeon.id}:${encounter.id}`).toBe("source-exact");
expect(encounter.source?.adaptations, `${dungeon.id}:${encounter.id}`).toEqual([]);
expect(encounter.source?.remainingGaps, `${dungeon.id}:${encounter.id}`).toEqual([]);
verifiedNoOps.push(dungeon.id);
}
}
}
expect(wowDungeons).toHaveLength(54);
expect(verifiedNoOps.sort()).toEqual([
"ragefire-chasm",
"razorfen-kraul",
"stormwind-stockade",
]);
});
it("gates every required event-only objective behind an instance completion authority", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
for (const dungeon of wowDungeons) {
const requiredEvents = dungeon.objectiveOrder.filter(
(objective) => objective.eventOnly && !objective.optional,
);
if (!requiredEvents.length) continue;
const progression = dungeon.encounters?.find(
(encounter) => encounter.source?.scope === "boss-progression",
);
const baselineGatedObjectiveIds = new Set((progression?.rules ?? []).flatMap((rule) => (
rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
))
? (rule.guards ?? []).flatMap((guard) => (
guard.kind === "objective-complete" && guard.equals ? [guard.objectiveId] : []
))
: []
)));
const completionAuthorities = (dungeon.encounters ?? []).filter((encounter) => (
encounter.source?.scope === "instance-orchestration"
&& encounter.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
for (const objective of requiredEvents) {
expect(
baselineGatedObjectiveIds.has(objective.id) || completionAuthorities.length > 0,
`${dungeon.id}:${objective.name}`,
).toBe(true);
if (progression && completionAuthorities.length) {
const completionRules = completionAuthorities.flatMap((encounter) => (
encounter.rules.filter((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)))
));
expect(completionRules.length, `${dungeon.id}:completion-authority`).toBeGreaterThan(0);
expect(completionRules.every((rule) => (
(rule.guards ?? []).some((guard) => (
guard.kind === "objective-complete"
&& guard.objectiveId === objective.id
&& guard.equals
))
|| rule.actions.some((action) => (
action.kind === "complete-objective" && action.objectiveId === objective.id
))
)), `${dungeon.id}:${objective.name}:premature-completion`).toBe(true);
}
}
}
});
it("only uses difficulty guards that match declared dungeon variants", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
));
for (const dungeon of wowDungeons) {
const difficultyIds = new Set(dungeon.difficultyVariants.map((variant) => variant.id));
for (const encounter of dungeon.encounters ?? []) {
for (const rule of encounter.rules) {
for (const guard of rule.guards ?? []) {
if (guard.kind !== "difficulty") continue;
expect(guard.difficultyIds.length, `${dungeon.id}:${rule.id}`).toBeGreaterThan(0);
for (const difficultyId of guard.difficultyIds) {
expect(difficultyIds.has(difficultyId), `${dungeon.id}:${rule.id}:${difficultyId}`).toBe(true);
}
}
}
}
}
});
it("completes ordinary dungeons regardless of required boss kill order", () => {
const dungeon = requireDungeonDefinition("deadmines");
const progression = dungeon.encounters?.find(
(encounter) => encounter.id === "deadmines:boss-progression",
);
expect(progression).toBeDefined();
const required = dungeon.objectiveOrder.filter((objective) => !objective.optional && !objective.eventOnly);
expect(required.length).toBeGreaterThan(1);
const killOrder = [required.at(-1)!, ...required.slice(0, -1)];
const deadEntityIds: string[] = [];
let snapshot = createDungeonEncounterSnapshot(progression!, 1_000);
for (const boss of killOrder) {
deadEntityIds.push(boss.id);
snapshot = reduceDungeonEncounter(progression!, snapshot, {
kind: "tick",
now: 1_000 + deadEntityIds.length * 100,
facts: {
registeredEntityIds: required.map((objective) => objective.id),
deadEntityIds,
},
}).snapshot;
}
expect(snapshot.flags["dungeon-complete"]).toBe(true);
expect(snapshot.phase).toBe("complete");
});
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
const sourceRows = objects.filter((object) => (
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
));
expect(wowDungeons).toHaveLength(54);
expect(sourceRows).toHaveLength(3_943);
expect(sourceRows.filter((object) => object.source?.clientDisplayId)).toHaveLength(3_905);
expect(sourceRows.filter((object) => object.source?.clientModelPath)).toHaveLength(3_837);
expect(sourceRows.filter((object) => object.source?.clientBounds)).toHaveLength(3_547);
for (const dungeon of wowDungeons) {
expect(new Set(dungeon.serverGameObjects.map((object) => object.id)).size)
.toBe(dungeon.serverGameObjects.length);
for (const object of dungeon.serverGameObjects) {
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.source?.files.length).toBeGreaterThan(0);
if (object.source?.spawnId !== undefined) {
expect(object.source.templateId).toBeGreaterThan(0);
expect(object.source.spawnId).toBeGreaterThan(0);
expect(object.source.gameObjectType).toBeGreaterThanOrEqual(0);
expect(object.source.gameObjectType).toBeLessThanOrEqual(35);
}
if (object.visual?.hidden) {
expect(object.interaction).toBeUndefined();
expect(object.states.every((state) => !state.blocksMovement && !state.blocksSight)).toBe(true);
}
}
}
});
it("publishes a deduplicated export manifest for source-backed gameobject visuals", () => {
expect(gameObjectAssetManifest.summary).toEqual({
typedInstances: 3_943,
visualDisplayInstances: 3_905,
nonVisualInstances: 38,
resolvedModelInstances: 3_837,
boundedInstances: 3_547,
uniqueAssets: 458,
unresolvedModelInstances: 68,
unresolvedBoundInstances: 358,
unresolvedModelDisplayIds: 9,
unresolvedBoundDisplayIds: 68,
});
expect(gameObjectAssetManifest.assets).toHaveLength(458);
expect(new Set(gameObjectAssetManifest.assets.map(
(asset) => asset.sourceModelPath.toLowerCase(),
)).size).toBe(gameObjectAssetManifest.assets.length);
expect(gameObjectAssetManifest.assets.every((asset) => (
["mdl", "mdx"].includes(asset.sourceType)
&& asset.targetUrl.startsWith("/assets/game/world-objects/")
&& asset.targetUrl.endsWith(".glb")
&& asset.instances > 0
&& asset.displayIds.length > 0
&& asset.dungeons.length > 0
))).toBe(true);
expect(gameObjectAssetManifest.assets.reduce((total, asset) => total + asset.instances, 0))
.toBe(gameObjectAssetManifest.summary.resolvedModelInstances);
expect(gameObjectAssetManifest.nonVisualObjects.reduce(
(total, object) => total + object.instances,
0,
)).toBe(gameObjectAssetManifest.summary.nonVisualInstances);
expect(gameObjectAssetManifest.unresolvedModels.map((entry) => entry.displayId)).toEqual([
9158, 9159, 9160, 9161, 9162, 9163, 9236, 9467, 9468,
]);
});
it("runs Wailing Caverns with the complete server population and authored presentation", () => {
const wailing = requireDungeonDefinition("wailing-caverns");
const runtimePopulation = wailing.staticSpawns.length
@@ -101,9 +417,17 @@ describe("generated dungeon campaign catalog", () => {
const sourcePopulation = source.staticSpawns.length
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
const runtimePopulation = runtime.staticSpawns.length
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0)
+ new Set((runtime.encounterSpawnGroups ?? [])
.flatMap((group) => group.spawns.map((spawn) => spawn.id))
.filter((id) => !runtime.staticSpawns.some((spawn) => spawn.id === id))).size;
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
if (source.id === "frozen-reach") {
expect(runtime.staticSpawns.some((spawn) => spawn.id.startsWith("generated-trash-"))).toBe(false);
expect(runtime.validation.warnings.join(" ")).toMatch(/recovered Alva\/Arktos/i);
} else {
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
}
}
});
@@ -113,7 +437,11 @@ describe("generated dungeon campaign catalog", () => {
...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
const optionalBosses = generated.definitions.flatMap((dungeon) => (
dungeon.objectiveOrder.filter((objective) => "optional" in objective && objective.optional)
dungeon.objectiveOrder.filter((objective) => {
if (!("optional" in objective) || !objective.optional) return false;
const spawn = dungeon.staticSpawns.find((candidate) => candidate.id === objective.id);
return Boolean(spawn && "spawnChance" in spawn);
})
));
expect(chanceSpawns).toHaveLength(24);
@@ -355,6 +683,44 @@ describe("generated dungeon campaign catalog", () => {
}
});
it("makes every required boss objective available in every declared difficulty", () => {
for (const dungeon of generated.definitions) {
const spawns = new Map(dungeon.staticSpawns.map((spawn) => [spawn.id, spawn]));
for (const boss of dungeon.objectiveOrder) {
if (("eventOnly" in boss && boss.eventOnly) || ("optional" in boss && boss.optional)) continue;
const spawn = spawns.get(boss.id);
expect(spawn, `${dungeon.id}:${boss.name}`).toBeTruthy();
for (const difficulty of dungeon.difficultyVariants) {
expect(
(spawn!.spawnMask & difficulty.spawnMask) !== 0,
`${dungeon.id}:${boss.name}:${difficulty.id}`,
).toBe(true);
}
}
}
});
it("preserves heroic-only Epoch bosses without gating normal completion", () => {
for (const [dungeonId, bossName] of [
["the-nexus", "Frozen Commander"],
["ahnkahet-old-kingdom", "Amanitar"],
["gundrak", "Eck the Ferocious"],
] as const) {
const dungeon = generated.definitions.find((entry) => entry.id === dungeonId)!;
const boss = dungeon.objectiveOrder.find((objective) => objective.name === bossName)!;
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
expect("optional" in boss && boss.optional, `${dungeonId}:${bossName}`).toBe(true);
expect(spawn.spawnMask, `${dungeonId}:${bossName}`).toBe(2);
}
const blackMorass = generated.definitions.find((dungeon) => dungeon.id === "black-morass")!;
for (const boss of blackMorass.objectiveOrder) {
const spawn = blackMorass.staticSpawns.find((entry) => entry.id === boss.id)!;
expect("optional" in boss && boss.optional, boss.name).not.toBe(true);
expect(spawn.spawnMask, boss.name).toBe(3);
}
});
it("aligns the Deadmines source population to its packaged interior route", () => {
const deadmines = requireDungeonDefinition("deadmines");
const firstPack = deadmines.staticSpawns.find(
+24
View File
@@ -46,6 +46,30 @@ describe("dungeon combat fidelity integration", () => {
expect(friendlyResult.reason).toBeUndefined();
});
it("cycles only living enemy targets that are in range and line of sight", () => {
for (const [id, position] of [
["blocked-near", [2, 0, 0]],
["visible", [4, 0, 0]],
["too-far", [41, 0, 0]],
] as const) {
useCombatStore.getState().registerMob(id, { name: id });
setMobPosition(id, position);
}
let blockVisibleTarget = false;
registerCombatSpatialProvider({
actor: (id) => ({ id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
hasLineOfSight: (_origin, target) => target[0] !== 2 && (!blockVisibleTarget || target[0] !== 4),
projectGroundPoint: (point) => point,
});
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
blockVisibleTarget = true;
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBeNull();
expect(useCombatStore.getState().selectedTargetId).toBeNull();
});
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
registerTarget();
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { deriveDungeonEncounterCombatEvents } from "./dungeonEncounterCombatEvents";
describe("dungeon encounter combat event bridge", () => {
it("emits stable engage, descending health, and death edges once", () => {
const engaged = deriveDungeonEncounterCombatEvents({}, {
boss: { engaged: true, dead: false, despawned: false, health: 1_000, maxHealth: 1_000 },
}, 1);
expect(engaged.events).toEqual([{ kind: "entity-engaged", entityId: "boss", now: 1 }]);
const damaged = deriveDungeonEncounterCombatEvents(engaged.cursor, {
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
}, 2);
expect(damaged.events).toEqual([{
kind: "entity-health",
entityId: "boss",
healthPercent: 49,
now: 2,
}]);
const unchanged = deriveDungeonEncounterCombatEvents(damaged.cursor, {
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
}, 3);
expect(unchanged.events).toEqual([]);
const dead = deriveDungeonEncounterCombatEvents(unchanged.cursor, {
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
}, 4);
expect(dead.events).toEqual([{ kind: "entity-death", entityId: "boss", now: 4 }]);
expect(deriveDungeonEncounterCombatEvents(dead.cursor, {
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
}, 5).events).toEqual([]);
});
it("does not treat scripted despawn as death and permits a later respawn edge", () => {
const first = deriveDungeonEncounterCombatEvents({}, {
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
}, 1);
const despawned = deriveDungeonEncounterCombatEvents(first.cursor, {
add: { engaged: false, dead: false, despawned: true, health: 100, maxHealth: 100 },
}, 2);
expect(despawned.events).toEqual([]);
expect(despawned.cursor).toEqual({});
expect(deriveDungeonEncounterCombatEvents(despawned.cursor, {
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
}, 3).events).toEqual([{ kind: "entity-engaged", entityId: "add", now: 3 }]);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { DungeonEncounterEvent } from "./dungeonEncounterTypes";
export interface DungeonEncounterMobObservation {
readonly engaged: boolean;
readonly dead: boolean;
readonly despawned: boolean;
readonly health: number;
readonly maxHealth: number;
}
export interface DungeonEncounterMobCursorEntry {
readonly engaged: boolean;
readonly dead: boolean;
readonly healthPercent: number;
}
export type DungeonEncounterMobCursor = Readonly<Record<string, DungeonEncounterMobCursorEntry>>;
type CombatEdgeEvent = Extract<
DungeonEncounterEvent,
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
>;
export interface DungeonEncounterCombatEventBatch {
readonly cursor: DungeonEncounterMobCursor;
readonly events: readonly CombatEdgeEvent[];
}
function healthPercent(mob: DungeonEncounterMobObservation): number {
if (!Number.isFinite(mob.maxHealth) || mob.maxHealth <= 0) return 0;
return Math.max(0, Math.min(100, (Math.max(0, mob.health) / mob.maxHealth) * 100));
}
/**
* Converts mutable combat-state edges into deterministic encounter events.
* Despawned actors are deliberately omitted so scripted cleanup never looks
* like a kill and a later respawn of the same stable id can engage normally.
*/
export function deriveDungeonEncounterCombatEvents(
previous: DungeonEncounterMobCursor,
mobs: Readonly<Record<string, DungeonEncounterMobObservation>>,
now: number,
): DungeonEncounterCombatEventBatch {
const cursor: Record<string, DungeonEncounterMobCursorEntry> = {};
const events: CombatEdgeEvent[] = [];
for (const entityId of Object.keys(mobs).sort()) {
const mob = mobs[entityId];
if (mob.despawned) continue;
const percent = healthPercent(mob);
const prior = previous[entityId];
cursor[entityId] = Object.freeze({
engaged: mob.engaged,
dead: mob.dead,
healthPercent: percent,
});
if (mob.engaged && !mob.dead && !prior?.engaged) {
events.push(Object.freeze({ kind: "entity-engaged", entityId, now }));
}
if (!mob.dead && percent < (prior?.healthPercent ?? 100)) {
events.push(Object.freeze({ kind: "entity-health", entityId, healthPercent: percent, now }));
}
if (mob.dead && !prior?.dead) {
events.push(Object.freeze({ kind: "entity-death", entityId, now }));
}
}
return Object.freeze({ cursor: Object.freeze(cursor), events: Object.freeze(events) });
}
+300 -2
View File
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
activateDungeonEncounterDefinitions,
activeDungeonEncounterRevision,
activeDungeonRunIsComplete,
createDungeonEncounterSnapshot,
dispatchActiveDungeonEntityEvent,
dispatchActiveDungeonInteraction,
dispatchDungeonEncounterSignal,
drainDungeonEncounterEffects,
@@ -12,6 +14,7 @@ import {
importDungeonEncounterSnapshots,
reduceDungeonEncounter,
registerDungeonEncounterEffectHandler,
resetDungeonEncounters,
resetDungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots,
validateDungeonEncounterDefinition,
@@ -104,14 +107,32 @@ const MANAGER_B = {
{ id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" },
],
},
{
id: "observe-boss-death",
trigger: { kind: "entity-death", entityId: "boss" },
actions: [{ id: "mark-boss-dead", kind: "set-flag", flagId: "boss-dead", value: true }],
},
],
} as const satisfies DungeonEncounterDefinition;
const MANAGER_DIFFICULTY = {
id: "test:manager-difficulty",
dungeonId: "manager-difficulty",
initialPhase: "idle",
rules: [{
id: "heroic-signal",
trigger: { kind: "signal", signalId: "test:heroic" },
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
actions: [{ id: "mark-live-heroic", kind: "set-flag", flagId: "heroic", value: true }],
}],
} as const satisfies DungeonEncounterDefinition;
afterEach(() => {
activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]);
useWailingEncounterStore.getState().reset();
drainDungeonEncounterEffects("manager-a");
drainDungeonEncounterEffects("manager-b");
drainDungeonEncounterEffects("manager-difficulty");
});
describe("dungeon encounter reducer", () => {
@@ -195,6 +216,57 @@ describe("dungeon encounter reducer", () => {
});
});
it("reconstructs persisted state instead of retaining a temporary overwrite on reset", () => {
const definition = {
id: "test:persistent-reconstruction",
dungeonId: "test-dungeon",
initialPhase: "locked",
initialState: { worldObjectStates: { herald: "locked" } },
resetPolicy: { bossWipePhase: "ready" },
rules: [
{
id: "unlock-herald",
trigger: { kind: "signal", signalId: "unlock" },
actions: [{
id: "persist-herald-ready",
kind: "set-world-object-state",
objectId: "herald",
state: "ready",
persistence: "run",
}],
},
{
id: "start-attempt",
trigger: { kind: "interact", objectId: "herald" },
guards: [{ kind: "world-object-state", objectId: "herald", states: ["ready"] }],
actions: [{
id: "temporarily-activate-herald",
kind: "set-world-object-state",
objectId: "herald",
state: "active",
}],
},
],
} as const satisfies DungeonEncounterDefinition;
const unlocked = reduceDungeonEncounter(
definition,
createDungeonEncounterSnapshot(definition),
{ kind: "signal", signalId: "unlock", now: 1 },
).snapshot;
const active = reduceDungeonEncounter(
definition,
unlocked,
{ kind: "interact", objectId: "herald", now: 2 },
).snapshot;
expect(active.worldObjectStates.herald).toBe("active");
const reset = resetDungeonEncounterSnapshot(definition, active, "boss-wipe", 3);
expect(reset).toMatchObject({
phase: "ready",
worldObjectStates: { herald: "ready" },
});
});
it("rejects duplicate action ids at definition validation", () => {
expect(() => validateDungeonEncounterDefinition({
id: "test:invalid",
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
}],
})).toThrow(/duplicate action id same/);
});
it("rejects empty difficulty guards at definition validation", () => {
expect(() => validateDungeonEncounterDefinition({
id: "test:invalid-difficulty",
dungeonId: "test-dungeon",
initialPhase: "idle",
rules: [{
id: "empty-difficulty",
trigger: { kind: "tick" },
guards: [{ kind: "difficulty", difficultyIds: [] }],
actions: [{ id: "should-not-fire", kind: "set-phase", phase: "invalid" }],
}],
})).toThrow(/empty difficulty guard/);
});
it("evaluates difficulty guards from ephemeral runtime context", () => {
const definition = {
id: "test:difficulty",
dungeonId: "test-dungeon",
initialPhase: "idle",
rules: [{
id: "heroic-branch",
trigger: { kind: "signal", signalId: "start" },
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
actions: [{ id: "mark-heroic", kind: "set-flag", flagId: "heroic", value: true }],
}],
} as const satisfies DungeonEncounterDefinition;
const initial = createDungeonEncounterSnapshot(definition);
const event = { kind: "signal", signalId: "start", now: 1 } as const;
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "normal" }).snapshot).toBe(initial);
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "heroic" }).snapshot.flags.heroic).toBe(true);
});
it("never turns an entities-dead safety timeout into boss or dungeon credit", () => {
const completion = {
id: "test:timeout-completion",
dungeonId: "test-dungeon",
initialPhase: "boss-active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "boss-cleared",
trigger: { kind: "entities-dead", entityIds: ["boss"], timeoutMs: 1_000 },
actions: [
{ id: "complete-timeout-boss", kind: "complete-objective", objectiveId: "boss", persistence: "run" },
{ id: "checkpoint-timeout-boss", kind: "set-checkpoint", checkpointId: "boss", persistence: "run" },
{ id: "complete-timeout-dungeon", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
],
}],
} as const satisfies DungeonEncounterDefinition;
const initial = createDungeonEncounterSnapshot(completion, 0);
const idled = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: ["boss"], deadEntityIds: [] },
}).snapshot;
const missing = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: [], deadEntityIds: [] },
}).snapshot;
expect(idled).toBe(initial);
expect(missing).toBe(initial);
const killed = reduceDungeonEncounter(completion, initial, {
kind: "tick",
now: 1_001,
facts: { registeredEntityIds: ["boss"], deadEntityIds: ["boss"] },
}).snapshot;
expect(killed.completedObjectiveIds).toEqual(["boss"]);
expect(killed.flags["dungeon-complete"]).toBe(true);
const recovery = {
id: "test:timeout-recovery",
dungeonId: "test-dungeon",
initialPhase: "wave-active",
rules: [{
id: "missing-wave-recovery",
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
actions: [
{ id: "despawn-missing-wave", kind: "deactivate-spawn-group", spawnGroupId: "missing-wave" },
{ id: "leave-missing-wave", kind: "set-phase", phase: "recovered" },
],
}],
} as const satisfies DungeonEncounterDefinition;
const recovered = reduceDungeonEncounter(
recovery,
createDungeonEncounterSnapshot(recovery, 0),
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
).snapshot;
expect(recovered.phase).toBe("recovered");
const surrogateProgress = {
id: "test:timeout-surrogate-progress",
dungeonId: "test-dungeon",
initialPhase: "wave-active",
initialState: { flags: { "escort-complete": false } },
rules: [{
id: "missing-wave-cannot-persist",
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
actions: [
{ id: "leave-missing-persistent-wave", kind: "set-phase", phase: "recovered" },
{
id: "persist-fake-escort-credit",
kind: "set-flag",
flagId: "escort-complete",
value: true,
persistence: "run",
},
],
}],
} as const satisfies DungeonEncounterDefinition;
const surrogateInitial = createDungeonEncounterSnapshot(surrogateProgress, 0);
const refused = reduceDungeonEncounter(
surrogateProgress,
surrogateInitial,
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
).snapshot;
expect(refused).toBe(surrogateInitial);
});
});
describe("active dungeon encounter manager", () => {
it("requires every declared completion authority before finishing a dungeon", () => {
const baseline = {
id: "test:completion-baseline",
dungeonId: "completion-dungeon",
initialPhase: "active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "complete-baseline",
trigger: { kind: "signal", signalId: "baseline-complete" },
actions: [{
id: "mark-baseline-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
}],
}],
} as const satisfies DungeonEncounterDefinition;
const instance = {
id: "test:completion-instance",
dungeonId: "completion-dungeon",
initialPhase: "active",
initialState: { flags: { "dungeon-complete": false } },
rules: [{
id: "complete-instance",
trigger: { kind: "signal", signalId: "instance-complete" },
actions: [{
id: "mark-instance-complete",
kind: "set-flag",
flagId: "dungeon-complete",
value: true,
persistence: "run",
}],
}],
} as const satisfies DungeonEncounterDefinition;
activateDungeonEncounterDefinitions("completion-dungeon", [baseline, instance]);
expect(activeDungeonRunIsComplete()).toBe(false);
dispatchDungeonEncounterSignal("baseline-complete", 1);
expect(activeDungeonRunIsComplete()).toBe(false);
dispatchDungeonEncounterSignal("instance-complete", 2);
expect(activeDungeonRunIsComplete()).toBe(true);
});
it("isolates shared signals to the active dungeon", () => {
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
dispatchDungeonEncounterSignal("test:shared", 1_000);
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1);
});
it("broadcasts live combat entity edges to the active dungeon", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
expect(dispatchActiveDungeonEntityEvent({
kind: "entity-death",
entityId: "boss",
now: 2_500,
})).toEqual([]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.flags["boss-dead"]).toBe(true);
const managerBRevision = dungeonEncounterSnapshot(MANAGER_B.id)?.revision;
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_600 });
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.revision).toBe(managerBRevision);
});
it("updates difficulty context when an existing dungeon provider is reactivated", () => {
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "normal");
dispatchDungeonEncounterSignal("test:heroic", 1);
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).not.toBe(true);
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "heroic");
dispatchDungeonEncounterSignal("test:heroic", 2);
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).toBe(true);
});
it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
const subscriber = vi.fn();
const effectHandler = vi.fn();
const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber);
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
actionId: "switch-cue",
signalId: "test:switch-opened",
});
expect(effectHandler).toHaveBeenCalled();
expect(effectHandler).toHaveBeenCalledTimes(3);
expect(subscriber).toHaveBeenCalled();
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
const exported = exportDungeonEncounterSnapshots("manager-b");
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
const olderAuthority = {
...hydrated,
phase: "authority-restored",
revision: Math.max(0, hydrated.revision - 1),
};
expect(importDungeonEncounterSnapshots("manager-b", [olderAuthority])).toEqual([]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
expect(importDungeonEncounterSnapshots(
"manager-b",
[olderAuthority],
{ authoritative: true },
)).toEqual([MANAGER_B.id]);
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("authority-restored");
unregisterEffect();
unsubscribe();
});
it("keeps effects drainable without a handler and clears queued effects on reset", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 4_000)).toHaveLength(3);
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 4_100)).toHaveLength(3);
resetDungeonEncounters("dungeon", "manager-b");
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
});
it("discards undrained effects when the active dungeon changes", () => {
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
resetDungeonEncounters("dungeon", "manager-b");
expect(dispatchActiveDungeonInteraction("switch", "player", 5_000)).toHaveLength(3);
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
});
});
+205 -69
View File
@@ -20,6 +20,15 @@ function unique(values: readonly string[]): readonly string[] {
return Object.freeze([...new Set(values)].sort());
}
function orderedUnique(values: readonly string[]): readonly string[] {
const seen = new Set<string>();
return Object.freeze(values.filter((value) => {
if (seen.has(value)) return false;
seen.add(value);
return true;
}));
}
export function createDungeonEncounterSnapshot(
definition: DungeonEncounterDefinition,
now = 0,
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
validateStableId(definition.id, "Encounter");
validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`);
validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`);
if (definition.source) {
if (!definition.source.files.length || definition.source.files.some((file) => !file.trim())) {
throw new Error(`Encounter ${definition.id} source review must name at least one source file`);
}
if (!definition.source.reviewedBehavior.trim()) {
throw new Error(`Encounter ${definition.id} source review must describe the reviewed behavior`);
}
if (definition.source.fidelity === "source-exact"
&& (definition.source.adaptations.length || definition.source.remainingGaps.length)) {
throw new Error(`Source-exact encounter ${definition.id} cannot retain adaptations or gaps`);
}
}
const ruleIds = new Set<string>();
const actionIds = new Set<string>();
for (const rule of definition.rules) {
validateStableId(rule.id, `Encounter ${definition.id} rule`);
if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`);
ruleIds.add(rule.id);
for (const guard of rule.guards ?? []) {
if (guard.kind === "difficulty") {
if (!guard.difficultyIds.length) {
throw new Error(`Encounter ${definition.id} rule ${rule.id} has an empty difficulty guard`);
}
for (const difficultyId of guard.difficultyIds) {
validateStableId(
difficultyId,
`Encounter ${definition.id} rule ${rule.id} difficulty`,
);
}
}
}
for (const action of rule.actions) {
validateStableId(action.id, `Encounter ${definition.id} action`);
if (actionIds.has(action.id)) {
@@ -72,8 +106,19 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
}
}
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
export interface DungeonEncounterRuntimeContext {
readonly difficultyId?: string;
}
function guardMatches(
snapshot: DungeonEncounterSnapshot,
guard: DungeonEncounterGuard,
context: DungeonEncounterRuntimeContext,
): boolean {
if (guard.kind === "phase") return guard.phases.includes(snapshot.phase);
if (guard.kind === "difficulty") {
return context.difficultyId !== undefined && guard.difficultyIds.includes(context.difficultyId);
}
if (guard.kind === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals;
if (guard.kind === "objective-complete") {
return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals;
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
return current <= guard.value;
}
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
function triggerMatches(
snapshot: DungeonEncounterSnapshot,
trigger: DungeonEncounterRule["trigger"],
event: DungeonEncounterEvent,
): boolean {
if (trigger.kind === "tick") return event.kind === "tick";
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId;
): DungeonEncounterTriggerMatch {
if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false;
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false;
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") {
return event.kind === trigger.kind && event.entityId === trigger.entityId;
return event.kind === trigger.kind && event.entityId === trigger.entityId ? "event" : false;
}
if (trigger.kind === "entity-health") {
return event.kind === "entity-health"
&& event.entityId === trigger.entityId
&& event.healthPercent <= trigger.atOrBelowPercent;
&& event.healthPercent <= trigger.atOrBelowPercent
? "event"
: false;
}
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId ? "event" : false;
if (trigger.kind === "elapsed") {
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs;
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs ? "event" : false;
}
if (event.kind !== "tick") return false;
const registered = new Set(event.facts?.registeredEntityIds ?? []);
const dead = new Set(event.facts?.deadEntityIds ?? []);
const allDead = trigger.entityIds.length > 0
&& trigger.entityIds.every((id) => registered.has(id) && dead.has(id));
if (allDead) return "event";
const hasAuthoritativeFacts = event.facts?.registeredEntityIds !== undefined
&& event.facts.deadEntityIds !== undefined;
const hasRegisteredLivingTarget = trigger.entityIds.some((id) => (
registered.has(id) && !dead.has(id)
));
const timedOut = trigger.timeoutMs !== undefined
&& hasAuthoritativeFacts
&& !hasRegisteredLivingTarget
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs;
return allDead || timedOut;
return timedOut ? "timeout" : false;
}
function ruleCanAdvanceOnMissingEntityTimeout(rule: DungeonEncounterRule): boolean {
return !rule.actions.some((action) => (
action.persistence === "checkpoint"
|| action.persistence === "run"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
|| action.kind === "grant-loot"
));
}
function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean {
@@ -193,7 +260,7 @@ function applyAction(
}
return {
snapshot: consume
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) }
? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
: next,
effect: actionEffect(action),
};
@@ -203,6 +270,7 @@ export function reduceDungeonEncounter(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
context: DungeonEncounterRuntimeContext = {},
): DungeonEncounterReduction {
if (snapshot.encounterId !== definition.id) {
throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${definition.id}`);
@@ -215,8 +283,10 @@ export function reduceDungeonEncounter(
.sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index);
for (const { rule } of rules) {
if (ruleAlreadyConsumed(next, rule)) continue;
if (!triggerMatches(next, rule.trigger, event)) continue;
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && rule.guards?.length) continue;
const triggerMatch = triggerMatches(next, rule.trigger, event);
if (!triggerMatch) continue;
if (triggerMatch === "timeout" && !ruleCanAdvanceOnMissingEntityTimeout(rule)) continue;
if (!rule.guards?.every((guard) => guardMatches(next, guard, context)) && rule.guards?.length) continue;
let fired = false;
for (const action of rule.actions) {
const applied = applyAction(next, action, event.now, rule.once ?? true);
@@ -246,44 +316,54 @@ function retainedActionIds(
return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id)));
}
function retainedStateKeys(definition: DungeonEncounterDefinition): {
readonly flags: ReadonlySet<string>;
readonly counters: ReadonlySet<string>;
readonly worldObjects: ReadonlySet<string>;
readonly transitions: ReadonlySet<string>;
readonly objectives: ReadonlySet<string>;
readonly checkpoints: boolean;
function reconstructedPersistentState(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
initial: DungeonEncounterSnapshot,
): {
readonly flags: Readonly<Record<string, boolean>>;
readonly counters: Readonly<Record<string, number>>;
readonly worldObjectStates: Readonly<Record<string, string>>;
readonly transitionStates: Readonly<Record<string, boolean>>;
readonly completedObjectiveIds: readonly string[];
readonly checkpointId: string | null;
} {
const actions = definition.rules.flatMap((rule) => rule.actions)
const actionsById = new Map(definition.rules
.flatMap((rule) => rule.actions)
.map((action) => [action.id, action] as const));
const actions = snapshot.consumedActionIds
.map((id) => actionsById.get(id))
.filter((action): action is DungeonEncounterAction => action !== undefined)
.filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
return {
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)),
counters: new Set(actions
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter")
.map((action) => action.counterId)),
worldObjects: new Set(actions
.filter((action) => action.kind === "set-world-object-state")
.map((action) => action.objectId)),
transitions: new Set(actions
.filter((action) => action.kind === "set-transition-active")
.map((action) => action.transitionId)),
objectives: new Set(actions
.filter((action) => action.kind === "complete-objective")
.map((action) => action.objectiveId)),
checkpoints: actions.some((action) => action.kind === "set-checkpoint"),
};
}
function retainedRecord<T>(
initial: Readonly<Record<string, T>>,
current: Readonly<Record<string, T>>,
keys: ReadonlySet<string>,
): Readonly<Record<string, T>> {
const retained: Record<string, T> = { ...initial };
for (const key of keys) {
if (current[key] !== undefined) retained[key] = current[key];
const flags = { ...initial.flags };
const counters = { ...initial.counters };
const worldObjectStates = { ...initial.worldObjectStates };
const transitionStates = { ...initial.transitionStates };
const completedObjectiveIds = new Set<string>();
let checkpointId: string | null = null;
for (const action of actions) {
if (action.kind === "set-flag") flags[action.flagId] = action.value;
else if (action.kind === "set-counter") counters[action.counterId] = action.value;
else if (action.kind === "increment-counter") {
counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1);
} else if (action.kind === "set-world-object-state") {
worldObjectStates[action.objectId] = action.state;
} else if (action.kind === "set-transition-active") {
transitionStates[action.transitionId] = action.active;
} else if (action.kind === "complete-objective") {
completedObjectiveIds.add(action.objectiveId);
} else if (action.kind === "set-checkpoint") {
checkpointId = action.checkpointId;
}
}
return Object.freeze(retained);
return {
flags: Object.freeze(flags),
counters: Object.freeze(counters),
worldObjectStates: Object.freeze(worldObjectStates),
transitionStates: Object.freeze(transitionStates),
completedObjectiveIds: Object.freeze([...completedObjectiveIds].sort()),
checkpointId,
};
}
export function resetDungeonEncounterSnapshot(
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
const phase = scope === "boss-wipe"
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
const retained = retainedStateKeys(definition);
const retained = reconstructedPersistentState(definition, snapshot, initial);
return Object.freeze({
...initial,
phase,
phaseStartedAt: finiteTime(now),
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags),
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters),
worldObjectStates: retainedRecord(
initial.worldObjectStates,
snapshot.worldObjectStates,
retained.worldObjects,
),
transitionStates: retainedRecord(
initial.transitionStates,
snapshot.transitionStates,
retained.transitions,
),
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
.filter((id) => retained.objectives.has(id))),
flags: retained.flags,
counters: retained.counters,
worldObjectStates: retained.worldObjectStates,
transitionStates: retained.transitionStates,
completedObjectiveIds: retained.completedObjectiveIds,
consumedActionIds: retainedActionIds(definition, snapshot, scope),
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
checkpointId: retained.checkpointId,
});
}
@@ -330,6 +401,8 @@ export interface DungeonEncounterProvider {
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
/** Hydrates authoritative online/save state without involving renderer state. */
readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void;
/** Updates ephemeral run context without polluting serialized snapshots. */
readonly setContext?: (context: DungeonEncounterRuntimeContext) => void;
}
const providers = new Map<string, DungeonEncounterProvider>();
@@ -337,6 +410,14 @@ const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
let activeDungeonId: string | null = null;
function clearPendingDungeonEncounterEffects(dungeonId?: string): void {
for (let index = pendingEffects.length - 1; index >= 0; index -= 1) {
if (!dungeonId || pendingEffects[index].dungeonId === dungeonId) {
pendingEffects.splice(index, 1);
}
}
}
function queueProviderEffects(
provider: DungeonEncounterProvider,
effects: readonly DungeonEncounterEffect[],
@@ -347,8 +428,11 @@ function queueProviderEffects(
encounterId: provider.encounterId,
effect,
});
pendingEffects.push(value);
for (const handler of effectHandlers) handler(value);
if (effectHandlers.size) {
for (const handler of effectHandlers) handler(value);
} else {
pendingEffects.push(value);
}
}
return effects;
}
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
return Object.freeze(effects);
}
export function dispatchActiveDungeonEntityEvent(
event: Extract<
DungeonEncounterEvent,
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
>,
): readonly DungeonEncounterEffect[] {
if (!activeDungeonId) return [];
const effects: DungeonEncounterEffect[] = [];
for (const provider of providers.values()) {
if (provider.dungeonId !== activeDungeonId) continue;
effects.push(...queueProviderEffects(provider, provider.dispatch(event)));
}
notifyEncounterSubscribers();
return Object.freeze(effects);
}
export function advanceDungeonEncounters(
dungeonId: string,
event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>,
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
dungeonId?: string,
now = 0,
): void {
clearPendingDungeonEncounterEffects(dungeonId);
for (const provider of providers.values()) {
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
}
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
export function activateDungeonEncounterDefinitions(
dungeonId: string,
definitions: readonly DungeonEncounterDefinition[],
difficultyId?: string,
): void {
if (activeDungeonId && activeDungeonId !== dungeonId) {
clearPendingDungeonEncounterEffects(activeDungeonId);
}
activeDungeonId = dungeonId;
for (const definition of definitions) {
validateDungeonEncounterDefinition(definition);
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
const existing = providers.get(definition.id);
if (existing) {
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
existing.setContext?.({ difficultyId });
continue;
}
let snapshot = createDungeonEncounterSnapshot(definition);
let context: DungeonEncounterRuntimeContext = { difficultyId };
const provider: DungeonEncounterProvider = {
definition,
encounterId: definition.id,
dungeonId,
getSnapshot: () => snapshot,
dispatch: (event) => {
const result = reduceDungeonEncounter(definition, snapshot, event);
const result = reduceDungeonEncounter(definition, snapshot, event, context);
snapshot = result.snapshot;
return result.effects;
},
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
}
snapshot = cloneEncounterSnapshot(value);
},
setContext: (value) => {
context = Object.freeze({ ...value });
},
};
providers.set(definition.id, provider);
}
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
transitionStates: Object.freeze({ ...value.transitionStates }),
activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
completedObjectiveIds: unique(value.completedObjectiveIds),
consumedActionIds: unique(value.consumedActionIds),
consumedActionIds: orderedUnique(value.consumedActionIds),
revision: Math.max(0, Math.trunc(value.revision)),
});
}
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
export function importDungeonEncounterSnapshots(
dungeonId: string,
snapshots: readonly DungeonEncounterSnapshot[],
options: { readonly authoritative?: boolean } = {},
): readonly string[] {
const imported: string[] = [];
for (const raw of snapshots) {
const provider = providers.get(raw.encounterId);
if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue;
const snapshot = cloneEncounterSnapshot(raw);
if (snapshot.revision < provider.getSnapshot().revision) continue;
if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
provider.replaceSnapshot(snapshot);
imported.push(snapshot.encounterId);
}
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
return Object.freeze(drained);
}
/** Immediate application hook; effects also remain drainable for frame-bound consumers. */
/** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */
export function registerDungeonEncounterEffectHandler(
handler: (value: DungeonEncounterEffectEnvelope) => void,
): () => void {
@@ -598,3 +709,28 @@ export function activeDungeonEncounterSpawnGroupIds(): readonly string[] {
return Object.freeze([...new Set(exportDungeonEncounterSnapshots()
.flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort());
}
export function activeDungeonCompletedObjectiveIds(dungeonId = activeDungeonId): readonly string[] {
return unique(exportDungeonEncounterSnapshots(dungeonId)
.flatMap((snapshot) => snapshot.completedObjectiveIds));
}
function definitionControlsDungeonCompletion(definition: DungeonEncounterDefinition): boolean {
return definition.rules.some((rule) => rule.actions.some((action) => (
action.kind === "set-flag"
&& action.flagId === "dungeon-complete"
&& action.value
)));
}
export function activeDungeonRunIsComplete(dungeonId = activeDungeonId): boolean {
if (!dungeonId) return false;
const completionProviders = [...providers.values()].filter((provider) => (
provider.dungeonId === dungeonId
&& definitionControlsDungeonCompletion(provider.definition)
));
return completionProviders.length > 0
&& completionProviders.every(
(provider) => provider.getSnapshot().flags["dungeon-complete"] === true,
);
}
+24 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "./dungeonEncounterSpawns";
const spawn = (id: string, spawnMask: number) => ({
id,
@@ -20,4 +23,24 @@ describe("encounter spawn groups", () => {
{ id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] },
], ["wave-a"], 2).map((entry) => entry.id)).toEqual(["two"]);
});
it("suppresses completed static and roaming boss runtime ids on checkpoint reload", () => {
const result = suppressCompletedDungeonPopulation(
[spawn("static-boss", 1), spawn("trash", 1)],
[{
id: "boss-patrol",
name: "Boss Patrol",
speed: 1,
formationSource: "database-solo",
waypoints: [[0, 0, 0], [1, 0, 0]],
members: [
{ id: "boss", entityId: "test-mob", role: "leader", formationOffset: [0, 0, 0] },
{ id: "escort", entityId: "test-mob", role: "follower", formationOffset: [1, 0, 0] },
],
}],
["static-boss", "boss-patrol:boss"],
);
expect(result.staticSpawns.map((entry) => entry.id)).toEqual(["trash"]);
expect(result.roamingPacks[0].members.map((member) => member.id)).toEqual(["escort"]);
});
});
+26 -1
View File
@@ -1,4 +1,8 @@
import { spawnMatchesDifficulty, type StaticMobSpawnDefinition } from "./mobPopulation";
import {
spawnMatchesDifficulty,
type MobPackDefinition,
type StaticMobSpawnDefinition,
} from "./mobPopulation";
import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
export function activeEncounterSpawnGroupSpawns(
@@ -20,3 +24,24 @@ export function activeEncounterSpawnGroupSpawns(
return true;
});
}
export function suppressCompletedDungeonPopulation(
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
completedObjectiveIds: readonly string[],
): {
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
readonly roamingPacks: readonly MobPackDefinition[];
} {
if (!completedObjectiveIds.length) return { staticSpawns, roamingPacks };
const completed = new Set(completedObjectiveIds);
return {
staticSpawns: staticSpawns.filter((spawn) => !completed.has(spawn.id)),
roamingPacks: roamingPacks.flatMap((pack) => {
const members = pack.members.filter(
(member) => !completed.has(`${pack.id}:${member.id}`),
);
return members.length ? [{ ...pack, members }] : [];
}),
};
}
+31 -2
View File
@@ -1,6 +1,8 @@
import type { Vector3Tuple } from "./dungeonTypes";
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 1;
// Version 2 preserves consumed action execution order so durable state can be
// reconstructed without reviving attempt-scoped object/flag overwrites.
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 2;
export type DungeonEncounterResetScope = "boss-wipe" | "checkpoint" | "dungeon";
export type DungeonEncounterValue = string | number | boolean;
@@ -52,12 +54,17 @@ export type DungeonEncounterTrigger =
| {
readonly kind: "entities-dead";
readonly entityIds: readonly string[];
/** A source-authored safety timeout prevents an encounter from soft locking. */
/**
* Missing-actor recovery window. It may advance presentation-only rules
* when no listed target is registered alive, but can never award an
* objective, checkpoint, loot, or dungeon completion.
*/
readonly timeoutMs?: number;
};
export type DungeonEncounterGuard =
| { readonly kind: "phase"; readonly phases: readonly string[] }
| { readonly kind: "difficulty"; readonly difficultyIds: readonly string[] }
| { readonly kind: "flag"; readonly flagId: string; readonly equals: boolean }
| {
readonly kind: "counter";
@@ -116,12 +123,34 @@ export interface DungeonEncounterResetPolicy {
readonly checkpointPhase?: string;
}
export type DungeonEncounterFidelity =
| "source-exact"
| "source-backed-adapted"
| "healer-man-authored";
export type DungeonEncounterScope = "boss-progression" | "instance-orchestration";
/**
* Review record kept beside executable encounter data. Adapted behavior must
* remain explicit so a safe approximation is never mistaken for a finished
* port of the original instance script.
*/
export interface DungeonEncounterSourceReview {
readonly fidelity: DungeonEncounterFidelity;
readonly scope: DungeonEncounterScope;
readonly files: readonly string[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
export interface DungeonEncounterDefinition {
readonly id: string;
readonly dungeonId: string;
readonly initialPhase: string;
readonly initialState?: DungeonEncounterInitialState;
readonly resetPolicy?: DungeonEncounterResetPolicy;
readonly source?: DungeonEncounterSourceReview;
readonly rules: readonly DungeonEncounterRule[];
}
+89 -1
View File
@@ -7,6 +7,18 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { gunzipSync, strFromU8 } from "fflate";
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
import { withWowWrathEncounterContent } from "./wowWrathEncounterContentCatalog";
import { withWowClassicEncounterContent } from "./wowClassicEncounterContentCatalog";
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
import { withWowWrathEncounterBatchTwoContent } from "./wowWrathEncounterBatchTwoCatalog";
import { withWowWrathEncounterBatchThreeContent } from "./wowWrathEncounterBatchThreeCatalog";
import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCatalog";
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -60,6 +72,45 @@ function enrichAuthoredDefinition(
.filter(([, entity]) => entity.kind === "boss")
.map(([id]) => id),
);
const authoredObjectiveByName = new Map(
authored.objectiveOrder.map((objective) => [normalizedName(objective.name), objective.id]),
);
const generatedObjectiveAliases = new Map(
generated.objectiveOrder.map((objective) => [
objective.id,
authoredObjectiveByName.get(normalizedName(objective.name)) ?? objective.id,
]),
);
const authoredId = (id: string): string => generatedObjectiveAliases.get(id) ?? id;
const remappedGeneratedEncounters = (generated.encounters ?? []).map((encounter) => (
encounter.source?.scope !== "boss-progression"
? encounter
: {
...encounter,
rules: encounter.rules.map((rule) => ({
...rule,
trigger: rule.trigger.kind === "entities-dead"
? { ...rule.trigger, entityIds: rule.trigger.entityIds.map(authoredId) }
: rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged"
? { ...rule.trigger, entityId: authoredId(rule.trigger.entityId) }
: rule.trigger,
guards: rule.guards?.map((guard) => (
guard.kind === "objective-complete"
? { ...guard, objectiveId: authoredId(guard.objectiveId) }
: guard
)),
actions: rule.actions.map((action) => {
if (action.kind === "complete-objective") {
return { ...action, objectiveId: authoredId(action.objectiveId) };
}
if (action.kind === "set-checkpoint") {
return { ...action, checkpointId: authoredId(action.checkpointId) };
}
return action;
}),
})),
}
));
return {
...authored,
entities: {
@@ -76,6 +127,15 @@ function enrichAuthoredDefinition(
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
)),
],
encounters: [
...remappedGeneratedEncounters,
...(authored.encounters ?? []),
],
encounterSpawnGroups: [
...(generated.encounterSpawnGroups ?? []),
...(authored.encounterSpawnGroups ?? []),
],
serverGameObjects: generated.serverGameObjects,
};
}
@@ -88,6 +148,31 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
throw new Error(`Default dungeon id mismatch: ${authoredWailingCaverns.id}`);
}
const DUNGEON_CONTENT_ADAPTERS: readonly (
(definition: DungeonDefinition) => DungeonDefinition
)[] = Object.freeze([
withWowEncounterContent,
withWowClassicEncounterContent,
withWowClassicEncounterBatchTwo,
withWowClassicEncounterBatchThree,
withWowVerifiedNoOpInstanceContent,
withWowWrathEncounterContent,
withWowWrathEncounterBatchTwoContent,
withWowWrathEncounterBatchThreeContent,
withWowProgressionObjectContent,
withWowTbcEncounterContent,
withWowTbcEncounterBatchTwoContent,
withAscensionCustomEncounterContent,
withRunewakerEncounterContent,
]);
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
return DUNGEON_CONTENT_ADAPTERS.reduce(
(current, applyContent) => applyContent(current),
definition,
);
}
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
[authoredWailingCaverns.id, authoredWailingCaverns],
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
@@ -99,7 +184,10 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
Object.fromEntries(dungeonRegistryEntries.map(
([id, definition]) => [id, withRunewakerEncounterContent(definition)] as const,
([id, definition]) => [
id,
applyDungeonContentAdapters(definition),
] as const,
)),
);
+13
View File
@@ -40,4 +40,17 @@ describe("dungeon run checkpoints", () => {
savedAt: 1,
})).toBeNull();
});
it("fails closed on legacy checkpoints whose sorted action history cannot be reconstructed", () => {
const currentSnapshot = createDungeonEncounterSnapshot(definition);
expect(normalizeDungeonRunCheckpoint({
version: 1,
dungeonId: "test",
difficultyId: "normal",
checkpointId: null,
spawn: { footPosition: [1, 2, 3], yaw: 0 },
encounters: [{ ...currentSnapshot, schemaVersion: 1 }],
savedAt: 1,
})).toBeNull();
});
});
+4 -1
View File
@@ -2,7 +2,10 @@ import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
export const DUNGEON_RUN_CHECKPOINT_VERSION = 1;
// Version 1 snapshots sorted consumed action ids and cannot be migrated
// reliably when multiple durable actions write the same state slot. Failing
// those active-run checkpoints closed is safer than resuming corrupted doors.
export const DUNGEON_RUN_CHECKPOINT_VERSION = 2;
export interface DungeonRunCheckpoint {
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
+11 -3
View File
@@ -12,10 +12,12 @@ import { useManastormAdminStore } from "./manastormAdminStore";
import type { GmReviewTarget } from "./gmContentReview";
import {
activateDungeonEncounterDefinitions,
exportDungeonEncounterSnapshots,
importDungeonEncounterSnapshots,
resetDungeonEncounters,
} from "./dungeonEncounterRuntime";
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { dungeonContentIsUsable } from "../content/contentManager";
export interface DungeonSessionOptions {
readonly gameMode?: GameMode;
@@ -39,13 +41,17 @@ export function activateDungeonSession(
options: DungeonSessionOptions = {},
): boolean {
const definition = dungeonDefinitionById(dungeonId);
if (!definition || !dungeonCanEnter(dungeonId)) {
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
return false;
}
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
activateDungeonEncounterDefinitions(dungeonId, definition.encounters ?? []);
activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
difficultyId ?? definition.defaultDifficultyId,
);
resetDungeonEncounters("dungeon", dungeonId);
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
@@ -91,7 +97,7 @@ export function activateDungeonSession(
gmReviewTarget: options.gmReviewTarget,
});
if (options.encounterSnapshots?.length) {
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots);
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
}
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
return true;
@@ -103,11 +109,13 @@ export function activateDungeonSession(
*/
export function restartActiveDungeonSession(): boolean {
const game = useGameStore.getState();
const encounterSnapshots = exportDungeonEncounterSnapshots(game.activeDungeonId);
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
gameMode: game.gameMode,
spawn: game.activeSpawn,
gmTestMode: game.gmTestMode,
gmReviewTarget: game.activeGmReviewTarget,
preserveCharacter: true,
encounterSnapshots,
});
}
+29 -6
View File
@@ -7,12 +7,16 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
import { useManastormAdminStore } from "./manastormAdminStore";
import { dungeonEncounterSnapshot } from "./dungeonEncounterRuntime";
import {
dungeonEncounterSnapshot,
importDungeonEncounterSnapshots,
} from "./dungeonEncounterRuntime";
import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter";
describe("dungeon session asset eviction", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
useGameStore.getState().activateDungeon("wailing-caverns");
});
@@ -35,11 +39,26 @@ describe("dungeon session asset eviction", () => {
expect(clear).not.toHaveBeenCalled();
});
it("restarts the active dungeon in place while preserving its party", () => {
const active = dungeonDefinitionById("deadmines")!;
useGameStore.getState().activateDungeon(active.id);
it("does not activate a direct production session before its dungeon pack is usable", () => {
vi.stubEnv("DEV", false);
useGameStore.getState().activateDungeon("wailing-caverns");
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
expect(activateDungeonSession("deadmines")).toBe(false);
expect(useGameStore.getState().activeDungeonId).toBe("wailing-caverns");
expect(clear).not.toHaveBeenCalled();
});
it("restarts the active dungeon in place while preserving its party and encounter state", () => {
const active = dungeonDefinitionById("wailing-caverns")!;
expect(activateDungeonSession(active.id)).toBe(true);
usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
const members = usePartyStore.getState().members.map((member) => member.id);
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const progressed = { ...current, phase: "ready", revision: current.revision + 1 } as const;
expect(importDungeonEncounterSnapshots(active.id, [progressed])).toEqual([
WAILING_NARALEX_ENCOUNTER.id,
]);
const revision = useGameStore.getState().sessionRevision;
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
@@ -48,6 +67,10 @@ describe("dungeon session asset eviction", () => {
expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
phase: "ready",
revision: progressed.revision,
});
});
it("applies the published dungeon group anchor at the session boundary", () => {
@@ -59,9 +82,9 @@ describe("dungeon session asset eviction", () => {
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
});
it("hydrates a safe encounter checkpoint after the dungeon reset", () => {
it("hydrates a same-process checkpoint even when the reset revision is newer", () => {
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
const saved = { ...current, phase: "ready", revision: current.revision + 10 } as const;
const saved = { ...current, phase: "ready" } as const;
expect(activateDungeonSession("wailing-caverns", "normal", {
encounterSnapshots: [saved],
})).toBe(true);
+42
View File
@@ -7,6 +7,20 @@ export type DungeonWorldObjectKind =
| "switch"
| "key"
| "chest"
| "resource"
| "generic"
| "chair"
| "spell-focus"
| "text"
| "trap"
| "transport"
| "camera"
| "ritual"
| "mailbox"
| "fishing-hole"
| "aura-generator"
| "spellcaster"
| "destructible"
| "npc"
| "quest-object"
| "trigger";
@@ -21,6 +35,14 @@ export interface DungeonWorldObjectSource {
readonly files: readonly string[];
readonly templateId?: number;
readonly spawnId?: number;
readonly gameObjectType?: number;
readonly clientDisplayId?: number;
readonly clientModelPath?: string;
/** Unscaled client x/y/z display bounds retained for asset/collision review. */
readonly clientBounds?: {
readonly min: MobVector3;
readonly max: MobVector3;
};
readonly note?: string;
}
@@ -45,6 +67,8 @@ export interface DungeonWorldObjectInteraction {
export interface DungeonWorldObjectVisual {
/** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */
readonly assetUrl?: string;
/** Source-adapted records stay hidden until their client display is reviewed. */
readonly hidden?: boolean;
readonly color?: string;
readonly emissive?: string;
readonly scale?: MobVector3;
@@ -111,6 +135,24 @@ export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition)
if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) {
throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`);
}
if (object.visual?.assetUrl && object.visual.hidden) {
throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`);
}
if (object.source?.gameObjectType !== undefined && (
!Number.isInteger(object.source.gameObjectType)
|| object.source.gameObjectType < 0
|| object.source.gameObjectType > 35
)) {
throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`);
}
const clientBounds = object.source?.clientBounds;
if (clientBounds && (
!clientBounds.min.every(Number.isFinite)
|| !clientBounds.max.every(Number.isFinite)
|| clientBounds.min.some((value, axis) => value >= clientBounds.max[axis])
)) {
throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`);
}
}
export function nearestDungeonWorldInteraction(
+16 -1
View File
@@ -20,7 +20,19 @@ export type EnemyDamageSchool =
| "shadow"
| "holy";
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
/**
* A persistent enemy aura that procs a short-lived, charged critical-strike
* modifier from successful main-hand auto attacks.
*/
export interface EnemyMeleeAutoCriticalProc {
readonly chance: number;
readonly triggeredAura: string;
readonly durationMs: number;
readonly criticalChancePercent: number;
readonly charges: number;
}
export type EnemyTelegraphShape = "circle" | "cone" | "line";
export type EnemyTelegraphAnchor = "source" | "target";
@@ -45,6 +57,7 @@ export type EnemyAbilityEffect =
readonly aura: string;
readonly durationMs: number;
readonly magnitude?: number;
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
}
| {
readonly kind: "target-aura";
@@ -55,6 +68,8 @@ export type EnemyAbilityEffect =
readonly kind: "control";
readonly mechanic: EnemyControlMechanic;
readonly durationMs: number;
/** Optional damage resolved against the same targets as the control. */
readonly damageMultiplier?: number;
}
| { readonly kind: "transform"; readonly form: string }
| {
+162
View File
@@ -250,6 +250,168 @@ describe("server-backed enemy ability runtime", () => {
}));
});
it("resolves compound enemy damage and control against the same target", () => {
useCombatStore.getState().registerMob("compound-hammer", {
name: "Compound Hammer",
maxHealth: 1_000,
attacks: [{
id: "hammer-pair",
name: "Hammer Pair",
delivery: "projectile",
target: "primary",
school: "holy",
animation: "cast",
castTimeMs: 0,
range: 20,
cooldownMs: 25_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [25_000, 25_000],
damageMultiplier: 1.3,
effect: {
kind: "control",
mechanic: "stun",
durationMs: 2_500,
damageMultiplier: 1.3,
},
}],
});
setMobPosition("compound-hammer", [2, 0, 0]);
useCombatStore.getState().damageMob("compound-hammer", 1, 1_000);
const healthBefore = useCombatStore.getState().health;
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
expect(useCombatStore.getState().health).toBeLessThan(healthBefore);
expect(useCombatStore.getState()).toMatchObject({
controlMechanic: "stun",
controlledUntil: 3_500,
});
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
sourceId: "compound-hammer",
targetId: PLAYER_AGGRO_ID,
expiresAt: 3_500,
}));
});
it("arms, limits, and consumes a next-melee critical proc only from main-hand autos", () => {
useCombatStore.getState().registerMob("melee-proc-boss-1", {
name: "Melee Proc Boss",
level: 20,
maxHealth: 1_000,
damageMultiplier: 0.05,
attacks: [
{
id: "vengeance-driver",
name: "Vengeance",
delivery: "area",
target: "self",
school: "holy",
animation: "cast",
castTimeMs: 0,
range: 0,
cooldownMs: 1,
initialCooldownMs: [0, 0],
repeatCooldownMs: [1, 1],
maximumCasts: 1,
damageMultiplier: 0,
effect: {
kind: "aura",
aura: "vengeance",
durationMs: 0,
meleeAutoCriticalProc: {
chance: 1,
triggeredAura: "vengeance-next-melee-critical",
durationMs: 6_000,
criticalChancePercent: 100,
charges: 1,
},
},
},
{
id: "melee-special",
name: "Melee Special",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 5,
cooldownMs: 1_000,
initialCooldownMs: [500, 500],
repeatCooldownMs: [1_000, 1_000],
damageMultiplier: 1,
},
{
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
target: "primary",
school: "physical",
animation: "attack",
castTimeMs: 0,
range: 4.5,
cooldownMs: 2_000,
initialCooldownMs: [1_000, 1_000],
repeatCooldownMs: [2_000, 2_000],
damageMultiplier: 1,
},
],
});
setMobPosition("melee-proc-boss-1", [2, 0, 0]);
useCombatStore.getState().damageMob("melee-proc-boss-1", 1, 1_000);
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
useCombatStore.getState().advanceMobCombat(1_500, [0, 0, 0]);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.map((aura) => aura.id))
.toEqual(["vengeance"]);
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(5);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual({
id: "vengeance-next-melee-critical",
sourceAbilityId: "vengeance-driver",
endsAt: 8_000,
nextMeleeCriticalChancePercent: 100,
charges: 1,
});
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 8_000, charges: 1 }),
);
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
outcome: "crit",
critical: true,
landed: true,
});
expect(useCombatStore.getState().lastCombatResult?.damageBeforeMitigation)
.toBe((useCombatStore.getState().lastCombatResult?.rawDamage ?? 0) * 2);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 10_000, charges: 1 }),
);
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
"melee-proc-boss-1": {
...state.mobs["melee-proc-boss-1"],
auras: state.mobs["melee-proc-boss-1"].auras.map((aura) => (
aura.id === "vengeance" ? { ...aura, meleeAutoCriticalProc: undefined } : aura
)),
},
},
}));
useCombatStore.getState().advanceMobCombat(5_000, [0, 0, 0]);
useCombatStore.getState().advanceMobCombat(6_000, [0, 0, 0]);
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.some(
(aura) => aura.id === "vengeance-next-melee-critical",
)).toBe(false);
});
it("honors encounter-state conditions before selecting an ability", () => {
useWailingEncounterStore.getState().reset();
useCombatStore.getState().registerMob("event-caster", {
@@ -1,2 +1,2 @@
// Generated by npm run dungeon:catalog. Do not edit.
export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI Line truncated
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
@@ -1 +1 @@
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
+13 -3
View File
@@ -6,17 +6,27 @@ import {
} from "./manastormStageAssets";
let initialized = false;
const generatedPackages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
export const GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT = registry.packages.length;
/** Resolves a runtime stage package to its independently downloadable content pack. */
export function generatedManastormContentPackId(assetPackageId: string): string | null {
const assetPackage = generatedPackages.find((candidate) => candidate.id === assetPackageId);
const assetUrl = assetPackage?.visual[0]?.url
?? assetPackage?.collision[0]?.url
?? assetPackage?.navigation.url;
const directory = /^\/assets\/game\/manastorm\/([^/]+)\//.exec(assetUrl ?? "")?.[1];
return directory ? `manastorm:${directory}` : null;
}
export function registerGeneratedManastormStageAssets(): readonly string[] {
const packages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
if (initialized) return Object.freeze(packages.map((assetPackage) => assetPackage.id));
if (initialized) return Object.freeze(generatedPackages.map((assetPackage) => assetPackage.id));
if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) {
throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`);
}
const registered: string[] = [];
for (const assetPackage of packages) {
for (const assetPackage of generatedPackages) {
registerManastormStageAssetPackage(assetPackage);
registered.push(assetPackage.id);
}
+7
View File
@@ -11,6 +11,7 @@ import {
preloadManastormStage,
releaseActiveManastormStage,
} from "./manastormStageLoader";
import { generatedManastormContentPackId } from "./manastormAssetRegistry";
const CHECKSUM = "a".repeat(64);
@@ -64,6 +65,12 @@ describe("Manastorm stage asset packages", () => {
]));
});
it("maps generated stages to the content pack needed by the download prompt", () => {
expect(generatedManastormContentPackId("manastorm:assets:43"))
.toBe("manastorm:43-wailingcaverns");
expect(generatedManastormContentPackId("missing-package")).toBeNull();
});
it("preloads one package, activates the stage, and releases the previous package", async () => {
registerManastormStageAssetPackage(assetPackage("first"));
registerManastormStageAssetPackage(assetPackage("second"));
@@ -0,0 +1,36 @@
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
import { describe, expect, it } from "vitest";
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
function navigationScene(): Group {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute([
0, 0, 0,
2, 0, 0,
0, 0, 2,
], 3));
const scene = new Group();
scene.add(new Mesh(geometry));
return scene;
}
describe("party navigation asset cache", () => {
it("reuses the graph prepared during expedition loading", () => {
const first = preparePartyNavigationGraph("/navigation/cache-test.glb", navigationScene(), []);
const second = preparePartyNavigationGraph("/navigation/cache-test.glb", new Group(), []);
expect(second).toBe(first);
expect(first.sourceTriangleCount).toBe(1);
});
it("keeps navigation variants with different authored links separate", () => {
const first = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), []);
const second = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), [{
id: "ledge",
start: [0, 0, 0],
end: [2, 0, 2],
bidirectional: true,
authoredReason: "Test link",
}]);
expect(second).not.toBe(first);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { BufferGeometry, type Group, type Mesh } from "three";
import type { DungeonNavigationLink } from "./dungeonTypes";
import {
buildPartyNavigationGraph,
type PartyNavigationGraph,
} from "./partyNavigation";
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>();
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
const links = offMeshLinks.map((link) => [
link.id,
link.start,
link.end,
link.bidirectional,
]);
return `${url}:${JSON.stringify(links)}`;
}
function navigationGeometry(scene: Group): BufferGeometry {
scene.updateMatrixWorld(true);
let sourceMesh: Mesh | null = null;
let largestVertexCount = 0;
scene.traverse((node) => {
const mesh = node as Mesh;
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
if (count > largestVertexCount) {
sourceMesh = mesh;
largestVertexCount = count;
}
});
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
const geometry = (sourceMesh as Mesh).geometry.clone();
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
return geometry;
}
export function preparePartyNavigationGraph(
url: string,
scene: Group,
offMeshLinks: readonly DungeonNavigationLink[],
): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks);
const cached = graphCache.get(cacheKey);
if (cached) {
graphCache.delete(cacheKey);
graphCache.set(cacheKey, cached);
return cached;
}
const geometry = navigationGeometry(scene);
let graph: PartyNavigationGraph;
try {
graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks,
});
} finally {
geometry.dispose();
}
graphCache.set(cacheKey, graph);
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
const oldest = graphCache.keys().next().value as string | undefined;
if (oldest) graphCache.delete(oldest);
}
return graph;
}
+13 -6
View File
@@ -29,6 +29,7 @@ import {
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import type { ActiveAura, DispelCategory } from "./combatAuras";
import {
@@ -121,13 +122,19 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
}] : [];
}
const game = useGameStore.getState();
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
const mobs = useCombatStore.getState().mobs;
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
.filter((objective) => !objective.optional);
if (game.activeDungeonId !== "wailing-caverns") return objectives;
const phase = useWailingEncounterStore.getState().phase;
return phase === "mutanus" || phase === "complete"
? objectives
: objectives.filter((objective) => objective.id !== "mutanus-spawn");
.filter((objective) => !objective.optional && !completed.has(objective.id));
const wailingPhase = game.activeDungeonId === "wailing-caverns"
? useWailingEncounterStore.getState().phase
: null;
return objectives.filter((objective) => (
!objective.eventOnly
|| Boolean(mobs[objective.id])
|| (objective.id === "mutanus-spawn"
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
));
}
export function resetPartyRuntimeClock(): void {
+21
View File
@@ -70,6 +70,8 @@ export interface GameState {
setAssetStatus: (status: AssetStatus, message?: string | null) => void;
updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void;
relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void;
/** Returns the party to its saved run spawn without requesting a full dungeon reset. */
resetAfterDefeat: () => void;
resetAtActiveSpawn: () => void;
resetAtEntrance: () => void;
}
@@ -227,6 +229,25 @@ export const useGameStore = create<GameState>((set) => ({
relocationRevision: state.relocationRevision + 1,
};
}),
resetAfterDefeat: () => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
overlay: null,
paused: false,
mapOpen: false,
cameraLookActive: false,
playerPosition: state.activeSpawn.footPosition,
playerNavigationPosition: state.activeSpawn.footPosition,
playerGrounded: true,
cameraYaw: state.activeSpawn.yaw,
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
distanceTravelled: 0,
hasMoved: false,
actionLayer: "primary",
resetRevision: state.resetRevision + 1,
relocationRevision: state.relocationRevision + 1,
};
}),
resetAtActiveSpawn: () => set((state) => {
const definition = requireDungeonDefinition(state.activeDungeonId);
return {
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog";
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiArtUrls, talentUiNodesForTree } from "./talentUiCatalog";
describe("talent UI catalog shards", () => {
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => {
@@ -17,4 +17,13 @@ describe("talent UI catalog shards", () => {
expect(talentUiNodesForTree(catalog, tree.id).every((node) => node.treeId === tree.id)).toBe(true);
}
});
it("returns each Priest talent artwork URL once", async () => {
const catalog = await loadTalentUiCatalog("priest");
const urls = talentUiArtUrls(catalog);
expect(loadedTalentUiCatalog("priest")).toBe(catalog);
expect(urls).toHaveLength(new Set(urls).size);
expect(urls).toContain("/assets/ui/talents/backgrounds/talent_priest1.png");
expect(urls).toContain("/assets/ui/talents/icons/spell_holy_powerwordshield.png");
});
});
+13
View File
@@ -50,6 +50,7 @@ interface TalentUiCatalogModule {
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
const loadedCatalogs = new Map<ClassId, TalentUiCatalog>();
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
const cached = loadCache.get(classId);
@@ -61,6 +62,7 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
}
loadedCatalogs.set(classId, module.default);
return module.default;
}).catch((error: unknown) => {
loadCache.delete(classId);
@@ -70,6 +72,10 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
return pending;
}
export function loadedTalentUiCatalog(classId: ClassId): TalentUiCatalog | null {
return loadedCatalogs.get(classId) ?? null;
}
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
const normalizedRank = Math.min(
node.maxRank,
@@ -91,3 +97,10 @@ export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string):
.filter((node) => node.treeId === treeId)
.sort((left, right) => left.row - right.row || left.index - right.index);
}
export function talentUiArtUrls(catalog: TalentUiCatalog): readonly string[] {
return [...new Set([
...catalog.trees.flatMap((tree) => [tree.icon, tree.background]),
...catalog.nodes.map((node) => node.icon),
])];
}
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import { requireDungeonDefinition } from "./dungeonRegistry";
import type { DungeonDifficultyVariant, DungeonSourceRecipe } from "./dungeonTypes";
const recipeModules = import.meta.glob<DungeonSourceRecipe>(
"../../dungeon-pipeline/recipes/*.json",
{ eager: true, import: "default" },
);
const TBC_DUNGEON_IDS = campaign.dungeons
.filter((dungeon) => dungeon.source === "burning-crusade")
.map((dungeon) => dungeon.slug);
function requireRecipe(dungeonId: string): DungeonSourceRecipe {
const path = `../../dungeon-pipeline/recipes/${dungeonId}.json`;
const recipe = recipeModules[path];
if (!recipe) throw new Error(`Missing Burning Crusade recipe: ${path}`);
return recipe;
}
function expectSelectableDifficultyContract(
label: string,
variants: readonly DungeonDifficultyVariant[],
): void {
const normal = variants.find((variant) => variant.id === "normal");
const heroic = variants.find((variant) => variant.id === "heroic");
expect(normal, `${label}:normal`).toMatchObject({
difficulty: "normal",
spawnMask: 1,
});
expect(heroic, `${label}:heroic`).toMatchObject({
difficulty: "heroic",
spawnMask: 2,
levelRange: [70, 70],
});
expect(new Set(variants.map((variant) => variant.id)).size, `${label}:unique-ids`)
.toBe(variants.length);
}
describe("Burning Crusade difficulty contract", () => {
it("advertises selectable normal and level-70 heroic variants in every recipe and compiled definition", () => {
expect(TBC_DUNGEON_IDS).toHaveLength(14);
for (const dungeonId of TBC_DUNGEON_IDS) {
const recipe = requireRecipe(dungeonId);
const definition = requireDungeonDefinition(dungeonId);
expect(recipe.slug, dungeonId).toBe(dungeonId);
expect(recipe.expansion, dungeonId).toBe("burning-crusade");
expectSelectableDifficultyContract(`${dungeonId}:recipe`, recipe.difficultyVariants);
expectSelectableDifficultyContract(`${dungeonId}:runtime`, definition.difficultyVariants);
}
});
it("only uses encounter difficulty guards that players can select", () => {
for (const dungeonId of TBC_DUNGEON_IDS) {
const definition = requireDungeonDefinition(dungeonId);
const selectableDifficultyIds = new Set(
definition.difficultyVariants.map((variant) => variant.id),
);
for (const encounter of definition.encounters ?? []) {
for (const rule of encounter.rules) {
for (const guard of rule.guards ?? []) {
if (guard.kind !== "difficulty") continue;
expect(guard.difficultyIds.length, `${dungeonId}:${encounter.id}:${rule.id}`)
.toBeGreaterThan(0);
for (const difficultyId of guard.difficultyIds) {
expect(
selectableDifficultyIds.has(difficultyId),
`${dungeonId}:${encounter.id}:${rule.id}:${difficultyId}`,
).toBe(true);
}
}
}
}
}
});
});
+41 -1
View File
@@ -114,12 +114,50 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
dungeonId: "wailing-caverns",
initialPhase: "locked",
resetPolicy: { bossWipePhase: "locked", checkpointPhase: "locked" },
source: {
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
files: [
"src/game/generated/azerothCoreReference.json",
"dungeon-pipeline/fixtures/wailing-caverns/source-snapshot.json",
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/instance_wailing_caverns.cpp",
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/wailing_caverns.h",
],
reviewedBehavior: "The four Fanglords unlock the Disciple of Naralex event, which advances through ravager, serpent, cleansing, and nightmare stages before summoning Mutanus and completing the dungeon.",
adaptations: [
"Disciple movement is deterministic waypoint interpolation using source positions and reviewed stage durations instead of server-side escort motion packets.",
"Wave safety timeouts prevent a missing or unreachable summon from permanently soft locking the event.",
],
remainingGaps: [
"Port the complete source dialogue/emote sequence and every waypoint pause trigger.",
"Bind the Disciple interaction to a reviewed source-backed NPC/gossip presentation instead of the current encounter signal adapter.",
"Record full traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest evidence.",
],
},
rules: [
{
id: "fanglords-defeated",
trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS },
guards: [{ kind: "phase", phases: ["locked"] }],
actions: [{ id: "unlock-naralex", kind: "set-phase", phase: "ready" }],
actions: [
{
id: "record-fanglords-complete",
kind: "set-flag",
flagId: "fanglords-complete",
value: true,
persistence: "run",
},
{ id: "unlock-naralex", kind: "set-phase", phase: "ready" },
],
},
{
id: "restore-ready-after-checkpoint",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["locked"] },
{ kind: "flag", flagId: "fanglords-complete", equals: true },
],
actions: [{ id: "restore-ready-phase", kind: "set-phase", phase: "ready" }],
},
{
id: "start-escort",
@@ -196,6 +234,8 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
actions: [
{ id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" },
{ id: "complete-mutanus-objective", kind: "complete-objective", objectiveId: "mutanus-spawn", persistence: "run" },
{ id: "save-mutanus-checkpoint", kind: "set-checkpoint", checkpointId: "mutanus-spawn", persistence: "checkpoint" },
{ id: "mark-wailing-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
],
},
],
+26
View File
@@ -136,5 +136,31 @@ describe("AzerothCore-backed Wailing Caverns parity slice", () => {
expect(useWailingEncounterStore.getState().phase).toBe("mutanus");
useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504);
expect(useWailingEncounterStore.getState().phase).toBe("complete");
expect(useWailingEncounterStore.getState().flags["dungeon-complete"]).toBe(true);
expect(useWailingEncounterStore.getState().checkpointId).toBe("mutanus-spawn");
});
it("restores the Naralex unlock after a checkpoint reload suppresses the cleared Fanglords", () => {
useWailingEncounterStore.getState().advance({
"lady-anacondra-spawn": deadMob,
"lord-cobrahn-spawn": deadMob,
"lord-pythas-spawn": deadMob,
"lord-serpentis-spawn": deadMob,
}, 1_000);
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "ready",
flags: { "fanglords-complete": true },
});
useWailingEncounterStore.getState().reset("checkpoint", 2_000);
expect(useWailingEncounterStore.getState()).toMatchObject({
phase: "locked",
flags: { "fanglords-complete": true },
});
// A loaded checkpoint intentionally has no live Fanglord runtime ids: the
// progression provider has already suppressed those completed bosses.
useWailingEncounterStore.getState().advance({}, 2_100);
expect(useWailingEncounterStore.getState().phase).toBe("ready");
});
});
@@ -0,0 +1,515 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import {
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS,
withWowClassicEncounterBatchThree,
} from "./wowClassicEncounterBatchThreeCatalog";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function original(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return definition;
}
function preadapted(id: string): DungeonDefinition {
return withWowEncounterContent(original(id));
}
function adapted(id: string): DungeonDefinition {
return withWowClassicEncounterBatchThree(preadapted(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-three`);
if (!value) throw new Error(`Missing Classic batch-three encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing encounter group ${id}`);
return value;
}
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
return matches[0];
}
function killGroup(
scripted: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
}
function staticEntries(definition: DungeonDefinition): readonly number[] {
return definition.staticSpawns.flatMap((spawn) => {
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
return entry === undefined ? [] : [entry];
});
}
describe("Classic source-backed encounter batch three", () => {
it("pins sources, validates references, suppresses event actors, remains idempotent, and preserves Solakar", () => {
const cases = [
["gnomeregan", [7_998]],
["dire-maul", [11_496]],
["blackrock-spire", [9_816, 10_316, 10_429, 10_264]],
] as const;
expect(WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId))
.toEqual(cases.map(([id]) => id));
const checkedPaths = new Set<string>();
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const sourceFile of review.sourceFiles) {
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(sourceFile.anchors.length).toBeGreaterThan(0);
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
if (!checkedPaths.has(sourceFile.path)) {
expect(createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex"), sourceFile.path)
.toBe(sourceFile.sha256);
checkedPaths.add(sourceFile.path);
}
}
}
const priorBRS = preadapted("blackrock-spire");
const priorSolakarEncounter = priorBRS.encounters?.find((value) => value.id === "blackrock-spire:wow-instance");
const priorSolakarGroups = priorBRS.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar"));
const priorFatherFlame = priorBRS.serverGameObjects.find((value) => value.source?.templateId === 175_245);
expect(priorSolakarEncounter).toBeDefined();
expect(priorSolakarGroups).toHaveLength(6);
expect(priorFatherFlame).toBeDefined();
for (const [id, suppressed] of cases) {
const definition = adapted(id);
const scripted = encounter(definition);
validateDungeonEncounterDefinition(scripted);
expect(scripted.source?.scope).toBe("instance-orchestration");
expect(withWowClassicEncounterBatchThree(definition)).toBe(definition);
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressed]));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
]);
const groupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
expect(objectIds.size).toBe(definition.serverGameObjects.length);
expect(groupIds.size).toBe(definition.encounterSpawnGroups?.length ?? 0);
const actionIds = new Set<string>();
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
for (const spawn of spawnGroup.spawns) expect(definition.entities[spawn.entityId], `${id}:${spawn.id}`).toBeDefined();
}
for (const rule of scripted.rules) {
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
}
for (const action of rule.actions) {
expect(actionIds.has(action.id), `${id}:duplicate action ${action.id}`).toBe(false);
actionIds.add(action.id);
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "complete-objective") {
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
}
}
const sourceSpawnIds = definition.serverGameObjects.flatMap((object) => (
object.source?.spawnId === undefined ? [] : [object.source.spawnId]
));
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
for (const object of definition.serverGameObjects.filter((candidate) => (
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
))) {
validateDungeonWorldObject(object);
expect(object.visual?.hidden).not.toBe(true);
}
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
}
const brs = adapted("blackrock-spire");
expect(brs.encounters?.find((value) => value.id === "blackrock-spire:wow-instance")).toEqual(priorSolakarEncounter);
expect(brs.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar")))
.toEqual(priorSolakarGroups);
expect(brs.serverGameObjects.find((value) => value.source?.templateId === 175_245)).toEqual(priorFatherFlame);
});
it("ports Gyth and Urok's pinned combat schedules instead of leaving event bosses basic-only", () => {
const definition = adapted("blackrock-spire");
const byEntry = (entry: number) => Object.values(definition.entities)
.find((entity) => entity.combat?.serverEntry === entry)!;
const gyth = byEntry(10_339);
const urok = byEntry(10_584);
expect(gyth.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "corrosive-acid-16359", spellId: 16_359, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "freeze-16350", spellId: 16_350, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "flame-breath-16390", spellId: 16_390, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
{ id: "knock-away-10101", spellId: 10_101, initial: [12_000, 18_000], repeat: [14_000, 20_000] },
]);
expect(gyth.combat?.mechanicIds).toEqual([
"corrosive-acid-16359",
"freeze-16350",
"flame-breath-16390",
"knock-away-10101",
]);
expect(gyth.combat?.attacks.find((attack) => attack.id === "corrosive-acid-16359"))
.toMatchObject({
target: "nearby-party",
school: "nature",
range: 20,
radius: 20,
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(gyth.combat?.attacks.find((attack) => attack.id === "freeze-16350"))
.toMatchObject({
target: "nearby-party",
school: "frost",
range: 20,
radius: 20,
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 },
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(gyth.combat?.attacks.find((attack) => attack.id === "flame-breath-16390"))
.toMatchObject({
target: "nearby-party",
school: "fire",
range: 20,
radius: 20,
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
});
expect(urok.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "rend-16509", spellId: 16_509, initial: [17_000, 20_000], repeat: [8_000, 10_000] },
{ id: "strike-15580", spellId: 15_580, initial: [10_000, 12_000], repeat: [8_000, 10_000] },
{ id: "intimidating-roar-16508", spellId: 16_508, initial: [25_000, 30_000], repeat: [40_000, 45_000] },
]);
expect(urok.combat?.attacks.find((attack) => attack.id === "intimidating-roar-16508"))
.toMatchObject({
target: "nearby-party",
range: 8,
radius: 8,
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 },
telegraph: { shape: "circle", anchor: "source", radius: 8 },
});
const review = WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
candidate.dungeonId === "blackrock-spire"
))!;
expect(review.remainingGaps.join(" ")).toContain("Freeze periodic damage");
expect(review.remainingGaps.join(" ")).toContain("split primary-root/nearby-fear targeting");
});
it("ports Crystal Fang's exact death summon while keeping the source-faithful rare boss melee-only", () => {
const definition = adapted("blackrock-spire");
const scripted = encounter(definition);
const crystalFang = definition.staticSpawns.find((spawn) => (
definition.entities[spawn.entityId]?.combat?.serverEntry === 10_376
))!;
const spiderling = group(definition, "blackrock-spire:batch-three:crystal-fang-spiderling");
expect(crystalFang.spawnChance).toBe(0.15);
expect(spiderling.spawns).toHaveLength(1);
expect(spiderling.spawns[0]).toMatchObject({
entityId: expect.stringContaining("entry-10375"),
position: crystalFang.position,
});
const snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "entity-death",
entityId: crystalFang.id,
now: 1,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(spiderling.id);
});
it("runs Gnomeregan's exact Emi group counts, cave-ins, retry, doors, and persistent Grubbis gate", () => {
const definition = adapted("gnomeregan");
const scripted = encounter(definition);
const groups = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"]
.map((name) => group(definition, `gnomeregan:batch-three:${name}`));
expect(groups.map((value) => value.spawns.length)).toEqual([10, 4, 5, 5, 2]);
expect(groups[2].spawns.map((spawn) => spawn.position)).toEqual(groups[3].spawns.map((spawn) => spawn.position));
const emi = definition.serverGameObjects.find((object) => object.id === "gnomeregan:batch-three:emi-shortfuse")!;
const caveOne = objectBySpawn(definition, 6_447);
const caveTwo = objectBySpawn(definition, 17_633);
const workshop = objectBySpawn(definition, 12_003);
const finalDoor = objectBySpawn(definition, 6_910);
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: workshop.id, now: 0 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: finalDoor.id, now: 1 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[0].id);
snapshot = killGroup(scripted, snapshot, groups[0], 3);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 4);
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[emi.id]).toBe("ready");
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("closed");
expect(snapshot.worldObjectStates[workshop.id]).toBe("open");
expect(snapshot.worldObjectStates[finalDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 10 }).snapshot;
snapshot = killGroup(scripted, snapshot, groups[0], 11);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_011 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[1].id);
snapshot = killGroup(scripted, snapshot, groups[1], 5_012);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 45_012 }).snapshot;
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(groups[2].id);
snapshot = killGroup(scripted, snapshot, groups[2], 45_013);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 48_013 }).snapshot;
snapshot = killGroup(scripted, snapshot, groups[3], 48_014);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 108_014 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groups[4].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "gnomeregan:batch-three:grubbis",
now: 108_015,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
expect(snapshot.worldObjectStates[emi.id]).toBe("complete");
expect(snapshot.checkpointId).toBe("gnomeregan-grubbis");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 108_016);
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
const fleet = definition.encounters?.find((value) => value.id === "gnomeregan:boss-progression")!;
const completion = fleet.rules.find((rule) => rule.id === "all-required-bosses-defeated")!;
expect(completion.guards).toContainEqual({
kind: "objective-complete",
objectiveId: "gnomeregan:batch-three:grubbis",
equals: true,
});
});
it("runs Dire Maul's pylons, Immol'thar recovery, east-wing wall, doors, and six tribute tiers", () => {
const definition = adapted("dire-maul");
const scripted = encounter(definition);
const pylons = [29_742, 29_743, 29_744, 29_745, 29_746].map((id) => objectBySpawn(definition, id));
const forcefield = objectBySpawn(definition, 29_747);
const courtyard = objectBySpawn(definition, 29_750);
const inner = objectBySpawn(definition, 29_756);
const conservatory = objectBySpawn(definition, 268_275);
const wall = objectBySpawn(definition, 268_276);
const ironbark = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:old-ironbark")!;
const tribute = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:gordok-tribute")!;
const immol = group(definition, "dire-maul:batch-three:immolthar");
const alzzin = group(definition, "dire-maul:batch-three:alzzin");
let snapshot = createDungeonEncounterSnapshot(scripted);
pylons.forEach((pylon, index) => {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pylon.id, now: index }).snapshot;
});
expect(snapshot.counters["pylons-off"]).toBe(5);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 10 }).snapshot;
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 11);
expect(snapshot.activeSpawnGroupIds).not.toContain(immol.id);
expect(snapshot.counters["pylons-off"]).toBe(5);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 12 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: immol.spawns[0].id, now: 13 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain(immol.spawns[0].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248429", now: 20 }).snapshot;
expect(snapshot.worldObjectStates[ironbark.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: ironbark.id, now: 21 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_021 }).snapshot;
expect(snapshot.worldObjectStates[conservatory.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 51, now: 7_022 }).snapshot;
expect(snapshot.worldObjectStates[wall.id]).toBe("closed");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 50, now: 7_023 }).snapshot;
expect(snapshot.worldObjectStates[wall.id]).toBe("open");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 7_024);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_025 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: alzzin.spawns[0].id, now: 7_026 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("dire-maul:batch-three:alzzin");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: courtyard.id, now: 8_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: inner.id, now: 8_001 }).snapshot;
expect(snapshot.worldObjectStates[courtyard.id]).toBe("open");
expect(snapshot.worldObjectStates[inner.id]).toBe("open");
for (const id of ["creature-248079", "creature-248080"]) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: id, now: snapshot.revision + 8_010 }).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248076", now: 9_000 }).snapshot;
expect(snapshot.counters["tribute-penalties"]).toBe(2);
expect(snapshot.worldObjectStates[tribute.id]).toBe("tier-4");
let perfect = createDungeonEncounterSnapshot(scripted);
perfect = reduceDungeonEncounter(scripted, perfect, { kind: "entity-death", entityId: "creature-248076", now: 1 }).snapshot;
expect(perfect.worldObjectStates[tribute.id]).toBe("tier-6");
const optionalIds = new Set(["creature-248077", "creature-248079", "creature-248080", "creature-248081", "creature-248082"]);
expect(definition.bosses.filter((boss) => optionalIds.has(boss.id)).every((boss) => boss.optional)).toBe(true);
const fleetCompletion = definition.encounters?.find((value) => value.id === "dire-maul:boss-progression")
?.rules.find((rule) => rule.id === "all-required-bosses-defeated");
expect(fleetCompletion?.guards?.some((guard) => guard.kind === "objective-complete" && optionalIds.has(guard.objectiveId)))
.toBe(false);
});
it("runs Blackrock Spire's runes, Emberseer reset, exact stadium, Drakkisath doors, and Urok retry", () => {
const definition = adapted("blackrock-spire");
const scripted = encounter(definition);
const hallRunes = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824].map((id) => objectBySpawn(definition, id));
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((id) => objectBySpawn(definition, id));
const emberIn = objectBySpawn(definition, 87_839);
const emberOut = objectBySpawn(definition, 87_841);
const emberDoors = objectBySpawn(definition, 87_842);
const emberTrigger = definition.serverGameObjects.find((object) => object.id === "blackrock-spire:batch-three:emberseer-trigger")!;
const incarcerators = group(definition, "blackrock-spire:batch-three:incarcerators");
const emberseer = group(definition, "blackrock-spire:batch-three:emberseer");
const runeMobs = [
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
["creature-137872", "creature-137896", "creature-137898"],
["creature-137870", "creature-137886", "creature-137929"],
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
];
let snapshot = createDungeonEncounterSnapshot(scripted);
runeMobs.forEach((ids, index) => {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: index + 1,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
});
expect(snapshot.counters["dragonspire-runes"]).toBe(7);
expect(hallRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "inactive")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 8 }).snapshot;
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
expect(snapshot.worldObjectStates[emberDoors.id]).toBe("open");
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 10 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_010 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(incarcerators.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2_011);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_012 }).snapshot;
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
expect(snapshot.activeSpawnGroupIds).not.toContain(incarcerators.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 3_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
snapshot = killGroup(scripted, snapshot, incarcerators, 5_001);
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "growing")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 30_001 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(emberseer.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: emberseer.spawns[0].id, now: 30_002 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
expect(snapshot.worldObjectStates[emberOut.id]).toBe("open");
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "released")).toBe(true);
const stadiumEntry = objectBySpawn(definition, 87_845);
const stadiumWaveDoor = objectBySpawn(definition, 87_843);
const stadiumExit = objectBySpawn(definition, 87_844);
const waves = Array.from({ length: 6 }, (_, index) => group(definition, `blackrock-spire:batch-three:stadium-wave-${index + 1}`));
expect(waves.map((value) => value.spawns.length)).toEqual([4, 3, 4, 4, 5, 5]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: stadiumEntry.id, now: 31_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 42_000 }).snapshot;
for (let index = 0; index < waves.length; index += 1) {
expect(snapshot.activeSpawnGroupIds).toContain(waves[index].id);
snapshot = killGroup(scripted, snapshot, waves[index], snapshot.phaseStartedAt + 1);
if (index < waves.length - 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 13_000 }).snapshot;
}
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 18_000 }).snapshot;
const gyth = group(definition, "blackrock-spire:batch-three:gyth");
const rend = group(definition, "blackrock-spire:batch-three:rend");
expect(snapshot.activeSpawnGroupIds).toContain(gyth.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 26, now: snapshot.phaseStartedAt + 1 }).snapshot;
expect(snapshot.activeSpawnGroupIds).not.toContain(rend.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 25, now: snapshot.phaseStartedAt + 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(rend.id);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: gyth.spawns[0].id, now: snapshot.phaseStartedAt + 3 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: rend.spawns[0].id, now: snapshot.phaseStartedAt + 4 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 5 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
expect(snapshot.worldObjectStates[stadiumEntry.id]).toBe("complete");
expect(snapshot.worldObjectStates[stadiumWaveDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[stadiumExit.id]).toBe("open");
const drakkDoors = [objectBySpawn(definition, 87_854), objectBySpawn(definition, 87_855)];
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-137971", now: snapshot.phaseStartedAt + 6 }).snapshot;
expect(drakkDoors.every((door) => snapshot.worldObjectStates[door.id] === "open")).toBe(true);
const pile = objectBySpawn(definition, 99_791);
const urok = group(definition, "blackrock-spire:batch-three:urok");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 7 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(urok.id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", snapshot.phaseStartedAt + 8);
expect(snapshot.worldObjectStates[pile.id]).toBe("ready");
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 9 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: urok.spawns[0].id, now: snapshot.phaseStartedAt + 10 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("blackrock-spire:batch-three:urok-doomhowl");
expect(snapshot.worldObjectStates[pile.id]).toBe("complete");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,482 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS,
withWowClassicEncounterBatchTwo,
} from "./wowClassicEncounterBatchTwoCatalog";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function original(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return definition;
}
function adapted(id: string): DungeonDefinition {
return withWowClassicEncounterBatchTwo(original(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-two`);
if (!value) throw new Error(`Missing Classic batch-two encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing encounter group ${id}`);
return value;
}
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
return matches[0];
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
) {
const entityIds = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: entityIds, deadEntityIds: entityIds },
}).snapshot;
}
function staticEntries(definition: DungeonDefinition): readonly number[] {
return definition.staticSpawns.flatMap((spawn) => {
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
return entry === undefined ? [] : [entry];
});
}
describe("Classic source-backed encounter batch two", () => {
it("pins exact source hashes, validates every reference, suppresses event actors, and stays idempotent", () => {
const cases = [
["sunken-temple", [8_580, 8_443, 5_709]],
["blackrock-depths", [9_035, 9_038, 9_040, 9_037, 9_036, 9_034, 9_039]],
["scholomance", [1_853]],
["stratholme", [10_440]],
] as const;
expect(WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId))
.toEqual(cases.map(([id]) => id));
const verifiedPaths = new Set<string>();
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const sourceFile of review.sourceFiles) {
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(sourceFile.anchors.length).toBeGreaterThan(0);
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
if (!verifiedPaths.has(sourceFile.path)) {
const actualHash = createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex");
expect(actualHash, sourceFile.path).toBe(sourceFile.sha256);
verifiedPaths.add(sourceFile.path);
}
}
}
for (const [id, suppressedEntries] of cases) {
const definition = adapted(id);
const scripted = encounter(definition);
validateDungeonEncounterDefinition(scripted);
expect(withWowClassicEncounterBatchTwo(definition)).toBe(definition);
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
]);
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
for (const spawn of spawnGroup.spawns) {
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
}
}
for (const rule of scripted.rules) {
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
}
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "complete-objective") {
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
}
}
const sourceSpawnIds = definition.serverGameObjects
.flatMap((object) => object.source?.spawnId === undefined ? [] : [object.source.spawnId]);
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
for (const object of definition.serverGameObjects.filter((candidate) => (
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
))) {
validateDungeonWorldObject(object);
expect(object.visual?.hidden).not.toBe(true);
}
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
}
});
it("runs Sunken Temple's ordered statues, ritual, defender gate, and Eranikus recovery", () => {
const definition = adapted("sunken-temple");
const scripted = encounter(definition);
const statues = [27_898, 27_899, 28_111, 28_112, 28_113, 28_114]
.map((spawnId) => objectBySpawn(definition, spawnId));
const forcefield = objectBySpawn(definition, 268_932);
const altar = objectBySpawn(definition, 11_922);
const flames = [47_561, 47_562, 47_563, 47_564].map((spawnId) => objectBySpawn(definition, spawnId));
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: statues[1].id, now: 1 }).snapshot;
expect(snapshot.counters["statue-progress"]).toBe(0);
for (let index = 0; index < statues.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "interact",
objectId: statues[index].id,
now: index + 2,
}).snapshot;
}
expect(snapshot).toMatchObject({ phase: "atal-alarion-active", counters: { "statue-progress": 6 } });
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 20);
expect(snapshot).toMatchObject({ phase: "recovery", counters: { "statue-progress": 6 }, activeSpawnGroupIds: [] });
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:atal-alarion"), 21);
const atalAlarion = definition.bosses.find((objective) => objective.name === "Atal'alarion")!;
expect(snapshot.completedObjectiveIds).toContain(atalAlarion.id);
const defenderIds = ["creature-39843", "creature-39844", "creature-39845", "creature-39846", "creature-34522", "creature-39847"];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 22,
facts: { registeredEntityIds: defenderIds, deadEntityIds: defenderIds },
}).snapshot;
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 30 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:hakkar-pack-1");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 31);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 31 }).snapshot;
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[altar.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 40 }).snapshot;
for (let index = 0; index < 4; index += 1) {
const ritualGroup = group(definition, `sunken-temple:hakkar-pack-${index + 1}`);
snapshot = killGroup(scripted, snapshot, ritualGroup, 41 + index * 2);
expect(snapshot.worldObjectStates[flames[index].id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "interact",
objectId: flames[index].id,
now: 42 + index * 2,
}).snapshot;
}
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:avatar-of-hakkar");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:avatar-of-hakkar"), 60);
const avatar = definition.bosses.find((objective) => objective.name === "Avatar of Hakkar")!;
expect(snapshot.completedObjectiveIds).toContain(avatar.id);
expect(flames.every((flame) => snapshot.worldObjectStates[flame.id] === "doused")).toBe(true);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-39737", now: 70 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 71);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 71 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:shade-of-eranikus"), 72);
const eranikus = definition.bosses.find((objective) => objective.name === "Shade of Eranikus")!;
expect(snapshot.completedObjectiveIds).toContain(eranikus.id);
expect(snapshot.checkpointId).toBe("sunken-temple-eranikus");
});
it("runs BRD's deterministic arena, twelve-coffer vault, and exact seven-stage tomb with retry", () => {
const definition = adapted("blackrock-depths");
const scripted = encounter(definition);
const arenaGate4 = objectBySpawn(definition, 15_309);
const arenaGate3 = objectBySpawn(definition, 15_295);
const arenaGroups = [
group(definition, "blackrock-depths:ring-wave-1"),
group(definition, "blackrock-depths:ring-wave-2"),
group(definition, "blackrock-depths:ring-boss"),
];
expect(arenaGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([3, 3, 1]);
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "interact",
objectId: arenaGate4.id,
now: 0,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_001);
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("ready");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: arenaGate4.id, now: 6_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 11_000 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[0], 11_001);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 16_001 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[1], 16_002);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 21_002 }).snapshot;
snapshot = killGroup(scripted, snapshot, arenaGroups[2], 21_003);
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:ring-of-law-gorosh");
expect(snapshot.worldObjectStates[arenaGate3.id]).toBe("open");
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("open");
const coffers = [15_306, 15_329, 15_330, 15_331, 15_363, 15_364, 15_365, 15_536, 17_904, 35_801, 35_864, 39_924]
.map((spawnId) => objectBySpawn(definition, spawnId));
for (let index = 0; index < 6; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 30 + index }).snapshot;
}
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 40);
expect(snapshot.counters["opened-coffers"]).toBe(6);
expect(coffers.slice(0, 6).every((coffer) => snapshot.worldObjectStates[coffer.id] === "open")).toBe(true);
for (let index = 6; index < coffers.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 41 + index }).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 60 }).snapshot;
const watchman = group(definition, "blackrock-depths:vault-watchman");
expect(snapshot.activeSpawnGroupIds).toContain(watchman.id);
snapshot = killGroup(scripted, snapshot, watchman, 61);
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:watchman-doomgrip");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 43_094).id]).toBe("open");
const marker = definition.serverGameObjects.find((object) => object.id === "blackrock-depths:doomrel-marker")!;
const tombGroups = Array.from({ length: 7 }, (_, index) => group(definition, `blackrock-depths:tomb-spectral-${index + 1}`));
expect(tombGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1, 1, 1, 1, 1]);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 100 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1_100 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(tombGroups[0].id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1_101);
expect(snapshot).toMatchObject({ phase: "idle", counters: { "tomb-kills": 0 }, activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[marker.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 2_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 3_000 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: tombGroups[0].spawns[0].id,
now: 3_001,
}).snapshot;
for (let index = 1; index < tombGroups.length; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: snapshot.phaseStartedAt + 30_000,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: tombGroups[index].spawns[0].id,
now: snapshot.phaseStartedAt + 1,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 2 }).snapshot;
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:tomb-of-seven");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 67_872).id]).toBe("available");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 15_334).id]).toBe("open");
});
it("gates Scholomance completion on six minibosses and a wipe-safe Gandling reveal", () => {
const definition = adapted("scholomance");
const scripted = encounter(definition);
const brazier = objectBySpawn(definition, 43_208);
const kirtonosGate = objectBySpawn(definition, 18_892);
const gandlingEntrance = objectBySpawn(definition, 43_201);
const progression = definition.encounters?.find((candidate) => candidate.id === "scholomance:boss-progression")!;
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
kind: "interact",
objectId: brazier.id,
now: 0,
}).snapshot;
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1 }).snapshot;
expect(snapshot.worldObjectStates[brazier.id]).toBe("ready");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: brazier.id, now: 2 }).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_002 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:kirtonos");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "scholomance:kirtonos-the-herald",
now: 5_003,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("scholomance:kirtonos-the-herald");
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("open");
const miniBossIds = ["creature-48867", "creature-48925", "creature-48946", "creature-48863", "creature-48857", "creature-48854"];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 6_000,
facts: { registeredEntityIds: miniBossIds, deadEntityIds: miniBossIds },
}).snapshot;
expect(snapshot.phase).toBe("gandling-reveal");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_001);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_001 }).snapshot;
expect(snapshot).toMatchObject({ phase: "gandling-ready", flags: { "gandling-unlocked": true } });
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:gandling");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-engaged",
entityId: "creature-247104",
now: 6_002,
}).snapshot;
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_003);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_003 }).snapshot;
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "creature-247104",
now: 6_004,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-247104");
const fleetRequired = definition.bosses.filter((objective) => !objective.optional && !objective.eventOnly);
for (const objective of fleetRequired) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: objective.id,
now: snapshot.revision + 10_000,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20_000 }).snapshot;
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
});
it("persists and resumes Stratholme's exact 3-to-13-to-1-to-33-to-5 slaughter chain", () => {
const definition = adapted("stratholme");
const scripted = encounter(definition);
const acolytes = Array.from({ length: 3 }, (_, index) => group(definition, `stratholme:ziggurat-${index + 1}-acolyte`));
const zigguratBosses = ["creature-54238", "creature-54239", "creature-54240"];
const zigguratDoors = [11_415, 11_414, 11_416].map((spawnId) => objectBySpawn(definition, spawnId));
const mindless = group(definition, "stratholme:mindless-undead");
const guards = group(definition, "stratholme:black-guards");
expect(acolytes.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1]);
expect(mindless.spawns).toHaveLength(33);
expect(guards.spawns).toHaveLength(5);
let snapshot = createDungeonEncounterSnapshot(scripted);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: zigguratBosses[0], now: 1 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2);
expect(snapshot.worldObjectStates[zigguratDoors[0].id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: acolytes[0].spawns[0].id,
now: 3,
}).snapshot;
for (let index = 1; index < 3; index += 1) {
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: zigguratBosses[index],
now: 4 + index * 2,
}).snapshot;
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: acolytes[index].spawns[0].id,
now: 5 + index * 2,
}).snapshot;
}
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
expect(snapshot.flags["ziggurats-complete"]).toBe(true);
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_911).id]).toBe("open");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_908).id]).toBe("open");
const slaughterIds = [
"creature-53969", "creature-54002", "creature-54018", "creature-54019",
"creature-54020", "creature-54021", "creature-54022", "creature-54026", "creature-54027",
"creature-54039", "creature-54040", "creature-54041", "creature-54050",
];
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "tick",
now: 21,
facts: { registeredEntityIds: slaughterIds, deadEntityIds: slaughterIds },
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 22);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 22 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "stratholme:ramstein-the-gorger",
now: 23,
}).snapshot;
expect(snapshot.phase).toBe("mindless-delay");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "checkpoint", 24);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 24 }).snapshot;
expect(snapshot.phase).toBe("mindless-delay");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_024 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(mindless.id);
snapshot = killGroup(scripted, snapshot, mindless, 5_025);
expect(snapshot.phase).toBe("black-guard-delay");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_026);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_026 }).snapshot;
expect(snapshot.phase).toBe("black-guard-delay");
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_026 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(guards.id);
expect(snapshot.worldObjectStates[objectBySpawn(definition, 11_808).id]).toBe("open");
snapshot = killGroup(scripted, snapshot, guards, 25_027);
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:baron-rivendare");
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-engaged",
entityId: "creature-54241",
now: 25_028,
}).snapshot;
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 25_029);
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_029 }).snapshot;
expect(snapshot).toMatchObject({ phase: "baron-ready", flags: { "guards-defeated": true } });
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
snapshot = reduceDungeonEncounter(scripted, snapshot, {
kind: "entity-death",
entityId: "creature-54241",
now: 25_030,
}).snapshot;
expect(snapshot.completedObjectiveIds).toContain("creature-54241");
expect(snapshot.checkpointId).toBe("creature-54241");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,334 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS,
withWowClassicEncounterContent,
} from "./wowClassicEncounterContentCatalog";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function adapted(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return withWowClassicEncounterContent(definition);
}
function classicEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:classic-instance`);
if (!encounter) throw new Error(`Missing Classic encounter for ${definition.id}`);
return encounter;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing spawn group ${id}`);
return value;
}
function objectByTemplate(definition: DungeonDefinition, templateId: number) {
const value = definition.serverGameObjects.find((object) => object.source?.templateId === templateId);
if (!value) throw new Error(`Missing gameobject template ${templateId}`);
return value;
}
function killGroup(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
) {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
});
}
function staticEntries(definition: DungeonDefinition): readonly number[] {
return definition.staticSpawns.flatMap((spawn) => {
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
return entry === undefined ? [] : [entry];
});
}
describe("Classic source-backed dungeon encounter content", () => {
it("pins source reviews, validates references, suppresses event bosses, and is idempotent", () => {
const cases = [
["blackfathom-deeps", []],
["uldaman", [2_748]],
["razorfen-downs", [7_355, 7_356]],
["zulfarrak", [7_273]],
] as const;
expect(WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(cases.map(([id]) => id));
for (const review of WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.length).toBeGreaterThan(0);
for (const sourceFile of review.sourceFiles) {
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(sourceFile.anchors.length).toBeGreaterThan(0);
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
}
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
}
for (const [id, suppressedEntries] of cases) {
const definition = adapted(id);
const encounter = classicEncounter(definition);
validateDungeonEncounterDefinition(encounter);
expect(withWowClassicEncounterContent(definition)).toBe(definition);
if (suppressedEntries.length > 0) {
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
}
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
for (const spawn of spawnGroup.spawns) {
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
}
}
for (const rule of encounter.rules) {
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
expect(objectIds.has(action.objectId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
if (action.kind === "complete-objective") {
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
}
}
}
for (const object of definition.serverGameObjects.filter((candidate) => (
encounter.initialState?.worldObjectStates?.[candidate.id] !== undefined
))) {
validateDungeonWorldObject(object);
expect(object.visual?.hidden).not.toBe(true);
}
for (const entry of suppressedEntries) {
const entityIds = new Set(Object.entries(definition.entities)
.filter(([, entity]) => entity.combat?.serverEntry === entry)
.map(([entityId]) => entityId));
const objective = definition.bosses.find((boss) => definition.encounterSpawnGroups?.some(
(spawnGroup) => spawnGroup.spawns.some((spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId)),
));
expect(objective, `${id}:${entry}`).toMatchObject({ eventOnly: true });
}
}
});
it("runs all four Blackfathom fires, opens the physical portal, and persists its checkpoint", () => {
const definition = adapted("blackfathom-deeps");
const encounter = classicEncounter(definition);
const fires = [21_118, 21_119, 21_120, 21_121].map((entry) => objectByTemplate(definition, entry));
let snapshot = createDungeonEncounterSnapshot(encounter);
fires.forEach((fire, index) => {
snapshot = reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: fire.id,
now: index * 10,
}).snapshot;
const spawnGroup = group(definition, `blackfathom-deeps:fire-${index + 1}-pack`);
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
snapshot = killGroup(encounter, snapshot, spawnGroup, index * 10 + 1).snapshot;
expect(snapshot.worldObjectStates[fire.id]).toBe("lit");
});
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100 }).snapshot;
const portal = objectByTemplate(definition, 21_117);
expect(snapshot).toMatchObject({ counters: { "completed-fires": 4 }, checkpointId: "blackfathom-fire-event" });
expect(snapshot.worldObjectStates[portal.id]).toBe("open");
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 200);
expect(reset.worldObjectStates[portal.id]).toBe("open");
expect(fires.every((fire) => reset.worldObjectStates[fire.id] === "lit")).toBe(true);
expect(reset.activeSpawnGroupIds).toEqual([]);
});
it("opens Uldaman's seal, awakens four keepers with wipe recovery, and source-gates Archaedas", () => {
const definition = adapted("uldaman");
const encounter = classicEncounter(definition);
const keystone = objectByTemplate(definition, 124_371);
const seal = objectByTemplate(definition, 124_372);
const keeperAltar = objectByTemplate(definition, 130_511);
const keeperDoor = objectByTemplate(definition, 124_367);
const archaedasAltar = objectByTemplate(definition, 133_234);
const archaedasRoom = objectByTemplate(definition, 141_869);
const vault = objectByTemplate(definition, 124_369);
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact",
objectId: keystone.id,
now: 0,
}).snapshot;
expect(snapshot.worldObjectStates[seal.id]).toBe("open");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 1 }).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:stone-keeper-1"), 2).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 3 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["uldaman:stone-keeper-2"]);
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 4);
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 1 } });
expect(snapshot.activeSpawnGroupIds).toEqual([]);
for (let number = 2; number <= 4; number += 1) {
snapshot = reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: keeperAltar.id,
now: number * 10,
}).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, `uldaman:stone-keeper-${number}`), number * 10 + 1).snapshot;
}
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 4 }, checkpointId: "uldaman-stone-keepers" });
expect(snapshot.worldObjectStates[keeperDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 50 }).snapshot;
expect(snapshot).toMatchObject({ phase: "archaedas-active", activeSpawnGroupIds: ["uldaman:archaedas"] });
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 51);
expect(snapshot).toMatchObject({ phase: "active", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 52 }).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:archaedas"), 53).snapshot;
const objective = definition.bosses.find((boss) => boss.name === "Archaedas")!;
expect(snapshot.completedObjectiveIds).toContain(objective.id);
expect(snapshot.worldObjectStates[vault.id]).toBe("open");
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
});
it("recovers Razorfen's gong and runs the full timed Belnistrasz event", () => {
const definition = adapted("razorfen-downs");
const encounter = classicEncounter(definition);
const gong = objectByTemplate(definition, 148_917);
const gongGroupIds = [
"razorfen-downs:gong-fiends",
"razorfen-downs:gong-reavers",
"razorfen-downs:tutenkash",
];
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact",
objectId: gong.id,
now: 0,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([gongGroupIds[0]]);
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 1 }).snapshot;
expect(snapshot).toMatchObject({ phase: "idle", counters: { "gong-round": 0 } });
for (let index = 0; index < gongGroupIds.length; index += 1) {
snapshot = reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: gong.id,
now: 10 + index * 2,
}).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, gongGroupIds[index]), 11 + index * 2).snapshot;
}
const tutenkash = definition.bosses.find((boss) => boss.name === "Tuten'kash")!;
expect(snapshot).toMatchObject({ counters: { "gong-round": 3 } });
expect(snapshot.completedObjectiveIds).toContain(tutenkash.id);
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
const belnistrasz = definition.serverGameObjects.find((object) => object.id === "razorfen-downs:belnistrasz")!;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: belnistrasz.id, now: 100 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 8_100 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("razorfen-downs:idol-wave-1");
const wiped = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 8_101);
const recovered = reduceDungeonEncounter(encounter, wiped, { kind: "tick", now: 8_101 }).snapshot;
expect(recovered).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(recovered.worldObjectStates[belnistrasz.id]).toBe("ready");
snapshot = reduceDungeonEncounter(encounter, recovered, { kind: "interact", objectId: belnistrasz.id, now: 9_000 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 17_000 }).snapshot;
for (let wave = 2; wave <= 8; wave += 1) {
snapshot = reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: snapshot.phaseStartedAt + 35_000,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(`razorfen-downs:idol-wave-${wave}`);
}
const plaguemawGroup = group(definition, "razorfen-downs:idol-wave-8");
snapshot = killGroup(encounter, snapshot, plaguemawGroup, snapshot.phaseStartedAt + 1).snapshot;
const plaguemaw = definition.bosses.find((boss) => boss.name === "Plaguemaw the Rotting")!;
expect(snapshot.completedObjectiveIds).toContain(plaguemaw.id);
snapshot = reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: snapshot.phaseStartedAt + 55_000,
}).snapshot;
expect(snapshot).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
const fires = definition.serverGameObjects.filter((object) => [151_951, 151_952, 151_973].includes(object.source?.templateId ?? -1));
expect(fires.every((fire) => snapshot.worldObjectStates[fire.id] === "extinguished")).toBe(true);
const resetComplete = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", snapshot.phaseStartedAt + 1);
const resumedComplete = reduceDungeonEncounter(encounter, resetComplete, { kind: "tick", now: snapshot.phaseStartedAt + 1 }).snapshot;
expect(resumedComplete).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
});
it("runs Zul'Farrak's exact 23/24/7 pyramid and independently recovers the Gahz'rilla gong", () => {
const definition = adapted("zulfarrak");
const encounter = classicEncounter(definition);
const groups = [1, 2, 3].map((wave) => group(definition, `zulfarrak:pyramid-wave-${wave}`));
expect(groups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([23, 24, 7]);
const cages = definition.serverGameObjects.filter((object) => object.name === "Troll Cage");
const endDoor = objectByTemplate(definition, 146_084);
const weegli = definition.serverGameObjects.find((object) => object.id === "zulfarrak:weegli-gate-charge")!;
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact",
objectId: cages[0].id,
now: 0,
}).snapshot;
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "open")).toBe(true);
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "closed")).toBe(true);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: cages[2].id, now: 2 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_002 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([groups[0].id]);
snapshot = killGroup(encounter, snapshot, groups[0], 5_003).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 15_003 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([groups[1].id]);
snapshot = killGroup(encounter, snapshot, groups[1], 15_004).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual([groups[2].id]);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_004 }).snapshot;
snapshot = killGroup(encounter, snapshot, groups[2], 20_005).snapshot;
expect(snapshot).toMatchObject({ phase: "pyramid-aftermath", activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[weegli.id]).toBe("ready");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: weegli.id, now: 20_006 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 24_006 }).snapshot;
expect(snapshot).toMatchObject({ phase: "pyramid-complete", flags: { "pyramid-complete": true }, checkpointId: "zulfarrak-pyramid" });
expect(snapshot.worldObjectStates[endDoor.id]).toBe("open");
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", 24_007);
expect(reset.worldObjectStates[endDoor.id]).toBe("open");
expect(cages.every((cage) => reset.worldObjectStates[cage.id] === "open")).toBe(true);
const gong = objectByTemplate(definition, 141_832);
snapshot = reduceDungeonEncounter(encounter, reset, { kind: "interact", objectId: gong.id, now: 25_000 }).snapshot;
const gahzrillaGroup = group(definition, "zulfarrak:gahzrilla");
expect(snapshot.activeSpawnGroupIds).toEqual([gahzrillaGroup.id]);
const gongWipe = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 25_001);
expect(gongWipe.worldObjectStates[gong.id]).toBe("ready");
snapshot = reduceDungeonEncounter(encounter, gongWipe, { kind: "interact", objectId: gong.id, now: 25_002 }).snapshot;
snapshot = killGroup(encounter, snapshot, gahzrillaGroup, 25_003).snapshot;
const gahzrilla = definition.bosses.find((boss) => boss.name === "Ghaz'rilla")!;
expect(snapshot.completedObjectiveIds).toContain(gahzrilla.id);
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
});
});
File diff suppressed because it is too large. Load diff
+32 -10
View File
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
import {
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
} from "./wowDungeonContentExceptions";
import {
createMobAnimationTransform,
getMobAnimationPhase,
@@ -15,14 +19,8 @@ import {
const publicRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../public");
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
const basicOnlyBossExceptions = new Set([
"wailing-caverns:kresh",
"wailing-caverns:deviate-faerie-dragon",
"blackrock-spire:entry-9218",
"blackrock-spire:entry-9219",
"blackrock-spire:entry-10376",
]);
const noTrashExceptions = new Set(["trial-of-the-champion"]);
const basicOnlyBossExceptions = new Set(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key }) => key));
const noTrashExceptions = new Set(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key }) => key));
const animationCache = new Map<string, Promise<readonly { readonly name: string }[]>>();
async function readAnimations(url: string): Promise<readonly { readonly name: string }[]> {
@@ -54,9 +52,23 @@ async function readAnimations(url: string): Promise<readonly { readonly name: st
}
describe("WoW dungeon combat and animation coverage", () => {
it("gives all 54 campaign dungeons complete executable populations", async () => {
it("gives all 54 campaign dungeons runtime-spawnable combat populations", async () => {
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
expect(dungeons).toHaveLength(54);
expect(basicOnlyBossExceptions.size).toBe(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length);
expect(noTrashExceptions.size).toBe(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length);
const actualBasicOnlyBosses = dungeons.flatMap((dungeon) => Object.entries(dungeon.entities)
.filter(([, entity]) => (
entity.kind === "boss"
&& !(entity.combat?.attacks ?? []).some((attack) => attack.id !== "basic-attack")
))
.map(([entityId]) => `${dungeon.id}:${entityId}`))
.sort();
expect(actualBasicOnlyBosses).toEqual([...basicOnlyBossExceptions].sort());
expect(dungeons
.filter((dungeon) => !Object.values(dungeon.entities).some((entity) => entity.kind === "mob"))
.map((dungeon) => dungeon.id)
.sort()).toEqual([...noTrashExceptions].sort());
for (const dungeon of dungeons) {
const entities = Object.values(dungeon.entities);
@@ -68,7 +80,9 @@ describe("WoW dungeon combat and animation coverage", () => {
expect(mobs.length, dungeon.id + ": mobs").toBeGreaterThan(0);
}
expect(
dungeon.staticSpawns.length + dungeon.roamingPacks.length,
dungeon.staticSpawns.length
+ dungeon.roamingPacks.length
+ (dungeon.encounterSpawnGroups?.reduce((count, group) => count + group.spawns.length, 0) ?? 0),
dungeon.id + ": population",
).toBeGreaterThan(0);
@@ -86,6 +100,14 @@ describe("WoW dungeon combat and animation coverage", () => {
).toBeDefined();
}
}
for (const group of dungeon.encounterSpawnGroups ?? []) {
for (const spawn of group.spawns) {
expect(
dungeon.entities[spawn.entityId],
dungeon.id + ":" + group.id + ":" + spawn.id,
).toBeDefined();
}
}
for (const entity of entities) {
const key = dungeon.id + ":" + entity.id;
+35
View File
@@ -0,0 +1,35 @@
export interface WowDungeonContentException {
readonly key: string;
readonly reason: string;
}
/** Exact runtime entity keys for source-verified bosses whose only attack is melee. */
export const WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([
Object.freeze({
key: "wailing-caverns:kresh",
reason: "Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
}),
Object.freeze({
key: "wailing-caverns:deviate-faerie-dragon",
reason: "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
}),
Object.freeze({
key: "gnomeregan:wow-classic-batch-three-entry-7361",
reason: "Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast.",
}),
Object.freeze({
key: "blackrock-spire:entry-9218",
reason: "Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
}),
Object.freeze({
key: "blackrock-spire:entry-9219",
reason: "Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
}),
Object.freeze({
key: "blackrock-spire:entry-10376",
reason: "Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter.",
}),
]);
/** Exact dungeon keys allowed to contain no mob-kind combat actors. */
export const WOW_DUNGEON_NO_TRASH_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([]);
+352
View File
@@ -0,0 +1,352 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_ENCOUNTER_CONTENT_REVIEWS,
WOW_ENCOUNTER_NEXT_BATCH,
withWowEncounterContent,
} from "./wowEncounterContentCatalog";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function adapted(id: string): DungeonDefinition {
const definition = campaign.definitions.find((candidate) => candidate.id === id);
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
return withWowEncounterContent(definition);
}
function wowEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:wow-instance`);
if (!encounter) throw new Error(`Missing WoW encounter for ${definition.id}`);
return encounter;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing spawn group ${id}`);
return value;
}
function staticEntries(definition: DungeonDefinition): readonly number[] {
return definition.staticSpawns.flatMap((spawn) => {
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
return entry === undefined ? [] : [entry];
});
}
function killGroup(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
) {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
});
}
describe("source-backed WoW encounter content", () => {
it("removes every adapted event boss from static population and validates its encounter", () => {
const cases = [
["black-morass", [17_879, 17_880, 17_881]],
["violet-hold", [29_315, 29_316, 31_134]],
["culling-of-stratholme", [26_529, 26_530, 26_532, 26_533]],
["escape-from-durnholde", [17_848, 17_862, 18_096]],
["arcatraz", [20_912]],
["blackrock-spire", [10_264]],
] as const;
for (const [id, entries] of cases) {
const definition = adapted(id);
const encounter = wowEncounter(definition);
validateDungeonEncounterDefinition(encounter);
expect(encounter.source?.fidelity).toBe("source-backed-adapted");
expect(encounter.source?.scope).toBe("instance-orchestration");
const remainingStaticEntries = staticEntries(definition);
for (const entry of entries) {
expect(remainingStaticEntries, `${id} static entry ${entry}`).not.toContain(entry);
const entityIds = new Set(Object.entries(definition.entities)
.filter(([, entity]) => entity.combat?.serverEntry === entry)
.map(([entityId]) => entityId));
const objective = definition.bosses.find((boss) => (
definition.encounterSpawnGroups?.some((spawnGroup) => spawnGroup.spawns.some(
(spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId),
))
));
expect(objective, `${id} entry ${entry}`).toMatchObject({ eventOnly: true });
}
expect(withWowEncounterContent(definition)).toBe(definition);
}
expect(staticEntries(adapted("violet-hold"))).not.toContain(29_395);
expect(WOW_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(expect.arrayContaining(cases.map(([id]) => id)));
expect(WOW_ENCOUNTER_NEXT_BATCH).toEqual([]);
});
it("resumes Black Morass at rift 7 and 13 after run-persistent boss checkpoints", () => {
const definition = adapted("black-morass");
const encounter = wowEncounter(definition);
let snapshot = reduceDungeonEncounter(
encounter,
createDungeonEncounterSnapshot(encounter),
{ kind: "interact", objectId: "black-morass:medivh-defense-start", now: 0 },
).snapshot;
let now = 0;
for (let rift = 1; rift <= 6; rift += 1) {
now = snapshot.phaseStartedAt + (rift === 1 ? 9_000 : 7_000);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
}
const deja = definition.bosses.find((boss) => boss.name === "Chrono Lord Deja")!;
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja" });
expect(snapshot.completedObjectiveIds).toContain(deja.id);
now = snapshot.phaseStartedAt + 156_000;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-07"]);
const resetAfterDeja = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", now + 1);
expect(resetAfterDeja.activeSpawnGroupIds).toEqual([]);
expect(resetAfterDeja.completedObjectiveIds).toEqual([deja.id]);
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], resetAfterDeja.activeSpawnGroupIds, 1)).toEqual([]);
snapshot = reduceDungeonEncounter(encounter, resetAfterDeja, { kind: "tick", now: now + 1 }).snapshot;
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja", counters: { rift: 6 } });
for (let rift = 7; rift <= 12; rift += 1) {
now = snapshot.phaseStartedAt + (rift === 7 ? 156_000 : 7_000);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
}
const temporus = definition.bosses.find((boss) => boss.name === "Temporus")!;
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus" });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([deja.id, temporus.id]));
now = snapshot.phaseStartedAt + 156_000;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-13"]);
const resetAfterTemporus = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", now + 1);
expect(resetAfterTemporus.activeSpawnGroupIds).toEqual([]);
snapshot = reduceDungeonEncounter(encounter, resetAfterTemporus, { kind: "tick", now: now + 1 }).snapshot;
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus", counters: { rift: 12 } });
});
it("makes reviewed Violet Hold doors visible and physically stateful", () => {
const definition = adapted("violet-hold");
const reviewedTemplates = new Set([191_723, 191_564, 191_563, 191_562, 191_606]);
const doors = definition.serverGameObjects.filter((object) => reviewedTemplates.has(object.source?.templateId ?? -1));
expect(doors.length).toBeGreaterThan(0);
for (const door of doors) {
validateDungeonWorldObject(door);
expect(door.states.find((state) => state.id === "closed")).toMatchObject({
visible: true,
blocksMovement: true,
blocksSight: true,
});
expect(door.states.find((state) => state.id === "open")).toMatchObject({
visible: true,
blocksMovement: false,
blocksSight: false,
});
expect(door.visual?.hidden).not.toBe(true);
}
});
it("runs all 18 Violet Hold waves and persists its three objectives", () => {
const definition = adapted("violet-hold");
const encounter = wowEncounter(definition);
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact", objectId: "violet-hold:sinclari-start", now: 0,
}).snapshot;
let now = 0;
for (let wave = 1; wave <= 18; wave += 1) {
const id = `violet-hold:wave-${String(wave).padStart(2, "0")}`;
if (!snapshot.activeSpawnGroupIds.includes(id)) {
const delay = wave === 1 ? 19_000 : wave === 7 || wave === 13 ? 35_000 : 3_000;
now = snapshot.phaseStartedAt + delay;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
now += 1;
}
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
});
it("persists Black Morass completion and deactivates the final group", () => {
const definition = adapted("black-morass");
const encounter = wowEncounter(definition);
const aeonus = definition.bosses.find((boss) => boss.name === "Aeonus")!;
const finalGroup = group(definition, "black-morass:rift-18");
const base = createDungeonEncounterSnapshot(encounter, 1_000);
const readyToClear = {
...base,
phase: "rift-18-active",
activeSpawnGroupIds: [finalGroup.id],
} as DungeonEncounterSnapshot;
const cleared = killGroup(encounter, readyToClear, finalGroup, 2_000);
expect(cleared.snapshot).toMatchObject({
phase: "complete",
flags: { "dungeon-complete": true },
activeSpawnGroupIds: [],
});
expect(cleared.snapshot.completedObjectiveIds).toContain(aeonus.id);
expect(cleared.effects).toContainEqual({
kind: "despawn-group",
actionId: "deactivate-rift-18",
spawnGroupId: finalGroup.id,
});
const reset = resetDungeonEncounterSnapshot(encounter, cleared.snapshot, "boss-wipe", 3_000);
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 3_000 }).snapshot;
expect(recovered).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
expect(recovered.completedObjectiveIds).toContain(aeonus.id);
});
it("runs Culling's city, rift, Epoch, and Mal'Ganis groups to completion", () => {
const definition = adapted("culling-of-stratholme");
const encounter = wowEncounter(definition);
const sequence = [
...Array.from({ length: 10 }, (_, index) => `culling-city-${String(index + 1).padStart(2, "0")}`),
"culling-town-01", "culling-town-02", "culling-town-03", "culling-epoch", "culling-malganis",
];
const delays = new Map<string, number>([[sequence[0], 10_000], ["culling-town-01", 5_000], ["culling-epoch", 32_000], ["culling-malganis", 5_000]]);
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact", objectId: "culling-of-stratholme:arthas-start", now: 0,
}).snapshot;
let now = 0;
for (const id of sequence) {
if (!snapshot.activeSpawnGroupIds.includes(id)) {
now = snapshot.phaseStartedAt + (delays.get(id) ?? 0);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
now += 1;
}
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
});
it("uses five imported Durnholde barrels before Drake, then gates the Thrall sequence", () => {
const definition = adapted("escape-from-durnholde");
const encounter = wowEncounter(definition);
const barrels = definition.serverGameObjects.filter((object) => object.source?.templateId === 182_589);
expect(barrels.length).toBeGreaterThanOrEqual(5);
expect(barrels.every((object) => object.interaction?.prompt === "Plant an incendiary bomb")).toBe(true);
let snapshot = createDungeonEncounterSnapshot(encounter);
barrels.slice(0, 5).forEach((barrel, index) => {
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: barrel.id, now: index }).snapshot;
});
expect(snapshot.counters.barrels).toBe(5);
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5 }).snapshot;
expect(snapshot.phase).toBe("durnholde-drake-wait");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_005 }).snapshot;
const drakeGroup = group(definition, "durnholde-drake");
snapshot = killGroup(encounter, snapshot, drakeGroup, 12_006).snapshot;
const drake = definition.bosses.find((boss) => boss.name === "Lieutenant Drake")!;
expect(snapshot).toMatchObject({ phase: "durnholde-await-thrall", checkpointId: drake.id });
const prisonDoor = definition.serverGameObjects.find((object) => object.source?.templateId === 184_393)!;
expect(snapshot.worldObjectStates[prisonDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: "escape-from-durnholde:thrall-escort", now: 12_007 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_007 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain("durnholde-skarloc");
const reset = resetDungeonEncounterSnapshot(encounter, killGroup(encounter, snapshot, group(definition, "durnholde-skarloc"), 12_008).snapshot, "boss-wipe", 12_009);
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 12_009 }).snapshot;
expect(recovered.phase).toBe("durnholde-barn-wait");
expect(recovered.activeSpawnGroupIds).toEqual([]);
snapshot = recovered;
let now = 12_009;
const remainder = [
["durnholde-barn", 3_000],
["durnholde-church", 3_000],
["durnholde-epoch-wave-1", 3_000],
["durnholde-epoch-wave-2", 0],
["durnholde-epoch-wave-3", 0],
["durnholde-epoch", 8_000],
] as const;
for (const [id, delay] of remainder) {
if (!snapshot.activeSpawnGroupIds.includes(id)) {
now = snapshot.phaseStartedAt + delay;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
now += 1;
}
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
});
it("opens reviewed Arcatraz pods in order before spawning Skyriss", () => {
const definition = adapted("arcatraz");
const encounter = wowEncounter(definition);
const pods = [183_961, 183_963, 183_964, 183_962, 183_965].map((templateId) => (
definition.serverGameObjects.find((object) => object.source?.templateId === templateId)!
));
for (const pod of pods) {
validateDungeonWorldObject(pod);
expect(pod.states.find((state) => state.id === "closed")).toMatchObject({ blocksMovement: true, blocksSight: true, visible: true });
expect(pod.states.find((state) => state.id === "open")).toMatchObject({ blocksMovement: false, blocksSight: false, visible: true });
expect(pod.visual?.hidden).not.toBe(true);
}
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact", objectId: "arcatraz:warden-start", now: 0,
}).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_000 }).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-alpha"), 5_001).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 10_001 }).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_001 }).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-delta"), 20_002).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 28_002 }).snapshot;
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-gamma"), 28_003).snapshot;
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 40_003 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toEqual(["arcatraz-skyriss"]);
expect(pods.every((pod) => snapshot.worldObjectStates[pod.id] === "open")).toBe(true);
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-skyriss"), 40_004).snapshot;
expect(snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
});
it("keeps Solakar waves overlapping and removes surviving adds when Solakar dies", () => {
const definition = adapted("blackrock-spire");
const encounter = wowEncounter(definition);
const fatherFlame = definition.serverGameObjects.find((object) => object.source?.templateId === 175_245)!;
expect(fatherFlame.interaction?.prompt).toBe("Activate Father Flame");
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
kind: "interact", objectId: fatherFlame.id, now: 0,
}).snapshot;
for (const now of [500, 30_500, 60_500, 90_500, 120_500, 150_500]) {
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
expect(snapshot.activeSpawnGroupIds).toHaveLength(6);
expect(snapshot.activeSpawnGroupIds).toContain("blackrock-spire:solakar");
const solakarGroup = group(definition, "blackrock-spire:solakar");
const cleared = killGroup(encounter, snapshot, solakarGroup, 150_501);
expect(cleared.snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
expect(cleared.effects.filter((effect) => effect.kind === "despawn-group")).toHaveLength(6);
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], cleared.snapshot.activeSpawnGroupIds, 1)).toEqual([]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,243 @@
import { describe, expect, it } from "vitest";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import { WOW_PROGRESSION_OBJECT_REVIEWS } from "./wowProgressionObjectContentCatalog";
function encounter(dungeonId: string, suffix: string) {
const definition = DUNGEON_REGISTRY[dungeonId];
const result = definition.encounters?.find((candidate) => candidate.id === `${dungeonId}:${suffix}`);
if (!result) throw new Error(`Missing ${dungeonId}:${suffix}`);
return result;
}
function actorId(dungeonId: string, entry: number): string {
const definition = DUNGEON_REGISTRY[dungeonId];
const staticSpawn = definition.staticSpawns.find(
(spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry === entry,
);
if (staticSpawn) return staticSpawn.id;
for (const pack of definition.roamingPacks) {
const member = pack.members.find(
(candidate) => definition.entities[candidate.entityId]?.combat?.serverEntry === entry,
);
if (member) return `${pack.id}:${member.id}`;
}
throw new Error(`${dungeonId} is missing entry ${entry}`);
}
describe("source-backed progression objects", () => {
it("pins source hashes and keeps every adaptation and remaining gap explicit", () => {
expect(WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => review.dungeonId)).toEqual([
"deadmines",
"shadowfang-keep",
"maraudon",
"mechanar",
"steamvault",
"shadow-labyrinth",
"scarlet-monastery",
]);
for (const review of WOW_PROGRESSION_OBJECT_REVIEWS) {
expect(review.files.length).toBeGreaterThan(0);
expect(review.files.every((file) => /sha256 [a-f0-9]{64}/.test(file))).toBe(true);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
const runtime = DUNGEON_REGISTRY[review.dungeonId].encounters?.find(
(candidate) => candidate.source?.reviewedBehavior === review.reviewedBehavior,
);
expect(runtime).toBeDefined();
validateDungeonEncounterDefinition(runtime!);
}
});
it("runs Deadmines factory, foundry, and cannon breach transitions", () => {
const dungeon = DUNGEON_REGISTRY.deadmines;
const definition = encounter("deadmines", "progression-objects");
const factory = dungeon.serverGameObjects.find((object) => object.source?.templateId === 13_965)!;
const foundry = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_399)!;
const ironClad = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_397)!;
const foundryLever = dungeon.serverGameObjects.find((object) => object.source?.templateId === 101_834)!;
const cannon = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_398)!;
let snapshot = createDungeonEncounterSnapshot(definition);
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("deadmines", 644),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[factory.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "interact",
objectId: foundryLever.id,
now: 2,
}).snapshot;
expect(snapshot.worldObjectStates[foundry.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "interact",
objectId: cannon.id,
now: 3,
}).snapshot;
expect(snapshot.worldObjectStates[ironClad.id]).toBe("open");
expect(snapshot.worldObjectStates[cannon.id]).toBe("used");
expect(snapshot.activeSpawnGroupIds).toContain("deadmines:cannon-reinforcements");
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === "deadmines:cannon-reinforcements")?.spawns).toHaveLength(2);
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
expect(reset.worldObjectStates[factory.id]).toBe("open");
expect(reset.worldObjectStates[foundry.id]).toBe("open");
expect(reset.worldObjectStates[ironClad.id]).toBe("open");
expect(reset.activeSpawnGroupIds).not.toContain("deadmines:cannon-reinforcements");
});
it("opens each Shadowfang Keep progression door at its source milestone", () => {
const dungeon = DUNGEON_REGISTRY["shadowfang-keep"];
const definition = encounter("shadowfang-keep", "progression-objects");
const courtyard = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_895)!;
const sorcerer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_972)!;
const arugal = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_971)!;
let snapshot = createDungeonEncounterSnapshot(definition);
for (const [entry, objectId, now] of [
[3_914, courtyard.id, 1],
[4_274, sorcerer.id, 2],
[3_927, arugal.id, 3],
] as const) {
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("shadowfang-keep", entry),
now,
}).snapshot;
expect(snapshot.worldObjectStates[objectId]).toBe("open");
}
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "checkpoint", 4);
expect(reset.worldObjectStates[courtyard.id]).toBe("open");
expect(reset.worldObjectStates[sorcerer.id]).toBe("open");
expect(reset.worldObjectStates[arugal.id]).toBe("open");
});
it("opens Maraudon's Corruption Spewer when Noxxion dies", () => {
const dungeon = DUNGEON_REGISTRY.maraudon;
const definition = encounter("maraudon", "progression-objects");
const spewer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 178_570)!;
let snapshot = createDungeonEncounterSnapshot(definition);
expect(snapshot.worldObjectStates[spewer.id]).toBe("closed");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("maraudon", 13_282),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[spewer.id]).toBe("open");
expect(resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2).worldObjectStates[spewer.id]).toBe("open");
});
it("opens Mechanar passage doors and resets Sepethrea's room door safely", () => {
const dungeon = DUNGEON_REGISTRY.mechanar;
const definition = encounter("mechanar", "progression-objects");
let snapshot = createDungeonEncounterSnapshot(definition);
const ironDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_632)!;
const gyroDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_322)!;
const sepethreaDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_449)!;
expect(ironDoor.states.find((state) => state.id === "closed")?.blocksMovement).toBe(true);
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("mechanar", 19_710),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("mechanar", 19_218),
now: 2,
}).snapshot;
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-engaged",
entityId: actorId("mechanar", 19_221),
now: 3,
}).snapshot;
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("closed");
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("open");
});
it("requires both Steamvault boss panels before opening the main chamber", () => {
const dungeon = DUNGEON_REGISTRY.steamvault;
const definition = encounter("steamvault", "access-panels");
let snapshot = createDungeonEncounterSnapshot(definition);
const hydro = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_125)!;
const mek = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_126)!;
const door = dungeon.serverGameObjects.find((object) => object.source?.templateId === 183_049)!;
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("steamvault", 17_797),
now: 1,
}).snapshot;
expect(snapshot.worldObjectStates[hydro.id]).toBe("unlocked");
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: hydro.id, now: 2 }).snapshot;
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
expect(snapshot.worldObjectStates[door.id]).toBe("closed");
snapshot = reduceDungeonEncounter(definition, snapshot, {
kind: "entity-death",
entityId: actorId("steamvault", 17_796),
now: 4,
}).snapshot;
expect(snapshot.worldObjectStates[mek.id]).toBe("unlocked");
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: mek.id, now: 5 }).snapshot;
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 6 }).snapshot;
expect(snapshot.worldObjectStates[door.id]).toBe("open");
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 7);
expect(reset.worldObjectStates[door.id]).toBe("open");
expect(reset.worldObjectStates[hydro.id]).toBe("used");
expect(reset.worldObjectStates[mek.id]).toBe("used");
});
it("holds Hellmaw out of static population until all common ritualists die", () => {
const dungeon = DUNGEON_REGISTRY["shadow-labyrinth"];
const definition = encounter("shadow-labyrinth", "progression-objects");
const ritualists = dungeon.staticSpawns
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_794 && (spawn.spawnMask ?? 3) === 3)
.map((spawn) => spawn.id);
const hellmawGroup = dungeon.encounterSpawnGroups?.find(
(group) => group.id === "shadow-labyrinth:hellmaw",
);
expect(ritualists).toHaveLength(18);
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_731)).toBe(false);
expect(hellmawGroup?.spawns).toHaveLength(1);
const result = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
kind: "tick",
now: 1,
facts: { registeredEntityIds: ritualists, deadEntityIds: ritualists },
});
expect(result.snapshot.activeSpawnGroupIds).toContain("shadow-labyrinth:hellmaw");
});
it("gates Whitemane behind Mograine and restores her attempt after a wipe", () => {
const dungeon = DUNGEON_REGISTRY["scarlet-monastery"];
const definition = encounter("scarlet-monastery", "cathedral-pair");
const groupId = "scarlet-monastery:whitemane";
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 3_977)).toBe(false);
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === groupId)?.spawns).toHaveLength(1);
let snapshot = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
kind: "entity-death",
entityId: actorId("scarlet-monastery", 3_976),
now: 1,
}).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2);
expect(snapshot.activeSpawnGroupIds).not.toContain(groupId);
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
});
});
@@ -0,0 +1,784 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
} from "./dungeonTypes";
import type {
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "./dungeonEncounterTypes";
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
import type { StaticMobSpawnDefinition } from "./mobPopulation";
interface ProgressionSourceReview {
readonly dungeonId: string;
readonly files: readonly string[];
readonly reviewedBehavior: string;
readonly adaptations: readonly string[];
readonly remainingGaps: readonly string[];
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
const WORLD_SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-world";
export const WOW_PROGRESSION_OBJECT_REVIEWS: readonly ProgressionSourceReview[] = Object.freeze([
{
dungeonId: "deadmines",
files: [
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/instance_deadmines.cpp (sha256 c13065819443a70ec431b921edd2e32bcd6e0ec66c4c84c3f2e932e9fb1cbbc9; saved Rhahk'Zor and cannon state lines 31-95)`,
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/deadmines.h (sha256 a510bb170ac09b78c2f095742aa668c1aac8bf7ba8b457f6923807f37ae98045; encounter and gameobject entries lines 25-45)`,
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; Rhahk'Zor rows 4777-4780, cannon rows 17399 and 43337-43343, lever rows 38147-38150)`,
],
reviewedBehavior: "Rhahk'Zor opens the Factory Door; the foundry lever opens the Foundry Door; the Defias Cannon breaches the Iron Clad Door and summons two Defias Squallshapers, with the nearby inside lever retaining its database-linked door control.",
adaptations: [
"The source cannon requires a Gunpowder spell hit; Healer Man exposes the cannon as a direct one-shot interaction because inventory quest-item spell casting is not part of the dungeon runtime.",
"The cannon's custom animation and Mr. Smite dialogue are collapsed into the immediate breach and exact two-add reinforcement spawn.",
"Source transforms and client bounds drive procedural doors, levers, and cannon collision while their native legacy models await export.",
],
remainingGaps: [
"Heavy Door 1, Heavy Door 2, and the Mast Room Door are still left nonblocking because the reviewed source only persists their generic client/core state and does not bind an instance progression event.",
"Cannon smoke, timed dialogue, native door/lever animation, and the original gunpowder item requirement remain unported presentation or inventory mechanics.",
],
},
{
dungeonId: "shadowfang-keep",
files: [
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/instance_shadowfang_keep.cpp (sha256 12d7568b35b30a7d2630de46729d4ffaa0d58de9c345c6c9143037bf88b59e07; saved courtyard, Fenrus, and Nandos door state lines 76-111)`,
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/shadowfang_keep.h (sha256 daa0ac298fe8c23c4e9353be898bec0fa6b7cdd9c35e821ef880aaa8600419fd; encounter and gameobject entries lines 25-48)`,
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; prisoner escort/unlock rows 7933-7945 and 40678-40693)`,
],
reviewedBehavior: "The courtyard route opens after the Rethilgore/prisoner section, Fenrus opens the Sorcerer Door, and Wolf Master Nandos opens the final Arugal Door.",
adaptations: [
"The two faction-specific prisoner escorts are collapsed into an immediate courtyard-door transition when Rethilgore dies, avoiding a mandatory faction/gossip branch that the current runtime cannot represent safely.",
"The exact imported door transforms and display bounds provide procedural blockers while native legacy door animations await export.",
],
remainingGaps: [
"Deathstalker Adamant and Sorcerer Ashcrombe dialogue, waypoint movement, spell visuals, faction-specific gossip, and despawn presentation remain unported.",
"The seasonal Apothecary Hummel event is intentionally outside the ordinary dungeon route and has not been made a required objective.",
],
},
{
dungeonId: "maraudon",
files: [
`${SOURCE_ROOT}/Kalimdor/Maraudon/instance_maraudon.cpp (sha256 edc4b2c0ba055f9953186bda5009ac990e1b18d206ae8d9aa60dcbf24f259d09; saved Noxxion state and spewer transition lines 31-79)`,
`${SOURCE_ROOT}/Kalimdor/Maraudon/maraudon.h (sha256 81939372ae6bb38d879cda349992c1895e7281f5364ddca4e0fc235140e6cbfa; encounter and Corruption Spewer entries lines 20-33)`,
],
reviewedBehavior: "Defeating Noxxion permanently opens the Corruption Spewer route transition.",
adaptations: [
"The imported transform and display bounds drive a conservative procedural barrier that disappears when opened; the legacy spewer animation and particles await native model export.",
],
remainingGaps: [
"Corruption spray/particle presentation, the native gameobject animation, optional quest interactions, and Celebras's Scepter shortcut are not part of this mandatory route gate.",
],
},
{
dungeonId: "mechanar",
files: [
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/instance_mechanar.cpp (sha256 6dac8f562fe69dd5ec06ef94ffea5399ae08c9b502eec3fa8790390a0fde90b7; DoorData lines 22-27)`,
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/mechanar.h (sha256 9c5baf35ca4b6126901d39278a289a42efce0b3d6c56d1ac1231cd8ddb6d9283; data and gameobject entries lines 29-52)`,
],
reviewedBehavior: "Gatewatcher Iron-Hand and Gatewatcher Gyro-Kill each open their bound Mo'arg passage door; Sepethrea's room door closes while she is engaged and opens after her defeat.",
adaptations: [
"The exact imported door transforms and display bounds drive procedural blockers while the three native MDX door models await export.",
"Door motion is represented by visible/blocked and hidden/unblocked states rather than the client door animation.",
],
remainingGaps: [
"Pathaleon's bridge trash death counter, bridge dialogue, Cache of the Legion rules, and elevator operation are not yet ported.",
],
},
{
dungeonId: "steamvault",
files: [
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/instance_steam_vault.cpp (sha256 55b1bd5fd6a01233ca18a35eb9b0c098bbb996cfd220d8f4c3e51f992e3c60cf; access panels lines 36-91 and boss state lines 154-174)`,
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/steam_vault.h (sha256 94d142e428a796cf4c0d36ec6b9b7fe70f059790d6378bbb55c7803bf9cda393; object entries lines 31-50)`,
],
reviewedBehavior: "Hydromancer Thespia and Mekgineer Steamrigger each unlock their nearby access panel; using both panels opens the Main Chambers door to Warlord Kalithresh.",
adaptations: [
"Source positions identify both panels and the main door, with compact procedural consoles and a procedural collision blocker until their client models are exported.",
"The original door-controller creature's movement and emote are collapsed into the immediate two-panel door transition.",
],
remainingGaps: [
"Door-controller presentation, distiller reset coupling, access-panel animation, and native object visuals remain unported.",
],
},
{
dungeonId: "shadow-labyrinth",
files: [
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/instance_shadow_labyrinth.cpp (sha256 b51d655ee5918d0bcb89b4ada5d9af752c8c0395032d99e4148b9edaaf34a834; ritualist release lines 58-89 and DoorData lines 24-29)`,
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/shadow_labyrinth.h (sha256 11f6bf85abe38fab9d4182325cc6a4693e3ea724e49f211355756345a7d44046; ritualist, Hellmaw, and door entries lines 28-44)`,
],
reviewedBehavior: "The common Cabal Ritualists keep Ambassador Hellmaw banished; Blackheart and Vorpil open the Refectory and Screaming Hall passage doors on defeat.",
adaptations: [
"Hellmaw is removed from the unsafe static spawn and appears only after the 18 normal/common ritualists die.",
"Heroic-only duplicate ritualists do not delay release because the encounter reducer has no active-difficulty guard; they remain ordinary combat population.",
"The exact imported door transforms and bounds use procedural blockers until native door models are exported.",
],
remainingGaps: [
"Hellmaw's banish channel presentation, heroic-only ritualist contribution, Blackheart room aggro choreography, and native door animations remain unported.",
],
},
{
dungeonId: "scarlet-monastery",
files: [
`${SOURCE_ROOT}/EasternKingdoms/ScarletMonastery/instance_scarlet_monastery.cpp (sha256 1bada7d72e38197e6d8d202d39940a27b69e6dbbf795a53e97744227aa8ca01e; cathedral pair and doors lines 38-69 and 132-197)`,
],
reviewedBehavior: "Commander Mograine gates High Inquisitor Whitemane behind the High Inquisitor's Door as one linked cathedral encounter.",
adaptations: [
"Whitemane is removed from the unsafe static spawn, the imported inner door opens on Mograine's first defeat, and Whitemane then spawns at her exact database position.",
"The source resurrection duet is represented as two ordered lives because the current encounter action vocabulary cannot revive an existing combat actor.",
"The Chapel Door is left open; its source Ashbringer event is optional external narrative content and must not block the ordinary cathedral route.",
],
remainingGaps: [
"Whitemane's sleep/resurrection of Mograine, Mograine's post-resurrection phase, cathedral-wide social aggro, Ashbringer scene, and native door animation remain unported.",
],
},
]);
const REVIEW_BY_DUNGEON = new Map(
WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => [review.dungeonId, review] as const),
);
function entityEntry(definition: DungeonDefinition, entityId: string): number | undefined {
return definition.entities[entityId]?.combat?.serverEntry;
}
function staticSpawnForEntry(definition: DungeonDefinition, entry: number): StaticMobSpawnDefinition {
const spawn = definition.staticSpawns.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
if (!spawn) throw new Error(`${definition.id} is missing static creature entry ${entry}`);
return spawn;
}
function actorIdForEntry(definition: DungeonDefinition, entry: number): string {
const staticSpawn = definition.staticSpawns.find(
(candidate) => entityEntry(definition, candidate.entityId) === entry,
);
if (staticSpawn) return staticSpawn.id;
for (const pack of definition.roamingPacks) {
const member = pack.members.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
if (member) return `${pack.id}:${member.id}`;
}
throw new Error(`${definition.id} is missing runtime actor for creature entry ${entry}`);
}
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
const actorIds = new Set<string>();
for (const spawn of definition.staticSpawns) {
if (entityEntry(definition, spawn.entityId) === entry) actorIds.add(spawn.id);
}
for (const pack of definition.roamingPacks) {
for (const member of pack.members) {
if (entityEntry(definition, member.entityId) === entry) actorIds.add(`${pack.id}:${member.id}`);
}
}
const objective = definition.bosses.find((candidate) => actorIds.has(candidate.id));
if (!objective) throw new Error(`${definition.id} is missing boss objective for creature entry ${entry}`);
return objective;
}
function sourceObject(definition: DungeonDefinition, templateId: number): DungeonWorldObjectDefinition {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
if (!object) throw new Error(`${definition.id} is missing source gameobject template ${templateId}`);
return object;
}
function reviewedDoor(
definition: DungeonDefinition,
templateId: number,
initialState: "closed" | "open",
halfExtents?: readonly [number, number, number],
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
return Object.freeze({
...source,
kind: "door",
...(halfExtents ? { halfExtents } : {}),
initialState,
states: Object.freeze([
{
id: "closed",
label: "Closed",
visible: true,
blocksMovement: true,
blocksSight: true,
interactionEnabled: false,
},
{
id: "open",
label: "Open",
visible: false,
blocksMovement: false,
blocksSight: false,
interactionEnabled: false,
},
]),
visual: Object.freeze({ color: "#544a3f", emissive: "#130f0a" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "Source transform and client bounds are retained. A procedural blocker provides reviewed gameplay collision until the native legacy door model is exported.",
}),
});
}
function reviewedPanel(
definition: DungeonDefinition,
templateId: number,
signalId: string,
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
return Object.freeze({
...source,
kind: "switch",
halfExtents: [0.85, 1.1, 0.45] as const,
initialState: "locked",
states: Object.freeze([
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "unlocked", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
]),
interaction: Object.freeze({
prompt: "Activate access panel",
range: 3,
signalId,
allowedStates: ["unlocked"],
oneShot: true,
}),
visual: Object.freeze({ color: "#2f6d75", emissive: "#173f46" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "Source placement identifies the console. Compact procedural geometry supplies a readable interaction target until its native legacy model is exported.",
}),
});
}
function reviewedInteractable(
definition: DungeonDefinition,
templateId: number,
options: {
readonly signalId: string;
readonly prompt: string;
readonly kind?: DungeonWorldObjectDefinition["kind"];
readonly initialState?: "ready" | "locked";
readonly range?: number;
},
): DungeonWorldObjectDefinition {
const source = sourceObject(definition, templateId);
const provenance = source.source;
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
const initialState = options.initialState ?? "ready";
return Object.freeze({
...source,
kind: options.kind ?? "switch",
initialState,
states: Object.freeze([
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
]),
interaction: Object.freeze({
prompt: options.prompt,
range: options.range ?? 3.2,
signalId: options.signalId,
allowedStates: ["ready"],
oneShot: true,
}),
visual: Object.freeze({ color: "#786344", emissive: "#2b2114" }),
source: Object.freeze({
...provenance,
fidelity: "source-backed-adapted",
note: "The source transform and client display bounds are retained. Procedural geometry supplies a readable interaction target until the native legacy model is exported.",
}),
});
}
function replaceObjects(
definition: DungeonDefinition,
replacements: readonly DungeonWorldObjectDefinition[],
): readonly DungeonWorldObjectDefinition[] {
const byId = new Map(replacements.map((object) => [object.id, object] as const));
return Object.freeze(definition.serverGameObjects.map((object) => byId.get(object.id) ?? object));
}
function sourceReview(definition: DungeonDefinition): DungeonEncounterDefinition["source"] {
const review = REVIEW_BY_DUNGEON.get(definition.id);
if (!review) throw new Error(`Missing progression-object source review for ${definition.id}`);
return Object.freeze({
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
files: review.files,
reviewedBehavior: review.reviewedBehavior,
adaptations: review.adaptations,
remainingGaps: review.remainingGaps,
});
}
function appendWarning(definition: DungeonDefinition, warning: string): DungeonDefinition["validation"] {
return Object.freeze({
...definition.validation,
warnings: Object.freeze([...new Set([...definition.validation.warnings, warning])]),
});
}
function buildDeadmines(definition: DungeonDefinition): DungeonDefinition {
const rhahkZor = actorIdForEntry(definition, 644);
const squallshaperSource = staticSpawnForEntry(definition, 1_732);
const factoryDoor = reviewedDoor(definition, 13_965, "closed");
const foundryDoor = reviewedDoor(definition, 16_399, "closed");
const ironCladDoor = reviewedDoor(definition, 16_397, "closed");
const factoryLever = reviewedInteractable(definition, 101_831, {
signalId: "deadmines:factory-lever",
prompt: "Use the factory lever",
initialState: "locked",
});
const ironCladLever = reviewedInteractable(definition, 101_833, {
signalId: "deadmines:iron-clad-lever",
prompt: "Use the iron-clad lever",
});
const foundryLever = reviewedInteractable(definition, 101_834, {
signalId: "deadmines:foundry-lever",
prompt: "Use the foundry lever",
});
const cannon = reviewedInteractable(definition, 16_398, {
signalId: "deadmines:fire-cannon",
prompt: "Load and fire the Defias Cannon",
kind: "quest-object",
range: 4.5,
});
const cannonReinforcements: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "deadmines:cannon-reinforcements",
spawns: Object.freeze([
{
id: "deadmines:cannon-squallshaper-one",
entityId: squallshaperSource.entityId,
position: [-709.2917, 9.0828, -100.7318] as const,
yaw: -1.58,
spawnMask: 1,
},
{
id: "deadmines:cannon-squallshaper-two",
entityId: squallshaperSource.entityId,
position: [-708.6717, 9.0028, -90.2018] as const,
yaw: -1.74,
spawnMask: 1,
},
]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "rhahk-zor-opens-factory",
trigger: { kind: "entity-death", entityId: rhahkZor },
actions: [
{ id: "open-factory-door", kind: "set-world-object-state", objectId: factoryDoor.id, state: "open", persistence: "run" },
{ id: "mark-factory-lever-used", kind: "set-world-object-state", objectId: factoryLever.id, state: "used", persistence: "run" },
],
},
{
id: "foundry-lever-opens-door",
trigger: { kind: "interact", objectId: foundryLever.id },
guards: [{ kind: "world-object-state", objectId: foundryLever.id, states: ["ready"] }],
actions: [
{ id: "open-foundry-door", kind: "set-world-object-state", objectId: foundryDoor.id, state: "open", persistence: "run" },
{ id: "mark-foundry-lever-used", kind: "set-world-object-state", objectId: foundryLever.id, state: "used", persistence: "run" },
],
},
{
id: "iron-clad-lever-opens-door",
trigger: { kind: "interact", objectId: ironCladLever.id },
guards: [{ kind: "world-object-state", objectId: ironCladLever.id, states: ["ready"] }],
actions: [
{ id: "lever-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
{ id: "mark-iron-clad-lever-used", kind: "set-world-object-state", objectId: ironCladLever.id, state: "used", persistence: "run" },
],
},
{
id: "cannon-breaches-iron-clad-door",
trigger: { kind: "interact", objectId: cannon.id },
guards: [{ kind: "world-object-state", objectId: cannon.id, states: ["ready"] }],
actions: [
{ id: "mark-defias-cannon-used", kind: "set-world-object-state", objectId: cannon.id, state: "used", persistence: "run" },
{ id: "cannon-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
{ id: "spawn-cannon-reinforcements", kind: "activate-spawn-group", spawnGroupId: cannonReinforcements.id },
],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "deadmines:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[factoryDoor.id]: "closed",
[foundryDoor.id]: "closed",
[ironCladDoor.id]: "closed",
[factoryLever.id]: "locked",
[foundryLever.id]: "ready",
[ironCladLever.id]: "ready",
[cannon.id]: "ready",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), cannonReinforcements]),
serverGameObjects: replaceObjects(definition, [
factoryDoor,
foundryDoor,
ironCladDoor,
factoryLever,
foundryLever,
ironCladLever,
cannon,
]),
validation: appendWarning(definition, "Deadmines now runs the source Rhahk'Zor, foundry-lever, and cannon/iron-clad transitions; generic heavy and mast doors remain explicitly nonblocking."),
});
}
function buildShadowfangKeep(definition: DungeonDefinition): DungeonDefinition {
const rethilgore = actorIdForEntry(definition, 3_914);
const fenrus = actorIdForEntry(definition, 4_274);
const nandos = actorIdForEntry(definition, 3_927);
const courtyardDoor = reviewedDoor(definition, 18_895, "closed");
const sorcererDoor = reviewedDoor(definition, 18_972, "closed");
const arugalDoor = reviewedDoor(definition, 18_971, "closed");
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "rethilgore-section-opens-courtyard",
trigger: { kind: "entity-death", entityId: rethilgore },
actions: [{ id: "open-courtyard-door", kind: "set-world-object-state", objectId: courtyardDoor.id, state: "open", persistence: "run" }],
},
{
id: "fenrus-opens-sorcerer-door",
trigger: { kind: "entity-death", entityId: fenrus },
actions: [{ id: "open-sorcerer-door", kind: "set-world-object-state", objectId: sorcererDoor.id, state: "open", persistence: "run" }],
},
{
id: "nandos-opens-arugal-door",
trigger: { kind: "entity-death", entityId: nandos },
actions: [{ id: "open-arugal-door", kind: "set-world-object-state", objectId: arugalDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "shadowfang-keep:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[courtyardDoor.id]: "closed",
[sorcererDoor.id]: "closed",
[arugalDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [courtyardDoor, sorcererDoor, arugalDoor]),
validation: appendWarning(definition, "Shadowfang Keep now binds all three source progression doors; the faction-specific prisoner escort is an explicit immediate post-Rethilgore adaptation."),
});
}
function buildMaraudon(definition: DungeonDefinition): DungeonDefinition {
const noxxion = actorIdForEntry(definition, 13_282);
const corruptionSpewer = reviewedDoor(definition, 178_570, "closed", [1.2, 7.7, 13.9]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "noxxion-opens-corruption-spewer",
trigger: { kind: "entity-death", entityId: noxxion },
actions: [{
id: "open-corruption-spewer",
kind: "set-world-object-state",
objectId: corruptionSpewer.id,
state: "open",
persistence: "run",
}],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "maraudon:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: { worldObjectStates: { [corruptionSpewer.id]: "closed" } },
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [corruptionSpewer]),
validation: appendWarning(definition, "Maraudon's source Noxxion-to-Corruption-Spewer route gate is executable; native spewer presentation remains an explicit asset adaptation."),
});
}
function buildMechanar(definition: DungeonDefinition): DungeonDefinition {
const ironHand = actorIdForEntry(definition, 19_710);
const gyroKill = actorIdForEntry(definition, 19_218);
const sepethrea = actorIdForEntry(definition, 19_221);
const ironDoor = reviewedDoor(definition, 184_632, "closed", [4.65, 7.98, 1.1]);
const gyroDoor = reviewedDoor(definition, 184_322, "closed", [4.65, 7.98, 1.1]);
const sepethreaDoor = reviewedDoor(definition, 184_449, "open", [4.65, 7.98, 1.1]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "iron-hand-opens-moarg-one",
trigger: { kind: "entity-death", entityId: ironHand },
actions: [{ id: "open-moarg-one", kind: "set-world-object-state", objectId: ironDoor.id, state: "open", persistence: "run" }],
},
{
id: "gyro-kill-opens-moarg-two",
trigger: { kind: "entity-death", entityId: gyroKill },
actions: [{ id: "open-moarg-two", kind: "set-world-object-state", objectId: gyroDoor.id, state: "open", persistence: "run" }],
},
{
id: "sepethrea-closes-room",
trigger: { kind: "entity-engaged", entityId: sepethrea },
actions: [{ id: "close-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "closed" }],
},
{
id: "sepethrea-opens-room",
trigger: { kind: "entity-death", entityId: sepethrea },
actions: [{ id: "open-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "mechanar:progression-objects",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
worldObjectStates: {
[ironDoor.id]: "closed",
[gyroDoor.id]: "closed",
[sepethreaDoor.id]: "open",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [ironDoor, gyroDoor, sepethreaDoor]),
validation: appendWarning(definition, "Mechanar source doors are executable; bridge scripting, elevator operation, and native door assets remain documented adaptations."),
});
}
function buildSteamvault(definition: DungeonDefinition): DungeonDefinition {
const thespia = actorIdForEntry(definition, 17_797);
const steamrigger = actorIdForEntry(definition, 17_796);
const hydroPanel = reviewedPanel(definition, 184_125, "steamvault:hydromancer-panel");
const mekPanel = reviewedPanel(definition, 184_126, "steamvault:mekgineer-panel");
const mainDoor = reviewedDoor(definition, 183_049, "closed", [7.2, 5.95, 0.9]);
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "thespia-unlocks-panel",
trigger: { kind: "entity-death", entityId: thespia },
actions: [{ id: "unlock-hydromancer-panel", kind: "set-world-object-state", objectId: hydroPanel.id, state: "unlocked", persistence: "run" }],
},
{
id: "steamrigger-unlocks-panel",
trigger: { kind: "entity-death", entityId: steamrigger },
actions: [{ id: "unlock-mekgineer-panel", kind: "set-world-object-state", objectId: mekPanel.id, state: "unlocked", persistence: "run" }],
},
{
id: "use-hydromancer-panel",
trigger: { kind: "interact", objectId: hydroPanel.id },
guards: [{ kind: "world-object-state", objectId: hydroPanel.id, states: ["unlocked"] }],
actions: [
{ id: "mark-hydromancer-panel-used", kind: "set-flag", flagId: "hydromancer-panel-used", value: true, persistence: "run" },
{ id: "set-hydromancer-panel-used", kind: "set-world-object-state", objectId: hydroPanel.id, state: "used", persistence: "run" },
],
},
{
id: "use-mekgineer-panel",
trigger: { kind: "interact", objectId: mekPanel.id },
guards: [{ kind: "world-object-state", objectId: mekPanel.id, states: ["unlocked"] }],
actions: [
{ id: "mark-mekgineer-panel-used", kind: "set-flag", flagId: "mekgineer-panel-used", value: true, persistence: "run" },
{ id: "set-mekgineer-panel-used", kind: "set-world-object-state", objectId: mekPanel.id, state: "used", persistence: "run" },
],
},
{
id: "both-panels-open-main-door",
trigger: { kind: "tick" },
guards: [
{ kind: "flag", flagId: "hydromancer-panel-used", equals: true },
{ kind: "flag", flagId: "mekgineer-panel-used", equals: true },
],
actions: [{ id: "open-main-chambers-door", kind: "set-world-object-state", objectId: mainDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "steamvault:access-panels",
dungeonId: definition.id,
initialPhase: "active",
initialState: {
flags: { "hydromancer-panel-used": false, "mekgineer-panel-used": false },
worldObjectStates: {
[hydroPanel.id]: "locked",
[mekPanel.id]: "locked",
[mainDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
source: sourceReview(definition),
rules,
});
return Object.freeze({
...definition,
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
serverGameObjects: replaceObjects(definition, [hydroPanel, mekPanel, mainDoor]),
validation: appendWarning(definition, "Steamvault's two boss-gated access panels and Kalithresh door are executable with documented procedural presentation."),
});
}
function buildShadowLabyrinth(definition: DungeonDefinition): DungeonDefinition {
const hellmawObjective = objectiveForEntry(definition, 18_731);
const hellmawSpawn = staticSpawnForEntry(definition, 18_731);
const ritualists = definition.staticSpawns
.filter((spawn) => entityEntry(definition, spawn.entityId) === 18_794 && (spawn.spawnMask ?? 3) === 3)
.map((spawn) => spawn.id);
if (ritualists.length !== 18) {
throw new Error(`shadow-labyrinth expected 18 common Cabal Ritualists, found ${ritualists.length}`);
}
const blackheart = actorIdForEntry(definition, 18_667);
const vorpil = actorIdForEntry(definition, 18_732);
const refectoryDoor = reviewedDoor(definition, 183_296, "closed", [3.5, 6, 0.8]);
const screamingHallDoor = reviewedDoor(definition, 183_295, "closed", [3.5, 6, 0.8]);
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "shadow-labyrinth:hellmaw",
spawns: Object.freeze([hellmawSpawn]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "release-hellmaw",
trigger: { kind: "entities-dead", entityIds: ritualists },
actions: [
{ id: "spawn-released-hellmaw", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "enter-hellmaw-active", kind: "set-phase", phase: "hellmaw-active" },
],
},
{
id: "blackheart-opens-refectory",
trigger: { kind: "entity-death", entityId: blackheart },
actions: [{ id: "open-refectory-door", kind: "set-world-object-state", objectId: refectoryDoor.id, state: "open", persistence: "run" }],
},
{
id: "vorpil-opens-screaming-hall",
trigger: { kind: "entity-death", entityId: vorpil },
actions: [{ id: "open-screaming-hall-door", kind: "set-world-object-state", objectId: screamingHallDoor.id, state: "open", persistence: "run" }],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "shadow-labyrinth:progression-objects",
dungeonId: definition.id,
initialPhase: "ritualists-active",
initialState: {
worldObjectStates: {
[refectoryDoor.id]: "closed",
[screamingHallDoor.id]: "closed",
},
},
resetPolicy: { bossWipePhase: "ritualists-active", checkpointPhase: "ritualists-active" },
source: sourceReview(definition),
rules,
});
const eventObjective = Object.freeze({ ...hellmawObjective, eventOnly: true });
return Object.freeze({
...definition,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== hellmawSpawn.id)),
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
serverGameObjects: replaceObjects(definition, [refectoryDoor, screamingHallDoor]),
validation: appendWarning(definition, "Shadow Labyrinth now gates Hellmaw on the common ritualists and binds both passage doors; heroic ritualist gating and presentation remain documented adaptations."),
});
}
function buildScarletMonastery(definition: DungeonDefinition): DungeonDefinition {
const mograine = actorIdForEntry(definition, 3_976);
const whitemaneObjective = objectiveForEntry(definition, 3_977);
const whitemaneSpawn = staticSpawnForEntry(definition, 3_977);
const chapelDoor = reviewedDoor(definition, 104_591, "open", [4.3, 5.5, 0.8]);
const inquisitorDoor = reviewedDoor(definition, 104_600, "closed", [3.6, 4.6, 0.8]);
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
id: "scarlet-monastery:whitemane",
spawns: Object.freeze([whitemaneSpawn]),
});
const rules: readonly DungeonEncounterRule[] = Object.freeze([
{
id: "mograine-releases-whitemane",
trigger: { kind: "entity-death", entityId: mograine },
actions: [
{ id: "remember-mograine-defeat", kind: "set-flag", flagId: "mograine-defeated", value: true, persistence: "run" },
{ id: "open-inquisitor-door", kind: "set-world-object-state", objectId: inquisitorDoor.id, state: "open", persistence: "run" },
{ id: "spawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "enter-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
],
},
{
id: "resume-whitemane-after-wipe",
trigger: { kind: "tick" },
guards: [
{ kind: "phase", phases: ["resume"] },
{ kind: "flag", flagId: "mograine-defeated", equals: true },
{ kind: "objective-complete", objectiveId: whitemaneObjective.id, equals: false },
],
actions: [
{ id: "respawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
{ id: "resume-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
],
},
]);
const encounter: DungeonEncounterDefinition = Object.freeze({
id: "scarlet-monastery:cathedral-pair",
dungeonId: definition.id,
initialPhase: "mograine-active",
initialState: {
flags: { "mograine-defeated": false },
worldObjectStates: { [chapelDoor.id]: "open", [inquisitorDoor.id]: "closed" },
},
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
source: sourceReview(definition),
rules,
});
const eventObjective = Object.freeze({ ...whitemaneObjective, eventOnly: true });
return Object.freeze({
...definition,
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== whitemaneSpawn.id)),
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
serverGameObjects: replaceObjects(definition, [chapelDoor, inquisitorDoor]),
validation: appendWarning(definition, "Scarlet Monastery now gates Whitemane behind Mograine and the source inner door; resurrection remains a documented two-stage adaptation."),
});
}
export function withWowProgressionObjectContent(definition: DungeonDefinition): DungeonDefinition {
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:progression-objects`)
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:access-panels`)
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:cathedral-pair`)) {
return definition;
}
if (definition.id === "deadmines") return buildDeadmines(definition);
if (definition.id === "shadowfang-keep") return buildShadowfangKeep(definition);
if (definition.id === "maraudon") return buildMaraudon(definition);
if (definition.id === "mechanar") return buildMechanar(definition);
if (definition.id === "steamvault") return buildSteamvault(definition);
if (definition.id === "shadow-labyrinth") return buildShadowLabyrinth(definition);
if (definition.id === "scarlet-monastery") return buildScarletMonastery(definition);
return definition;
}
@@ -0,0 +1,587 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
import {
WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS,
withWowTbcEncounterBatchTwoContent,
} from "./wowTbcEncounterBatchTwoCatalog";
const TARGETS = [
"underbog",
"slave-pens",
"mana-tombs",
"auchenai-crypts",
"magisters-terrace",
] as const;
const EXPECTED_SOURCE_HASHES = Object.freeze({
underbog: [
"89a7406ee90f7a068a9ca0e3c4d8b9f8995325a704bbf9e15dfa75a17c78deca",
"57aa7cbbdeef9b2bf6bf911e99a118f8b8daa51b9d71e305c673c15b0d101dca",
"c10c6e02bc9af2e9c8c876f77cda8abf295f79c3240e789315afd156511a25d9",
"28a3e2b836b349b2104d187fdf8b424455ded2b067f221b209b636c509933ac3",
"875f6aadf0a44d3119de5cc5751fba232d156ece30bfa5e522bb24f4c005884d",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"5109b88955dfd4f718e581ca88f0f9ec478fc64cdf0904cea07e17157bd68ccb",
],
"slave-pens": [
"c1dc38b81d430d16b7a55ff128bb8989fa8ef487bc033540cb7cd5bf5d2f2967",
"1e83c465f3cf74797474ee51e3b8a7f6682ede63ba9b2fdd18841ce1b4bf91e8",
"09c7f29871b5f2e429c5f643db6f645f0c33a163b143e48e4806ac7231d174d6",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"mana-tombs": [
"f243c4ee42d272202023b0c81762221b96e62029006fcfbafbcd5d2bf7b40c7d",
"dbb6971658836e2137e6c7a590ce13a790b3d75f79a97edc84e6c8ac8624de1b",
"490d328811925cb815949e55ba88919512ae11fed6ce7e0c243947a6014a342c",
"f9407bd388db7434746b6f650ae98772933ef47908fb484e19a8460a16e0e783",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"auchenai-crypts": [
"5a5192f9f89726bf0d73a9b92fccd5e684c47ef22a53465a1ccc6153dabf490c",
"ee1e867c866a9c023d1257f90c99925975a321a67f2303e83a8373e2da3315e0",
"970edcd88536186ea675f84ef28cdb3f99b19554f289c5fbb1b42e895d13c8be",
"6e34fbe51bed87ca4495a8e0b006017ea2de52673d00d6d5148b00e138ebf752",
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
"magisters-terrace": [
"8aa316ceaa914b64e88fe420a0f37537bf016bf5635f8106bf32f82b3eb25728",
"43ff46dc05da63bfc4ed49cc5fce8451656857cce544f4ed146736285d4f626d",
"458daa44853b6a94a058ab8ccfe08ae4c8ccd34a0387cb766544e12bcbf45dee",
"9f5b4a05eb6b6e2acc361f764fd1e472ec255bb9a32369c16e7cc8303e5693e2",
"25884cccec0c8bf123c73294bbcdcaee7f6da9a7a0a4af8b3e4d79b66c6b3024",
"ed4a734dcdf8b7e186dd4ceeb0ee7d890784aa18edfeab5acf03180a8da1d47f",
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
],
});
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function adapted(id: typeof TARGETS[number]): DungeonDefinition {
const generated = campaign.definitions.find((definition) => definition.id === id);
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
const baseline = withWowTbcEncounterContent(withWowProgressionObjectContent(withWowEncounterContent(generated)));
return withWowTbcEncounterBatchTwoContent(baseline);
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-batch-two-instance`);
if (!value) throw new Error(`Missing TBC batch-two encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing spawn group ${id}`);
return value;
}
function objectIdForSpawn(definition: DungeonDefinition, spawnId: number): string {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId);
if (!object) throw new Error(`${definition.id} missing object spawn ${spawnId}`);
return object.id;
}
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
}
function entityForEntry(definition: DungeonDefinition, entry: number) {
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
return entity;
}
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
if (!spawn) throw new Error(`${definition.id} missing static boss entry ${entry}`);
return spawn.id;
}
function dispatch(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
difficultyId?: "normal" | "heroic",
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
difficultyId?: "normal" | "heroic",
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return dispatch(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}, difficultyId);
}
describe("source-backed adapted TBC encounter batch two", () => {
it("pins every reviewed source hash/anchor and publishes explicit adaptations and gaps", () => {
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const source of review.sourceFiles) {
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
.toBe(source.sha256);
}
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
}
});
it("gives each reviewed event actor its source schedule and keeps Frozen Core passive", () => {
const auchenai = adapted("auchenai-crypts");
const avatar = entityForEntry(auchenai, 18_478);
expect(avatar.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_391 },
{
id: "mortal-strike-16856",
name: "Mortal Strike",
spellId: 16_856,
target: "primary",
school: "physical",
initialCooldownMs: [8_500, 16_800],
repeatCooldownMs: [10_900, 24_100],
damageMultiplier: 2,
source: { entry: 18_478, rowId: 0, eventType: 0, actionType: 11, targetType: 2 },
},
{
id: "sunder-armor-16145",
name: "Sunder Armor",
spellId: 16_145,
target: "primary",
initialCooldownMs: [6_500, 11_500],
repeatCooldownMs: [6_200, 15_700],
effect: { kind: "target-aura", aura: "sunder-armor", durationMs: 30_000 },
source: { entry: 18_478, rowId: 1, eventType: 0, actionType: 11, targetType: 2 },
},
]);
const manaTombs = adapted("mana-tombs");
const yor = entityForEntry(manaTombs, 22_930);
expect(yor.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 2_000 },
{
id: "double-breath-38361",
name: "Double Breath",
spellId: 38_361,
target: "nearby-party",
school: "fire",
range: 5,
initialCooldownMs: [25_500, 30_500],
repeatCooldownMs: [10_000, 20_000],
telegraph: { shape: "cone", anchor: "source", length: 20 },
},
{
id: "stomp-36405",
name: "Stomp",
spellId: 36_405,
target: "nearby-party",
school: "physical",
initialCooldownMs: [12_000, 18_000],
repeatCooldownMs: [14_000, 24_000],
},
]);
const slavePens = adapted("slave-pens");
const ahune = entityForEntry(slavePens, 25_740);
const frozenCore = entityForEntry(slavePens, 25_865);
expect(ahune).toMatchObject({ kind: "boss", hasLoot: true });
expect(ahune.combat?.attacks).toMatchObject([
{
id: "ahunes-shield-45954",
name: "Ahune's Shield",
spellId: 45_954,
target: "self",
castTimeMs: 0,
initialCooldownMs: [4, 4],
maximumCasts: 1,
effect: { kind: "aura", aura: "ahunes-shield", durationMs: 0, magnitude: 0.75 },
},
{
id: "ahune-spanky-hands-46146",
name: "[PH] Ahune Spanky Hands",
spellId: 46_146,
target: "self",
castTimeMs: 0,
initialCooldownMs: [4, 4],
maximumCasts: 1,
},
]);
expect(frozenCore).toMatchObject({ kind: "mob", hasLoot: false });
expect(frozenCore.combat?.healthMultiplier).toBe(4.3);
expect(frozenCore.combat?.attacks).toMatchObject([
{
id: "ice-spear-control-aura-46371",
name: "Ice Spear Control Aura",
spellId: 46_371,
target: "self",
castTimeMs: 0,
maximumCasts: 1,
},
{
id: "synch-health-46430",
name: "Synch Health",
spellId: 46_430,
target: "self",
castTimeMs: 0,
initialCooldownMs: [3_000, 3_000],
repeatCooldownMs: [3_000, 3_000],
effect: { kind: "encounter-event", eventId: "ahune-synch-health" },
},
]);
for (const entity of [avatar, yor, ahune, frozenCore]) {
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
expect(entity.combat?.mechanicIds).toEqual(
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
);
}
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.find((review) => review.dungeonId === "slave-pens")
?.remainingGaps.join(" ")).toContain("proc-driven Cold Slap");
});
it("validates definitions, stable action ids, references, timeouts, and wrapper idempotency", () => {
for (const id of TARGETS) {
const definition = adapted(id);
const value = encounter(definition);
validateDungeonEncounterDefinition(value);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groups = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
const objects = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
const spawns = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
]);
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
expect(new Set(actionIds).size, id).toBe(actionIds.length);
for (const rule of value.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
rule.trigger.entityIds.forEach((entityId) => expect(spawns.has(entityId), entityId).toBe(true));
}
if (rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health" || rule.trigger.kind === "entity-death") {
expect(spawns.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "interact") expect(objects.has(rule.trigger.objectId), rule.id).toBe(true);
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groups.has(action.spawnGroupId), action.id).toBe(true);
}
if (action.kind === "set-world-object-state") expect(objects.has(action.objectId), action.id).toBe(true);
}
}
expect(withWowTbcEncounterBatchTwoContent(definition)).toBe(definition);
}
});
it("suppresses event-only/static duplicates and preserves source order and cardinality", () => {
const underbog = adapted("underbog");
expect(underbog.staticSpawns.some((spawn) => [18_105, 17_827].includes(entryForSpawn(underbog, spawn.id) ?? -1))).toBe(false);
expect(group(underbog, "underbog:ghazan-platform").spawns).toHaveLength(1);
expect(group(underbog, "underbog:claw").spawns).toHaveLength(1);
expect(underbog.bosses.find((boss) => boss.name === "Ghaz'an")).toMatchObject({ eventOnly: true });
const slavePens = adapted("slave-pens");
expect(slavePens.staticSpawns.some((spawn) => entryForSpawn(slavePens, spawn.id) === 17_893)).toBe(false);
expect(group(slavePens, "slave-pens:naturalist-attackers").spawns).toHaveLength(3);
expect(group(slavePens, "slave-pens:ahune-coldwaves").spawns).toHaveLength(2);
expect(slavePens.objectiveOrder.at(-1)).toMatchObject({ id: "slave-pens:ahune", optional: true, eventOnly: true });
const manaTombs = adapted("mana-tombs");
expect(group(manaTombs, "mana-tombs:pandemonius-room-pull").spawns).toHaveLength(12);
expect(group(manaTombs, "mana-tombs:shaheen-wave-1").spawns).toHaveLength(4);
expect(group(manaTombs, "mana-tombs:shaheen-wave-2").spawns).toHaveLength(4);
expect(group(manaTombs, "mana-tombs:shaheen-wave-3").spawns).toHaveLength(1);
expect(manaTombs.staticSpawns.some((spawn) => /^creature-833(0[9]|1[0-9]|21)$/.test(spawn.id))).toBe(false);
expect(manaTombs.objectiveOrder.at(-1)).toMatchObject({ id: "mana-tombs:yor", optional: true, eventOnly: true });
const auchenai = adapted("auchenai-crypts");
expect(group(auchenai, "auchenai-crypts:stolen-soul").spawns).toHaveLength(1);
expect(group(auchenai, "auchenai-crypts:avatar").spawns).toHaveLength(1);
const magisters = adapted("magisters-terrace");
expect(magisters.staticSpawns.some((spawn) => entryForSpawn(magisters, spawn.id) === 24_560)).toBe(false);
expect(group(magisters, "magisters-terrace:selin-crystals").spawns).toHaveLength(5);
expect(group(magisters, "magisters-terrace:delrissa-party").spawns).toHaveLength(5);
expect(group(magisters, "magisters-terrace:kael-spheres").spawns).toHaveLength(3);
expect(magisters.objectiveOrder.map((boss) => boss.name)).toEqual([
"Selin Fireheart", "Vexallus", "Priestess Delrissa", "Kael'thas Sunstrider",
]);
});
it("replaces only reviewed GO rows by exact source spawnId with visible procedural states", () => {
const expected = Object.freeze({
underbog: [],
"slave-pens": [50_458, 1_267],
"mana-tombs": [43_146, 2_134_514],
"auchenai-crypts": [],
"magisters-terrace": [99_800, 99_802, 99_807, 99_808, 99_809, 99_813],
} as const);
for (const id of TARGETS) {
const definition = adapted(id);
for (const spawnId of expected[id]) {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.source?.files.length).toBeGreaterThan(0);
expect(object.visual?.hidden).not.toBe(true);
}
}
const magisters = adapted("magisters-terrace");
for (const spawnId of [99_802, 99_807, 99_808, 99_809, 99_813]) {
const door = magisters.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
expect(door.states.find((state) => state.id === "closed")).toMatchObject({
visible: true,
blocksMovement: true,
blocksSight: true,
});
expect(door.states.find((state) => state.id === "open")).toMatchObject({
visible: false,
blocksMovement: false,
blocksSight: false,
});
}
});
it("runs Underbog difficulty branches, Ghaz'an release, Claw rescue, and wipe retry", () => {
const definition = adapted("underbog");
const value = encounter(definition);
const hungarfen = bossIdForEntry(definition, 17_770);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "normal");
normal = dispatch(value, normal, { kind: "tick", now: 9_999 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual([]);
normal = dispatch(value, normal, { kind: "tick", now: 10_000 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-normal"]);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "heroic");
heroic = dispatch(value, heroic, { kind: "tick", now: 3_000 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-heroic"]);
heroic = resetDungeonEncounterSnapshot(value, heroic, "boss-wipe", 3_001);
heroic = dispatch(value, heroic, { kind: "tick", now: 3_002 }, "heroic");
expect(heroic).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
let snapshot = dispatch(value, heroic, { kind: "interact", objectId: "underbog:ghazan-platform-trigger", now: 4_000 });
expect(snapshot.activeSpawnGroupIds).toEqual(["underbog:ghazan-platform"]);
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: "creature-138392", now: 4_001 });
expect(snapshot.completedObjectiveIds).toContain("creature-138392");
expect(snapshot.flags["ghazan-complete"]).toBe(true);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 4_002);
expect(reset.worldObjectStates["underbog:ghazan-platform-trigger"]).toBe("complete");
snapshot = dispatch(value, reset, { kind: "tick", now: 4_003 });
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 17_826), now: 4_004 });
expect(snapshot.activeSpawnGroupIds).toContain("underbog:claw");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "creature-138499", healthPercent: 20, now: 4_005 });
expect(snapshot.flags["claw-rescued"]).toBe(true);
expect(snapshot.activeSpawnGroupIds).not.toContain("underbog:claw");
expect(snapshot.worldObjectStates["underbog:rescued-claw"]).toBe("complete");
});
it("runs Naturalist and Ahune as finite, persistent, retryable Slave Pens events", () => {
const definition = adapted("slave-pens");
const value = encounter(definition);
const cage = objectIdForSpawn(definition, 50_458);
const iceStone = objectIdForSpawn(definition, 1_267);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: cage, now: 0 });
snapshot = killGroup(value, snapshot, group(definition, "slave-pens:naturalist-attackers"), 1);
expect(snapshot.phase).toBe("naturalist-handoff");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_441 });
expect(snapshot.flags["naturalist-complete"]).toBe(true);
expect(snapshot.worldObjectStates[cage]).toBe("complete");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 4_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_000 });
expect(snapshot.activeSpawnGroupIds).toEqual(["slave-pens:ahune"]);
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 14_001);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_002 });
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
expect(snapshot.flags["naturalist-complete"]).toBe(true);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 15_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 29_000 });
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-hailstone");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 37_000 });
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-coldwaves");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 123_000 });
expect(snapshot.phase).toBe("ahune-core");
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:frozen-core");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "slave-pens:frozen-core").spawns[0].id, now: 123_001 });
expect(snapshot).toMatchObject({ phase: "ready", flags: { "ahune-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.completedObjectiveIds).toContain("slave-pens:ahune");
});
it("runs Mana-Tombs room pull, Sha'heen ambushes, and heroic-only Yor", () => {
const definition = adapted("mana-tombs");
const value = encounter(definition);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 18_341), now: 0 });
expect(snapshot.activeSpawnGroupIds).toEqual(["mana-tombs:pandemonius-room-pull"]);
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 1);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 2 }, "normal");
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const panel = objectIdForSpawn(definition, 43_146);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: panel, now: 3 });
for (let index = 1; index <= 3; index += 1) {
snapshot = killGroup(value, snapshot, group(definition, `mana-tombs:shaheen-wave-${index}`), 3 + index);
}
expect(snapshot.flags["shaheen-complete"]).toBe(true);
expect(snapshot.worldObjectStates[panel]).toBe("complete");
const persisted = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 7);
expect(persisted.flags["shaheen-complete"]).toBe(true);
const stasis = objectIdForSpawn(definition, 2_134_514);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "tick", now: 0 }, "normal");
expect(normal.worldObjectStates[stasis]).toBe("hidden");
normal = dispatch(value, normal, { kind: "interact", objectId: stasis, now: 1 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual([]);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "tick", now: 0 }, "heroic");
expect(heroic.worldObjectStates[stasis]).toBe("ready");
heroic = dispatch(value, heroic, { kind: "interact", objectId: stasis, now: 1 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["mana-tombs:yor"]);
heroic = dispatch(value, heroic, { kind: "entity-death", entityId: group(definition, "mana-tombs:yor").spawns[0].id, now: 2 }, "heroic");
expect(heroic.flags["yor-complete"]).toBe(true);
expect(heroic.completedObjectiveIds).toContain("mana-tombs:yor");
});
it("runs Maladaar's finite soul/avatar transition and keeps the avatar independently killable", () => {
const definition = adapted("auchenai-crypts");
const value = encounter(definition);
const maladaar = bossIdForEntry(definition, 18_373);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: maladaar, now: 0 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:stolen-soul");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: maladaar, healthPercent: 25, now: 25_001 });
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: maladaar, now: 25_002 });
expect(snapshot.activeSpawnGroupIds).not.toContain("auchenai-crypts:stolen-soul");
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "auchenai-crypts:avatar").spawns[0].id, now: 25_003 });
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 25_004);
expect(dispatch(value, reset, { kind: "tick", now: 25_005 }).phase).toBe("ready");
});
it("runs Magisters' exact doors, difficulty branches, Delrissa recovery, and reachable completion", () => {
const definition = adapted("magisters-terrace");
const value = encounter(definition);
const selin = bossIdForEntry(definition, 24_723);
const vexallus = bossIdForEntry(definition, 24_744);
const kael = bossIdForEntry(definition, 24_664);
const selinPassage = objectIdForSpawn(definition, 99_802);
const selinRoom = objectIdForSpawn(definition, 99_809);
const vexPassage = objectIdForSpawn(definition, 99_813);
const delrissaPassage = objectIdForSpawn(definition, 99_807);
const kaelRoom = objectIdForSpawn(definition, 99_808);
const orb = objectIdForSpawn(definition, 99_800);
let normal = createDungeonEncounterSnapshot(value);
normal = dispatch(value, normal, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "normal");
expect(normal.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-normal"]);
expect(group(definition, normal.activeSpawnGroupIds[0]).spawns).toHaveLength(1);
let heroic = createDungeonEncounterSnapshot(value);
heroic = dispatch(value, heroic, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "heroic");
expect(heroic.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-heroic"]);
expect(group(definition, heroic.activeSpawnGroupIds[0]).spawns).toHaveLength(2);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: selin, now: 0 });
expect(snapshot.worldObjectStates[selinRoom]).toBe("closed");
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:selin-crystals");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: selin, now: 1 });
expect(snapshot.worldObjectStates[selinPassage]).toBe("open");
expect(snapshot.worldObjectStates[selinRoom]).toBe("open");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: vexallus, now: 2 });
expect(snapshot.worldObjectStates[vexPassage]).toBe("open");
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:delrissa-party");
const prematureKael = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 2.5 });
expect(prematureKael.flags["dungeon-complete"]).not.toBe(true);
expect(prematureKael.completedObjectiveIds).not.toContain("creature-96966");
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 4 });
expect(snapshot.phase).toBe("delrissa-active");
expect(snapshot.activeSpawnGroupIds).toEqual(["magisters-terrace:delrissa-party"]);
snapshot = killGroup(value, snapshot, group(definition, "magisters-terrace:delrissa-party"), 5);
expect(snapshot.completedObjectiveIds).toContain("creature-96966");
expect(snapshot.worldObjectStates[delrissaPassage]).toBe("open");
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kael, now: 6 });
expect(snapshot.worldObjectStates[kaelRoom]).toBe("closed");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 15_006 });
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-phoenix");
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: kael, healthPercent: 50, now: 15_007 });
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-spheres");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 15_008 });
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
expect(snapshot.worldObjectStates[kaelRoom]).toBe("open");
expect(snapshot.worldObjectStates[orb]).toBe("unlocked");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 15_009);
const resumed = dispatch(value, reset, { kind: "tick", now: 15_010 });
expect(resumed.phase).toBe("complete");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,518 @@
import { describe, expect, it } from "vitest";
import { gunzipSync, strFromU8 } from "fflate";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
import {
WOW_TBC_ENCOUNTER_CONTENT_REVIEWS,
withWowTbcEncounterContent,
} from "./wowTbcEncounterContentCatalog";
const TARGETS = [
"blood-furnace",
"shattered-halls",
"hellfire-ramparts",
"botanica",
"sethekk-halls",
] as const;
const EXPECTED_SOURCE_HASHES = Object.freeze({
"blood-furnace": [
"ec11055881a2cb1a01c299ab9999f91f732ddefe2ed79e3b817734179694e398",
"d1c55db2400e0dd5563f5832028dce474ad55ff0fc21471442bee7af3a10750e",
"3ddeb8493dd32d30f76ca1612ee768d78783842cc3d187c48cc66861b92e0e36",
],
"shattered-halls": [
"8e1e6178a4c36d5bfd245529625df928a3c1e9178562bf7c03daf29cee725dbc",
"8edd2a23afb4d6db7c72d63b16d86a860c68bd6bee2dc21ece7d09e307092dfd",
"1d4eae585fc0084c50e5d8b6caf1502643cc2f5739b42e79d668439afdf7341a",
"6fee37fc2e07515aa887155701c83bb0383ba8a41bb5549e90da4a83e95dd94e",
"c4c2e6724b0d7ca9daa659a2f4e64df53e1eae67e913fe901ceb74865d29f850",
],
"hellfire-ramparts": [
"0b70541937db116dfd1d9eba75c0f9b662679edf5f61ca52c88d3198319ebb45",
"32fa87c630d4512b62b4f7fb7f22ea82b8e4d93c9cfd5bd17e452e0b6245357c",
"5365a041c9bacdb8c1a46f25b8ed90f2a8ff06e1925f7e8a34cd77ee1ea8df93",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
botanica: [
"58e3f612984d30d765d4ebe98016e530629d7cd5a66499b3ee1c541f16cb378f",
"062c751d365cb5d205c47a5dca93c64fa46cfda7629456e8e58096dfe8aeaa22",
"b350507ced2b94f6a9178db0102176c98e130cfe5b844ce11f649b62f94cae83",
"a428f3f216e1e91a93339b837c9e1049f4d0eca4fa735312c574fcc388971dc7",
"484bebc3e3211ab624bb933fd0b59bb1e9f6c2891b66d36c318dac8df7705399",
],
"sethekk-halls": [
"223f84da402f20cfb5d99cbdee8b1bdf4cd22bf2f74eed1516cb078b9759250f",
"2d44ec532e3d62d804cf0b4c26304cc9ad12b38f626ef5876e5e02b0b99e5bb8",
"93ecd4af480331bc4026d24431c6db9288a17f9502966795e4318615eec774ca",
"b15531207673a2b908e263acb6e5c3360810ffc682cc93f6cadaaad67f01a703",
"73fdb3c48d6a1763600a1a3293639849f6a61cc465e1c186958c1322e3003f3f",
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
],
});
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
};
function adapted(id: typeof TARGETS[number]): DungeonDefinition {
const generated = campaign.definitions.find((definition) => definition.id === id);
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
const baseline = withWowProgressionObjectContent(withWowEncounterContent(generated));
return withWowTbcEncounterContent(baseline);
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-instance`);
if (!value) throw new Error(`Missing TBC encounter for ${definition.id}`);
return value;
}
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`Missing spawn group ${id}`);
return value;
}
function objectIdForTemplate(definition: DungeonDefinition, templateId: number): string {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
if (!object) throw new Error(`${definition.id} missing object template ${templateId}`);
return object.id;
}
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
}
function entityForEntry(definition: DungeonDefinition, entry: number) {
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
return entity;
}
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
if (!spawn) throw new Error(`${definition.id} missing boss entry ${entry}`);
return spawn.id;
}
function dispatch(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
spawnGroup: DungeonEncounterSpawnGroupDefinition,
now: number,
): DungeonEncounterSnapshot {
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
return dispatch(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
});
}
describe("source-backed adapted TBC dungeon orchestration", () => {
it("pins reviewed source hashes, symbol/line anchors, and explicit unsupported behavior", () => {
expect(WOW_TBC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_TBC_ENCOUNTER_CONTENT_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
expect(review.remainingGaps.join(" ")).toMatch(/remain|require|need|unported|incomplete/i);
for (const source of review.sourceFiles) {
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
.toBe(source.sha256);
}
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
}
});
it("gives the event-only Ramparts and Sethekk bosses their pinned source combat schedules", () => {
const ramparts = adapted("hellfire-ramparts");
const vazruden = entityForEntry(ramparts, 17_537);
const nazan = entityForEntry(ramparts, 17_536);
const sethekk = adapted("sethekk-halls");
const anzu = entityForEntry(sethekk, 23_035);
expect(vazruden.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_300 },
{
id: "revenge-19130",
name: "Revenge",
spellId: 19_130,
target: "primary",
school: "physical",
initialCooldownMs: [4_000, 4_000],
repeatCooldownMs: [6_000, 6_000],
},
]);
expect(nazan.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 2_000 },
{
id: "fireball-33793",
name: "Fireball",
spellId: 33_793,
target: "random-party",
school: "fire",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [4_000, 6_000],
},
{
id: "cone-of-fire-30926",
name: "Cone of Fire",
spellId: 30_926,
target: "nearby-party",
school: "fire",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [12_000, 12_000],
},
]);
expect(anzu.combat?.attacks).toMatchObject([
{ id: "basic-attack", cooldownMs: 1_400 },
{
id: "paralyzing-screech-40184",
name: "Paralyzing Screech",
spellId: 40_184,
target: "nearby-party",
school: "shadow",
initialCooldownMs: [14_000, 14_000],
repeatCooldownMs: [23_000, 23_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
},
{
id: "spell-bomb-40303",
name: "Spell Bomb",
spellId: 40_303,
target: "random-party",
initialCooldownMs: [5_000, 5_000],
repeatCooldownMs: [16_000, 24_500],
effect: { kind: "target-aura", aura: "spell-bomb", durationMs: 8_000 },
},
{
id: "cyclone-of-feathers-40321",
name: "Cyclone of Feathers",
spellId: 40_321,
target: "random-party",
school: "shadow",
initialCooldownMs: [8_000, 8_000],
repeatCooldownMs: [22_000, 27_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
},
]);
for (const entity of [vazruden, nazan, anzu]) {
expect(entity.kind).toBe("boss");
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
expect(entity.combat?.mechanicIds).toEqual(
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
);
}
});
it("validates every encounter, stable action id, spawn/object reference, and safety timeout", () => {
for (const id of TARGETS) {
const definition = adapted(id);
const value = encounter(definition);
validateDungeonEncounterDefinition(value);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
const objectIds = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
const spawnIds = new Set([
...definition.staticSpawns.map((spawn) => spawn.id),
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
]);
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
expect(new Set(actionIds).size, id).toBe(actionIds.length);
for (const rule of value.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
for (const entityId of rule.trigger.entityIds) expect(spawnIds.has(entityId), `${id}:${rule.id}:${entityId}`).toBe(true);
}
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds.has(action.spawnGroupId), `${id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
expect(objectIds.has(action.objectId), `${id}:${action.id}`).toBe(true);
}
}
}
expect(withWowTbcEncounterContent(definition)).toBe(definition);
}
});
it("exposes collision only for source-reviewed imported objects", () => {
const reviewedTemplates = Object.freeze({
"blood-furnace": [181_811, 181_812, 181_822, 181_819, 181_823, 181_766, 181_821, 181_818, 181_820, 181_817, 181_982],
"shattered-halls": [182_539, 182_540],
"hellfire-ramparts": [185_168],
botanica: [],
"sethekk-halls": [183_398, 187_372],
} as const);
for (const id of TARGETS) {
const definition = adapted(id);
for (const templateId of reviewedTemplates[id]) {
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId)!;
expect(object.source?.fidelity).toBe("source-backed-adapted");
expect(object.visual?.hidden).not.toBe(true);
expect(object.source?.files.length).toBeGreaterThan(0);
}
const allowed = new Set<number>(reviewedTemplates[id]);
const untouchedImports = definition.serverGameObjects.filter((object) => (
object.source?.spawnId !== undefined && !allowed.has(object.source.templateId ?? -1)
));
expect(untouchedImports.every((object) => object.visual?.hidden === true)).toBe(true);
expect(untouchedImports.every((object) => object.states.every((state) => !state.blocksMovement && !state.blocksSight))).toBe(true);
}
for (const [id, templateId] of [
["blood-furnace", 181_811],
["shattered-halls", 182_539],
["sethekk-halls", 183_398],
] as const) {
const definition = adapted(id);
const object = definition.serverGameObjects.find((candidate) => candidate.id === objectIdForTemplate(definition, templateId))!;
expect(object.states.find((state) => state.id === "closed")).toMatchObject({
visible: true,
blocksMovement: true,
blocksSight: true,
});
expect(object.states.find((state) => state.id === "open")).toMatchObject({
visible: false,
blocksMovement: false,
blocksSight: false,
});
}
});
it("removes unsafe static event actors and preserves finite source-shaped cardinality", () => {
const blood = adapted("blood-furnace");
expect(blood.staticSpawns.some((spawn) => entryForSpawn(blood, spawn.id) === 17_380)).toBe(false);
expect([1, 2, 3, 4].map((index) => group(blood, `blood-furnace:broggok-cell-${index}`).spawns.length).sort()).toEqual([3, 4, 5, 6]);
expect(group(blood, "blood-furnace:broggok").spawns).toHaveLength(1);
expect(blood.staticSpawns.some((spawn) => (
entryForSpawn(blood, spawn.id) === 17_398
&& spawn.position[0] < -400
&& spawn.position[2] >= 75
&& spawn.position[2] <= 125
))).toBe(false);
const shattered = adapted("shattered-halls");
expect(shattered.staticSpawns.some((spawn) => entryForSpawn(shattered, spawn.id) === 16_807)).toBe(false);
expect(group(shattered, "shattered-halls:nethekurse-converts").spawns).toHaveLength(4);
expect(group(shattered, "shattered-halls:nethekurse").spawns).toHaveLength(1);
expect(group(shattered, "shattered-halls:kargath-reinforcements").spawns).toHaveLength(7);
const ramparts = adapted("hellfire-ramparts");
expect(ramparts.staticSpawns.some((spawn) => [17_536, 17_537].includes(entryForSpawn(ramparts, spawn.id) ?? -1))).toBe(false);
expect(group(ramparts, "hellfire-ramparts:vazruden-sentries").spawns).toHaveLength(2);
expect(group(ramparts, "hellfire-ramparts:vazruden-and-nazan").spawns).toHaveLength(2);
const botanica = adapted("botanica");
expect(group(botanica, "botanica:freywinn-frayers").spawns).toHaveLength(3);
expect(group(botanica, "botanica:laj-thorns").spawns).toHaveLength(2);
expect(group(botanica, "botanica:warp-saplings").spawns).toHaveLength(6);
const sethekk = adapted("sethekk-halls");
expect([90, 50, 10].map((threshold) => group(sethekk, `sethekk-halls:syth-elementals-${threshold}`).spawns.length)).toEqual([4, 4, 4]);
expect(group(sethekk, "sethekk-halls:anzu").spawns).toHaveLength(1);
expect([66, 33].map((threshold) => group(sethekk, `sethekk-halls:anzu-broods-${threshold}`).spawns.length)).toEqual([5, 5]);
expect(sethekk.bosses.find((boss) => boss.id === "sethekk-halls:anzu")).toMatchObject({ optional: true, eventOnly: true });
});
it("runs Blood Furnace cells and recovers the Broggok checkpoint without duplication", () => {
const definition = adapted("blood-furnace");
const value = encounter(definition);
const leverId = objectIdForTemplate(definition, 181_982);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: leverId, now: 1 });
expect(snapshot.phase).toBe("cell-1-active");
for (let index = 1; index <= 4; index += 1) {
snapshot = killGroup(value, snapshot, group(definition, `blood-furnace:broggok-cell-${index}`), index + 1);
}
expect(snapshot).toMatchObject({ phase: "broggok-active", activeSpawnGroupIds: ["blood-furnace:broggok"] });
snapshot = killGroup(value, snapshot, group(definition, "blood-furnace:broggok"), 7);
expect(snapshot.phase).toBe("post-broggok");
expect(snapshot.completedObjectiveIds).toContain(group(definition, "blood-furnace:broggok").spawns[0].id);
expect(snapshot.activeSpawnGroupIds).toEqual([]);
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 8);
expect(reset).toMatchObject({ phase: "resume", activeSpawnGroupIds: [] });
const resumed = dispatch(value, reset, { kind: "tick", now: 9 });
expect(resumed.phase).toBe("post-broggok");
expect(resumed.activeSpawnGroupIds).toEqual([]);
});
it("runs Nethekurse's convert gate, wipe recovery, and bounded Kargath adds", () => {
const definition = adapted("shattered-halls");
const value = encounter(definition);
const entrance = objectIdForTemplate(definition, 182_539);
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: entrance, now: 1 });
expect(snapshot.phase).toBe("nethekurse-converts-active");
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 2);
expect(snapshot.phase).toBe("nethekurse-active");
const wiped = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
const ready = dispatch(value, wiped, { kind: "tick", now: 4 });
expect(ready).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
snapshot = dispatch(value, ready, { kind: "interact", objectId: entrance, now: 5 });
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 6);
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse"), 7);
expect(snapshot.phase).toBe("post-nethekurse");
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 182_540)]).toBe("open");
const kargathId = bossIdForEntry(definition, 16_808);
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kargathId, now: 8 });
expect(snapshot.activeSpawnGroupIds).toContain("shattered-halls:kargath-reinforcements");
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kargathId, now: 9 });
expect(snapshot.activeSpawnGroupIds).not.toContain("shattered-halls:kargath-reinforcements");
});
it("makes the previously missing Ramparts finale the only completion path and recovers it", () => {
const definition = adapted("hellfire-ramparts");
const value = encounter(definition);
const progression = definition.encounters?.find((candidate) => candidate.id === "hellfire-ramparts:boss-progression")!;
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
expect(definition.bosses.at(-1)).toMatchObject({ id: "hellfire-ramparts:vazruden-and-nazan", eventOnly: true });
let snapshot = createDungeonEncounterSnapshot(value);
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_306), now: 1 });
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_308), now: 2 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3 });
expect(snapshot.phase).toBe("ready");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "hellfire-ramparts:vazruden-herald", now: 4 });
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 5);
expect(snapshot.phase).toBe("herald-descent");
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_005 });
expect(snapshot.phase).toBe("bosses-active");
const interrupted = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3_006);
const retryReady = dispatch(value, interrupted, { kind: "tick", now: 3_007 });
expect(retryReady).toMatchObject({
phase: "ready",
worldObjectStates: { "hellfire-ramparts:vazruden-herald": "ready" },
});
snapshot = dispatch(value, retryReady, {
kind: "interact",
objectId: "hellfire-ramparts:vazruden-herald",
now: 3_008,
});
expect(snapshot.phase).toBe("sentries-active");
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 3_009);
snapshot = dispatch(value, snapshot, { kind: "tick", now: 6_009 });
expect(snapshot.phase).toBe("bosses-active");
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-and-nazan"), 6_010);
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 185_168)]).toBe("unlocked");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 6_011);
const resumed = dispatch(value, reset, { kind: "tick", now: 6_012 });
expect(resumed).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
});
it("runs each Botanica summon once and resets an interrupted finite cycle", () => {
const definition = adapted("botanica");
const value = encounter(definition);
const cases = [
[17_975, "freywinn", "botanica:freywinn-frayers", 30_000],
[17_980, "laj", "botanica:laj-thorns", 20_000],
[17_977, "warp-splinter", "botanica:warp-saplings", 40_000],
] as const;
let snapshot = createDungeonEncounterSnapshot(value);
let now = 1;
for (const [entry, prefix, groupId, delay] of cases) {
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now });
expect(snapshot.phase).toBe(`${prefix}-wait`);
if (prefix === "freywinn") {
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", now + 1);
snapshot = dispatch(value, reset, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now: now + 2 });
now += 2;
}
snapshot = dispatch(value, snapshot, { kind: "tick", now: now + delay });
expect(snapshot.activeSpawnGroupIds).toEqual([groupId]);
snapshot = killGroup(value, snapshot, group(definition, groupId), now + delay + 1);
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
now += delay + 2;
}
});
it("runs Syth thresholds, Ikiss objects, and optional Anzu lifecycle with recovery", () => {
const definition = adapted("sethekk-halls");
const value = encounter(definition);
let snapshot = createDungeonEncounterSnapshot(value);
const sythId = bossIdForEntry(definition, 18_472);
for (const [threshold, health] of [[90, 89], [50, 49], [10, 9]] as const) {
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: sythId, healthPercent: health, now: 100 - health });
const wave = group(definition, `sethekk-halls:syth-elementals-${threshold}`);
expect(snapshot.activeSpawnGroupIds).toContain(wave.id);
snapshot = killGroup(value, snapshot, wave, 101 - health);
expect(snapshot.activeSpawnGroupIds).not.toContain(wave.id);
}
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 18_473), now: 100 });
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 183_398)]).toBe("open");
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 187_372)]).toBe("unlocked");
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 101 });
expect(snapshot.phase).toBe("anzu-intro");
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 102);
snapshot = dispatch(value, reset, { kind: "tick", now: 103 });
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 104 });
snapshot = dispatch(value, snapshot, { kind: "tick", now: 16_104 });
expect(snapshot.activeSpawnGroupIds).toEqual(["sethekk-halls:anzu"]);
for (const [threshold, health] of [[66, 65], [33, 32]] as const) {
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "sethekk-halls:anzu", healthPercent: health, now: 17_000 - health });
const broods = group(definition, `sethekk-halls:anzu-broods-${threshold}`);
expect(snapshot.activeSpawnGroupIds).toContain(broods.id);
snapshot = killGroup(value, snapshot, broods, 17_001 - health);
}
snapshot = killGroup(value, snapshot, group(definition, "sethekk-halls:anzu"), 18_000);
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [], flags: { "anzu-complete": true } });
expect(snapshot.completedObjectiveIds).toContain("sethekk-halls:anzu");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,62 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import { validateDungeonEncounterDefinition } from "./dungeonEncounterRuntime";
import type { DungeonDefinition } from "./dungeonTypes";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS,
withWowVerifiedNoOpInstanceContent,
} from "./wowVerifiedNoOpInstanceCatalog";
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
describe("verified no-op WoW instance scripts", () => {
it("matches every pinned source file by SHA-256", async () => {
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
const source = await readFile(new URL(`../../${review.sourceFile}`, import.meta.url));
expect(createHash("sha256").update(source).digest("hex"), review.dungeonId).toBe(review.sha256);
}
});
it("adds a source-exact empty orchestration review without altering population", () => {
expect(WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.map((review) => review.dungeonId)).toEqual([
"stormwind-stockade",
"razorfen-kraul",
"ragefire-chasm",
]);
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
const source = generatedDefinitions.find((definition) => definition.id === review.dungeonId);
if (!source) throw new Error(`Missing generated dungeon ${review.dungeonId}`);
const adapted = withWowVerifiedNoOpInstanceContent(source);
const encounter = adapted.encounters?.find(
(candidate) => candidate.id === `${review.dungeonId}:source-reviewed-instance`,
);
expect(encounter).toBeDefined();
validateDungeonEncounterDefinition(encounter!);
expect(encounter?.source).toMatchObject({
fidelity: "source-exact",
scope: "instance-orchestration",
adaptations: [],
remainingGaps: [],
});
expect(encounter?.rules).toEqual([]);
expect(adapted.entities).toBe(source.entities);
expect(adapted.staticSpawns).toBe(source.staticSpawns);
expect(adapted.roamingPacks).toBe(source.roamingPacks);
expect(withWowVerifiedNoOpInstanceContent(adapted)).toBe(adapted);
}
});
});
@@ -0,0 +1,75 @@
import type { DungeonDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
export interface WowVerifiedNoOpInstanceReview {
readonly dungeonId: "stormwind-stockade" | "razorfen-kraul" | "ragefire-chasm";
readonly sourceFile: string;
readonly sha256: string;
readonly reviewedBehavior: string;
}
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
/**
* These pinned instance scripts only register an empty InstanceScript. Keeping
* that fact executable prevents the production audit from inventing doors,
* waves, or gates that the authoritative source does not define.
*/
export const WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS: readonly WowVerifiedNoOpInstanceReview[] = Object.freeze([
{
dungeonId: "stormwind-stockade",
sourceFile: `${SOURCE_ROOT}/EasternKingdoms/TheStockade/instance_the_stockade.cpp`,
sha256: "5e86a879ea7919840567607e6fbc1b6df2c50581f23574295430b46701f54a49",
reviewedBehavior: "The pinned Stockade instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
{
dungeonId: "razorfen-kraul",
sourceFile: `${SOURCE_ROOT}/Kalimdor/RazorfenKraul/instance_razorfen_kraul.cpp`,
sha256: "50309cf599020ca404d3230f134919349177a98aa572bf61132dd0e14504da54",
reviewedBehavior: "The pinned Razorfen Kraul instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
{
dungeonId: "ragefire-chasm",
sourceFile: `${SOURCE_ROOT}/Kalimdor/RagefireChasm/instance_ragefire_chasm.cpp`,
sha256: "294d043e5e6f02f3fbe82f369129659617da30727e048ffa4a333237e58a22f6",
reviewedBehavior: "The pinned Ragefire Chasm instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
},
]);
function reviewedNoOpEncounter(
definition: DungeonDefinition,
review: WowVerifiedNoOpInstanceReview,
): DungeonEncounterDefinition {
return Object.freeze({
id: `${definition.id}:source-reviewed-instance`,
dungeonId: definition.id,
initialPhase: "active",
source: Object.freeze({
fidelity: "source-exact",
scope: "instance-orchestration",
files: Object.freeze([review.sourceFile]),
reviewedBehavior: review.reviewedBehavior,
adaptations: Object.freeze([]),
remainingGaps: Object.freeze([]),
}),
rules: Object.freeze([]),
});
}
export function withWowVerifiedNoOpInstanceContent(
definition: DungeonDefinition,
): DungeonDefinition {
const review = WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.find(
(candidate) => candidate.dungeonId === definition.id,
);
if (!review) return definition;
const encounterId = `${definition.id}:source-reviewed-instance`;
if (definition.encounters?.some((encounter) => encounter.id === encounterId)) return definition;
return Object.freeze({
...definition,
encounters: Object.freeze([
...(definition.encounters ?? []),
reviewedNoOpEncounter(definition, review),
]),
});
}
@@ -0,0 +1,668 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterEvent,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS,
withWowWrathEncounterBatchThreeContent,
type WowWrathEncounterBatchThreeDungeonId,
} from "./wowWrathEncounterBatchThreeCatalog";
const TARGETS = [
"utgarde-keep",
"the-nexus",
"draktharon-keep",
"azjol-nerub",
"halls-of-lightning",
"ahnkahet-old-kingdom",
"forge-of-souls",
] as const satisfies readonly WowWrathEncounterBatchThreeDungeonId[];
const REVIEWED_TEMPLATES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
"utgarde-keep": [186_756, 186_694],
"the-nexus": [188_526, 188_527, 188_528],
"draktharon-keep": [189_299, 189_300, 189_301, 189_302],
"azjol-nerub": [192_395, 192_396, 192_397, 192_398],
"halls-of-lightning": [191_325, 191_326],
"ahnkahet-old-kingdom": [192_236, 193_093, 193_094, 193_564],
"forge-of-souls": [],
});
const SUPPRESSED_ENTRIES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
"utgarde-keep": [24_200, 24_201, 23_954],
"the-nexus": [26_731, 26_763, 26_723],
"draktharon-keep": [26_631, 26_632],
"azjol-nerub": [28_684, 28_921, 29_120, 28_729, 28_730, 28_731],
"halls-of-lightning": [28_546],
"ahnkahet-old-kingdom": [29_309, 29_308, 29_310, 29_311],
"forge-of-souls": [36_497],
});
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
function sourceDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
const source = generatedDefinitions.find((definition) => definition.id === id);
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return source;
}
function adaptedDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
return withWowWrathEncounterBatchThreeContent(sourceDefinition(id));
}
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-batch-three-instance`);
if (!value) throw new Error(`Missing batch-three encounter for ${definition.id}`);
return value;
}
function normalized(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
const fragment = normalized(nameFragment);
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
return value;
}
function group(definition: DungeonDefinition, id: string) {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
return value;
}
function send(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
event: DungeonEncounterEvent,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
}
function killGroup(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
actorIds: readonly string[],
now: number,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
return send(definition, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: actorIds, deadEntityIds: actorIds },
}, difficultyId);
}
function assertRecoverable(
definition: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
difficultyId: "normal" | "heroic" = "normal",
): DungeonEncounterSnapshot {
const completed = snapshot.completedObjectiveIds;
const checkpoint = snapshot.checkpointId;
expect(checkpoint).not.toBeNull();
let recovered = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", now);
expect(recovered.phase).toBe("resume");
recovered = send(definition, recovered, { kind: "tick", now: now + 1 }, difficultyId);
expect(recovered.phase).not.toBe("resume");
expect(recovered.completedObjectiveIds).toEqual(completed);
expect(recovered.checkpointId).toBe(checkpoint);
return recovered;
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
];
}
describe("Wrath encounter batch three source contract", () => {
it("pins every reviewed AzerothCore source file by SHA-256", async () => {
expect(WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const sourceFile of review.sourceFiles) {
expect(sourceFile.anchors.length).toBeGreaterThan(0);
const bytes = await readFile(sourceFile.path);
expect(createHash("sha256").update(bytes).digest("hex"), sourceFile.path).toBe(sourceFile.sha256);
}
}
});
it("is idempotent and keeps all definitions, references, and source-row replacements valid", () => {
for (const id of TARGETS) {
const source = sourceDefinition(id);
const adapted = adaptedDefinition(id);
const controller = encounter(adapted);
expect(withWowWrathEncounterBatchThreeContent(adapted)).toBe(adapted);
expect(controller.source?.scope).toBe("instance-orchestration");
validateDungeonEncounterDefinition(controller);
adapted.serverGameObjects.forEach(validateDungeonWorldObject);
const objectiveIds = adapted.bosses.map((boss) => boss.id);
expect(new Set(objectiveIds).size, `${id} objective ids`).toBe(objectiveIds.length);
const orderedObjectiveIds = adapted.objectiveOrder.map((boss) => boss.id);
expect(new Set(orderedObjectiveIds).size, `${id} objective-order ids`).toBe(orderedObjectiveIds.length);
const spawnIds = runtimeSpawnIds(adapted);
expect(new Set(spawnIds).size, `${id} runtime spawn ids`).toBe(spawnIds.length);
const objectIds = adapted.serverGameObjects.map((object) => object.id);
expect(new Set(objectIds).size, `${id} object ids`).toBe(objectIds.length);
const sourceObjectSpawnIds = adapted.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined);
expect(new Set(sourceObjectSpawnIds).size, `${id} source object rows`).toBe(sourceObjectSpawnIds.length);
for (const templateId of REVIEWED_TEMPLATES[id]) {
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
const adaptedRows = adapted.serverGameObjects.filter((object) => object.source?.templateId === templateId);
expect(adaptedRows.length, `${id} template ${templateId}`).toBe(originalRows.length);
expect(adaptedRows.map((object) => object.source?.spawnId)).toEqual(originalRows.map((object) => object.source?.spawnId));
expect(adaptedRows.every((object) => object.source?.fidelity === "source-backed-adapted")).toBe(true);
expect(adaptedRows.some((object) => originalRows.some((original) => original.id === object.id))).toBe(false);
}
const remainingEntries = new Set(adapted.staticSpawns.map((spawn) => adapted.entities[spawn.entityId]?.combat?.serverEntry));
for (const entry of SUPPRESSED_ENTRIES[id]) expect(remainingEntries.has(entry), `${id} static entry ${entry}`).toBe(false);
const spawnGroupIds = new Set((adapted.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id));
const worldObjectIds = new Set(adapted.serverGameObjects.map((object) => object.id));
const validObjectiveIds = new Set(adapted.bosses.map((boss) => boss.id));
const validEntityIds = new Set(runtimeSpawnIds(adapted));
for (const rule of controller.rules) {
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
expect(validEntityIds.has(rule.trigger.entityId), `${id} trigger entity ${rule.trigger.entityId}`).toBe(true);
}
if (rule.trigger.kind === "entities-dead") {
rule.trigger.entityIds.forEach((entityId) => expect(validEntityIds.has(entityId), `${id} dead-set entity ${entityId}`).toBe(true));
}
if (rule.trigger.kind === "interact") expect(worldObjectIds.has(rule.trigger.objectId), `${id} interaction ${rule.trigger.objectId}`).toBe(true);
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(spawnGroupIds.has(action.spawnGroupId), `${id} action group ${action.spawnGroupId}`).toBe(true);
}
if (action.kind === "set-world-object-state") expect(worldObjectIds.has(action.objectId), `${id} action object ${action.objectId}`).toBe(true);
if (action.kind === "complete-objective") expect(validObjectiveIds.has(action.objectiveId), `${id} action objective ${action.objectiveId}`).toBe(true);
}
}
}
});
});
describe("Wrath encounter batch three lifecycle", () => {
it("runs Utgarde Keep's order-safe duo ghosts and timed Ingvar resurrection with wipe recovery", () => {
const dungeon = adaptedDefinition("utgarde-keep");
const controller = encounter(dungeon);
const keleseth = objective(dungeon, "Keleseth");
const duo = objective(dungeon, "Skarvold");
const ingvar = objective(dungeon, "Ingvar");
const skarvald = group(dungeon, "utgarde-keep:skarvald");
const dalronn = group(dungeon, "utgarde-keep:dalronn");
const skarvaldGhost = group(dungeon, "utgarde-keep:skarvald-ghost");
const dalronnGhost = group(dungeon, "utgarde-keep:dalronn-ghost");
const ingvarLiving = group(dungeon, "utgarde-keep:ingvar-living");
const ingvarUndead = group(dungeon, "utgarde-keep:ingvar-undead");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keleseth.id, now: ++now });
expect(snapshot.phase).toBe("duo-active");
expect(snapshot.activeSpawnGroupIds).toEqual(expect.arrayContaining([skarvald.id, dalronn.id]));
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: ++now });
expect(snapshot.flags["dalronn-dead"]).toBe(true);
expect(snapshot.activeSpawnGroupIds).toContain(dalronnGhost.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(duo.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(skarvaldGhost.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(dalronnGhost.id);
expect(snapshot.phase).toBe("ingvar-living-active");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarLiving.spawns[0].id, now: ++now });
expect(snapshot.phase).toBe("ingvar-resurrection");
const resurrectionStarted = now;
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 19_999 });
expect(snapshot.phase).toBe("ingvar-resurrection");
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_000 });
expect(snapshot.phase).toBe("ingvar-undead-active");
expect(snapshot.activeSpawnGroupIds).toContain(ingvarUndead.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarUndead.spawns[0].id, now: resurrectionStarted + 20_001 });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([keleseth.id, duo.id, ingvar.id]));
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-one"]).toBe("open");
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-two"]).toBe("open");
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_002 });
expect(snapshot.phase).toBe("complete");
let alternate = createDungeonEncounterSnapshot(controller, 0);
alternate = send(controller, alternate, { kind: "entity-death", entityId: keleseth.id, now: 1 });
alternate = send(controller, alternate, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: 2 });
expect(alternate.activeSpawnGroupIds).toContain(skarvaldGhost.id);
alternate = send(controller, alternate, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: 3 });
expect(alternate.completedObjectiveIds).toContain(duo.id);
});
it("keeps the source combat schedules on Utgarde Keep's ghost and undead replacement actors", () => {
const dungeon = adaptedDefinition("utgarde-keep");
const entityForGroup = (groupId: string) => {
const entityId = group(dungeon, groupId).spawns[0].entityId;
return dungeon.entities[entityId];
};
const attacksForGroup = (groupId: string) => {
return entityForGroup(groupId).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
};
expect(entityForGroup("utgarde-keep:dalronn-ghost").combat?.attacks[0]).toMatchObject({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
school: "physical",
animation: "attack",
range: 4.5,
});
expect(attacksForGroup("utgarde-keep:skarvald-ghost").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "charge-43651", spellId: 43_651, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
{ id: "stone-strike-48583", spellId: 48_583, initial: [10_000, 10_000], repeat: [5_000, 10_000] },
]);
expect(attacksForGroup("utgarde-keep:dalronn-ghost").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "shadow-bolt-43649", spellId: 43_649, initial: [1_000, 1_000], repeat: [2_000, 2_000] },
{ id: "debilitate-43650", spellId: 43_650, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
]);
expect(attacksForGroup("utgarde-keep:dalronn-ghost")[1].effect)
.toEqual({ kind: "target-aura", aura: "debilitate", durationMs: 8_000 });
expect(attacksForGroup("utgarde-keep:ingvar-undead").map((attack) => ({
id: attack.id,
spellId: attack.spellId,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "dreadful-roar-42729", spellId: 42_729, initial: [15_000, 15_000], repeat: [15_000, 20_000] },
{ id: "woe-strike-42730", spellId: 42_730, initial: [2_000, 2_000], repeat: [3_000, 7_000] },
{ id: "dark-smash-42723", spellId: 42_723, initial: [5_000, 5_000], repeat: [9_000, 11_000] },
{ id: "shadow-axe-42749", spellId: 42_749, initial: [10_000, 10_000], repeat: [35_000, 35_000] },
]);
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
attack.id === "woe-strike-42730"
))?.school).toBe("physical");
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
attack.id === "shadow-axe-42749"
))?.school).toBe("physical");
const review = WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
candidate.dungeonId === "utgarde-keep"
))!;
expect(review.remainingGaps.join(" ")).toContain("Woe Strike's damage-on-heal reaction");
expect(review.remainingGaps.join(" ")).toContain("Shadow Axe's outbound/returning actor");
});
it("runs Nexus spheres, normal Telestra split, Keristrasza release, and checkpoint recovery", () => {
const dungeon = adaptedDefinition("the-nexus");
const controller = encounter(dungeon);
const anomalus = objective(dungeon, "Anomalus");
const ormorok = objective(dungeon, "Ormorok");
const telestra = objective(dungeon, "Telestra");
const keristrasza = objective(dungeon, "Keristrasza");
const opening = group(dungeon, "the-nexus:telestra-opening");
const anomalusOpening = group(dungeon, "the-nexus:anomalus-opening");
const anomalusRift = group(dungeon, "the-nexus:anomalus-empowered-rift");
const anomalusFinal = group(dungeon, "the-nexus:anomalus-final");
const clones = group(dungeon, "the-nexus:telestra-first-clones");
const normalFinal = group(dungeon, "the-nexus:telestra-normal-final");
const keristraszaActor = group(dungeon, "the-nexus:keristrasza");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: anomalusOpening.spawns[0].id, healthPercent: 51, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(anomalusRift.id);
snapshot = killGroup(controller, snapshot, anomalusRift.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.activeSpawnGroupIds).toContain(anomalusFinal.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: anomalusFinal.spawns[0].id, now: ++now });
expect(snapshot.worldObjectStates["the-nexus:reviewed-anomalus-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ormorok.id, now: ++now });
expect(snapshot.worldObjectStates["the-nexus:reviewed-ormorok-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: ++now });
expect(snapshot.phase).toBe("telestra-first-clones");
snapshot = killGroup(controller, snapshot, clones.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.phase).toBe("telestra-normal-final");
expect(snapshot.activeSpawnGroupIds).toContain(normalFinal.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: normalFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(telestra.id);
expect(snapshot.worldObjectStates["the-nexus:reviewed-telestra-sphere"]).toBe("ready");
snapshot = assertRecoverable(controller, snapshot, ++now);
for (const sphere of [
"the-nexus:reviewed-telestra-sphere",
"the-nexus:reviewed-anomalus-sphere",
"the-nexus:reviewed-ormorok-sphere",
]) snapshot = send(controller, snapshot, { kind: "interact", objectId: sphere, now: ++now });
expect(snapshot.counters["spheres-used"]).toBe(3);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(keristraszaActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keristraszaActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(keristrasza.id);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
it("selects Telestra's second split only on heroic", () => {
const dungeon = adaptedDefinition("the-nexus");
const controller = encounter(dungeon);
const opening = group(dungeon, "the-nexus:telestra-opening");
const firstClones = group(dungeon, "the-nexus:telestra-first-clones");
const middle = group(dungeon, "the-nexus:telestra-heroic-middle");
const secondClones = group(dungeon, "the-nexus:telestra-second-clones");
const final = group(dungeon, "the-nexus:telestra-heroic-final");
let snapshot = createDungeonEncounterSnapshot(controller, 0);
snapshot = send(controller, snapshot, { kind: "tick", now: 1 }, "heroic");
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: 2 }, "heroic");
snapshot = killGroup(controller, snapshot, firstClones.spawns.map((spawn) => spawn.id), 3, "heroic");
expect(snapshot.phase).toBe("telestra-heroic-middle");
expect(snapshot.activeSpawnGroupIds).toContain(middle.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: middle.spawns[0].id, healthPercent: 11, now: 4 }, "heroic");
expect(snapshot.activeSpawnGroupIds).toContain(secondClones.id);
snapshot = killGroup(controller, snapshot, secondClones.spawns.map((spawn) => spawn.id), 5, "heroic");
expect(snapshot.phase).toBe("telestra-heroic-final");
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
});
it("runs Drak'Tharon's four crystal waves and Tharon'ja stage with each checkpoint recoverable", () => {
const dungeon = adaptedDefinition("draktharon-keep");
const controller = encounter(dungeon);
const trollgore = objective(dungeon, "Trollgore");
const novos = objective(dungeon, "Novos");
const dred = objective(dungeon, "King Dred");
const tharon = objective(dungeon, "Tharon'ja");
const novosActor = group(dungeon, "draktharon-keep:novos");
const tharonOpening = group(dungeon, "draktharon-keep:tharon-opening");
const tharonFlesh = group(dungeon, "draktharon-keep:tharon-flesh");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: trollgore.id, now: ++now });
expect(snapshot.phase).toBe("novos-handler-1");
snapshot = assertRecoverable(controller, snapshot, ++now);
for (let waveIndex = 1; waveIndex <= 4; waveIndex += 1) {
const wave = group(dungeon, `draktharon-keep:novos-handler-wave-${waveIndex}`);
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.worldObjectStates[`draktharon-keep:reviewed-novos-crystal-${waveIndex}`]).toBe("complete");
}
expect(snapshot.phase).toBe("novos-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: novosActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(novos.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.phase).toBe("dred-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dred.id, now: ++now });
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(tharonOpening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: tharonOpening.spawns[0].id, healthPercent: 55, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(tharonFlesh.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: tharonFlesh.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([trollgore.id, novos.id, dred.id, tharon.id]));
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
});
function advanceAzjolToAnubarak(difficultyId: "normal" | "heroic") {
const dungeon = adaptedDefinition("azjol-nerub");
const controller = encounter(dungeon);
const krik = objective(dungeon, "Krik'thir");
const hadronox = objective(dungeon, "Hadronox");
const watchers = group(dungeon, "azjol-nerub:krikthir-watchers");
const krikActor = group(dungeon, "azjol-nerub:krikthir");
const hadronoxActor = group(dungeon, "azjol-nerub:hadronox");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now }, difficultyId);
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), ++now, difficultyId);
expect(snapshot.activeSpawnGroupIds).toContain(krikActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: krikActor.spawns[0].id, now: ++now }, difficultyId);
expect(snapshot.completedObjectiveIds).toContain(krik.id);
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
expect(snapshot.worldObjectStates["azjol-nerub:reviewed-krikthir-passage"]).toBe("open");
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:hadronox-start", now: ++now }, difficultyId);
for (let waveIndex = 1; waveIndex <= 3; waveIndex += 1) {
const wave = group(dungeon, `azjol-nerub:crusher-wave-${waveIndex}`);
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now, difficultyId);
}
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: hadronoxActor.spawns[0].id, now: ++now }, difficultyId);
expect(snapshot.completedObjectiveIds).toContain(hadronox.id);
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:anubarak-start", now: ++now }, difficultyId);
expect(snapshot.phase).toBe("anubarak-stage-1");
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("closed");
}
return { dungeon, controller, snapshot, now };
}
describe("Wrath encounter batch three route gates", () => {
it("runs Azjol-Nerub's normal watcher, crusher, and three-submerge route", () => {
const prepared = advanceAzjolToAnubarak("normal");
const { dungeon, controller } = prepared;
const anub = objective(dungeon, "Anub'arak");
let { snapshot, now } = prepared;
const thresholds = [75, 50, 25] as const;
for (let index = 0; index < thresholds.length; index += 1) {
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
const adds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
snapshot = send(controller, snapshot, {
kind: "entity-health",
entityId: stage.spawns[0].id,
healthPercent: thresholds[index],
now: ++now,
});
expect(snapshot.activeSpawnGroupIds).toContain(adds.id);
expect(adds.spawns.length).toBe([7, 8, 13][index]);
snapshot = killGroup(controller, snapshot, adds.spawns.map((spawn) => spawn.id), ++now);
expect(snapshot.phase).toBe(`anubarak-stage-${index + 2}`);
}
const finalStage = group(dungeon, "azjol-nerub:anubarak-stage-4");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: finalStage.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(anub.id);
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("open");
}
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
});
it("selects every larger Anub'arak intermission on heroic", () => {
const prepared = advanceAzjolToAnubarak("heroic");
const { dungeon, controller } = prepared;
let { snapshot, now } = prepared;
const thresholds = [75, 50, 25] as const;
for (let index = 0; index < thresholds.length; index += 1) {
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
const heroicAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-heroic`);
const normalAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
snapshot = send(controller, snapshot, {
kind: "entity-health",
entityId: stage.spawns[0].id,
healthPercent: thresholds[index],
now: ++now,
}, "heroic");
expect(snapshot.activeSpawnGroupIds).toContain(heroicAdds.id);
expect(snapshot.activeSpawnGroupIds).not.toContain(normalAdds.id);
expect(heroicAdds.spawns.length).toBe([8, 10, 15][index]);
expect(heroicAdds.spawns.every((spawn) => spawn.spawnMask === 2)).toBe(true);
snapshot = killGroup(controller, snapshot, heroicAdds.spawns.map((spawn) => spawn.id), ++now, "heroic");
}
expect(snapshot.phase).toBe("anubarak-stage-4");
});
it("runs Halls of Lightning's timed Ionar split and mandatory watcher checkpoint", () => {
const dungeon = adaptedDefinition("halls-of-lightning");
const controller = encounter(dungeon);
const bjarngrim = objective(dungeon, "Bjarngrim");
const volkhan = objective(dungeon, "Volkhan");
const ionar = objective(dungeon, "Ionar");
const loken = objective(dungeon, "Loken");
const opening = group(dungeon, "halls-of-lightning:ionar-opening");
const sparks = group(dungeon, "halls-of-lightning:ionar-sparks");
const final = group(dungeon, "halls-of-lightning:ionar-final");
const watchers = group(dungeon, "halls-of-lightning:awakened-watchers");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: bjarngrim.id, now: ++now });
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.phase).toBe("volkhan-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volkhan.id, now: ++now });
expect(snapshot.worldObjectStates["halls-of-lightning:reviewed-volkhan-door"]).toBe("open");
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 50, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(sparks.id);
const splitStarted = now;
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 19_999 });
expect(snapshot.phase).toBe("ionar-sparks-active");
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_000 });
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: final.spawns[0].id, now: splitStarted + 20_001 });
expect(snapshot.completedObjectiveIds).toContain(ionar.id);
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_002);
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), splitStarted + 20_003);
expect(snapshot.flags["watchers-cleared"]).toBe(true);
expect(snapshot.phase).toBe("loken-active");
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_004);
expect(snapshot.phase).toBe("loken-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: loken.id, now: splitStarted + 20_005 });
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_006 });
expect(snapshot.phase).toBe("complete");
});
it("runs Ahn'kahet's spheres, guardian, ritual, insanity stages, and recovers each boss checkpoint", () => {
const dungeon = adaptedDefinition("ahnkahet-old-kingdom");
const controller = encounter(dungeon);
const nadox = objective(dungeon, "Nadox");
const taldaram = objective(dungeon, "Taldaram");
const jedoga = objective(dungeon, "Jedoga");
const volazj = objective(dungeon, "Volazj");
const nadoxOpening = group(dungeon, "ahnkahet-old-kingdom:nadox-opening");
const nadoxGuardian = group(dungeon, "ahnkahet-old-kingdom:nadox-guardian");
const nadoxFinal = group(dungeon, "ahnkahet-old-kingdom:nadox-final");
const taldaramActor = group(dungeon, "ahnkahet-old-kingdom:taldaram");
const initiates = group(dungeon, "ahnkahet-old-kingdom:jedoga-initiates");
const jedogaOpening = group(dungeon, "ahnkahet-old-kingdom:jedoga-opening");
const volunteer = group(dungeon, "ahnkahet-old-kingdom:jedoga-volunteer");
const jedogaFinal = group(dungeon, "ahnkahet-old-kingdom:jedoga-final");
let now = 0;
let snapshot = createDungeonEncounterSnapshot(controller, now);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
for (let sphereIndex = 1; sphereIndex <= 2; sphereIndex += 1) {
snapshot = send(controller, snapshot, { kind: "interact", objectId: `ahnkahet-old-kingdom:reviewed-sphere-${sphereIndex}`, now: ++now });
}
expect(snapshot.counters["spheres-used"]).toBe(2);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: nadoxOpening.spawns[0].id, healthPercent: 50, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(nadoxGuardian.id);
snapshot = killGroup(controller, snapshot, nadoxGuardian.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: nadoxFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(nadox.id);
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
expect(snapshot.worldObjectStates["ahnkahet-old-kingdom:reviewed-taldaram-platform"]).toBe("open");
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: taldaramActor.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(taldaram.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
expect(snapshot.activeSpawnGroupIds).toContain(initiates.id);
snapshot = killGroup(controller, snapshot, initiates.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: jedogaOpening.spawns[0].id, healthPercent: 55, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(volunteer.id);
snapshot = killGroup(controller, snapshot, volunteer.spawns.map((spawn) => spawn.id), ++now);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: jedogaFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toContain(jedoga.id);
snapshot = assertRecoverable(controller, snapshot, ++now);
for (const [index, threshold] of [66, 33].entries()) {
const stage = group(dungeon, `ahnkahet-old-kingdom:volazj-stage-${index + 1}`);
const visages = group(dungeon, `ahnkahet-old-kingdom:volazj-visages-${index + 1}`);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: stage.spawns[0].id, healthPercent: threshold, now: ++now });
expect(snapshot.activeSpawnGroupIds).toContain(visages.id);
snapshot = killGroup(controller, snapshot, visages.spawns.map((spawn) => spawn.id), ++now);
}
const volazjFinal = group(dungeon, "ahnkahet-old-kingdom:volazj-stage-3");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volazjFinal.spawns[0].id, now: ++now });
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([nadox.id, taldaram.id, jedoga.id, volazj.id]));
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
expect(snapshot.phase).toBe("complete");
const amanitarEntry = dungeon.staticSpawns
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 30_258);
expect(amanitarEntry).toHaveLength(1);
expect(amanitarEntry[0].spawnMask).toBe(2);
});
it("keeps Forge of Souls doorless while staging Bronjahm's 35-percent Soulstorm", () => {
const dungeon = adaptedDefinition("forge-of-souls");
const controller = encounter(dungeon);
const bronjahm = objective(dungeon, "Bronjahm");
const devourer = objective(dungeon, "Devourer");
const opening = group(dungeon, "forge-of-souls:bronjahm-opening");
const soulstorm = group(dungeon, "forge-of-souls:bronjahm-soulstorm");
let snapshot = createDungeonEncounterSnapshot(controller, 0);
snapshot = send(controller, snapshot, { kind: "tick", now: 1 });
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 35, now: 2 });
expect(snapshot.activeSpawnGroupIds).toContain(soulstorm.id);
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: soulstorm.spawns[0].id, now: 3 });
expect(snapshot.completedObjectiveIds).toContain(bronjahm.id);
snapshot = assertRecoverable(controller, snapshot, 4);
expect(snapshot.phase).toBe("devourer-active");
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: devourer.id, now: 5 });
snapshot = send(controller, snapshot, { kind: "tick", now: 6 });
expect(snapshot.phase).toBe("complete");
expect(REVIEWED_TEMPLATES["forge-of-souls"]).toEqual([]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,673 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { gunzipSync, strFromU8 } from "fflate";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterGuard,
DungeonEncounterSnapshot,
DungeonEncounterTrigger,
} from "./dungeonEncounterTypes";
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
import {
WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS,
withWowWrathEncounterBatchTwoContent,
} from "./wowWrathEncounterBatchTwoCatalog";
const TARGETS = [
"halls-of-stone",
"pit-of-saron",
"utgarde-pinnacle",
"gundrak",
] as const;
type TargetId = typeof TARGETS[number];
const REVIEWED_TEMPLATES: Readonly<Record<TargetId, readonly number[]>> = Object.freeze({
"halls-of-stone": [193_907, 193_906, 191_296],
"pit-of-saron": [201_885, 201_848],
"utgarde-pinnacle": [191_745, 188_593, 192_173, 192_174],
gundrak: [
192_518, 192_519, 192_520,
192_564, 192_565, 192_567, 192_566,
193_188, 192_633, 192_632, 192_569,
192_568, 193_208, 193_209,
],
});
const EVENT_BOSS_NAMES: Readonly<Record<TargetId, readonly string[]>> = Object.freeze({
"halls-of-stone": ["Tribunal of Ages"],
"pit-of-saron": ["Tyrannus"],
"utgarde-pinnacle": ["Svala", "Gortok", "Skadi"],
gundrak: ["Eck"],
});
const FINAL_BOSS_NAME: Readonly<Record<TargetId, string>> = Object.freeze({
"halls-of-stone": "Sjonnir",
"pit-of-saron": "Tyrannus",
"utgarde-pinnacle": "Ymiron",
gundrak: "Gal'darah",
});
const EXPECTED_NONFINAL_CHECKPOINTS: Readonly<Record<TargetId, number>> = Object.freeze({
"halls-of-stone": 3,
"pit-of-saron": 3,
"utgarde-pinnacle": 3,
gundrak: 4,
});
function decodeBase64(value: string): Uint8Array {
const decoded = atob(value);
const bytes = new Uint8Array(decoded.length);
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
return bytes;
}
const generatedDefinitions = (
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
readonly definitions: readonly DungeonDefinition[];
}
).definitions;
function sourceDefinition(id: TargetId): DungeonDefinition {
const source = generatedDefinitions.find((definition) => definition.id === id);
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return source;
}
function adaptedDefinition(id: TargetId): DungeonDefinition {
return withWowWrathEncounterBatchTwoContent(sourceDefinition(id));
}
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((candidate) => (
candidate.id === `${definition.id}:wrath-batch-two-instance`
));
if (!encounter) throw new Error(`Missing batch-two encounter for ${definition.id}`);
return encounter;
}
function normalized(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
const fragment = normalized(nameFragment);
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
return value;
}
function group(definition: DungeonDefinition, id: string) {
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
return value;
}
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
return [
...definition.staticSpawns.map((spawn) => spawn.id),
...definition.roamingPacks.flatMap((pack) => (
pack.members.map((member) => `${pack.id}:${member.id}`)
)),
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => (
spawnGroup.spawns.map((spawn) => spawn.id)
)),
];
}
function activeSpawnIds(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
ignoredGroupIds: ReadonlySet<string> = new Set(),
): readonly string[] {
const active = new Set(snapshot.activeSpawnGroupIds);
return (definition.encounterSpawnGroups ?? [])
.filter((spawnGroup) => active.has(spawnGroup.id) && !ignoredGroupIds.has(spawnGroup.id))
.flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id));
}
function killIds(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
ids: readonly string[],
now: number,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: ids, deadEntityIds: ids },
}).snapshot;
}
function interact(
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
objectId: string,
now: number,
): DungeonEncounterSnapshot {
return reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId, now }).snapshot;
}
function incompleteObjective(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
names: readonly string[],
): DungeonBossObjective | undefined {
return names
.map((name) => objective(definition, name))
.find((candidate) => !snapshot.completedObjectiveIds.includes(candidate.id));
}
function advanceOneStep(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
): DungeonEncounterSnapshot {
if (snapshot.phase === "resume") {
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (definition.id === "halls-of-stone") {
if (snapshot.phase === "tribunal-ready") {
const remaining = incompleteObjective(definition, snapshot, ["Krystallus", "Maiden"]);
return remaining
? killIds(encounter, snapshot, [remaining.id], now)
: interact(encounter, snapshot, "halls-of-stone:reviewed-tribunal-console", now);
}
if (snapshot.phase === "sjonnir-ready") {
return killIds(encounter, snapshot, [objective(definition, "Sjonnir").id], now);
}
}
if (definition.id === "pit-of-saron") {
if (snapshot.phase === "progression") {
const remaining = incompleteObjective(definition, snapshot, ["Garfrost", "Ick"]);
return remaining
? killIds(encounter, snapshot, [remaining.id], now)
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (snapshot.phase === "gauntlet-ready") {
return interact(encounter, snapshot, "pit-of-saron:gauntlet-start", now);
}
if (snapshot.phase === "tunnel-ready") {
return interact(encounter, snapshot, "pit-of-saron:tunnel-start", now);
}
}
if (definition.id === "utgarde-pinnacle") {
if (snapshot.phase === "svala-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:svala-start", now);
}
if (snapshot.phase === "svala-phase-one-active") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "entity-health",
entityId: "utgarde-pinnacle:svala-phase-one-actor",
healthPercent: 50,
now,
}).snapshot;
}
if (snapshot.phase === "gortok-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:reviewed-stasis-sphere", now);
}
if (snapshot.phase === "skadi-ready") {
return interact(encounter, snapshot, "utgarde-pinnacle:skadi-start", now);
}
const harpoonByPhase: Readonly<Record<string, string>> = {
"harpoon-one-ready": "utgarde-pinnacle:harpoon-1",
"harpoon-two-ready": "utgarde-pinnacle:harpoon-2",
"harpoon-three-ready": "utgarde-pinnacle:harpoon-3",
};
const harpoon = harpoonByPhase[snapshot.phase];
if (harpoon) return interact(encounter, snapshot, harpoon, now);
if (snapshot.phase === "ymiron-ready") {
return killIds(encounter, snapshot, [objective(definition, "Ymiron").id], now);
}
}
if (definition.id === "gundrak") {
if (snapshot.phase === "altar-progression") {
const remaining = incompleteObjective(
definition,
snapshot,
["Slad'ran", "Moorabi", "Drakkari Colossus"],
);
if (remaining) return killIds(encounter, snapshot, [remaining.id], now);
const altar = definition.serverGameObjects.find((object) => (
object.id.startsWith("gundrak:reviewed-altar-")
&& snapshot.worldObjectStates[object.id] === "ready"
));
return altar
? interact(encounter, snapshot, altar.id, now)
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
if (snapshot.phase === "bridge-activating") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: Math.max(now, snapshot.phaseStartedAt + 5_000),
}).snapshot;
}
if (snapshot.phase === "gal-darah-ready") {
return killIds(encounter, snapshot, [objective(definition, "Gal'darah").id], now);
}
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
const spawnIds = activeSpawnIds(definition, snapshot);
if (spawnIds.length) return killIds(encounter, snapshot, spawnIds, now);
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
}
interface FinishedEncounter {
readonly snapshot: DungeonEncounterSnapshot;
readonly phases: readonly string[];
readonly checkpoints: readonly DungeonEncounterSnapshot[];
}
function finishEncounter(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
initial = createDungeonEncounterSnapshot(encounter),
): FinishedEncounter {
let snapshot = initial;
const phases = [snapshot.phase];
const checkpoints: DungeonEncounterSnapshot[] = [];
const seenCheckpoints = new Set<string>();
const finalObjectiveId = objective(definition, FINAL_BOSS_NAME[definition.id as TargetId]).id;
let now = snapshot.phaseStartedAt + 1;
for (let step = 0; step < 100 && snapshot.phase !== "complete"; step += 1) {
const previous = snapshot;
snapshot = advanceOneStep(definition, encounter, snapshot, now);
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
if (snapshot.checkpointId
&& snapshot.checkpointId !== finalObjectiveId
&& !seenCheckpoints.has(snapshot.checkpointId)) {
seenCheckpoints.add(snapshot.checkpointId);
checkpoints.push(snapshot);
}
if (snapshot === previous) throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
}
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
return { snapshot, phases, checkpoints };
}
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
let cursor = -1;
for (const phase of expected) {
cursor = phases.indexOf(phase, cursor + 1);
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
}
}
function expectTriggerReferences(
trigger: DungeonEncounterTrigger,
spawnIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
): void {
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
}
if (trigger.kind === "entities-dead") {
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
}
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
}
function expectGuardReferences(
guard: DungeonEncounterGuard,
groupIds: ReadonlySet<string>,
objectIds: ReadonlySet<string>,
objectiveIds: ReadonlySet<string>,
): void {
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
}
function expectActionReferences(
action: DungeonEncounterAction,
groupIds: ReadonlySet<string>,
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
objectiveIds: ReadonlySet<string>,
): void {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
}
if (action.kind === "set-world-object-state") {
const object = objectsById.get(action.objectId);
expect(object, `action object ${action.objectId}`).toBeDefined();
expect(object?.states.map((state) => state.id), `${action.objectId}:${action.state}`)
.toContain(action.state);
}
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
}
describe("source-reviewed Wrath encounter batch two", () => {
it("pins every reviewed AzerothCore source file by actual SHA-256 and line/symbol anchors", async () => {
expect(WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const source of review.sourceFiles) {
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(source.anchors.every((anchor) => /lines? \d+/i.test(anchor))).toBe(true);
const digest = createHash("sha256").update(await readFile(source.path)).digest("hex");
expect(digest, source.path).toBe(source.sha256);
}
}
});
it("attaches idempotently with unique rows, valid references, and finite event groups", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
expect(withWowWrathEncounterBatchTwoContent(definition)).toBe(definition);
expect(encounter.source).toMatchObject({
fidelity: "source-backed-adapted",
scope: "instance-orchestration",
});
validateDungeonEncounterDefinition(encounter);
const spawnIdList = runtimeSpawnIds(definition);
expect(new Set(spawnIdList).size, `${id} duplicate runtime spawn id`).toBe(spawnIdList.length);
const groupIdList = (definition.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id);
expect(new Set(groupIdList).size, `${id} duplicate group id`).toBe(groupIdList.length);
const objectIdList = definition.serverGameObjects.map((object) => object.id);
expect(new Set(objectIdList).size, `${id} duplicate object id`).toBe(objectIdList.length);
const encounterIdList = (definition.encounters ?? []).map((candidate) => candidate.id);
expect(new Set(encounterIdList).size, `${id} duplicate encounter id`).toBe(encounterIdList.length);
const sourceSpawnIds = definition.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined);
expect(new Set(sourceSpawnIds).size, `${id} duplicate source gameobject row`).toBe(sourceSpawnIds.length);
const spawnIds = new Set(spawnIdList);
const groupIds = new Set(groupIdList);
const objectIds = new Set(objectIdList);
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
for (const spawn of spawnGroup.spawns) {
expect(definition.entities[spawn.entityId], `${spawnGroup.id}:${spawn.id}`).toBeDefined();
}
}
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
for (const rule of encounter.rules) {
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
for (const guard of rule.guards ?? []) {
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
}
for (const action of rule.actions) {
expectActionReferences(action, groupIds, objectsById, objectiveIds);
}
}
const batchGroups = (definition.encounterSpawnGroups ?? [])
.filter((spawnGroup) => spawnGroup.id.startsWith(`${id}:`));
for (const spawnGroup of batchGroups) {
const completion = encounter.rules.find((rule) => (
rule.trigger.kind === "entities-dead"
&& rule.trigger.entityIds.length === spawnGroup.spawns.length
&& rule.trigger.entityIds.every((spawnId) => spawnGroup.spawns.some((spawn) => spawn.id === spawnId))
));
const isHealthGatedSvala = spawnGroup.id === "utgarde-pinnacle:svala-phase-one";
expect(Boolean(completion) || isHealthGatedSvala, `${id} finite completion for ${spawnGroup.id}`).toBe(true);
if (completion?.trigger.kind === "entities-dead") {
expect(completion.trigger.timeoutMs, `${id} timeout for ${spawnGroup.id}`).toBeGreaterThan(0);
}
}
}
});
it("replaces each reviewed imported object by spawnId without dropping or duplicating source rows", () => {
for (const id of TARGETS) {
const source = sourceDefinition(id);
const definition = adaptedDefinition(id);
const sourceRowIds = source.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined)
.sort((left, right) => left - right);
const adaptedRowIds = definition.serverGameObjects
.map((object) => object.source?.spawnId)
.filter((spawnId): spawnId is number => spawnId !== undefined)
.sort((left, right) => left - right);
expect(adaptedRowIds, `${id} source-row inventory`).toEqual(sourceRowIds);
for (const templateId of REVIEWED_TEMPLATES[id]) {
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
expect(originalRows, `${id} template ${templateId}`).toHaveLength(1);
const original = originalRows[0];
expect(original.source?.spawnId).toEqual(expect.any(Number));
const replacements = definition.serverGameObjects.filter((object) => (
object.source?.spawnId === original.source?.spawnId
));
expect(replacements, `${id} spawn ${original.source?.spawnId}`).toHaveLength(1);
const replacement = replacements[0];
expect(replacement.id).not.toBe(original.id);
expect(replacement.id).toContain(":reviewed-");
expect(replacement.position).toEqual(original.position);
expect(replacement.halfExtents).toEqual(original.halfExtents);
expect(replacement.yaw).toEqual(original.yaw);
expect(replacement.visual?.hidden).not.toBe(true);
expect(replacement.source?.fidelity).toBe("source-backed-adapted");
}
}
expect(adaptedDefinition("halls-of-stone").serverGameObjects
.find((object) => object.id === "halls-of-stone:reviewed-tribunal-console")?.interaction)
.toBeDefined();
expect(adaptedDefinition("utgarde-pinnacle").serverGameObjects
.find((object) => object.id === "utgarde-pinnacle:reviewed-stasis-sphere")?.interaction)
.toBeDefined();
expect(adaptedDefinition("gundrak").serverGameObjects
.filter((object) => object.id.startsWith("gundrak:reviewed-altar-")).every((object) => object.interaction))
.toBe(true);
});
it("suppresses only source-gated event objectives and preserves exact finite cardinalities", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const staticIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
for (const name of EVENT_BOSS_NAMES[id]) {
const eventBoss = objective(definition, name);
expect(eventBoss.eventOnly, `${id}:${eventBoss.name}`).toBe(true);
expect(staticIds, `${id}:${eventBoss.name}`).not.toContain(eventBoss.id);
}
}
const halls = adaptedDefinition("halls-of-stone");
expect([
group(halls, "halls-of-stone:tribunal-protectors").spawns.length,
group(halls, "halls-of-stone:tribunal-stormcallers").spawns.length,
group(halls, "halls-of-stone:tribunal-custodian").spawns.length,
]).toEqual([3, 2, 1]);
expect(incompleteObjective(halls, createDungeonEncounterSnapshot(adaptedEncounter(halls)), ["Krystallus", "Maiden"]))
.toBeDefined();
const pit = adaptedDefinition("pit-of-saron");
expect(group(pit, "pit-of-saron:first-ambush").spawns).toHaveLength(10);
expect(group(pit, "pit-of-saron:second-ambush-normal").spawns).toHaveLength(6);
expect(group(pit, "pit-of-saron:tyrannus").spawns).toHaveLength(1);
expect(pit.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
objective(pit, "Garfrost").id,
objective(pit, "Ick").id,
]));
const pinnacle = adaptedDefinition("utgarde-pinnacle");
expect(group(pinnacle, "utgarde-pinnacle:svala-ritual-channelers").spawns).toHaveLength(3);
expect(group(pinnacle, "utgarde-pinnacle:skadi-first-wave").spawns).toHaveLength(13);
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-one").spawns).toHaveLength(3);
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-two").spawns).toHaveLength(3);
expect(pinnacle.staticSpawns.map((spawn) => spawn.id)).toContain(objective(pinnacle, "Ymiron").id);
const gundrak = adaptedDefinition("gundrak");
expect(group(gundrak, "gundrak:eck").spawns).toHaveLength(1);
expect(group(gundrak, "gundrak:eck").spawns[0].spawnMask).toBe(2);
expect(objective(gundrak, "Eck").optional).toBe(true);
expect(gundrak.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
objective(gundrak, "Slad'ran").id,
objective(gundrak, "Moorabi").id,
objective(gundrak, "Drakkari Colossus").id,
objective(gundrak, "Gal'darah").id,
]));
});
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
const halls = adaptedDefinition("halls-of-stone");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
expectOrderedPhases(hallsRun.phases, [
"tribunal-protectors-active",
"tribunal-stormcallers-active",
"tribunal-custodian-active",
"sjonnir-ready",
"complete",
]);
expect(hallsRun.snapshot.worldObjectStates["halls-of-stone:reviewed-sjonnir-door"]).toBe("open");
const pit = adaptedDefinition("pit-of-saron");
const pitRun = finishEncounter(pit, adaptedEncounter(pit));
expectOrderedPhases(pitRun.phases, [
"gauntlet-ready",
"first-ambush-active",
"second-ambush-active",
"tunnel-ready",
"tyrannus-active",
"complete",
]);
expect(pitRun.snapshot.flags["gauntlet-complete"]).toBe(true);
expect(pitRun.snapshot.worldObjectStates["pit-of-saron:reviewed-portcullis"]).toBe("open");
const pinnacle = adaptedDefinition("utgarde-pinnacle");
const pinnacleRun = finishEncounter(pinnacle, adaptedEncounter(pinnacle));
expectOrderedPhases(pinnacleRun.phases, [
"svala-phase-one-active",
"svala-ritual-active",
"svala-phase-two-active",
"gortok-ready",
"stasis-worgen-active",
"stasis-furbolg-active",
"gortok-active",
"skadi-ready",
"skadi-first-wave-active",
"harpoon-one-ready",
"skadi-reinforcement-one-active",
"harpoon-two-ready",
"skadi-reinforcement-two-active",
"harpoon-three-ready",
"skadi-ground-active",
"ymiron-ready",
"complete",
]);
expect(pinnacleRun.snapshot.counters["harpoon-hits"]).toBe(3);
expect(pinnacleRun.snapshot.worldObjectStates["utgarde-pinnacle:reviewed-ymiron-door"]).toBe("open");
const gundrak = adaptedDefinition("gundrak");
const gundrakRun = finishEncounter(gundrak, adaptedEncounter(gundrak));
expectOrderedPhases(gundrakRun.phases, [
"altar-progression",
"bridge-activating",
"gal-darah-ready",
"complete",
]);
expect(gundrakRun.snapshot.counters["keys-in"]).toBe(3);
expect(gundrakRun.snapshot.flags["bridge-open"]).toBe(true);
expect(gundrakRun.snapshot.worldObjectStates["gundrak:reviewed-bridge"]).toBe("open");
expect(gundrakRun.snapshot.completedObjectiveIds).not.toContain(objective(gundrak, "Eck").id);
expect(gundrakRun.snapshot.activeSpawnGroupIds).toContain("gundrak:eck");
for (const [definition, result] of [
[halls, hallsRun],
[pit, pitRun],
[pinnacle, pinnacleRun],
[gundrak, gundrakRun],
] as const) {
expect(result.snapshot.flags["dungeon-complete"], definition.id).toBe(true);
expect(result.snapshot.completedObjectiveIds, definition.id).toEqual(expect.arrayContaining(
definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id),
));
}
});
it("recovers from both wipe scopes at every nonfinal checkpoint, including optional Eck", () => {
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
const run = finishEncounter(definition, encounter);
expect(run.checkpoints, id).toHaveLength(EXPECTED_NONFINAL_CHECKPOINTS[id]);
for (const checkpoint of run.checkpoints) {
for (const scope of ["boss-wipe", "checkpoint"] as const) {
const reset = resetDungeonEncounterSnapshot(
encounter,
checkpoint,
scope,
checkpoint.phaseStartedAt + 10_000,
);
expect(reset.phase, `${id}:${checkpoint.checkpointId}:${scope}`).toBe("resume");
expect(reset.activeSpawnGroupIds).toEqual([]);
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
expect(reset.checkpointId).toBe(checkpoint.checkpointId);
expect(finishEncounter(definition, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
}
}
}
const gundrak = adaptedDefinition("gundrak");
const encounter = adaptedEncounter(gundrak);
let eckCheckpoint = killIds(
encounter,
createDungeonEncounterSnapshot(encounter),
[objective(gundrak, "Moorabi").id],
1,
);
eckCheckpoint = killIds(
encounter,
eckCheckpoint,
group(gundrak, "gundrak:eck").spawns.map((spawn) => spawn.id),
2,
);
expect(eckCheckpoint.checkpointId).toBe(objective(gundrak, "Eck").id);
for (const scope of ["boss-wipe", "checkpoint"] as const) {
const reset = resetDungeonEncounterSnapshot(encounter, eckCheckpoint, scope, 3);
expect(reset.completedObjectiveIds).toContain(objective(gundrak, "Eck").id);
expect(finishEncounter(gundrak, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
}
});
it("keeps completion order-safe when a static final boss is reported dead before its event gate", () => {
for (const id of ["halls-of-stone", "utgarde-pinnacle", "gundrak"] as const) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
const finalBoss = objective(definition, FINAL_BOSS_NAME[id]);
const premature = killIds(encounter, createDungeonEncounterSnapshot(encounter), [finalBoss.id], 1);
expect(premature.flags["dungeon-complete"], id).toBe(false);
const completed = finishEncounter(definition, encounter, premature).snapshot;
expect(completed.flags["dungeon-complete"], id).toBe(true);
if (id === "gundrak") expect(completed.flags["bridge-open"]).toBe(true);
}
const pit = adaptedDefinition("pit-of-saron");
const pitEncounter = adaptedEncounter(pit);
const tyrannusGroup = group(pit, "pit-of-saron:tyrannus");
const prematureTyrannus = killIds(
pitEncounter,
createDungeonEncounterSnapshot(pitEncounter),
tyrannusGroup.spawns.map((spawn) => spawn.id),
1,
);
expect(prematureTyrannus.completedObjectiveIds).not.toContain(objective(pit, "Tyrannus").id);
expect(prematureTyrannus.phase).toBe("progression");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,406 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import {
createDungeonEncounterSnapshot,
reduceDungeonEncounter,
resetDungeonEncounterSnapshot,
validateDungeonEncounterDefinition,
} from "./dungeonEncounterRuntime";
import type {
DungeonEncounterDefinition,
DungeonEncounterSnapshot,
} from "./dungeonEncounterTypes";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import type { DungeonDefinition } from "./dungeonTypes";
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
import {
WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS,
withWowWrathEncounterContent,
} from "./wowWrathEncounterContentCatalog";
const TARGETS = [
"halls-of-reflection",
"the-oculus",
"trial-of-the-champion",
] as const;
const START_OBJECT_BY_DUNGEON = Object.freeze({
"halls-of-reflection": "halls-of-reflection:wave-start",
"the-oculus": "the-oculus:expedition-start",
"trial-of-the-champion": "trial-of-the-champion:announcer-start",
});
function adaptedDefinition(id: typeof TARGETS[number]): DungeonDefinition {
const source = DUNGEON_REGISTRY[id];
if (!source) throw new Error(`Missing generated dungeon ${id}`);
return withWowWrathEncounterContent(source);
}
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
const encounter = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-instance`);
if (!encounter) throw new Error(`Missing Wrath encounter for ${definition.id}`);
return encounter;
}
function activeSpawnIds(
definition: DungeonDefinition,
snapshot: DungeonEncounterSnapshot,
): readonly string[] {
const active = new Set(snapshot.activeSpawnGroupIds);
return (definition.encounterSpawnGroups ?? [])
.filter((group) => active.has(group.id))
.flatMap((group) => group.spawns.map((spawn) => spawn.id));
}
function advanceOneStep(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
snapshot: DungeonEncounterSnapshot,
now: number,
): DungeonEncounterSnapshot {
if (snapshot.phase === "ready") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: START_OBJECT_BY_DUNGEON[definition.id as keyof typeof START_OBJECT_BY_DUNGEON],
now,
}).snapshot;
}
if (snapshot.phase === "escape-ready") {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "interact",
objectId: "halls-of-reflection:escape-start",
now,
}).snapshot;
}
const spawnIds = activeSpawnIds(definition, snapshot);
if (spawnIds.length) {
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now,
facts: { registeredEntityIds: spawnIds, deadEntityIds: spawnIds },
}).snapshot;
}
return reduceDungeonEncounter(encounter, snapshot, {
kind: "tick",
now: Math.max(now, snapshot.phaseStartedAt + 1_000_000),
facts: { registeredEntityIds: [], deadEntityIds: [] },
}).snapshot;
}
function finishEncounter(
definition: DungeonDefinition,
encounter: DungeonEncounterDefinition,
initial = createDungeonEncounterSnapshot(encounter),
): { readonly snapshot: DungeonEncounterSnapshot; readonly phases: readonly string[] } {
let snapshot = initial;
const phases = [snapshot.phase];
let now = snapshot.phaseStartedAt + 1;
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
const previous = snapshot;
snapshot = advanceOneStep(definition, encounter, snapshot, now);
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
if (snapshot === previous) {
throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
}
}
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
return { snapshot, phases };
}
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
let cursor = -1;
for (const phase of expected) {
cursor = phases.indexOf(phase, cursor + 1);
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
}
}
describe("source-reviewed Wrath instance adapters", () => {
it("keeps immutable file hashes and source anchors beside every adapter", async () => {
expect(WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
for (const review of WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS) {
expect(review.fidelity).toBe("source-backed-adapted");
expect(review.sourceFiles.length).toBeGreaterThanOrEqual(3);
expect(review.adaptations.length).toBeGreaterThan(0);
expect(review.remainingGaps.length).toBeGreaterThan(0);
for (const source of review.sourceFiles) {
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(source.anchors.length).toBeGreaterThan(0);
expect(createHash("sha256").update(await readFile(source.path)).digest("hex"), source.path)
.toBe(source.sha256);
}
}
});
it("validates definitions, group references, safety timeouts, and reviewed blocker geometry", () => {
for (const id of TARGETS) {
const source = DUNGEON_REGISTRY[id];
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
validateDungeonEncounterDefinition(encounter);
definition.serverGameObjects.forEach(validateDungeonWorldObject);
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
for (const rule of encounter.rules) {
if (rule.trigger.kind === "entities-dead") {
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
}
for (const action of rule.actions) {
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
expect(groupIds.has(action.spawnGroupId), `${id}:${action.id}`).toBe(true);
}
if (action.kind === "set-world-object-state") {
expect(objectIds.has(action.objectId), `${id}:${action.id}`).toBe(true);
}
}
}
const reviewedBlockers = definition.serverGameObjects.filter((object) => (
object.id.includes(":reviewed-") || object.id.includes(":ice-wall-")
));
expect(reviewedBlockers.length).toBeGreaterThan(0);
for (const blocker of reviewedBlockers) {
const closed = blocker.states.find((state) => state.id === "closed");
const open = blocker.states.find((state) => state.id === "open");
expect(closed).toMatchObject({ visible: true, blocksMovement: true });
expect(open).toMatchObject({ blocksMovement: false });
}
const originalImportedObjects = definition.serverGameObjects.filter((object) => (
object.source?.spawnId !== undefined && !object.id.includes(":reviewed-")
));
const originalImportedObjectIds = new Set(originalImportedObjects.map((object) => object.id));
for (const object of source.serverGameObjects.filter((candidate) => (
candidate.source?.spawnId !== undefined && !candidate.id.includes(":reviewed-")
))) {
expect(originalImportedObjectIds.has(object.id), `${id} dropped imported object ${object.id}`).toBe(true);
}
expect(originalImportedObjects.every((object) => object.visual?.hidden === true)).toBe(true);
expect(originalImportedObjects.every((object) => object.states.every((state) => !state.blocksMovement))).toBe(true);
expect(withWowWrathEncounterContent(definition)).toBe(definition);
}
});
it("removes every unsafe static event boss and preserves exact sourced stage cardinality", () => {
const expectations = {
"halls-of-reflection": [38_112, 38_113],
"the-oculus": [27_447, 27_654, 27_655, 27_656],
"trial-of-the-champion": [30_188, 35_451, 35_569, 35_571, 35_572],
} as const;
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const staticEntries = definition.staticSpawns.map((spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry);
for (const entry of expectations[id]) expect(staticEntries).not.toContain(entry);
expect(definition.bosses.every((boss) => boss.eventOnly)).toBe(true);
const staticSpawnIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
for (const boss of definition.bosses) {
expect(staticSpawnIds.has(boss.id), `${id} left event boss ${boss.name} statically spawned`).toBe(false);
}
}
const halls = adaptedDefinition("halls-of-reflection");
expect([1, 2, 3, 4, 6, 7, 8, 9].map((wave) => (
halls.encounterSpawnGroups?.find((group) => group.id === `halls-of-reflection:wave-${String(wave).padStart(2, "0")}`)?.spawns.length
))).toEqual([3, 3, 4, 4, 5, 5, 5, 5]);
const oculus = adaptedDefinition("the-oculus");
expect(oculus.encounterSpawnGroups?.find((group) => group.id === "the-oculus:centrifuge-constructs")?.spawns).toHaveLength(10);
expect([1, 2, 3].map((wave) => (
oculus.encounterSpawnGroups?.find((group) => group.id === `the-oculus:urom-menagerie-${wave}`)?.spawns.length
))).toEqual([4, 4, 4]);
const trial = adaptedDefinition("trial-of-the-champion");
expect([1, 2, 3].map((wave) => (
trial.encounterSpawnGroups?.find((group) => group.id.startsWith(`trial-of-the-champion:minions-${wave}-`))?.spawns.length
))).toEqual([3, 3, 3]);
expect(trial.encounterSpawnGroups?.find((group) => group.id === "trial-of-the-champion:argent-soldiers")?.spawns).toHaveLength(9);
expect([1, 2, 3].map((phase) => (
trial.encounterSpawnGroups?.find((group) => group.id === `trial-of-the-champion:black-knight-phase-${phase}`)?.spawns.length
))).toEqual([1, 1, 1]);
});
it("gives every Trial event boss its pinned phase-specific combat schedule", () => {
const trial = adaptedDefinition("trial-of-the-champion");
const trialReview = WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.find(
(review) => review.dungeonId === "trial-of-the-champion",
);
expect(trialReview?.adaptations.join(" ")).toContain(
"six-second, one-charge +100% critical chance modifier",
);
const entity = (id: string) => trial.entities[id];
const specials = (id: string) => entity(id).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
for (const id of [
"wrath-event-mounted-mokra",
"wrath-event-mounted-eressea",
"wrath-event-mounted-runok",
]) {
expect(specials(id).map((attack) => attack.id)).toEqual([
"mounted-charge-63010",
"shield-breaker-68504",
"thrust-68505",
]);
expect(specials(id).map((attack) => [attack.initialCooldownMs, attack.repeatCooldownMs])).toEqual([
[[2_500, 4_000], [4_500, 6_000]],
[[5_000, 8_000], [6_000, 8_000]],
[[3_000, 5_000], [3_000, 5_000]],
]);
}
expect(specials("wrath-event-eadric").map((attack) => attack.spellId)).toEqual([
66_865,
66_935,
66_867,
]);
expect(specials("wrath-event-eadric").find((attack) => attack.id === "vengeance-66865")?.effect)
.toEqual({
kind: "aura",
aura: "vengeance",
durationMs: 0,
meleeAutoCriticalProc: {
chance: 1,
triggeredAura: "vengeance-next-melee-critical-66889",
durationMs: 6_000,
criticalChancePercent: 100,
charges: 1,
},
});
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935")?.repeatCooldownMs)
.toEqual([16_000, 16_000]);
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935"))
.toMatchObject({
school: "holy",
damageMultiplier: 0.9,
effect: { kind: "control", mechanic: "confuse", durationMs: 2_000, damageMultiplier: 0.9 },
});
expect(specials("wrath-event-eadric").find((attack) => attack.id === "hammer-of-justice-and-righteous-66863-66867"))
.toMatchObject({
name: "Hammer of Justice + Hammer of the Righteous",
target: "random-party",
initialCooldownMs: [25_000, 25_000],
repeatCooldownMs: [25_000, 25_000],
effect: { kind: "control", mechanic: "stun", durationMs: 6_000, damageMultiplier: 1.3 },
});
expect(specials("wrath-event-eadric").some((attack) => attack.id === "hammer-of-justice-66863")).toBe(false);
for (const id of ["wrath-event-mounted-eressea", "wrath-event-black-knight-phase-3"]) {
expect(entity(id).combat?.attacks[0]).toMatchObject({
id: "basic-attack",
name: "Basic Attack",
delivery: "melee",
school: "physical",
animation: "attack",
range: 4.5,
});
}
expect(specials("wrath-event-black-knight-phase-1").map((attack) => attack.id)).toEqual([
"plague-strike-67724",
"icy-touch-67718",
"death-respite-67745",
"obliterate-67725",
]);
expect(specials("wrath-event-black-knight-phase-1")[0]).toMatchObject({
id: "plague-strike-67724",
school: "physical",
});
expect(specials("wrath-event-black-knight-phase-2").map((attack) => attack.id)).toEqual([
"plague-strike-67724",
"icy-touch-67718",
"obliterate-67725",
"desecration-67778",
]);
expect(specials("wrath-event-black-knight-phase-2")[0]).toMatchObject({
id: "plague-strike-67724",
school: "physical",
});
expect(specials("wrath-event-black-knight-phase-3").map((attack) => ({
id: attack.id,
initial: attack.initialCooldownMs,
repeat: attack.repeatCooldownMs,
}))).toEqual([
{ id: "death-bite-67808", initial: [2_000, 2_000], repeat: [2_000, 4_000] },
{ id: "marked-for-death-67823", initial: [1_000, 1_000], repeat: [9_000, 9_000] },
]);
expect(specials("wrath-event-black-knight-phase-3")[1].effect)
.toEqual({ kind: "target-aura", aura: "marked-for-death", durationMs: 9_000 });
});
it("reduces every complete source-ordered lifecycle without renderer state", () => {
const halls = adaptedDefinition("halls-of-reflection");
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
expectOrderedPhases(hallsRun.phases, [
"wave-01-active", "wave-05-active", "wave-06-source-pause", "wave-06-active",
"wave-10-active", "general-active", "escape-ready", "escape-1-active", "escape-4-active", "complete",
]);
const oculus = adaptedDefinition("the-oculus");
const oculusRun = finishEncounter(oculus, adaptedEncounter(oculus));
expectOrderedPhases(oculusRun.phases, [
"drakos-active", "constructs-active", "varos-active", "urom-menagerie-1-active",
"urom-menagerie-3-active", "urom-active", "eregos-active", "complete",
]);
const trial = adaptedDefinition("trial-of-the-champion");
const trialRun = finishEncounter(trial, adaptedEncounter(trial));
expectOrderedPhases(trialRun.phases, [
"champion-minions-1-active", "champion-minions-3-active", "mounted-champions-active",
"foot-champions-active", "argent-soldiers-active", "eadric-active",
"black-knight-phase-1-active", "black-knight-phase-3-active", "complete",
]);
for (const [definition, result] of [
[halls, hallsRun],
[oculus, oculusRun],
[trial, trialRun],
] as const) {
expect(result.snapshot.flags["dungeon-complete"]).toBe(true);
expect(result.snapshot.completedObjectiveIds).toEqual(
expect.arrayContaining(definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id)),
);
expect(result.snapshot.activeSpawnGroupIds).toEqual([]);
}
});
it("recovers from every non-final persistent checkpoint without a soft lock", () => {
const expectedCheckpointCounts = {
"halls-of-reflection": 3,
"the-oculus": 3,
"trial-of-the-champion": 2,
} as const;
for (const id of TARGETS) {
const definition = adaptedDefinition(id);
const encounter = adaptedEncounter(definition);
let snapshot = createDungeonEncounterSnapshot(encounter);
let now = 1;
const checkpointSnapshots: DungeonEncounterSnapshot[] = [];
const seenCheckpoints = new Set<string>();
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
snapshot = advanceOneStep(definition, encounter, snapshot, now);
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
if (snapshot.checkpointId && !seenCheckpoints.has(snapshot.checkpointId) && snapshot.phase !== "complete") {
seenCheckpoints.add(snapshot.checkpointId);
checkpointSnapshots.push(snapshot);
}
}
expect(checkpointSnapshots, id).toHaveLength(expectedCheckpointCounts[id]);
for (const checkpoint of checkpointSnapshots) {
const reset = resetDungeonEncounterSnapshot(encounter, checkpoint, "boss-wipe", now + 1);
expect(reset.phase).toBe("resume");
expect(reset.activeSpawnGroupIds).toEqual([]);
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
const resumed = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: now + 2 }).snapshot;
expect(resumed.phase).not.toBe("resume");
expect(
resumed.activeSpawnGroupIds.length > 0 || resumed.phase === "escape-ready",
`${id}:${checkpoint.checkpointId}`,
).toBe(true);
expect(finishEncounter(definition, encounter, resumed).snapshot.flags["dungeon-complete"]).toBe(true);
}
}
});
});
File diff suppressed because it is too large. Load diff
+300
View File
@@ -0,0 +1,300 @@
import {
Component,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
type ErrorInfo,
type ReactNode,
} from "react";
import { useShellStore } from "../app/shellStore";
import type { ClassId } from "../app/characterCatalog";
import type { AvatarIdentity } from "../avatar/CharacterModel";
import { resolveAvatarAppearance } from "../avatar/appearanceResolver";
import { loadAvatarTextureSet } from "../avatar/appearanceTextureCache";
import {
availableAvatarManifestUrls,
getAvatarEntry,
loadAvatarManifest,
type AvatarManifestEntry,
} from "../avatar/characterManifest";
import { resolveContentUrl } from "../content/contentManager";
import { useCombatStore } from "../game/combatStore";
import { PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
import { resolvedEquipmentItems } from "../game/heirloomEquipment";
import type { InventoryItem } from "../game/lootTypes";
import type { DungeonNavigationLink } from "../game/dungeonTypes";
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
import { usePartyStore } from "../game/partyStore";
import { preloadTalentGameplayResources } from "../ui/talentGameplayResources";
import { useGameGLTFs } from "./useGameGLTF";
interface WarmupBoundaryProps {
readonly children: ReactNode;
readonly label: string;
readonly onSettled: () => void;
}
class WarmupBoundary extends Component<WarmupBoundaryProps, { readonly failed: boolean }> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown, info: ErrorInfo): void {
console.warn(`${this.props.label} could not be fully prepared before entering the expedition.`, error, info.componentStack);
this.props.onSettled();
}
render(): ReactNode {
return this.state.failed ? null : this.props.children;
}
}
function WarmupCommit({ onSettled }: { readonly onSettled: () => void }) {
useEffect(() => {
const frame = window.requestAnimationFrame(onSettled);
return () => window.cancelAnimationFrame(frame);
}, [onSettled]);
return null;
}
function SceneAssetProbe({
navigationLinks,
navigationUrl,
urls,
onSettled,
}: {
readonly navigationLinks: readonly DungeonNavigationLink[];
readonly navigationUrl: string | null;
readonly urls: readonly string[];
readonly onSettled: () => void;
}) {
const gltfs = useGameGLTFs(urls);
const navigationIndex = navigationUrl ? urls.indexOf(navigationUrl) : -1;
useMemo(() => {
if (navigationIndex < 0 || !navigationUrl) return null;
return preparePartyNavigationGraph(
navigationUrl,
gltfs[navigationIndex].scene,
navigationLinks,
);
}, [gltfs, navigationIndex, navigationLinks, navigationUrl]);
return <WarmupCommit onSettled={onSettled} />;
}
interface AvatarWarmupPlan {
readonly identity: AvatarIdentity;
readonly entry: AvatarManifestEntry;
readonly equipment: readonly InventoryItem[];
}
interface AvatarWarmupPayload {
readonly modelUrls: readonly string[];
readonly textureLoad: Promise<unknown>;
}
function AsyncWarmupCommit({
label,
onSettled,
promise,
}: {
readonly label: string;
readonly onSettled: () => void;
readonly promise: Promise<unknown>;
}) {
useEffect(() => {
let active = true;
void promise.catch((error: unknown) => {
console.warn(`${label} could not be fully prepared before entering the expedition.`, error);
}).then(() => {
if (active) onSettled();
});
return () => {
active = false;
};
}, [label, onSettled, promise]);
return null;
}
function ResolvedAvatarAssetProbe({
modelUrls,
onSettled,
textureLoad,
}: AvatarWarmupPayload & { readonly onSettled: () => void }) {
useGameGLTFs(modelUrls);
return (
<AsyncWarmupCommit
label="Party avatar textures"
promise={textureLoad}
onSettled={onSettled}
/>
);
}
function AvatarAssetProbe({ onSettled }: { readonly onSettled: () => void }) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const members = usePartyStore((state) => state.members);
const inventory = useCombatStore((state) => state.inventory);
const equipment = useCombatStore((state) => state.equipment);
const level = useCombatStore((state) => state.level);
const specializationId = useCombatStore((state) => state.specializationId);
const gearRole = useCombatStore((state) => state.gearRole);
const [payload, setPayload] = useState<AvatarWarmupPayload | null>(null);
useEffect(() => {
let active = true;
setPayload(null);
void loadAvatarManifest().then((manifest) => {
const plans: AvatarWarmupPlan[] = [];
if (activeCharacter) {
const entry = getAvatarEntry(manifest, activeCharacter.raceId, activeCharacter.gender);
if (entry) {
plans.push({
identity: activeCharacter,
entry,
equipment: resolvedEquipmentItems(equipment, inventory, {
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
ownerKind: "player",
classId: activeCharacter.classId,
specializationId,
role: gearRole,
level,
}),
});
}
}
for (const member of members) {
const entry = getAvatarEntry(manifest, member.raceId, member.gender);
if (!entry) continue;
plans.push({
identity: member,
entry,
equipment: resolvedEquipmentItems(equipment, inventory, {
ownerId: member.id,
ownerKind: "party",
classId: member.classId,
specializationId: member.specializationId,
role: member.role,
level: member.level,
}),
});
}
const resolvedPlans = plans.map((plan) => ({
...plan,
appearance: resolveAvatarAppearance(
manifest,
plan.entry,
plan.identity.appearance,
plan.identity.classId,
plan.equipment,
),
}));
const modelUrls = [...new Set(resolvedPlans.flatMap(({ appearance, entry }) => [
entry.model,
...appearance.equipmentModels.map((model) => model.url),
]))].map(resolveContentUrl);
const textureLoad = Promise.all(resolvedPlans.map((plan) => (
loadAvatarTextureSet(manifest, plan.entry, plan.appearance)
)));
if (active) setPayload({ modelUrls, textureLoad });
}).catch((error: unknown) => {
console.warn("Party avatar assets could not be fully prepared before entering the expedition.", error);
if (active) onSettled();
});
return () => {
active = false;
};
}, [activeCharacter, equipment, gearRole, inventory, level, members, onSettled, specializationId]);
if (!payload) return null;
if (!payload.modelUrls.length) {
return (
<AsyncWarmupCommit
label="Party avatar textures"
promise={payload.textureLoad}
onSettled={onSettled}
/>
);
}
return <ResolvedAvatarAssetProbe {...payload} onSettled={onSettled} />;
}
function PartyDataProbe({ onSettled }: { readonly onSettled: () => void }) {
const classIdsKey = usePartyStore((state) => [...new Set(state.members
.map((member) => member.classId)
.filter((classId) => !classId.startsWith("rom-")))]
.sort()
.join("|"));
useEffect(() => {
let active = true;
const classIds = classIdsKey ? classIdsKey.split("|") as ClassId[] : [];
void Promise.all(classIds.map((classId) => preloadTalentGameplayResources(classId)))
.catch((error: unknown) => {
console.warn("Party talent catalogs could not be fully prepared before entering the expedition.", error);
})
.then(() => {
if (active) onSettled();
});
return () => {
active = false;
};
}, [classIdsKey, onSettled]);
return null;
}
export function ActivityAssetWarmup({
navigationLinks,
navigationUrl,
sceneAssetUrls,
onReady,
}: {
readonly navigationLinks: readonly DungeonNavigationLink[];
readonly navigationUrl: string | null;
readonly sceneAssetUrls: readonly string[];
readonly onReady: () => void;
}) {
const [sceneReady, setSceneReady] = useState(false);
const [avatarsReady, setAvatarsReady] = useState(!availableAvatarManifestUrls().length);
const [partyDataReady, setPartyDataReady] = useState(false);
const markSceneReady = useCallback(() => setSceneReady(true), []);
const markAvatarsReady = useCallback(() => setAvatarsReady(true), []);
const markPartyDataReady = useCallback(() => setPartyDataReady(true), []);
useEffect(() => {
if (!sceneReady || !avatarsReady || !partyDataReady) return;
onReady();
}, [avatarsReady, onReady, partyDataReady, sceneReady]);
return (
<>
{sceneAssetUrls.length ? (
<WarmupBoundary label="Expedition models and navigation" onSettled={markSceneReady}>
<Suspense fallback={null}>
<SceneAssetProbe
navigationLinks={navigationLinks}
navigationUrl={navigationUrl}
urls={sceneAssetUrls}
onSettled={markSceneReady}
/>
</Suspense>
</WarmupBoundary>
) : <WarmupCommit onSettled={markSceneReady} />}
{!avatarsReady ? (
<WarmupBoundary label="Party avatar assets" onSettled={markAvatarsReady}>
<Suspense fallback={null}>
<AvatarAssetProbe onSettled={markAvatarsReady} />
</Suspense>
</WarmupBoundary>
) : null}
<WarmupBoundary label="Party talent catalogs" onSettled={markPartyDataReady}>
<Suspense fallback={null}>
<PartyDataProbe onSettled={markPartyDataReady} />
</Suspense>
</WarmupBoundary>
</>
);
}
+8 -3
View File
@@ -24,9 +24,12 @@ function AssetObject({ definition }: { readonly definition: DungeonWorldObjectDe
function ProceduralObject({ definition }: { readonly definition: DungeonWorldObjectDefinition }) {
const [x, y, z] = definition.halfExtents;
const round = definition.kind === "trap" || definition.kind === "ritual" || definition.kind === "trigger";
return (
<mesh castShadow receiveShadow>
<boxGeometry args={[x * 2, y * 2, z * 2]} />
{round
? <cylinderGeometry args={[Math.max(x, z), Math.max(x, z), y * 2, 16]} />
: <boxGeometry args={[x * 2, y * 2, z * 2]} />}
<meshStandardMaterial
color={definition.visual?.color ?? (definition.kind === "switch" ? "#dca24a" : "#65584b")}
emissive={definition.visual?.emissive ?? "#000000"}
@@ -45,6 +48,8 @@ function WorldObject({
}) {
const state = dungeonWorldObjectState(definition, stateId);
if (!state.visible) return null;
const showVisual = definition.visual?.hidden !== true;
if (!showVisual && !state.blocksMovement && !state.blocksSight) return null;
return (
<RigidBody
key={`${definition.id}:${state.id}`}
@@ -67,9 +72,9 @@ function WorldObject({
sensor
/>
)}
{definition.visual?.assetUrl
{showVisual && (definition.visual?.assetUrl
? <AssetObject definition={definition} />
: <ProceduralObject definition={definition} />}
: <ProceduralObject definition={definition} />)}
</RigidBody>
);
}
+83 -8
View File
@@ -54,12 +54,20 @@ import { useDocumentVisibility } from "../platform/useDocumentVisibility";
import { CappedFrameLoop } from "./CappedFrameLoop";
import { selectDungeonSessionPopulation } from "../game/dungeonSpawnSelection";
import { DungeonWorldObjects } from "./DungeonWorldObjects";
import { ActivityAssetWarmup } from "./ActivityAssetWarmup";
import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dungeonAssets";
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { shouldLoadPartyNavigation } from "../game/partyNavigationPolicy";
import {
activeDungeonCompletedObjectiveIds,
activeDungeonEncounterRevision,
activeDungeonEncounterSpawnGroupIds,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { activeEncounterSpawnGroupSpawns } from "../game/dungeonEncounterSpawns";
import {
activeEncounterSpawnGroupSpawns,
suppressCompletedDungeonPopulation,
} from "../game/dungeonEncounterSpawns";
export function GameScene() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -74,6 +82,9 @@ export function GameScene() {
activeDungeonEncounterRevision,
);
const dungeon = requireDungeonDefinition(activeDungeonId);
const completedObjectiveIds = gameMode === "dungeon"
? activeDungeonCompletedObjectiveIds(activeDungeonId)
: [];
const cameraFar = Math.max(
950,
Math.min(
@@ -100,6 +111,26 @@ export function GameScene() {
: { entities: dungeon.entities, staticSpawns: dungeon.staticSpawns },
[dungeon, gameMode, manastormEncounter],
);
const manastormAssetPackage = gameMode === "manastorm"
? activeManastormStageAssetPackage()
: null;
const activityAssets = useMemo(
() => manastormAssetPackage
? resolveManastormStageAssets(manastormAssetPackage)
: resolveDungeonAssets(dungeon),
[dungeon, manastormAssetPackage, sessionRevision],
);
const navigationUrl = activityAssets.navigation
&& shouldLoadPartyNavigation(gameMode, dungeon, manastormAssetPackage)
? activityAssets.navigation
: null;
const navigationLinks = manastormAssetPackage ? [] : dungeon.navigationLinks;
const activitySceneAssetUrls = useMemo(() => [...new Set([
...Object.values(stagePopulation.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : []
)),
...(navigationUrl ? [navigationUrl] : []),
])], [navigationUrl, stagePopulation.entities]);
const difficultyRoamingPacks = manastormStageRoamingPacks(
dungeon.roamingPacks.filter((pack) => spawnMatchesDifficulty(pack.spawnMask, difficultyMask)),
gameMode === "manastorm" ? manastormEncounter : null,
@@ -107,14 +138,19 @@ export function GameScene() {
const difficultySpawns = stagePopulation.staticSpawns.filter(
(spawn) => spawnMatchesDifficulty(spawn.spawnMask, difficultyMask),
);
const persistentPopulation = suppressCompletedDungeonPopulation(
difficultySpawns,
difficultyRoamingPacks,
completedObjectiveIds,
);
const phasedStaticSpawns = activeDungeonId === "wailing-caverns" && gameMode === "dungeon"
? wailingStaticSpawnsForPhase(difficultySpawns, wailingPhase)
: difficultySpawns;
? wailingStaticSpawnsForPhase(persistentPopulation.staticSpawns, wailingPhase)
: persistentPopulation.staticSpawns;
const sessionPopulation = selectDungeonSessionPopulation(
activeDungeonId,
sessionRevision,
phasedStaticSpawns,
difficultyRoamingPacks,
persistentPopulation.roamingPacks,
);
const activeSpawnGroupIds = activeDungeonEncounterSpawnGroupIds();
const encounterSpawns = activeEncounterSpawnGroupSpawns(
@@ -122,7 +158,7 @@ export function GameScene() {
activeSpawnGroupIds,
difficultyMask,
sessionPopulation.staticSpawns,
);
).filter((spawn) => !completedObjectiveIds.includes(spawn.id));
const staticSpawns = [
...sessionPopulation.staticSpawns,
...encounterSpawns,
@@ -136,7 +172,10 @@ export function GameScene() {
const onlineAuthority = useOnlineSessionStore((state) => state.snapshot?.authority ?? true);
const documentVisible = useDocumentVisibility();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [environmentReadySessionRevision, setEnvironmentReadySessionRevision] = useState<number | null>(null);
const [activityAssetsReadySessionRevision, setActivityAssetsReadySessionRevision] = useState<number | null>(null);
const [readySessionRevision, setReadySessionRevision] = useState<number | null>(null);
const proxyAssetStateRef = useRef<{ readonly revision: number; readonly message: string | null } | null>(null);
const [safeGraphics, setSafeGraphics] = useState(false);
const [adaptiveDpr, setAdaptiveDpr] = useState(1.5);
const [canvasRevision, setCanvasRevision] = useState(0);
@@ -164,15 +203,42 @@ export function GameScene() {
[gameMode, manastormEncounter, manastormPhase],
);
const markWorldReady = useCallback(() => {
setReadySessionRevision(sessionRevision);
const markEnvironmentReady = useCallback(() => {
const game = useGameStore.getState();
proxyAssetStateRef.current = game.assetStatus === "proxy"
? { revision: sessionRevision, message: game.assetMessage }
: null;
game.setAssetStatus("loading", "Preparing party, creatures, and navigation.");
setEnvironmentReadySessionRevision(sessionRevision);
setGraphicsRecovery((state) => state === "recovering" ? null : state);
}, [sessionRevision]);
const markActivityAssetsReady = useCallback(() => {
setActivityAssetsReadySessionRevision(sessionRevision);
}, [sessionRevision]);
useEffect(() => {
if (
environmentReadySessionRevision !== sessionRevision
|| activityAssetsReadySessionRevision !== sessionRevision
) return;
setReadySessionRevision(sessionRevision);
}, [activityAssetsReadySessionRevision, environmentReadySessionRevision, sessionRevision]);
useEffect(() => {
if (!worldReady) return;
const proxy = proxyAssetStateRef.current?.revision === sessionRevision
? proxyAssetStateRef.current
: null;
useGameStore.getState().setAssetStatus(proxy ? "proxy" : "ready", proxy?.message ?? null);
}, [sessionRevision, worldReady]);
const restartRenderer = useCallback(() => {
releasePointerLock();
useGameStore.getState().resetAtActiveSpawn();
setSafeGraphics(true);
setEnvironmentReadySessionRevision(null);
setActivityAssetsReadySessionRevision(null);
setReadySessionRevision(null);
setGraphicsRecovery("recovering");
setCanvasRevision((revision) => revision + 1);
@@ -273,6 +339,15 @@ export function GameScene() {
/>
)}
<GameGltfLoaderLifecycle>
{!worldReady && (
<ActivityAssetWarmup
key={`activity-warmup:${sessionRevision}`}
navigationLinks={navigationLinks}
navigationUrl={navigationUrl}
sceneAssetUrls={activitySceneAssetUrls}
onReady={markActivityAssetsReady}
/>
)}
{!safeGraphics && worldReady && (
<AdaptiveGraphicsQuality
frameRateLimit={gameplayFrameRate}
@@ -306,7 +381,7 @@ export function GameScene() {
paused={simulationBlocked}
timeStep={1 / 60}
>
<DungeonEnvironment onReady={markWorldReady} />
<DungeonEnvironment onReady={markEnvironmentReady} />
{worldReady && (
<>
<PlayerRig />
+6 -32
View File
@@ -14,7 +14,7 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
import { BufferGeometry, Quaternion, type Group, type Mesh } from "three";
import { Quaternion, type Group } from "three";
import { classById, raceById, type GenderId, type RaceId } from "../app/characterCatalog";
import { CharacterModel } from "../avatar/CharacterModel";
import { ProceduralAvatar } from "../avatar/ProceduralAvatar";
@@ -49,12 +49,11 @@ import {
type PartyMovementProfile,
} from "../game/partyMovement";
import {
buildPartyNavigationGraph,
findNearestPartyNavigationSurface,
type PartyNavigationGraph,
} from "../game/partyNavigation";
import { preparePartyNavigationGraph } from "../game/partyNavigationAssetCache";
import {
MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
partyObjectivesForNavigation,
shouldLoadPartyNavigation,
} from "../game/partyNavigationPolicy";
@@ -198,24 +197,6 @@ class NavigationAssetBoundary extends Component<NavigationAssetBoundaryProps, Na
}
}
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;
}
function PartyNavigationLoader({
url,
offMeshLinks,
@@ -226,17 +207,10 @@ function PartyNavigationLoader({
readonly onReady: (graph: PartyNavigationGraph) => void;
}) {
const gltf = useGLTF(resolveContentUrl(url));
const graph = useMemo(() => {
const geometry = navigationGeometry(gltf.scene);
try {
return buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
offMeshLinks,
});
} finally {
geometry.dispose();
}
}, [gltf.scene, offMeshLinks]);
const graph = useMemo(
() => preparePartyNavigationGraph(url, gltf.scene, offMeshLinks),
[gltf.scene, offMeshLinks, url],
);
useEffect(() => onReady(graph), [graph, onReady]);
return null;
-1
View File
@@ -261,7 +261,6 @@ export function ProductionDungeon({
if (signalledFirstFrame.current) return;
if (preparedCollisions.current.size < assets.collision.length) return;
signalledFirstFrame.current = true;
useGameStore.getState().setAssetStatus("ready");
onReady?.();
}, [assets.collision.length, onReady]);
@@ -0,0 +1,28 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const gameSceneSource = readFileSync(new URL("./GameScene.tsx", import.meta.url), "utf8");
const productionDungeonSource = readFileSync(new URL("./ProductionDungeon.tsx", import.meta.url), "utf8");
const warmupSource = readFileSync(new URL("./ActivityAssetWarmup.tsx", import.meta.url), "utf8");
const manastormBridgeSource = readFileSync(new URL("../game/ManastormBridge.tsx", import.meta.url), "utf8");
describe("activity asset warmup integration", () => {
it("keeps the world blocked until activity-specific assets finish", () => {
expect(gameSceneSource).toContain("<ActivityAssetWarmup");
expect(gameSceneSource).toContain("environmentReadySessionRevision !== sessionRevision");
expect(gameSceneSource).toContain("activityAssetsReadySessionRevision !== sessionRevision");
expect(gameSceneSource).toContain('setAssetStatus(proxy ? "proxy" : "ready"');
expect(productionDungeonSource).not.toContain('setAssetStatus("ready")');
});
it("warms scene, avatar, navigation, and party data while the loading screen is active", () => {
expect(warmupSource).toContain("useGameGLTFs(urls)");
expect(warmupSource).toContain("preparePartyNavigationGraph(");
expect(warmupSource).toContain("loadAvatarTextureSet(");
expect(warmupSource).toContain("preloadTalentGameplayResources(classId)");
});
it("defers next-stage Manastorm traffic until combat has ended", () => {
expect(manastormBridgeSource).toContain('status !== "portal-ready" && status !== "transitioning"');
});
});
+10
View File
@@ -43,3 +43,13 @@ export function useGameGLTF(url: string) {
return useGLTF(resolvedUrl, false, true, extendLoader);
}
export function useGameGLTFs(urls: readonly string[]) {
const resolvedUrls = urls.map(resolveContentUrl);
const renderer = useThree((state) => state.gl);
const extendLoader = useCallback((loader: GLTFLoader) => {
loader.setKTX2Loader(ktx2LoaderFor(renderer));
}, [renderer]);
return useGLTF(resolvedUrls, false, true, extendLoader);
}
+55 -6
View File
@@ -6,6 +6,8 @@
--moss: #92b77b;
--moss-bright: #c7dc8b;
--gold: #d4ab55;
--dispel-bright: #57ff68;
--dispel-rgb: 87, 255, 104;
--cave: #07110d;
--panel: rgba(8, 20, 15, 0.84);
--panel-solid: #0b1a14;
@@ -32,7 +34,11 @@ button { font: inherit; }
height: 100%;
}
.scene-shell { cursor: crosshair; }
.scene-shell {
z-index: 0;
isolation: isolate;
cursor: crosshair;
}
.scene-shell canvas { display: block; }
.hud {
@@ -158,6 +164,48 @@ button { font: inherit; }
.loot-reward small { margin-top: 2px; overflow: hidden; color: #d8d9c2; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; }
.loot-reward em { margin-top: 3px; color: #8fb78a; font-size: 6px; font-style: normal; letter-spacing: .06em; text-transform: uppercase; }
.dungeon-complete-banner {
position: fixed;
z-index: 48;
top: 62px;
left: 50%;
display: flex;
width: min(460px, calc(100vw - 24px));
align-items: center;
gap: 10px;
padding: 9px 11px;
border: 1px solid #c8a64b;
border-radius: 8px;
background: linear-gradient(135deg, rgba(25, 28, 18, .96), rgba(10, 15, 12, .94));
box-shadow: 0 8px 24px rgba(0, 0, 0, .52), inset 0 0 18px rgba(196, 157, 57, .08);
transform: translateX(-50%) scale(var(--game-ui-scale, 1));
transform-origin: top center;
pointer-events: auto;
}
.dungeon-complete-banner > span {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 32px;
place-items: center;
border: 1px solid #e2ca70;
border-radius: 50%;
color: #f5df8a;
}
.dungeon-complete-banner p {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
margin: 0;
}
.dungeon-complete-banner strong { color: #f0dda0; font: 13px Georgia, serif; }
.dungeon-complete-banner small { margin-top: 3px; color: #cbd2bf; font-size: 8px; line-height: 1.35; }
.dungeon-complete-banner .button { min-width: 112px; padding: 7px 10px; font-size: 8px; }
.boss-loot-window {
position: absolute;
top: 50%;
@@ -651,7 +699,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.party-frames {
position: absolute;
top: max(101px, calc(env(safe-area-inset-top) + 89px));
top: max(12px, env(safe-area-inset-top));
left: max(12px, env(safe-area-inset-left));
display: grid;
width: 224px;
@@ -681,6 +729,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
}
.party-frame:hover, .party-frame:focus-visible { border-color: rgba(213, 229, 176, .42); outline: none; }
.party-frame.is-selected { border-color: rgba(229, 207, 110, .75); background: linear-gradient(100deg, rgba(39, 43, 20, .96), rgba(18, 38, 24, .88)); box-shadow: 0 0 0 1px rgba(229,207,110,.14), 0 6px 18px rgba(0,0,0,.32); }
.party-frame.is-dispellable { border-color: var(--dispel-bright); box-shadow: 0 0 0 1px rgba(var(--dispel-rgb),.72), 0 0 12px rgba(var(--dispel-rgb),.62), 0 6px 18px rgba(0,0,0,.32); }
.party-frame.is-down { filter: grayscale(.75); opacity: .58; }
.party-frame--player { opacity: .9; }
.party-frame--player.is-selected { opacity: 1; }
@@ -702,7 +751,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.party-frame__resource--energy i { background: linear-gradient(90deg, #958321, #e0cb43); }
.party-frame__resource--runic-power i { background: linear-gradient(90deg, #327784, #62c5ce); }
.party-frame__resource--focus i { background: linear-gradient(90deg, #4b7d6b, #71c8a7); }
.party-frame__dispel { justify-self: end; margin-top: -2px; padding: 1px 3px; border: 1px solid rgba(224,132,102,.55); border-radius: 3px; color: #ffd0a9; font-size: 5px; letter-spacing: .08em; }
.party-frame__dispel { justify-self: end; margin-top: -2px; padding: 1px 3px; border: 1px solid rgba(var(--dispel-rgb),.78); border-radius: 3px; color: #9dffa7; font-size: 5px; letter-spacing: .08em; box-shadow: 0 0 5px rgba(var(--dispel-rgb),.35); }
.summon-frames { position: absolute; top: max(101px, calc(env(safe-area-inset-top) + 89px)); left: max(244px, calc(env(safe-area-inset-left) + 232px)); display: grid; width: 150px; gap: 3px; scale: var(--game-ui-scale, 1); transform-origin: top left; }
.summon-frame { width: 100%; padding: 4px 6px; border: 1px solid rgba(115,183,205,.28); border-radius: 5px; background: rgba(5,16,19,.88); box-shadow: 0 5px 14px rgba(0,0,0,.3); color: inherit; text-align: left; cursor: pointer; }
.summon-frame.is-selected { border-color: rgba(117,225,181,.82); box-shadow: 0 0 0 1px rgba(117,225,181,.28), 0 5px 14px rgba(0,0,0,.3); }
@@ -778,6 +827,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.aura__stacks { position: absolute; right: 1px; top: 0; font: 700 8px/8px var(--font-ui); color: #fff4b8; text-shadow: 0 1px 2px #000; }
.aura--inspectable { cursor: help; transition: border-color 100ms ease, filter 100ms ease, transform 100ms ease; }
.aura--inspectable:hover, .aura--inspectable:focus-visible { z-index: 2; border-color: #f4dd8c; outline: 1px solid rgba(244,221,140,.58); filter: brightness(1.16); transform: translateY(-1px); }
.aura--dispellable, .aura--dispellable:hover, .aura--dispellable:focus-visible { z-index: 1; border: 2px solid var(--dispel-bright); outline-color: rgba(var(--dispel-rgb),.78); box-shadow: 0 0 0 1px rgba(13,44,18,.82), 0 0 8px rgba(var(--dispel-rgb),.82); }
.aura-strip--target { justify-content: center; margin-top: 3px; }
.aura-strip--party { min-height: 16px; margin-top: 1px; }
.aura-strip--party .aura { width: 16px; height: 16px; }
@@ -1468,8 +1518,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
@media (max-width: 760px) {
.player-frame { width: 225px; grid-template-columns: 48px 1fr; }
.player-frame__portrait { height: 48px; }
.party-frames { top: max(91px, calc(env(safe-area-inset-top) + 79px)); width: 194px; }
.party-frame--player { display: none; }
.party-frames { width: 194px; }
.hud-top-right { width: 124px; }
.hud-minimap__map { width: 108px; height: 108px; }
.spellbook-panel__layout { grid-template-columns: 1fr; }
@@ -1504,7 +1553,7 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
}
.player-frame { width: 205px; }
.player-frame__body header span { display: none; }
.party-frames { top: max(87px, calc(env(safe-area-inset-top) + 75px)); width: 176px; }
.party-frames { width: 176px; }
.party-frame { min-height: 34px; grid-template-columns: 25px minmax(0,1fr); padding: 3px 4px; }
.party-frame__sigil { width: 23px; height: 23px; }
.party-frame__identity small { display: none; }
+10
View File
@@ -5,6 +5,7 @@ import {
auraDisplayName,
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsLongTerm,
auraDescription,
groupAurasForDisplay,
@@ -64,6 +65,15 @@ describe("aura strip visibility", () => {
expect(auraDefinitionIsLongTerm(aura({ durationMs: LONG_TERM_AURA_DURATION_MS - 1 }))).toBe(false);
});
it("identifies only categorized debuffs as dispellable player effects", () => {
expect(auraDefinitionIsDispellableDebuff(aura({ dispelCategory: "magic" }))).toBe(true);
expect(auraDefinitionIsDispellableDebuff(aura())).toBe(false);
expect(auraDefinitionIsDispellableDebuff(aura({
disposition: "buff",
dispelCategory: "magic",
}))).toBe(false);
});
it("prefers authored tooltip copy and explains absorb-only fallback auras", () => {
expect(auraDescription(activeAura(aura({ description: "Raises armor by 10%." })))).toBe(
"Raises armor by 10%.",
+9 -3
View File
@@ -5,6 +5,7 @@ import { formatTimedEffectTimer } from "../game/timedEffectDisplay";
import {
auraDefinitionDisplaysInStrip,
auraDefinitionIcon,
auraDefinitionIsDispellableDebuff,
auraDefinitionIsLongTerm,
auraDescription,
auraDisplayName,
@@ -19,6 +20,7 @@ interface AuraStripProps {
readonly className?: string;
readonly hideLongTerm?: boolean;
readonly showOnlyLongTerm?: boolean;
readonly highlightDispellableDebuffs?: boolean;
readonly targetLabels?: Readonly<Record<string, string>>;
readonly onInspect?: StatusEffectInspectHandler;
readonly tooltipId?: string;
@@ -31,6 +33,7 @@ export function AuraStrip({
className = "",
hideLongTerm = false,
showOnlyLongTerm = false,
highlightDispellableDebuffs = false,
targetLabels,
onInspect,
tooltipId,
@@ -64,6 +67,9 @@ export function AuraStrip({
const targetLabel = targetLabels?.[aura.targetId];
const initial = displayName.charAt(0).toUpperCase() || "*";
const icon = auraDefinitionIcon(aura.definition);
const dispellable = highlightDispellableDebuffs
&& auraDefinitionIsDispellableDebuff(aura.definition);
const dispelLabel = dispellable ? `, dispellable ${aura.definition.dispelCategory}` : "";
const inspection: StatusEffectInspection = {
id: aura.instanceId,
name: displayName,
@@ -79,11 +85,11 @@ export function AuraStrip({
return (
<span
key={aura.instanceId}
className={`aura aura--${aura.definition.disposition} ${onInspect ? "aura--inspectable" : ""}`.trim()}
className={`aura aura--${aura.definition.disposition} ${dispellable ? "aura--dispellable" : ""} ${onInspect ? "aura--inspectable" : ""}`.trim()}
tabIndex={onInspect ? 0 : undefined}
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${timer ? `, ${timer} remaining` : ""}`}
aria-label={`${displayName}${targetLabel ? ` on ${targetLabel}` : ""}, ${aura.definition.disposition}${dispelLabel}${timer ? `, ${timer} remaining` : ""}`}
aria-describedby={onInspect ? tooltipId : undefined}
title={onInspect ? undefined : `${displayName}${timer ? ` · ${timer} remaining` : ""}`}
title={onInspect ? undefined : `${displayName}${dispellable ? ` · Dispellable ${aura.definition.dispelCategory}` : ""}${timer ? ` · ${timer} remaining` : ""}`}
onPointerEnter={onInspect ? () => onInspect(inspection) : undefined}
onPointerLeave={onInspect ? (event) => {
if (event.currentTarget !== document.activeElement) onInspect(null);
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
BOSS_LOOT_AUTO_DISMISS_MS,
scheduleBossLootAutoDismiss,
} from "./BossLootWindow";
describe("boss loot auto-dismiss", () => {
afterEach(() => {
vi.useRealTimers();
});
it("dismisses the visible reward after ten seconds", () => {
vi.useFakeTimers();
const dismiss = vi.fn();
scheduleBossLootAutoDismiss(42, dismiss);
vi.advanceTimersByTime(BOSS_LOOT_AUTO_DISMISS_MS - 1);
expect(dismiss).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(dismiss).toHaveBeenCalledOnce();
expect(dismiss).toHaveBeenCalledWith(42);
});
it("cancels a pending auto-dismiss when the popup changes or closes", () => {
vi.useFakeTimers();
const dismiss = vi.fn();
const cancel = scheduleBossLootAutoDismiss(42, dismiss);
cancel();
vi.advanceTimersByTime(BOSS_LOOT_AUTO_DISMISS_MS);
expect(dismiss).not.toHaveBeenCalled();
});
});
+21
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useCombatStore } from "../game/combatStore";
import { useGameStore } from "../game/store";
import { classCanEquipItem, PLAYER_EQUIPMENT_OWNER_ID } from "../game/equipment";
@@ -7,6 +8,16 @@ import {
} from "../game/heirloomEquipment";
import { gearProfileFor } from "../game/specializationCatalog";
export const BOSS_LOOT_AUTO_DISMISS_MS = 10_000;
export function scheduleBossLootAutoDismiss(
rewardId: number,
dismiss: (id: number) => void,
): () => void {
const timeoutId = setTimeout(() => dismiss(rewardId), BOSS_LOOT_AUTO_DISMISS_MS);
return () => clearTimeout(timeoutId);
}
function itemType(inventorySlot: string): string {
return inventorySlot
.split(/[-_]/)
@@ -23,6 +34,16 @@ export function BossLootWindow() {
const level = useCombatStore((state) => state.level);
const equipment = useCombatStore((state) => state.equipment);
const inventory = useCombatStore((state) => state.inventory);
const visibleRewardId = reward?.id;
useEffect(() => {
if (visibleRewardId === undefined) return;
return scheduleBossLootAutoDismiss(
visibleRewardId,
(rewardId) => useCombatStore.getState().dismissBossLootReward(rewardId),
);
}, [visibleRewardId]);
if (!reward) return null;
const profile = gearProfileFor(classId, specializationId, role);
+9 -2
View File
@@ -28,8 +28,12 @@ import { ActionBar, type ActionBarItem, type ActionBarItems } from "./ActionBar"
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { ExperienceBar } from "./ExperienceBar";
import { BrandMark, FrontSurface } from "./FrontSurface";
import {
getLoadedCompanionManastormPanel,
loadCompanionManastormPanel,
} from "./gameplayResources";
const CompanionManastormPanel = lazy(() => import("./CompanionManastormPanel").then((module) => ({
const CompanionManastormPanel = lazy(() => loadCompanionManastormPanel().then((module) => ({
default: module.CompanionManastormPanel,
})));
@@ -154,6 +158,7 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}) {
const selected = snapshot.characters.find((character) => character.id === snapshot.selectedCharacterId) ?? null;
const active = snapshot.activeCharacter ?? selected;
const PreloadedCompanionManastormPanel = getLoadedCompanionManastormPanel();
if (snapshot.phase === "login") {
return (
@@ -295,7 +300,9 @@ export function CompanionPanel({ snapshot, send, preferControllerBinds = false }
}
if (snapshot.phase === "manastorms") {
return (
return PreloadedCompanionManastormPanel ? (
<PreloadedCompanionManastormPanel selected={selected} send={send} />
) : (
<Suspense fallback={<FrontSurface bottom className="companion-panel" ariaLabel="Loading Manastorm companion screen"><span>Loading Manastorm data…</span></FrontSurface>}>
<CompanionManastormPanel selected={selected} send={send} />
</Suspense>
+116 -16
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { contentPackIsUsable } from "../content/contentManager";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { classById, raceById } from "../app/characterCatalog";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
@@ -44,8 +44,13 @@ const ROLE_DETAILS: Readonly<Record<PartyRole, { readonly sigil: string; readonl
},
};
interface PendingDungeonDownload {
readonly dungeonId: DungeonId;
readonly role: PartyRole | null;
}
export function DungeonSelectScreen() {
useContentManagerSnapshot();
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const enterDungeon = useShellStore((state) => state.enterDungeon);
@@ -60,12 +65,17 @@ export function DungeonSelectScreen() {
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
const [rolePickerOpen, setRolePickerOpen] = useState(false);
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
const [downloadError, setDownloadError] = useState("");
const [downloading, setDownloading] = useState(false);
const categoryDungeons = dungeonsByCategory(selectedCategory);
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
?? firstCategoryDungeon
?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
const savedSpecialization = specializationById(character?.specializationId);
const availableRoles = character
@@ -74,6 +84,11 @@ export function DungeonSelectScreen() {
const selectDungeon = (dungeonId: DungeonId) => {
setSelectedDungeonId(dungeonId);
setRolePickerOpen(false);
setDownloadError("");
const dungeon = DUNGEON_CATALOG.find((candidate) => candidate.id === dungeonId);
if (dungeon?.available && !dungeonContentIsUsable(dungeonId)) {
setPendingDownload({ dungeonId, role: null });
}
};
const selectCategory = (category: DungeonCategoryId) => {
const dungeons = dungeonsByCategory(category);
@@ -83,7 +98,7 @@ export function DungeonSelectScreen() {
setRolePickerOpen(false);
};
const enterAsRole = async (role: PartyRole) => {
const continueAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(selectedDungeon.id, role);
@@ -100,7 +115,51 @@ export function DungeonSelectScreen() {
partySize: 5,
});
};
const actions = useMemo<MenuAction[]>(() => [
const requestRolePicker = () => {
if (!selectedDungeon?.available) return;
if (!dungeonContentIsUsable(selectedDungeon.id)) {
setDownloadError("");
setPendingDownload({ dungeonId: selectedDungeon.id, role: null });
return;
}
setRolePickerOpen(true);
};
const enterAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!dungeonContentIsUsable(selectedDungeon.id)) {
setDownloadError("");
setPendingDownload({ dungeonId: selectedDungeon.id, role });
return;
}
await continueAsRole(role);
};
const confirmDownload = async () => {
if (!pendingDownload || downloading) return;
setDownloading(true);
setDownloadError("");
try {
await installContentPack(`dungeon:${pendingDownload.dungeonId}`);
if (!dungeonContentIsUsable(pendingDownload.dungeonId)) {
throw new Error("The downloaded expedition could not be verified on this device.");
}
const role = pendingDownload.role;
setPendingDownload(null);
if (role) await continueAsRole(role);
else setRolePickerOpen(true);
} catch (error) {
setDownloadError(error instanceof Error ? error.message : "The expedition download failed.");
} finally {
setDownloading(false);
}
};
const actions = useMemo<MenuAction[]>(() => pendingDownload ? [
{ id: "dungeon-download-cancel", run: () => setPendingDownload(null), enabled: !downloading },
{ id: "dungeon-download-confirm", run: () => void confirmDownload(), enabled: !downloading },
] : [
...DUNGEON_CATEGORIES.map((category, index) => ({
id: `dungeon-category-${category.id}`,
run: () => selectCategory(category.id),
@@ -123,7 +182,7 @@ export function DungeonSelectScreen() {
}))
: [{
id: "choose-dungeon-role",
run: () => setRolePickerOpen(true),
run: requestRolePicker,
enabled: Boolean(selectedDungeon?.available),
}]),
...(rolePickerOpen ? [{
@@ -131,17 +190,16 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, enterDungeon, firstCategoryDungeon, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu,
onBack: pendingDownload
? () => { if (!downloading) setPendingDownload(null); }
: rolePickerOpen ? () => setRolePickerOpen(false) : returnToMainMenu,
});
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
const selectedPackInstalled = selectedDungeon ? contentPackIsUsable(`dungeon:${selectedDungeon.id}`) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
return (
<DualDisplayFrame
contextLabel="Dungeon details"
@@ -203,7 +261,7 @@ export function DungeonSelectScreen() {
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
<em>{dungeon.available
? contentPackIsUsable(`dungeon:${dungeon.id}`) ? "Ready" : "Download required"
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em>
</ControllerButton>
))}
@@ -242,7 +300,7 @@ export function DungeonSelectScreen() {
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
)}
{selectedDungeon.available && !selectedPackInstalled && (
<p className="front-notice" role="status">Install this expedition from Main menu / Downloads before playing offline.</p>
<p className="front-notice" role="status">Select this expedition to download its required files.</p>
)}
<OnlineGroupPanel />
{rolePickerOpen && characterClass ? (
@@ -298,11 +356,11 @@ export function DungeonSelectScreen() {
select={controller.select}
className="front-button front-button--primary dungeon-select-enter"
type="button"
disabled={!canStart}
onClick={() => setRolePickerOpen(true)}
disabled={!selectedDungeon.available}
onClick={requestRolePicker}
>
<span>Choose role</span>
<small>{canStart ? "Select your role before entering" : selectedDungeon.available ? "Install this expedition from Downloads" : "This expedition is not ready yet"}</small>
<span>{canStart ? "Choose role" : selectedDungeon.available ? "Download and choose role" : "Unavailable"}</span>
<small>{canStart ? "Select your role before entering" : selectedDungeon.available ? "Required files will be verified before play" : "This expedition is not ready yet"}</small>
</ControllerButton>
</>
)}
@@ -318,6 +376,48 @@ export function DungeonSelectScreen() {
</main>
<ControllerLegend back />
{pendingDownload && (
<div className="front-modal-backdrop">
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="dungeon-download-title">
<p className="front-kicker">Download required</p>
<h2 id="dungeon-download-title">
Download {DUNGEON_CATALOG.find((dungeon) => dungeon.id === pendingDownload.dungeonId)?.title ?? "this expedition"}?
</h2>
<p>The map, creatures, and shared dependencies are not available on this device. Download and verify them now, then continue?</p>
{downloading && (
<p role="status">
Downloading and verifying files
{contentState.totalBytes > 0 ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "..."}
</p>
)}
{downloadError && <p className="front-notice" role="alert">{downloadError}</p>}
<div>
<ControllerButton
controlId="dungeon-download-cancel"
selectedId={controller.selectedId}
select={controller.select}
className="front-button"
type="button"
disabled={downloading}
onClick={() => setPendingDownload(null)}
>
Not now
</ControllerButton>
<ControllerButton
controlId="dungeon-download-confirm"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary"
type="button"
disabled={downloading}
onClick={() => void confirmDownload()}
>
{downloading ? "Downloading..." : downloadError ? "Try again" : "Download and continue"}
</ControllerButton>
</div>
</section>
</div>
)}
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
+26 -25
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo } from "react";
import { lazy, Suspense, useMemo } from "react";
import { classById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
import { useCombatStore } from "../game/combatStore";
@@ -12,24 +12,16 @@ import { AbilityBindingsPanel } from "./AbilityBindingsPanel";
import { SpellbookPanel } from "./SpellbookPanel";
import { EquipmentPanel } from "./EquipmentPanel";
import { RomSkillsPanel } from "./RomSkillsPanel";
import { getLoadedTalentGameplayPanel, loadTalentGameplayPanel } from "./talentGameplayResources";
import {
getLoadedBossGuidePanel,
getLoadedPartyManagementPanel,
loadBossGuidePanel,
loadPartyManagementPanel,
} from "./gameplayResources";
const PartyManagementPanel = lazy(() => import("./PartyManagementPanel").then((module) => ({ default: module.PartyManagementPanel })));
const BossGuidePanel = lazy(() => import("./BossGuidePanel").then((module) => ({ default: module.BossGuidePanel })));
let talentGameplayPanelModule: Promise<typeof import("./TalentGameplayPanel")> | null = null;
function loadTalentGameplayPanel() {
if (!talentGameplayPanelModule) {
talentGameplayPanelModule = import("./TalentGameplayPanel").catch((error: unknown) => {
talentGameplayPanelModule = null;
throw error;
});
}
return talentGameplayPanelModule;
}
function preloadTalentGameplayPanel() {
void loadTalentGameplayPanel().catch(() => undefined);
}
const PartyManagementPanel = lazy(() => loadPartyManagementPanel().then((module) => ({ default: module.PartyManagementPanel })));
const BossGuidePanel = lazy(() => loadBossGuidePanel().then((module) => ({ default: module.BossGuidePanel })));
const TalentGameplayPanel = lazy(loadTalentGameplayPanel);
@@ -61,10 +53,9 @@ export function GameplayPanels() {
const presentationSettings = useShellStore((state) => state.combatPresentationSettings);
const overlay = useGameStore((state) => state.overlay);
const classDefinition = classById(classId);
useEffect(() => {
if (overlay === "pause" && !classId.startsWith("rom-")) preloadTalentGameplayPanel();
}, [classId, overlay]);
const PreloadedTalentGameplayPanel = getLoadedTalentGameplayPanel();
const PreloadedPartyManagementPanel = getLoadedPartyManagementPanel();
const PreloadedBossGuidePanel = getLoadedBossGuidePanel();
const spellbookAbilities = useMemo(() => [...abilities]
.sort((left, right) => left.unlockLevel - right.unlockLevel)
@@ -110,15 +101,25 @@ export function GameplayPanels() {
return (
<>
{overlay === "party" ? <Suspense fallback={null}><PartyManagementPanel /></Suspense> : null}
{overlay === "boss-guide" ? <Suspense fallback={null}><BossGuidePanel /></Suspense> : null}
{overlay === "party"
? PreloadedPartyManagementPanel
? <PreloadedPartyManagementPanel />
: <Suspense fallback={null}><PartyManagementPanel /></Suspense>
: null}
{overlay === "boss-guide"
? PreloadedBossGuidePanel
? <PreloadedBossGuidePanel />
: <Suspense fallback={null}><BossGuidePanel /></Suspense>
: null}
<EquipmentPanel />
<AbilityBindingsPanel />
<SpellbookPanel abilities={spellbookAbilities} playerLevel={level} onActivate={castAbility} />
{overlay === "talents"
? classId.startsWith("rom-")
? <RomSkillsPanel />
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
: PreloadedTalentGameplayPanel
? <PreloadedTalentGameplayPanel />
: <Suspense fallback={<TalentsPanelLoading />}><TalentGameplayPanel /></Suspense>
: null}
<OptionsPanel
settings={settings}
+48 -13
View File
@@ -1,4 +1,11 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from "react";
import {
useCallback,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type CSSProperties,
} from "react";
import { useProgress } from "@react-three/drei";
import { classById, raceById } from "../app/characterCatalog";
import { useShellStore } from "../app/shellStore";
@@ -19,7 +26,6 @@ import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline";
import { PlayerFrame } from "./PlayerFrame";
import { PartyFrames } from "./PartyFrames";
import { TimedEffectStrip } from "./TimedEffectStrip";
import { AuraStrip } from "./AuraStrip";
@@ -29,6 +35,11 @@ import { BossLootWindow } from "./BossLootWindow";
import { SummonFrames } from "./SummonFrames";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { onlineAccountIdFromActor, onlinePlayerActorId } from "../game/onlineSessionRuntime";
import {
activeDungeonEncounterRevision,
activeDungeonRunIsComplete,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
function LoadingOverlay() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
@@ -106,6 +117,27 @@ export function DefeatedPrompt({
);
}
export function DungeonCompletePrompt({
dungeonTitle,
onFinish,
}: {
readonly dungeonTitle: string;
readonly onFinish: () => void;
}) {
return (
<section className="dungeon-complete-banner" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p>
<strong>{dungeonTitle} complete</strong>
<small>Every required encounter is defeated. Loot the room or finish the run.</small>
</p>
<button type="button" className="button button--primary" onClick={onFinish}>
Finish dungeon
</button>
</section>
);
}
export function Hud() {
const character = useShellStore((state) => state.activeCharacter);
const gameMode = useGameStore((state) => state.gameMode);
@@ -130,10 +162,6 @@ export function Hud() {
const xpToNext = useCombatStore((state) => state.xpToNext);
const health = useCombatStore((state) => state.health);
const maxHealth = useCombatStore((state) => state.maxHealth);
const shield = useCombatStore((state) => state.shield);
const namedAbsorb = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID)
.reduce((total, aura) => total + aura.absorbs.reduce((sum, pool) => sum + pool.remaining, 0), 0));
const resource = useCombatStore((state) => state.resource);
const maxResource = useCombatStore((state) => state.maxResource);
const resourcePools = useCombatStore((state) => state.resourcePools);
@@ -160,6 +188,13 @@ export function Hud() {
const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
const [clock, setClock] = useState(() => Date.now());
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const dungeonComplete = gameMode === "dungeon"
&& activeDungeonRunIsComplete(activeDungeonId);
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -318,24 +353,24 @@ export function Hud() {
}
respawnDefeatedPlayer();
};
const finishDungeon = () => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useShellStore.getState().returnToMainMenu();
};
return (
<div className="hud" style={{ "--game-ui-scale": settings.uiScale } as CSSProperties}>
<ManastormStatus />
<BossLootWindow />
{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
) : null}
{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p><strong>Loot acquired</strong><small>{feedback.message}</small><em>Press I to inspect and equip</em></p>
</section>
) : null}
<PlayerFrame
character={character ? { ...character, level } : null}
health={{ current: health, maximum: maxHealth, label: "Health", tone: "health" }}
resource={{ current: resource, maximum: maxResource, label: resourceName, tone: resourceTone }}
shield={shield + namedAbsorb}
dead={health <= 0}
/>
<PartyFrames />
<SummonFrames now={clock} />
+113 -6
View File
@@ -4,8 +4,13 @@ import { installManastormShellRuntime } from "../app/manastormShellRuntime";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { preloadAvatarModels } from "../avatar/avatarPreloader";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { contentPackIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { MANASTORM_CATALOG_SUMMARY } from "../game/manastormCatalogSummary";
import { GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT } from "../game/manastormAssetRegistry";
import {
GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT,
generatedManastormContentPackId,
} from "../game/manastormAssetRegistry";
import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "../game/manastormInstalledCatalog";
import { effectiveManastormCatalog } from "../game/manastormAdminConfig";
import { useManastormAdminStore } from "../game/manastormAdminStore";
@@ -46,6 +51,7 @@ const ROLE_DETAILS: Readonly<Record<PartyRole, { readonly sigil: string; readonl
};
export function ManastormSelectScreen() {
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const enterManastorm = useShellStore((state) => state.enterManastorm);
@@ -80,9 +86,20 @@ export function ManastormSelectScreen() {
);
const modeEnabled = adminConfig.global.enabled
&& (modeId === 2 ? adminConfig.global.endgameEnabled : adminConfig.global.levelingEnabled);
const hasReadyBosses = Boolean(effectiveCatalog.stages?.some((stage) => (
const eligibleStages = useMemo(() => effectiveCatalog.stages?.filter((stage) => (
!stage.modeIds?.length || stage.modeIds.includes(modeId)
)));
)) ?? [], [effectiveCatalog, modeId]);
const hasReadyBosses = eligibleStages.length > 0;
const requiredContentPackIds = useMemo(() => Object.freeze([...new Set(eligibleStages.flatMap((stage) => {
if (stage.assetPackageId) {
const packId = generatedManastormContentPackId(stage.assetPackageId);
return packId ? [packId] : [];
}
return stage.dungeonId ? [`dungeon:${stage.dungeonId}`] : [];
}))].sort()), [eligibleStages]);
const missingContentPackIds = import.meta.env.DEV
? []
: requiredContentPackIds.filter((packId) => !contentPackIsUsable(packId));
const canEnter = modeEnabled && hasReadyBosses;
const modeProgress = manastormModeProgress(progress, modeId);
const modeLabel = modeId === 2 ? "End-game" : "Leveling";
@@ -124,6 +141,9 @@ export function ManastormSelectScreen() {
);
const persistedSpellIds = progress.loadout.filter((id) => availableSpellIds.has(id));
const [selectedSpellIds, setSelectedSpellIds] = useState<readonly string[]>(persistedSpellIds);
const [pendingDownloadRole, setPendingDownloadRole] = useState<PartyRole | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState("");
useEffect(() => {
setSelectedSpellIds(persistedSpellIds);
@@ -155,7 +175,7 @@ export function ManastormSelectScreen() {
});
};
const enterAsRole = async (role: PartyRole) => {
const continueAsRole = async (role: PartyRole) => {
if (!canEnter || !character) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterManastorm(role, partySize, startingLevel);
@@ -174,7 +194,42 @@ export function ManastormSelectScreen() {
});
};
const actions = useMemo<MenuAction[]>(() => [
const enterAsRole = async (role: PartyRole) => {
if (!canEnter || !character) return;
if (missingContentPackIds.length > 0) {
setDownloadError("");
setPendingDownloadRole(role);
return;
}
await continueAsRole(role);
};
const confirmDownload = async () => {
if (!pendingDownloadRole || downloading) return;
setDownloading(true);
setDownloadError("");
try {
for (const packId of requiredContentPackIds) {
if (!contentPackIsUsable(packId)) await installContentPack(packId);
}
const stillMissing = requiredContentPackIds.filter((packId) => !contentPackIsUsable(packId));
if (stillMissing.length > 0) {
throw new Error("One or more downloaded Manastorm maps could not be verified on this device.");
}
const role = pendingDownloadRole;
setPendingDownloadRole(null);
await continueAsRole(role);
} catch (error) {
setDownloadError(error instanceof Error ? error.message : "The Manastorm download failed.");
} finally {
setDownloading(false);
}
};
const actions = useMemo<MenuAction[]>(() => pendingDownloadRole ? [
{ id: "manastorm-download-cancel", run: () => setPendingDownloadRole(null), enabled: !downloading },
{ id: "manastorm-download-confirm", run: () => void confirmDownload(), enabled: !downloading },
] : [
...MANASTORM_PARTY_SIZES.map((size) => ({
id: `manastorm-party-${size}`,
run: () => setPartySize(size),
@@ -196,10 +251,13 @@ export function ManastormSelectScreen() {
availableRoles,
canEnter,
checkpoint,
contentState.installedRevision,
groupHumanCount,
fillWithAi,
onlineGroup,
effectivePartySize,
downloading,
pendingDownloadRole,
returnToMainMenu,
selectedSpellIds,
spellLoadoutModel,
@@ -207,7 +265,9 @@ export function ManastormSelectScreen() {
]);
const controller = useMenuController(actions, {
initialId: "manastorm-party-5",
onBack: returnToMainMenu,
onBack: pendingDownloadRole
? () => { if (!downloading) setPendingDownloadRole(null); }
: returnToMainMenu,
});
const race = character ? raceById(character.raceId) : null;
@@ -246,6 +306,11 @@ export function ManastormSelectScreen() {
: "No bosses are currently marked Ready for this Manastorm mode."}
</p>
) : null}
{canEnter && missingContentPackIds.length > 0 ? (
<p className="front-notice" role="status">
{missingContentPackIds.length} Manastorm map {missingContentPackIds.length === 1 ? "pack is" : "packs are"} not on this device. Choose a role to download them and continue.
</p>
) : null}
<dl>
<div><dt>Catalog maps</dt><dd>{MANASTORM_CATALOG_SUMMARY.maps}</dd></div>
<div><dt>Streamed maps</dt><dd>{GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT}</dd></div>
@@ -412,6 +477,48 @@ export function ManastormSelectScreen() {
</section>
</main>
<ControllerLegend back />
{pendingDownloadRole && (
<div className="front-modal-backdrop">
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="manastorm-download-title">
<p className="front-kicker">Download required</p>
<h2 id="manastorm-download-title">Download {modeLabel} Manastorm maps?</h2>
<p>
This mode can draw {missingContentPackIds.length} map {missingContentPackIds.length === 1 ? "pack" : "packs"} that {missingContentPackIds.length === 1 ? "is" : "are"} not on this device. Download and verify the required files now, then enter the storm?
</p>
{downloading && (
<p role="status">
Downloading and verifying files
{contentState.totalBytes > 0 ? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "..."}
</p>
)}
{downloadError && <p className="front-notice" role="alert">{downloadError}</p>}
<div>
<ControllerButton
controlId="manastorm-download-cancel"
selectedId={controller.selectedId}
select={controller.select}
className="front-button"
type="button"
disabled={downloading}
onClick={() => setPendingDownloadRole(null)}
>
Not now
</ControllerButton>
<ControllerButton
controlId="manastorm-download-confirm"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary"
type="button"
disabled={downloading}
onClick={() => void confirmDownload()}
>
{downloading ? "Downloading..." : downloadError ? "Try again" : "Download and continue"}
</ControllerButton>
</div>
</section>
</div>
)}
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
+27 -7
View File
@@ -16,6 +16,7 @@ import type { OnlineGroupMember } from "../app/onlineGroupTypes";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import type { OnlineSessionPlayer } from "../app/onlineSessionTypes";
import { onlinePlayerActorId } from "../game/onlineSessionRuntime";
import { auraDefinitionIsDispellableDebuff } from "./auraDisplay";
const PARTY_COMMANDS: readonly {
command: PartyCommand;
@@ -119,19 +120,19 @@ function MemberFrame({ member, selected, onSelect }: {
), 0));
const absorbPercent = Math.min(100 - percent, healthPercent(absorb, member.maxHealth));
const dispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory
aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition)
? total + 1
: total
), 0));
const dispellableNames = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === member.id && aura.definition.disposition === "debuff" && aura.definition.dispelCategory)
.filter((aura) => aura.targetId === member.id && auraDefinitionIsDispellableDebuff(aura.definition))
.map((aura) => aura.definition.name)
.join(", "));
const resourcePercent = healthPercent(member.resource, member.maxResource);
return (
<button
type="button"
className={`party-frame ${selected ? "is-selected" : ""} ${member.health <= 0 ? "is-down" : ""}`}
className={`party-frame ${selected ? "is-selected" : ""} ${dispellableCount ? "is-dispellable" : ""} ${member.health <= 0 ? "is-down" : ""}`}
onClick={onSelect}
aria-pressed={selected}
aria-label={`Target ${member.name}, ${member.gender} ${race.name}, ${member.specialization} ${characterClass.name}`}
@@ -149,7 +150,12 @@ function MemberFrame({ member, selected, onSelect }: {
<i style={{ width: `${resourcePercent}%` }} />
</span>
<TimedEffectStrip targetId={member.id} className="timed-effect-strip--party" />
<AuraStrip targetId={member.id} className="aura-strip--party" hideLongTerm />
<AuraStrip
targetId={member.id}
className="aura-strip--party"
hideLongTerm
highlightDispellableDebuffs
/>
</span>
</button>
);
@@ -214,6 +220,15 @@ export function PartyFrames() {
: total
), 0));
const playerShield = useCombatStore((state) => state.shield);
const playerDispellableCount = useCombatStore((state) => state.auras.reduce((total, aura) => (
aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition)
? total + 1
: total
), 0));
const playerDispellableNames = useCombatStore((state) => state.auras
.filter((aura) => aura.targetId === PLAYER_AGGRO_ID && auraDefinitionIsDispellableDebuff(aura.definition))
.map((aura) => aura.definition.name)
.join(", "));
const members = usePartyStore((state) => state.members);
const selectedMemberId = usePartyStore((state) => state.selectedMemberId);
const selfSelected = usePartyStore((state) => state.selfSelected);
@@ -232,7 +247,6 @@ export function PartyFrames() {
const onlinePlayersByAccount = new Map(
(onlineSession?.players ?? []).map((player) => [player.accountId, player]),
);
if (!members.length && !onlineMembers.length) return null;
const groupSize = members.length + onlineMembers.length + 1;
const selectMember = (id: string) => {
@@ -257,7 +271,7 @@ export function PartyFrames() {
{members.length ? <PartyCommandBar /> : null}
<button
type="button"
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
className={`party-frame party-frame--player ${selfSelected ? "is-selected" : ""} ${playerDispellableCount ? "is-dispellable" : ""} ${playerHealth <= 0 ? "is-down" : ""}`}
onClick={selectSelf}
aria-pressed={selfSelected}
aria-label={`Target ${character?.name ?? "Player"}, you`}
@@ -265,6 +279,7 @@ export function PartyFrames() {
<span className="party-frame__sigil">YOU</span>
<span className="party-frame__body">
<span className="party-frame__identity"><strong>{character?.name ?? "Adventurer"}</strong><small>You · {PARTY_ROLE_LABELS[playerRole]}</small></span>
{playerDispellableCount ? <span className="party-frame__dispel" title={playerDispellableNames}>DISPEL {playerDispellableCount}</span> : null}
<span className="party-frame__health" role="progressbar" aria-label="Player health" aria-valuemin={0} aria-valuemax={playerMaxHealth} aria-valuenow={Math.max(0, playerHealth)}>
<i style={{ width: `${playerPercent}%` }} />
{playerAbsorb > 0 ? <em style={{ left: `${playerPercent}%`, width: `${playerAbsorbPercent}%` }} /> : null}
@@ -278,7 +293,12 @@ export function PartyFrames() {
targetIds={[null, PLAYER_AGGRO_ID]}
className="timed-effect-strip--party"
/>
<AuraStrip targetId={PLAYER_AGGRO_ID} className="aura-strip--party" hideLongTerm />
<AuraStrip
targetId={PLAYER_AGGRO_ID}
className="aura-strip--party"
hideLongTerm
highlightDispellableDebuffs
/>
</span>
</button>
{onlineMembers.map((member) => {
+2 -2
View File
@@ -6,7 +6,7 @@ import {
runtimeUnspentCoaEssence,
runtimeUnspentTalentPoints,
} from "../game/talentRuntimeCatalog";
import { loadTalentUiCatalog, talentUiNodesForTree } from "../game/talentUiCatalog";
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiNodesForTree } from "../game/talentUiCatalog";
import { TalentsPanel } from "./TalentsPanel";
function treeSigil(name: string): string {
@@ -18,7 +18,7 @@ export default function TalentGameplayPanel() {
const level = useCombatStore((state) => state.level);
const talentRanks = useCombatStore((state) => state.talentRanks);
const classDefinition = classById(classId);
const catalog = use(loadTalentUiCatalog(classId));
const catalog = loadedTalentUiCatalog(classId) ?? use(loadTalentUiCatalog(classId));
const talentTrees = useMemo(() => catalog.trees.map((tree) => ({
id: tree.id,
name: tree.name,
+4
View File
@@ -55,6 +55,10 @@ export function auraDefinitionIsLongTerm(definition: AuraDefinition): boolean {
return definition.durationMs === null || definition.durationMs >= LONG_TERM_AURA_DURATION_MS;
}
export function auraDefinitionIsDispellableDebuff(definition: AuraDefinition): boolean {
return definition.disposition === "debuff" && definition.dispelCategory !== undefined;
}
export function auraDescription(aura: ActiveAura): string {
const authored = aura.definition.description?.trim();
if (authored) return authored;
+14 -1
View File
@@ -1,7 +1,7 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DefeatedPrompt } from "./Hud";
import { DefeatedPrompt, DungeonCompletePrompt } from "./Hud";
describe("defeated prompt", () => {
it("offers an explicit dungeon respawn while explaining that waiting is allowed", () => {
@@ -26,3 +26,16 @@ describe("defeated prompt", () => {
expect(markup).not.toContain("Resurrect now");
});
});
describe("dungeon completion prompt", () => {
it("keeps final-room looting available while exposing an explicit run exit", () => {
const markup = renderToStaticMarkup(createElement(DungeonCompletePrompt, {
dungeonTitle: "The Deadmines",
onFinish: () => undefined,
}));
expect(markup).toContain("The Deadmines complete");
expect(markup).toContain("Loot the room or finish the run");
expect(markup).toContain("Finish dungeon");
});
});
+37 -3
View File
@@ -5,12 +5,46 @@ const gameplayPanelsSource = readFileSync(
new URL("./GameplayPanels.tsx", import.meta.url),
"utf8",
);
const talentResourcesSource = readFileSync(
new URL("./talentGameplayResources.ts", import.meta.url),
"utf8",
);
const appSource = readFileSync(
new URL("../App.tsx", import.meta.url),
"utf8",
);
const gameplayResourcesSource = readFileSync(
new URL("./gameplayResources.ts", import.meta.url),
"utf8",
);
const companionPanelSource = readFileSync(
new URL("./CompanionPanel.tsx", import.meta.url),
"utf8",
);
describe("gameplay panel loading integration", () => {
it("starts loading talents from the pause menu and keeps a visible first-open surface", () => {
expect(gameplayPanelsSource).toContain('overlay === "pause"');
expect(gameplayPanelsSource).toContain("preloadTalentGameplayPanel()");
it("loads talent resources behind the main-menu catalog gate, not during gameplay", () => {
expect(appSource).toContain("preloadTalentGameplayResources(talentClassId)");
expect(talentResourcesSource).toContain("loadTalentUiCatalog(classId)");
expect(talentResourcesSource).toContain("loadTalentGameplayPanel()");
expect(talentResourcesSource).toContain("image.decode()");
expect(gameplayPanelsSource).toContain("getLoadedTalentGameplayPanel()");
expect(gameplayPanelsSource).toContain("<PreloadedTalentGameplayPanel />");
expect(gameplayPanelsSource).not.toContain("requestIdleCallback");
expect(gameplayPanelsSource).not.toContain("preloadTalentGameplayResources");
expect(gameplayPanelsSource).toContain("fallback={<TalentsPanelLoading />}");
expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />");
});
it("warms the shared runtime and first-open panels before showing the main menu", () => {
expect(appSource).toContain("preloadSharedGameplayResources(originalMediaEnabled)");
expect(appSource).toContain("getLoadedGameRuntime()");
expect(gameplayResourcesSource).toContain('import("../GameRuntime")');
expect(gameplayResourcesSource).toContain('import("./PartyManagementPanel")');
expect(gameplayResourcesSource).toContain('import("./BossGuidePanel")');
expect(gameplayResourcesSource).toContain('import("./CompanionManastormPanel")');
expect(gameplayPanelsSource).toContain("<PreloadedPartyManagementPanel />");
expect(gameplayPanelsSource).toContain("<PreloadedBossGuidePanel />");
expect(companionPanelSource).toContain("<PreloadedCompanionManastormPanel");
});
});
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff. Show more