Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
+21
-4
@@ -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}</>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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." });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,13 @@ import { useManastormStore } from "./manastormStore";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
advanceDungeonEncounters,
|
||||
dispatchActiveDungeonEntityEvent,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
deriveDungeonEncounterCombatEvents,
|
||||
type DungeonEncounterMobCursor,
|
||||
} from "./dungeonEncounterCombatEvents";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
onlineSessionIsActive,
|
||||
@@ -27,9 +32,11 @@ export function CombatBridge() {
|
||||
const character = useShellStore((state) => state.activeCharacter);
|
||||
const gameMode = useGameStore((state) => state.gameMode);
|
||||
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const activeDifficultyId = useGameStore((state) => state.activeDifficultyId);
|
||||
const activeRole = useShellStore((state) => state.activeDungeonRole);
|
||||
const encounterResetRevision = useGameStore((state) => state.encounterResetRevision);
|
||||
const mountedResetRevision = useRef(encounterResetRevision);
|
||||
const encounterMobCursor = useRef<DungeonEncounterMobCursor>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!character) return;
|
||||
@@ -44,12 +51,14 @@ export function CombatBridge() {
|
||||
|
||||
useEffect(() => {
|
||||
const definition = requireDungeonDefinition(activeDungeonId);
|
||||
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? []);
|
||||
}, [activeDungeonId]);
|
||||
encounterMobCursor.current = {};
|
||||
activateDungeonEncounterDefinitions(activeDungeonId, definition.encounters ?? [], activeDifficultyId);
|
||||
}, [activeDifficultyId, activeDungeonId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedResetRevision.current === encounterResetRevision) return;
|
||||
mountedResetRevision.current = encounterResetRevision;
|
||||
encounterMobCursor.current = {};
|
||||
useCombatStore.getState().resetEncounter();
|
||||
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
||||
}, [encounterResetRevision]);
|
||||
@@ -126,6 +135,13 @@ export function CombatBridge() {
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
if (onlineSessionOwnsWorldSimulation() && game.gameMode === "dungeon") {
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const combatEvents = deriveDungeonEncounterCombatEvents(
|
||||
encounterMobCursor.current,
|
||||
mobs,
|
||||
epochNow,
|
||||
);
|
||||
encounterMobCursor.current = combatEvents.cursor;
|
||||
for (const event of combatEvents.events) dispatchActiveDungeonEntityEvent(event);
|
||||
const registeredEntityIds = Object.keys(mobs);
|
||||
advanceDungeonEncounters(game.activeDungeonId, {
|
||||
kind: "tick",
|
||||
|
||||
@@ -3,9 +3,17 @@ import { applyDungeonEncounterEffect } from "./DungeonEncounterEffectBridge";
|
||||
import { clearPartyRuntimeRegistry, getPartyRuntimePosition, updatePartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import {
|
||||
clearMobRuntimeRegistry,
|
||||
getMobRuntimePosition,
|
||||
registerMobRuntime,
|
||||
} from "./mobRuntimeRegistry";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
|
||||
afterEach(() => {
|
||||
clearPartyRuntimeRegistry();
|
||||
clearMobRuntimeRegistry();
|
||||
usePartyStore.setState({ members: [] });
|
||||
});
|
||||
|
||||
@@ -26,4 +34,34 @@ describe("dungeon encounter effects", () => {
|
||||
expect(useGameStore.getState().playerPosition).toEqual([12, 3, -8]);
|
||||
expect(getPartyRuntimePosition(allyId)).toEqual([12, 3, -8]);
|
||||
});
|
||||
|
||||
it("removes every combat and spatial actor owned by a deactivated spawn group", () => {
|
||||
const dungeon = DUNGEON_REGISTRY["wailing-caverns"];
|
||||
const group = dungeon.encounterSpawnGroups?.find(
|
||||
(candidate) => candidate.id === "naralex-ravagers",
|
||||
);
|
||||
expect(group).toBeDefined();
|
||||
for (const spawn of group!.spawns) {
|
||||
useCombatStore.getState().registerMob(spawn.id, { name: spawn.id, maxHealth: 100 });
|
||||
registerMobRuntime(spawn.id, spawn.position);
|
||||
}
|
||||
useCombatStore.getState().registerMob("unrelated", { name: "Unrelated", maxHealth: 100 });
|
||||
|
||||
applyDungeonEncounterEffect({
|
||||
dungeonId: dungeon.id,
|
||||
encounterId: "wailing-caverns:naralex",
|
||||
effect: {
|
||||
kind: "despawn-group",
|
||||
actionId: "cleanup-ravagers",
|
||||
spawnGroupId: group!.id,
|
||||
},
|
||||
}, 100);
|
||||
|
||||
for (const spawn of group!.spawns) {
|
||||
expect(useCombatStore.getState().mobs[spawn.id]).toBeUndefined();
|
||||
expect(getMobRuntimePosition(spawn.id)).toBeNull();
|
||||
}
|
||||
expect(useCombatStore.getState().mobs.unrelated).toBeDefined();
|
||||
useCombatStore.getState().removeMob("unrelated");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import { useCombatStore } from "./combatStore";
|
||||
import { useGameStore } from "./store";
|
||||
import type { DungeonEncounterEffectEnvelope } from "./dungeonEncounterTypes";
|
||||
import { publishOnlinePartyTeleport } from "./onlineSessionRuntime";
|
||||
import { dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { removeMobPosition } from "./mobRuntimeRegistry";
|
||||
|
||||
function grantEncounterLoot(lootTableId: string, now: number): boolean {
|
||||
const table = LOOT_CATALOG.bosses.find((candidate) => candidate.lootTableId === lootTableId);
|
||||
@@ -33,7 +35,7 @@ export function DungeonEncounterEffectBridge() {
|
||||
}
|
||||
|
||||
export function applyDungeonEncounterEffect(
|
||||
{ effect }: DungeonEncounterEffectEnvelope,
|
||||
{ dungeonId, effect }: DungeonEncounterEffectEnvelope,
|
||||
now = Date.now(),
|
||||
): void {
|
||||
if (effect.kind === "signal") {
|
||||
@@ -49,6 +51,18 @@ export function applyDungeonEncounterEffect(
|
||||
publishOnlinePartyTeleport(effect.destination, effect.actionId, now);
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "despawn-group") {
|
||||
const group = dungeonDefinitionById(dungeonId)?.encounterSpawnGroups?.find(
|
||||
(candidate) => candidate.id === effect.spawnGroupId,
|
||||
);
|
||||
if (!group) return;
|
||||
const combat = useCombatStore.getState();
|
||||
for (const spawn of group.spawns) {
|
||||
combat.removeMob(spawn.id);
|
||||
removeMobPosition(spawn.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "loot-grant" && !grantEncounterLoot(effect.lootTableId, now)) {
|
||||
useShellStore.getState().setNotice(`Loot table ${effect.lootTableId} is not packaged for this encounter.`);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ export function ManastormBridge() {
|
||||
}, [gameMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameMode !== "manastorm" || !nextEncounterId) return;
|
||||
if (
|
||||
gameMode !== "manastorm"
|
||||
|| (status !== "portal-ready" && status !== "transitioning")
|
||||
|| !nextEncounterId
|
||||
) return;
|
||||
const nextEncounter = manastormEncounterById(nextEncounterId);
|
||||
const packageId = nextEncounter?.assetPackageId;
|
||||
if (!packageId || packageId === encounter?.assetPackageId) return;
|
||||
@@ -67,7 +71,7 @@ export function ManastormBridge() {
|
||||
console.warn(`Could not preload the next Manastorm package ${packageId}.`, error);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [encounter?.assetPackageId, gameMode, nextEncounterId]);
|
||||
}, [encounter?.assetPackageId, gameMode, nextEncounterId, status]);
|
||||
|
||||
useEffect(() => useCombatStore.subscribe((state, previous) => {
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OnlineSharedWorld } from "../app/onlineSessionTypes";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
dungeonEncounterSnapshot,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { applySharedWorld } from "./OnlineSessionBridge";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
WAILING_NARALEX_ENCOUNTER,
|
||||
useWailingEncounterStore,
|
||||
} from "./wailingCavernsEncounter";
|
||||
|
||||
const EMPTY_SHARED_WORLD = {
|
||||
dungeonId: "wailing-caverns",
|
||||
mobs: {},
|
||||
mobTransforms: {},
|
||||
partyMembers: [],
|
||||
partyPositions: {},
|
||||
partyRuntime: {
|
||||
command: "defend",
|
||||
activeMobId: null,
|
||||
objectiveBossId: null,
|
||||
memberTargetIds: {},
|
||||
},
|
||||
wailing: null,
|
||||
manastorm: null,
|
||||
} as const satisfies OnlineSharedWorld;
|
||||
|
||||
describe("online shared-world hydration", () => {
|
||||
afterEach(() => {
|
||||
useWailingEncounterStore.getState().reset("dungeon", 0);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
|
||||
it("accepts an authority snapshot older than a follower's local revision", () => {
|
||||
activateDungeonEncounterDefinitions(
|
||||
"wailing-caverns",
|
||||
[WAILING_NARALEX_ENCOUNTER],
|
||||
"normal",
|
||||
);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
const authorityBase = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
resetDungeonEncounters("dungeon", "wailing-caverns");
|
||||
const follower = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const authority = {
|
||||
...authorityBase,
|
||||
phase: "ready",
|
||||
revision: Math.max(0, follower.revision - 1),
|
||||
} as const;
|
||||
expect(authority.revision).toBeLessThan(follower.revision);
|
||||
|
||||
applySharedWorld({ ...EMPTY_SHARED_WORLD, encounters: [authority] });
|
||||
|
||||
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
|
||||
phase: "ready",
|
||||
revision: authority.revision,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ function sharedWorld(): OnlineSharedWorld {
|
||||
};
|
||||
}
|
||||
|
||||
function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
export function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
useCombatStore.setState({ mobs: world.mobs });
|
||||
usePartyStore.setState({
|
||||
members: world.partyMembers,
|
||||
@@ -103,7 +103,7 @@ function applySharedWorld(world: OnlineSharedWorld): void {
|
||||
if (world.manastorm) useManastormStore.setState(world.manastorm as never);
|
||||
const activeDungeonId = useGameStore.getState().activeDungeonId;
|
||||
if (world.dungeonId === activeDungeonId && world.encounters) {
|
||||
importDungeonEncounterSnapshots(activeDungeonId, world.encounters);
|
||||
importDungeonEncounterSnapshots(activeDungeonId, world.encounters, { authoritative: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterAction,
|
||||
DungeonEncounterGuard,
|
||||
DungeonEncounterTrigger,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import {
|
||||
ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE,
|
||||
withAscensionCustomEncounterContent,
|
||||
} from "./ascensionCustomEncounterCatalog";
|
||||
|
||||
const CUSTOM_DUNGEON_IDS = [
|
||||
"frozen-reach",
|
||||
"forgotten-mine",
|
||||
"tinkertech-showdown",
|
||||
] as const;
|
||||
|
||||
function adaptedDungeon(id: typeof CUSTOM_DUNGEON_IDS[number]): DungeonDefinition {
|
||||
const source = DUNGEON_REGISTRY[id];
|
||||
expect(source, id).toBeDefined();
|
||||
return withAscensionCustomEncounterContent(source);
|
||||
}
|
||||
|
||||
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
|
||||
return [
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((group) => group.spawns.map((spawn) => spawn.id)),
|
||||
];
|
||||
}
|
||||
|
||||
function expectTriggerReferences(
|
||||
trigger: DungeonEncounterTrigger,
|
||||
spawnIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
|
||||
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
|
||||
}
|
||||
if (trigger.kind === "entities-dead") {
|
||||
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
|
||||
}
|
||||
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
|
||||
}
|
||||
|
||||
function expectGuardReferences(
|
||||
guard: DungeonEncounterGuard,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
|
||||
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
|
||||
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
|
||||
}
|
||||
|
||||
function expectActionReferences(
|
||||
action: DungeonEncounterAction,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = objectsById.get(action.objectId);
|
||||
expect(object, `action object ${action.objectId}`).toBeDefined();
|
||||
expect(object?.states.map((state) => state.id), `object state ${action.objectId}:${action.state}`)
|
||||
.toContain(action.state);
|
||||
}
|
||||
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
|
||||
}
|
||||
|
||||
describe("Ascension custom encounter content", () => {
|
||||
it("attaches idempotently with no dangling or duplicate static/runtime references", () => {
|
||||
expect(ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.map((entry) => entry.dungeonId).sort())
|
||||
.toEqual([...CUSTOM_DUNGEON_IDS].sort());
|
||||
|
||||
for (const id of CUSTOM_DUNGEON_IDS) {
|
||||
const definition = adaptedDungeon(id);
|
||||
expect(withAscensionCustomEncounterContent(definition)).toBe(definition);
|
||||
const spawnIdList = runtimeSpawnIds(definition);
|
||||
const spawnIds = new Set(spawnIdList);
|
||||
expect(spawnIds.size, `${id} duplicate runtime spawn ids`).toBe(spawnIdList.length);
|
||||
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
|
||||
const objectiveIds = new Set([
|
||||
...definition.bosses.map((objective) => objective.id),
|
||||
...definition.objectiveOrder.map((objective) => objective.id),
|
||||
]);
|
||||
|
||||
for (const spawn of definition.staticSpawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id} static ${spawn.id}`).toBeDefined();
|
||||
}
|
||||
for (const group of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of group.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id} group ${group.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
|
||||
for (const encounter of definition.encounters ?? []) {
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
expect(encounter.dungeonId).toBe(id);
|
||||
for (const rule of encounter.rules) {
|
||||
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expectActionReferences(action, groupIds, objectsById, objectiveIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("removes Frozen Reach placeholder trash and drives the Arktos-to-vengeance lifecycle", () => {
|
||||
const definition = adaptedDungeon("frozen-reach");
|
||||
expect(definition.staticSpawns.map((spawn) => spawn.id)).toEqual(["encounter-2168"]);
|
||||
expect(definition.entities["frozen-reach-guardian"]).toBeUndefined();
|
||||
expect(definition.entities["frozen-reach-caster"]).toBeUndefined();
|
||||
expect(definition.entities["frozen-reach-brute"]).toBeUndefined();
|
||||
expect(definition.entities["entry-97700"]).toMatchObject({
|
||||
name: "Alva",
|
||||
combat: { serverEntry: 97_700 },
|
||||
});
|
||||
expect(definition.entities["frozen-reach:arktos"]).toMatchObject({
|
||||
name: "Arktos",
|
||||
combat: { serverEntry: 97_701 },
|
||||
});
|
||||
const alvaSpellIds = definition.entities["entry-97700"].combat?.attacks
|
||||
.flatMap((entry) => entry.spellId ?? []);
|
||||
expect(alvaSpellIds).toEqual(expect.arrayContaining([
|
||||
255_347,
|
||||
255_349,
|
||||
255_352,
|
||||
255_358,
|
||||
255_363,
|
||||
255_394,
|
||||
255_397,
|
||||
255_410,
|
||||
255_419,
|
||||
255_421,
|
||||
]));
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "frozen-reach:alva-and-arktos");
|
||||
expect(encounter?.source?.fidelity).toBe("source-backed-adapted");
|
||||
const arktosGroup = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "frozen-reach:arktos-attempt");
|
||||
expect(arktosGroup?.spawns).toMatchObject([{
|
||||
id: "frozen-reach:arktos",
|
||||
entityId: "frozen-reach:arktos",
|
||||
}]);
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "duo-active",
|
||||
activeSpawnGroupIds: ["frozen-reach:arktos-attempt"],
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "frozen-reach:arktos",
|
||||
now: 1_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "vengeance",
|
||||
flags: { "arktos-defeated": true },
|
||||
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
||||
});
|
||||
const addIds = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "frozen-reach:rebirth-alvajar")
|
||||
?.spawns.map((spawn) => spawn.id) ?? [];
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 2_000,
|
||||
facts: { registeredEntityIds: addIds, deadEntityIds: addIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("vengeance");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
||||
expect(reset).toMatchObject({
|
||||
phase: "duo-active",
|
||||
flags: { "arktos-defeated": false },
|
||||
activeSpawnGroupIds: [],
|
||||
});
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter!, reset, {
|
||||
kind: "tick",
|
||||
now: 3_001,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["frozen-reach:arktos-attempt"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "frozen-reach:arktos",
|
||||
now: 4_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "vengeance",
|
||||
flags: { "arktos-defeated": true },
|
||||
activeSpawnGroupIds: ["frozen-reach:rebirth-alvajar"],
|
||||
});
|
||||
});
|
||||
|
||||
it("runs Vek's Geospike, Resonance, and repeatable Resonite shard lifecycle", () => {
|
||||
const definition = adaptedDungeon("forgotten-mine");
|
||||
expect(definition.entities["entry-254272"]).toMatchObject({
|
||||
name: "Geolord Vek",
|
||||
combat: { serverEntry: 254_272 },
|
||||
});
|
||||
expect(definition.entities["forgotten-mine-guardian"].name).toBe("Forgotten Mine Stoneguard");
|
||||
expect(definition.entities["forgotten-mine-caster"].name).toBe("Forgotten Mine Geomancer");
|
||||
expect(definition.entities["forgotten-mine-brute"].name).toBe("Forgotten Mine Quarry Brute");
|
||||
expect(definition.entities["entry-254272"].combat?.attacks.flatMap((entry) => entry.spellId ?? []))
|
||||
.toEqual(expect.arrayContaining([354_000, 354_001, 354_002, 354_004, 354_013, 354_015, 354_017]));
|
||||
|
||||
const shards = definition.serverGameObjects.filter((object) => object.source?.templateId === 254_051);
|
||||
expect(shards).toHaveLength(3);
|
||||
for (const shard of shards) {
|
||||
expect(shard).toMatchObject({
|
||||
kind: "resource",
|
||||
halfExtents: [2.25, 2.4, 2.25],
|
||||
interaction: { range: 4.5, allowedStates: ["ready"] },
|
||||
source: { fidelity: "source-backed-adapted", templateId: 254_051 },
|
||||
});
|
||||
}
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "forgotten-mine:geolord-vek");
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "signal",
|
||||
signalId: "forgotten-mine:geospike-cast",
|
||||
now: 100,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("pre-resonance-geospike");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_599 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["forgotten-mine:geospike"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 3_600 }).snapshot;
|
||||
expect(snapshot.phase).toBe("boss-active");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-5069",
|
||||
healthPercent: 50,
|
||||
now: 4_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-active");
|
||||
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: "forgotten-mine:resonite-shard-1",
|
||||
now: 5_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "resonance-cleansed",
|
||||
counters: { "resonite-cleanses": 1 },
|
||||
worldObjectStates: { "forgotten-mine:resonite-shard-1": "consumed" },
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 12_999 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-cleansed");
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 13_000 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-recharge");
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, { kind: "tick", now: 35_000 }).snapshot;
|
||||
expect(snapshot.phase).toBe("resonance-active");
|
||||
expect(Object.values(snapshot.worldObjectStates)).toEqual(["ready", "ready", "ready"]);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: "forgotten-mine:resonite-shard-1",
|
||||
now: 36_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.counters["resonite-cleanses"]).toBe(2);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 37_000);
|
||||
expect(reset.phase).toBe("boss-active");
|
||||
expect(Object.values(reset.worldObjectStates)).toEqual(["dormant", "dormant", "dormant"]);
|
||||
expect(reset.counters["resonite-cleanses"]).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Tinkertech explicitly authored and switches Mechatron to faster overdrive with reinforcements", () => {
|
||||
const definition = adaptedDungeon("tinkertech-showdown");
|
||||
expect(definition.entities["entry-663012"]).toMatchObject({ name: "Mechatron", combat: { serverEntry: 663_012 } });
|
||||
expect(definition.entities["tinkertech-showdown-guardian"].name).toBe("Tinkertech Guardbot");
|
||||
expect(definition.entities["tinkertech-showdown-caster"].name).toBe("Tinkertech Coilcaster");
|
||||
expect(definition.entities["tinkertech-showdown-brute"].name).toBe("Tinkertech Demolisher");
|
||||
const review = ASCENSION_CUSTOM_ENCOUNTER_EVIDENCE.find((entry) => entry.dungeonId === definition.id)!;
|
||||
expect(review.fidelity).toBe("healer-man-authored");
|
||||
expect(review.exactSpellFamilies).toEqual({});
|
||||
expect(review.candidateUnboundSpellFamilies).toHaveProperty("gatlingMissileBarrage");
|
||||
|
||||
const attacks = definition.entities["entry-663012"].combat?.attacks ?? [];
|
||||
for (const spellId of [257_438, 257_459, 257_470, 257_472]) {
|
||||
const normal = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-normal"));
|
||||
const overdrive = attacks.find((entry) => entry.spellId === spellId && entry.id.endsWith("-overdrive"));
|
||||
expect(normal, `normal spell ${spellId}`).toBeDefined();
|
||||
expect(overdrive?.cooldownMs, `overdrive spell ${spellId}`).toBe(Math.round(normal!.cooldownMs * 0.8));
|
||||
}
|
||||
|
||||
const encounter = definition.encounters?.find((entry) => entry.id === "tinkertech-showdown:mechatron");
|
||||
expect(encounter?.source?.fidelity).toBe("healer-man-authored");
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter!, 0);
|
||||
expect(reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-9013",
|
||||
healthPercent: 36,
|
||||
now: 1_000,
|
||||
}).snapshot).toBe(snapshot);
|
||||
snapshot = reduceDungeonEncounter(encounter!, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "encounter-9013",
|
||||
healthPercent: 35,
|
||||
now: 2_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({
|
||||
phase: "overdrive",
|
||||
flags: { overdrive: true },
|
||||
activeSpawnGroupIds: ["tinkertech-showdown:overdrive-reinforcements"],
|
||||
});
|
||||
const reinforcements = definition.encounterSpawnGroups
|
||||
?.find((group) => group.id === "tinkertech-showdown:overdrive-reinforcements");
|
||||
expect(reinforcements?.spawns).toHaveLength(2);
|
||||
expect(reinforcements?.spawns.every((spawn) => spawn.entityId === "tinkertech-showdown-caster"))
|
||||
.toBe(true);
|
||||
const reset = resetDungeonEncounterSnapshot(encounter!, snapshot, "boss-wipe", 3_000);
|
||||
expect(reset).toMatchObject({ phase: "active", flags: { overdrive: false }, activeSpawnGroupIds: [] });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -185,5 +185,13 @@ describe("boss guide model", () => {
|
||||
});
|
||||
const flowingChaos = guideByDungeon.get("forsaken-abbey")!.bosses.find((boss) => boss.name === "Flowing Chaos")!;
|
||||
expect(flowingChaos.mechanics[0].fidelityLabel).toBe("HealerMan fallback");
|
||||
const theSeven = guideByDungeon.get("blackrock-depths")!.bosses.find((boss) => boss.name === "The Seven")!;
|
||||
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "blackrock-depths")
|
||||
?.entities[theSeven.entityId!].combat?.serverEntry).toBe(9_039);
|
||||
const escape = guideByDungeon.get("halls-of-reflection")!.bosses
|
||||
.find((boss) => boss.name === "Escape from the Lich King")!;
|
||||
expect(DUNGEON_DEFINITIONS.find((dungeon) => dungeon.id === "halls-of-reflection")
|
||||
?.entities[escape.entityId!].combat?.serverEntry).toBe(36_954);
|
||||
expect(escape.mechanics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
+58
-9
@@ -1,4 +1,4 @@
|
||||
import type { DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
|
||||
import type { DungeonBossObjective, DungeonDefinition, DungeonExpansion } from "./dungeonTypes";
|
||||
import {
|
||||
normalizeEnemyAttack,
|
||||
type EnemyAbilityCondition,
|
||||
@@ -114,6 +114,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
|
||||
}
|
||||
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
|
||||
if (effect.kind === "aura") {
|
||||
if (effect.meleeAutoCriticalProc) {
|
||||
const proc = effect.meleeAutoCriticalProc;
|
||||
return `Applies ${titleCase(effect.aura)} to ${target}; successful main-hand autos have a ${Math.round(proc.chance * 100)}% chance to grant ${proc.charges} charge${proc.charges === 1 ? "" : "s"} of +${proc.criticalChancePercent}% critical chance to the next main-hand auto for ${seconds(proc.durationMs)}`;
|
||||
}
|
||||
const magnitude = effect.magnitude === undefined ? "" : ` at ${effect.magnitude} strength`;
|
||||
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}${magnitude}`;
|
||||
}
|
||||
@@ -121,7 +125,10 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
|
||||
return `Applies ${titleCase(effect.aura)} to ${target} for ${seconds(effect.durationMs)}`;
|
||||
}
|
||||
if (effect.kind === "control") {
|
||||
return `${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
|
||||
const damage = effect.damageMultiplier === undefined
|
||||
? ""
|
||||
: `${titleCase(attack.school)} damage (${Number(effect.damageMultiplier.toFixed(2))}× boss power) and `;
|
||||
return `${damage}${titleCase(effect.mechanic)} controls ${target} for ${seconds(effect.durationMs)}`;
|
||||
}
|
||||
if (effect.kind === "transform") return `Transforms ${target} into ${titleCase(effect.form)}`;
|
||||
if (effect.kind === "summon") return `Summons ${effect.count} add${effect.count === 1 ? "" : "s"} (creature ${effect.creatureEntry})`;
|
||||
@@ -271,18 +278,25 @@ function normalizedName(value: string): string {
|
||||
|
||||
function bossEntityForObjective(
|
||||
dungeon: DungeonDefinition,
|
||||
objectiveId: string,
|
||||
objectiveName: string,
|
||||
objective: DungeonBossObjective,
|
||||
): readonly [string, PopulationEntityDefinition] | undefined {
|
||||
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objectiveId);
|
||||
const staticSpawn = dungeon.staticSpawns.find((spawn) => spawn.id === objective.id);
|
||||
if (staticSpawn) {
|
||||
const entity = dungeon.entities[staticSpawn.entityId];
|
||||
if (entity) return [staticSpawn.entityId, entity];
|
||||
}
|
||||
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
const spawn = group.spawns.find((candidate) => candidate.id === objective.id);
|
||||
if (spawn) {
|
||||
const entity = dungeon.entities[spawn.entityId];
|
||||
if (entity) return [spawn.entityId, entity];
|
||||
}
|
||||
}
|
||||
|
||||
for (const pack of dungeon.roamingPacks) {
|
||||
const member = pack.members.find((candidate) => (
|
||||
`${pack.id}:${candidate.id}` === objectiveId || candidate.id === objectiveId
|
||||
`${pack.id}:${candidate.id}` === objective.id || candidate.id === objective.id
|
||||
));
|
||||
if (member) {
|
||||
const entity = dungeon.entities[member.entityId];
|
||||
@@ -290,11 +304,46 @@ function bossEntityForObjective(
|
||||
}
|
||||
}
|
||||
|
||||
const wanted = normalizedName(objectiveName);
|
||||
return Object.entries(dungeon.entities).find(([, entity]) => (
|
||||
const wanted = normalizedName(objective.name);
|
||||
const named = Object.entries(dungeon.entities).find(([, entity]) => (
|
||||
entity.kind === "boss"
|
||||
&& (normalizedName(entity.name) === wanted || normalizedName(entity.title) === wanted)
|
||||
));
|
||||
if (named) return named;
|
||||
|
||||
if (objective.eventOnly) {
|
||||
const related = Object.entries(dungeon.entities)
|
||||
.filter(([, entity]) => {
|
||||
const entityName = normalizedName(entity.name);
|
||||
return entityName.length >= 5 && (wanted.includes(entityName) || entityName.includes(wanted));
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const leftBoss = left[1].kind === "boss" ? 1 : 0;
|
||||
const rightBoss = right[1].kind === "boss" ? 1 : 0;
|
||||
if (leftBoss !== rightBoss) return rightBoss - leftBoss;
|
||||
const leftMechanics = left[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
|
||||
const rightMechanics = right[1].combat?.attacks.filter((attack) => attack.id !== "basic-attack").length ?? 0;
|
||||
return rightMechanics - leftMechanics || left[0].localeCompare(right[0]);
|
||||
});
|
||||
if (related[0]) return related[0];
|
||||
}
|
||||
|
||||
// Aggregate objectives such as Blackrock Depths' "The Seven" use a
|
||||
// synthetic objective id but retain the final source actor's exact position.
|
||||
// That representative may be typed as a mob because the objective covers a
|
||||
// multi-actor event rather than one loot-bearing boss.
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
const exact = group.spawns.filter((candidate) => {
|
||||
const dx = candidate.position[0] - objective.position[0];
|
||||
const dy = candidate.position[1] - objective.position[1];
|
||||
const dz = candidate.position[2] - objective.position[2];
|
||||
return dx * dx + dy * dy + dz * dz <= 0.25 * 0.25;
|
||||
});
|
||||
const spawn = exact.find((candidate) => dungeon.entities[candidate.entityId]?.kind === "boss")
|
||||
?? exact.find((candidate) => dungeon.entities[candidate.entityId] !== undefined);
|
||||
if (spawn) return [spawn.entityId, dungeon.entities[spawn.entityId]];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bossGuide(
|
||||
@@ -325,7 +374,7 @@ export function buildDungeonBossGuide(
|
||||
): DungeonBossGuide {
|
||||
const representedEntityIds = new Set<string>();
|
||||
const objectiveBosses = dungeon.bosses.map((objective) => {
|
||||
const resolved = bossEntityForObjective(dungeon, objective.id, objective.name);
|
||||
const resolved = bossEntityForObjective(dungeon, objective);
|
||||
if (resolved) representedEntityIds.add(resolved[0]);
|
||||
return bossGuide(dungeon, objective.id, objective.name, resolved, overrides);
|
||||
});
|
||||
|
||||
@@ -59,12 +59,18 @@ describe("named aura combat runtime", () => {
|
||||
});
|
||||
|
||||
it("grants learned long-term stat buffs to the full party on load", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 8 });
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
specializationId: "priest:holy",
|
||||
level: 8,
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
usePartyStore.getState().initializeParty("default-party-buffs", 8, useCombatStore.getState().health);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const playerMaxHealth = useCombatStore.getState().maxHealth;
|
||||
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
|
||||
|
||||
expect(playerMaxHealth).toBe(407);
|
||||
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
|
||||
|
||||
const state = useCombatStore.getState();
|
||||
@@ -75,7 +81,7 @@ describe("named aura combat runtime", () => {
|
||||
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
|
||||
}));
|
||||
}
|
||||
expect(state.maxHealth).toBeGreaterThan(playerMaxHealth);
|
||||
expect(state.maxHealth).toBe(437);
|
||||
for (const member of usePartyStore.getState().members) {
|
||||
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface AuraDefinition {
|
||||
/** Explicit non-numeric gameplay state such as stealth, tracking, flight, or water walking. */
|
||||
readonly utilityTags?: readonly string[];
|
||||
/** Enemy control mechanics ignored while this aura is active. */
|
||||
readonly controlImmunities?: readonly ("fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||
readonly controlImmunities?: readonly ("confuse" | "fear" | "sleep" | "root" | "stun" | "silence")[];
|
||||
/** Chance from 0 through 1 to reflect an incoming spell. */
|
||||
readonly reflectSpellChance?: number;
|
||||
/** Portion of incoming target damage transferred to the aura source. */
|
||||
|
||||
@@ -237,6 +237,20 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().maxResource).toBe(necromancerBase.mana);
|
||||
});
|
||||
|
||||
it("gives a geared level-eight Priest the expanded live-game mana pool", () => {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "priest",
|
||||
specializationId: "priest:holy",
|
||||
level: 8,
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
resource: 3_285,
|
||||
maxResource: 3_285,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses equipped spell power, stamina, and armor in player combat", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 });
|
||||
const baseMaxHealth = useCombatStore.getState().maxHealth;
|
||||
|
||||
+192
-41
@@ -179,6 +179,7 @@ import {
|
||||
type EnemyAttackDefinition,
|
||||
type EnemyControlMechanic,
|
||||
type EnemyDamageSchool,
|
||||
type EnemyMeleeAutoCriticalProc,
|
||||
} from "./enemyAttacks";
|
||||
import {
|
||||
creatureRuntimeBaseStats,
|
||||
@@ -294,6 +295,11 @@ export interface MobAura {
|
||||
readonly sourceAbilityId: string;
|
||||
readonly endsAt: number;
|
||||
readonly magnitude?: number;
|
||||
/** Persistent driver for a content-authored main-hand auto-attack proc. */
|
||||
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
|
||||
/** A charged modifier consumed by a successful main-hand auto attack. */
|
||||
readonly nextMeleeCriticalChancePercent?: number;
|
||||
readonly charges?: number;
|
||||
}
|
||||
|
||||
export interface MobMagicResistances {
|
||||
@@ -715,7 +721,10 @@ export interface CombatState {
|
||||
engageNearbyMobs: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
advanceMobCombat: (now?: number, playerPosition?: CombatPosition) => number;
|
||||
tick: (deltaSeconds: number, now?: number) => void;
|
||||
resetEncounter: () => void;
|
||||
resetEncounter: (options?: {
|
||||
readonly preserveDefeated?: boolean;
|
||||
readonly preserveDefeatedIds?: readonly string[];
|
||||
}) => void;
|
||||
allocateTalent: (treeId: string) => boolean;
|
||||
resetTalents: () => void;
|
||||
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
|
||||
@@ -1354,6 +1363,81 @@ function enemyAttackKind(attack: EnemyAttackDefinition): WotlkAttackKind {
|
||||
return "spell";
|
||||
}
|
||||
|
||||
function isEnemyMainHandAutoAttack(attack: EnemyAttackDefinition): boolean {
|
||||
return attack.id === DEFAULT_ENEMY_ATTACK.id
|
||||
&& attack.delivery === "melee"
|
||||
&& attack.animation === "attack"
|
||||
&& (attack.castTimeMs ?? 0) === 0;
|
||||
}
|
||||
|
||||
function enemyMeleeCriticalCharge(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
now: number,
|
||||
): MobAura | undefined {
|
||||
if (!isEnemyMainHandAutoAttack(attack)) return undefined;
|
||||
return mob.auras.find((aura) => (
|
||||
(aura.endsAt <= 0 || aura.endsAt > now)
|
||||
&& (aura.charges ?? 0) > 0
|
||||
&& (aura.nextMeleeCriticalChancePercent ?? 0) > 0
|
||||
));
|
||||
}
|
||||
|
||||
function advanceEnemyMeleeAutoProcAuras(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
now: number,
|
||||
attackRevision: number,
|
||||
landed: boolean,
|
||||
consumedAuraId?: string,
|
||||
): readonly MobAura[] {
|
||||
if (!landed || !isEnemyMainHandAutoAttack(attack)) return mob.auras;
|
||||
|
||||
let changed = false;
|
||||
let nextAuras = mob.auras.flatMap((aura): MobAura[] => {
|
||||
if (aura.id !== consumedAuraId) return [aura];
|
||||
changed = true;
|
||||
const charges = Math.max(0, Math.round(aura.charges ?? 0) - 1);
|
||||
return charges > 0 ? [{ ...aura, charges }] : [];
|
||||
});
|
||||
|
||||
for (const driver of mob.auras) {
|
||||
const proc = driver.meleeAutoCriticalProc;
|
||||
if (!proc || (driver.endsAt > 0 && driver.endsAt <= now)) continue;
|
||||
const chance = clamp(Number.isFinite(proc.chance) ? proc.chance : 0, 0, 1);
|
||||
if (
|
||||
chance <= 0
|
||||
|| stableCombatUnit(
|
||||
`${mob.id}:${driver.id}:${attack.id}:${attackRevision}:${now}:melee-auto-critical-proc`,
|
||||
) >= chance
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const triggeredAura = proc.triggeredAura.trim();
|
||||
const criticalChancePercent = Math.max(
|
||||
0,
|
||||
Number.isFinite(proc.criticalChancePercent) ? proc.criticalChancePercent : 0,
|
||||
);
|
||||
const charges = Math.max(1, Math.round(
|
||||
Number.isFinite(proc.charges) ? proc.charges : 1,
|
||||
));
|
||||
if (!triggeredAura || criticalChancePercent <= 0) continue;
|
||||
nextAuras = [
|
||||
...nextAuras.filter((aura) => aura.id !== triggeredAura),
|
||||
{
|
||||
id: triggeredAura,
|
||||
sourceAbilityId: driver.sourceAbilityId,
|
||||
endsAt: proc.durationMs > 0 ? now + Math.round(proc.durationMs) : 0,
|
||||
nextMeleeCriticalChancePercent: criticalChancePercent,
|
||||
charges,
|
||||
},
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? nextAuras : mob.auras;
|
||||
}
|
||||
|
||||
function resolveEnemyDamageResult(
|
||||
mob: MobCombatState,
|
||||
attack: EnemyAttackDefinition,
|
||||
@@ -1362,6 +1446,7 @@ function resolveEnemyDamageResult(
|
||||
actorId: AggroActorId,
|
||||
attackRevision: number,
|
||||
now: number,
|
||||
bonusCriticalChancePercent = 0,
|
||||
): CombatResult {
|
||||
const attackKind = enemyAttackKind(attack);
|
||||
return resolveCombatResult({
|
||||
@@ -1370,7 +1455,7 @@ function resolveEnemyDamageResult(
|
||||
damageSchool: attack.school,
|
||||
attacker: {
|
||||
level: mob.level,
|
||||
criticalChancePercent: 5,
|
||||
criticalChancePercent: 5 + Math.max(0, bonusCriticalChancePercent),
|
||||
},
|
||||
defender: combatDefenderFromCharacterStats(defender),
|
||||
rules: {
|
||||
@@ -2194,7 +2279,8 @@ function evaluateProcsInWork(
|
||||
function eligibleTargetIds(state: CombatState, origin: CombatPosition, maxRange: number): string[] {
|
||||
return nearbyMobRuntimeIds(origin, maxRange).filter((id) => {
|
||||
const mob = state.mobs[id];
|
||||
return Boolean(mob && !mob.dead);
|
||||
const position = getMobPosition(id);
|
||||
return Boolean(mob && !mob.dead && position && combatHasLineOfSight(origin, position));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6603,6 +6689,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
sourceAbilityId: attack.id,
|
||||
endsAt,
|
||||
...(effect.magnitude === undefined ? {} : { magnitude: effect.magnitude }),
|
||||
...(effect.meleeAutoCriticalProc === undefined
|
||||
? {}
|
||||
: { meleeAutoCriticalProc: effect.meleeAutoCriticalProc }),
|
||||
},
|
||||
];
|
||||
namedAuras = [...applyCombatAura(namedAuras, {
|
||||
@@ -6633,6 +6722,24 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}).auras];
|
||||
}
|
||||
} else if (effect.kind === "control") {
|
||||
if ((effect.damageMultiplier ?? 0) > 0) {
|
||||
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.damageMultiplier!));
|
||||
const attackDamage = attack.runewakerFormula
|
||||
? adaptRunewakerEnemyDamage({
|
||||
formula: attack.runewakerFormula,
|
||||
baselineDamage: mob.attackDamage,
|
||||
}).amount
|
||||
: baselineDamage;
|
||||
for (const actorId of targets) {
|
||||
incomingHits.push({
|
||||
actorId,
|
||||
amount: attackDamage,
|
||||
mob,
|
||||
attack,
|
||||
attackRevision,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const actorId of targets) {
|
||||
if (hasControlImmunity(namedAuras, actorId, effect.mechanic)) continue;
|
||||
const control = { mechanic: effect.mechanic, endsAt: now + effect.durationMs };
|
||||
@@ -6823,6 +6930,29 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const procMob = get().mobs[hit.mob.id] ?? hit.mob;
|
||||
const meleeCriticalCharge = enemyMeleeCriticalCharge(procMob, hit.attack, now);
|
||||
const bonusCriticalChancePercent = meleeCriticalCharge?.nextMeleeCriticalChancePercent ?? 0;
|
||||
const commitMeleeAutoProc = (landed: boolean): void => {
|
||||
const current = get();
|
||||
const currentMob = current.mobs[hit.mob.id];
|
||||
if (!currentMob) return;
|
||||
const auras = advanceEnemyMeleeAutoProcAuras(
|
||||
currentMob,
|
||||
hit.attack,
|
||||
now,
|
||||
hit.attackRevision,
|
||||
landed,
|
||||
meleeCriticalCharge?.id,
|
||||
);
|
||||
if (auras === currentMob.auras) return;
|
||||
set({
|
||||
mobs: {
|
||||
...current.mobs,
|
||||
[currentMob.id]: { ...currentMob, auras },
|
||||
},
|
||||
});
|
||||
};
|
||||
if (hit.actorId === PLAYER_AGGRO_ID) {
|
||||
const current = get();
|
||||
const defender = applyAuraStats(
|
||||
@@ -6844,7 +6974,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
);
|
||||
commitMeleeAutoProc(combatResult.landed);
|
||||
const auraAdjustedDamage = resolveAuraDamageValue(
|
||||
combatResult.finalDamage,
|
||||
aurasForEntity(current.auras, PLAYER_AURA_ENTITY_ID),
|
||||
@@ -6984,8 +7116,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const summon = get().summons.find((candidate) => candidate.id === hit.actorId);
|
||||
if (summon) {
|
||||
const current = get();
|
||||
const critical = bonusCriticalChancePercent > 0;
|
||||
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
||||
commitMeleeAutoProc(true);
|
||||
const adjusted = resolveAuraDamageValue(
|
||||
hit.amount,
|
||||
incomingAmount,
|
||||
aurasForEntity(current.auras, summon.id),
|
||||
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
||||
);
|
||||
@@ -7010,12 +7145,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetActorId: summon.id,
|
||||
abilityId: hit.attack.id,
|
||||
school: hit.attack.school,
|
||||
rawAmount: hit.amount,
|
||||
rawAmount: incomingAmount,
|
||||
effectiveAmount: applied,
|
||||
overheal: 0,
|
||||
absorbed: absorbed.absorbed,
|
||||
critical: false,
|
||||
outcome: applied > 0 ? "hit" : "absorb",
|
||||
critical,
|
||||
outcome: applied > 0 ? (critical ? "crit" : "hit") : "absorb",
|
||||
}),
|
||||
});
|
||||
appliedDamage += applied;
|
||||
@@ -7024,8 +7159,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const onlineActor = onlinePlayerRuntimeActor(hit.actorId);
|
||||
if (onlineActor) {
|
||||
const current = get();
|
||||
const critical = bonusCriticalChancePercent > 0;
|
||||
const incomingAmount = Math.round(hit.amount * (critical ? 2 : 1));
|
||||
commitMeleeAutoProc(true);
|
||||
const adjusted = resolveAuraDamageValue(
|
||||
hit.amount,
|
||||
incomingAmount,
|
||||
aurasForEntity(current.auras, onlineActor.id),
|
||||
{ direction: "taken", school: hit.attack.school, attackKind: enemyAttackKind(hit.attack) },
|
||||
);
|
||||
@@ -7052,12 +7190,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
targetActorId: onlineActor.id,
|
||||
abilityId: hit.attack.id,
|
||||
school: hit.attack.school,
|
||||
rawAmount: hit.amount,
|
||||
rawAmount: incomingAmount,
|
||||
effectiveAmount: damaged,
|
||||
overheal: 0,
|
||||
absorbed: absorbed.absorbed,
|
||||
critical: false,
|
||||
outcome: damaged > 0 ? "hit" : "absorb",
|
||||
critical,
|
||||
outcome: damaged > 0 ? (critical ? "crit" : "hit") : "absorb",
|
||||
}),
|
||||
});
|
||||
appliedDamage += damaged;
|
||||
@@ -7090,7 +7228,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
hit.actorId,
|
||||
hit.attackRevision,
|
||||
now,
|
||||
bonusCriticalChancePercent,
|
||||
);
|
||||
commitMeleeAutoProc(combatResult.landed);
|
||||
const currentAuras = get().auras;
|
||||
const auraAdjustedDamage = resolveAuraDamageValue(
|
||||
combatResult.finalDamage,
|
||||
@@ -8035,41 +8175,47 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
resetEncounter: () => set((state) => {
|
||||
resetEncounter: (options = {}) => set((state) => {
|
||||
resetCombatPresentationSession();
|
||||
const preserveDefeatedIds = new Set(options.preserveDefeatedIds ?? []);
|
||||
for (const mob of Object.values(state.mobs)) {
|
||||
if (mob.summonedBy) removeMobPosition(mob.id);
|
||||
}
|
||||
const mobs = Object.fromEntries(Object.entries(state.mobs)
|
||||
.filter(([, mob]) => !mob.summonedBy)
|
||||
.map(([id, mob]) => [id, {
|
||||
...mob,
|
||||
health: mob.maxHealth,
|
||||
dead: false,
|
||||
pendingLoot: [],
|
||||
looted: false,
|
||||
despawned: false,
|
||||
diedAt: null,
|
||||
corpseDespawnAt: null,
|
||||
experienceGranted: false,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
lastDamageAt: 0,
|
||||
engaged: false,
|
||||
combatPhase: "idle" as const,
|
||||
homePosition: null,
|
||||
nextAttackAt: 0,
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextWoundAnimationAt: 0,
|
||||
}]));
|
||||
.map(([id, mob]) => {
|
||||
const preserveDefeated = mob.dead && (
|
||||
options.preserveDefeated === true || preserveDefeatedIds.has(id)
|
||||
);
|
||||
return [id, {
|
||||
...mob,
|
||||
health: preserveDefeated ? 0 : mob.maxHealth,
|
||||
dead: preserveDefeated,
|
||||
pendingLoot: preserveDefeated ? mob.pendingLoot : [],
|
||||
looted: preserveDefeated ? mob.looted : false,
|
||||
despawned: preserveDefeated ? mob.despawned : false,
|
||||
diedAt: preserveDefeated ? mob.diedAt : null,
|
||||
corpseDespawnAt: preserveDefeated ? mob.corpseDespawnAt : null,
|
||||
experienceGranted: preserveDefeated ? mob.experienceGranted : false,
|
||||
statuses: [],
|
||||
controlDiminishingReturns: {},
|
||||
tauntDiminishingReturns: { applications: 0, resetAt: 0 },
|
||||
auras: [],
|
||||
threatByActor: {},
|
||||
targetActorId: null,
|
||||
forcedTarget: null,
|
||||
lastDamageAt: 0,
|
||||
engaged: false,
|
||||
combatPhase: "idle" as const,
|
||||
homePosition: null,
|
||||
nextAttackAt: 0,
|
||||
abilityReadyAt: {},
|
||||
abilityCastCounts: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
nextWoundAnimationAt: 0,
|
||||
}] as const;
|
||||
}));
|
||||
const auras = syncPassiveAuras([], state.abilities, state.level, state.talentRanks, combatNow());
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
@@ -8116,7 +8262,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
combatEvents: [],
|
||||
actors: {},
|
||||
lastCombatResult: null,
|
||||
feedback: nextFeedback("success", "The encounter has been reset."),
|
||||
feedback: nextFeedback(
|
||||
"success",
|
||||
options.preserveDefeated || preserveDefeatedIds.size > 0
|
||||
? "The current pull has been reset; defeated enemies remain cleared."
|
||||
: "The encounter has been reset.",
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { respawnDefeatedPlayer } from "./deathRespawn";
|
||||
@@ -15,13 +14,22 @@ describe("death respawn", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
});
|
||||
|
||||
it("atomically restores the player and encounter at the Wailing Caverns entrance", () => {
|
||||
it("resets only the current pull and preserves cleared enemies and run progress", () => {
|
||||
const startingResource = useCombatStore.getState().maxResource;
|
||||
const checkpointSpawn = { footPosition: [4, -70, 12] as const, yaw: 0.25 };
|
||||
useGameStore.getState().relocatePlayer(checkpointSpawn, true);
|
||||
useGameStore.getState().updatePlayerSnapshot({ position: [10, -50, 20], yaw: 0.5, distanceDelta: 30 });
|
||||
useGameStore.getState().setCameraLookActive(true);
|
||||
|
||||
useCombatStore.getState().registerMob("respawn-pack:a", { name: "Ravager", maxHealth: 100 });
|
||||
useCombatStore.getState().registerMob("cleared-boss", { name: "Cleared Boss", maxHealth: 50, boss: true });
|
||||
useCombatStore.getState().registerMob("naralex-event:ravager:1", {
|
||||
name: "Event Ravager",
|
||||
maxHealth: 75,
|
||||
});
|
||||
setMobPosition("respawn-pack:a", [11, -50, 20]);
|
||||
useCombatStore.getState().damageMob("cleared-boss", 50, 900);
|
||||
useCombatStore.getState().damageMob("naralex-event:ravager:1", 75, 950);
|
||||
useCombatStore.getState().selectMob("respawn-pack:a");
|
||||
useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000);
|
||||
expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true);
|
||||
@@ -30,14 +38,25 @@ describe("death respawn", () => {
|
||||
);
|
||||
|
||||
const revisionBefore = useGameStore.getState().resetRevision;
|
||||
useWailingEncounterStore.setState({ phase: "mutanus", phaseStartedAt: 5_000 });
|
||||
useWailingEncounterStore.setState({
|
||||
phase: "mutanus",
|
||||
phaseStartedAt: 5_000,
|
||||
flags: { "dungeon-complete": true },
|
||||
completedObjectiveIds: ["mutanus-spawn"],
|
||||
consumedActionIds: [
|
||||
"complete-mutanus-objective",
|
||||
"save-mutanus-checkpoint",
|
||||
"mark-wailing-complete",
|
||||
],
|
||||
checkpointId: "mutanus-spawn",
|
||||
});
|
||||
useCombatStore.getState().damagePlayer(useCombatStore.getState().maxHealth);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(respawnDefeatedPlayer()).toBe(true);
|
||||
|
||||
expect(useGameStore.getState()).toMatchObject({
|
||||
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
|
||||
cameraYaw: DUNGEON_MANIFEST.entrance.yaw,
|
||||
playerPosition: checkpointSpawn.footPosition,
|
||||
cameraYaw: checkpointSpawn.yaw,
|
||||
cameraLookActive: false,
|
||||
resetRevision: revisionBefore + 1,
|
||||
});
|
||||
@@ -45,16 +64,29 @@ describe("death respawn", () => {
|
||||
health: useCombatStore.getState().maxHealth,
|
||||
resource: startingResource,
|
||||
selectedTargetId: null,
|
||||
playerPosition: DUNGEON_MANIFEST.entrance.footPosition,
|
||||
playerPosition: checkpointSpawn.footPosition,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["respawn-pack:a"]).toMatchObject({
|
||||
health: 100,
|
||||
dead: false,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["cleared-boss"]).toMatchObject({
|
||||
health: 0,
|
||||
dead: true,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useCombatStore.getState().mobs["naralex-event:ravager:1"]).toMatchObject({
|
||||
health: 75,
|
||||
dead: false,
|
||||
engaged: false,
|
||||
});
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "locked",
|
||||
phaseStartedAt: 0,
|
||||
flags: { "dungeon-complete": true },
|
||||
completedObjectiveIds: ["mutanus-spawn"],
|
||||
checkpointId: "mutanus-spawn",
|
||||
});
|
||||
|
||||
expect(respawnDefeatedPlayer()).toBe(false);
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { useGameStore } from "./store";
|
||||
import { resetDungeonEncounters } from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
activeDungeonCompletedObjectiveIds,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
|
||||
/**
|
||||
* Atomically returns a defeated player to the dungeon entrance and restores
|
||||
* the encounter. Keeping this outside the render tree makes the behavior easy
|
||||
* to verify and prevents a frame where the camera resets but combat stays dead.
|
||||
* Atomically returns a defeated player to the saved run spawn and resets the
|
||||
* current pull. Defeated enemies and persisted dungeon progress remain clear,
|
||||
* matching a wipe instead of silently starting a brand-new dungeon.
|
||||
*/
|
||||
export function respawnDefeatedPlayer(): boolean {
|
||||
const combat = useCombatStore.getState();
|
||||
if (combat.health > 0) return false;
|
||||
|
||||
const dungeonId = useGameStore.getState().activeDungeonId;
|
||||
const completedObjectiveIds = new Set(activeDungeonCompletedObjectiveIds(dungeonId));
|
||||
const retrySpawnIds = new Set(
|
||||
(requireDungeonDefinition(dungeonId).encounterSpawnGroups ?? [])
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id)),
|
||||
);
|
||||
const preserveDefeatedIds = Object.values(combat.mobs)
|
||||
.filter((mob) => (
|
||||
mob.dead
|
||||
&& (!retrySpawnIds.has(mob.id) || completedObjectiveIds.has(mob.id))
|
||||
))
|
||||
.map((mob) => mob.id);
|
||||
|
||||
if (typeof document !== "undefined" && document.pointerLockElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
useGameStore.getState().resetAtEntrance();
|
||||
resetDungeonEncounters("dungeon", useGameStore.getState().activeDungeonId);
|
||||
const entrance = useGameStore.getState().playerPosition;
|
||||
combat.resetEncounter();
|
||||
useCombatStore.getState().setPlayerPosition(entrance);
|
||||
useGameStore.getState().resetAfterDefeat();
|
||||
resetDungeonEncounters("boss-wipe", dungeonId);
|
||||
const respawnPosition = useGameStore.getState().playerPosition;
|
||||
combat.resetEncounter({ preserveDefeatedIds });
|
||||
useCombatStore.getState().setPlayerPosition(respawnPosition);
|
||||
return true;
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import epochInstances from "../../dungeon-pipeline/epoch-five-player-instances.json";
|
||||
import gameObjectAssetManifest from "../../dungeon-pipeline/WOW_GAMEOBJECT_ASSET_MANIFEST.json";
|
||||
import generated from "./generated/dungeonCampaignCatalog.json";
|
||||
import manastormPackages from "./generated/manastormAssetPackages.json";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
|
||||
const SOURCE_NATIVE_DUNGEON_IDS = [
|
||||
"ahnkahet-old-kingdom",
|
||||
@@ -77,6 +86,313 @@ describe("generated dungeon campaign catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("never exposes raw database gameobject rows to the live world-object renderer", () => {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const object of dungeon.serverGameObjects) {
|
||||
expect(() => validateDungeonWorldObject(object), `${dungeon.id}:${object.id}`).not.toThrow();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every WoW dungeon executable persisted boss progression", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
|
||||
for (const dungeon of wowDungeons) {
|
||||
const objectiveIds = dungeon.objectiveOrder.map((objective) => objective.id);
|
||||
expect(new Set(objectiveIds).size, `${dungeon.id}:duplicate-objective-id`).toBe(objectiveIds.length);
|
||||
const encounters = dungeon.encounters ?? [];
|
||||
for (const encounter of encounters) {
|
||||
expect(() => validateDungeonEncounterDefinition(encounter), `${dungeon.id}:${encounter.id}`).not.toThrow();
|
||||
}
|
||||
const allCompletedObjectiveIds = new Set(encounters.flatMap((encounter) => (
|
||||
encounter.rules.flatMap((rule) => rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
)))
|
||||
)));
|
||||
for (const objective of dungeon.objectiveOrder) {
|
||||
expect(allCompletedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
|
||||
}
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.source?.scope === "boss-progression",
|
||||
) as DungeonEncounterDefinition | undefined;
|
||||
const instanceCompletion = encounters.find((encounter) => (
|
||||
encounter.source?.scope === "instance-orchestration"
|
||||
&& encounter.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
const authority = progression ?? instanceCompletion;
|
||||
expect(authority, dungeon.id).toBeDefined();
|
||||
expect(authority!.source?.fidelity, dungeon.id).toBe(
|
||||
dungeon.id === "tinkertech-showdown"
|
||||
? "healer-man-authored"
|
||||
: "source-backed-adapted",
|
||||
);
|
||||
|
||||
if (!progression) {
|
||||
expect(instanceCompletion?.source?.scope, dungeon.id).toBe("instance-orchestration");
|
||||
const completedObjectiveIds = new Set(instanceCompletion!.rules.flatMap((rule) => (
|
||||
rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
))
|
||||
)));
|
||||
for (const objective of dungeon.objectiveOrder.filter((candidate) => !candidate.optional)) {
|
||||
expect(completedObjectiveIds.has(objective.id), `${dungeon.id}:${objective.name}`).toBe(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const progressionObjectiveIds = new Set(progression.rules.flatMap((rule) => (
|
||||
rule.actions.flatMap((action) => (
|
||||
action.kind === "complete-objective" ? [action.objectiveId] : []
|
||||
))
|
||||
)));
|
||||
const runtimeBosses = dungeon.objectiveOrder.filter((objective) => (
|
||||
progressionObjectiveIds.has(objective.id)
|
||||
));
|
||||
let snapshot = createDungeonEncounterSnapshot(progression, 1_000);
|
||||
const deadEntityIds: string[] = [];
|
||||
for (const boss of runtimeBosses) {
|
||||
deadEntityIds.push(boss.id);
|
||||
snapshot = reduceDungeonEncounter(progression, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1_000 + deadEntityIds.length * 100,
|
||||
facts: {
|
||||
registeredEntityIds: runtimeBosses.map((objective) => objective.id),
|
||||
deadEntityIds,
|
||||
},
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds, `${dungeon.id}:${boss.id}`).toContain(boss.id);
|
||||
}
|
||||
|
||||
const checkpoint = resetDungeonEncounterSnapshot(progression, snapshot, "checkpoint", 5_000);
|
||||
expect(checkpoint.completedObjectiveIds, dungeon.id)
|
||||
.toEqual(expect.arrayContaining(runtimeBosses.map((boss) => boss.id)));
|
||||
const completesFromObjectivesAlone = progression.rules.some((rule) => (
|
||||
rule.actions.some((action) => (
|
||||
action.kind === "set-flag" && action.flagId === "dungeon-complete" && action.value
|
||||
))
|
||||
&& (rule.guards ?? []).every((guard) => guard.kind === "objective-complete")
|
||||
));
|
||||
if (!completesFromObjectivesAlone) {
|
||||
// A full source adapter may own completion inside a separate encounter;
|
||||
// its focused lifecycle suite exercises that path.
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false);
|
||||
} else {
|
||||
expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true);
|
||||
expect(snapshot.phase, dungeon.id).toBe("complete");
|
||||
expect(checkpoint.flags["dungeon-complete"], dungeon.id).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("has an instance-level source review for every campaign dungeon", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
const verifiedNoOps: string[] = [];
|
||||
for (const dungeon of wowDungeons) {
|
||||
const reviewed = (dungeon.encounters ?? []).filter(
|
||||
(encounter) => encounter.source?.scope === "instance-orchestration",
|
||||
);
|
||||
expect(reviewed.length, dungeon.id).toBeGreaterThan(0);
|
||||
for (const encounter of reviewed) {
|
||||
expect(encounter.source?.files.length, `${dungeon.id}:${encounter.id}:files`).toBeGreaterThan(0);
|
||||
expect(encounter.source?.reviewedBehavior.trim(), `${dungeon.id}:${encounter.id}:review`).not.toBe("");
|
||||
if (!encounter.rules.length) {
|
||||
expect(encounter.source?.fidelity, `${dungeon.id}:${encounter.id}`).toBe("source-exact");
|
||||
expect(encounter.source?.adaptations, `${dungeon.id}:${encounter.id}`).toEqual([]);
|
||||
expect(encounter.source?.remainingGaps, `${dungeon.id}:${encounter.id}`).toEqual([]);
|
||||
verifiedNoOps.push(dungeon.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
expect(verifiedNoOps.sort()).toEqual([
|
||||
"ragefire-chasm",
|
||||
"razorfen-kraul",
|
||||
"stormwind-stockade",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gates every required event-only objective behind an instance completion authority", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
for (const dungeon of wowDungeons) {
|
||||
const requiredEvents = dungeon.objectiveOrder.filter(
|
||||
(objective) => objective.eventOnly && !objective.optional,
|
||||
);
|
||||
if (!requiredEvents.length) continue;
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.source?.scope === "boss-progression",
|
||||
);
|
||||
const baselineGatedObjectiveIds = new Set((progression?.rules ?? []).flatMap((rule) => (
|
||||
rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
))
|
||||
? (rule.guards ?? []).flatMap((guard) => (
|
||||
guard.kind === "objective-complete" && guard.equals ? [guard.objectiveId] : []
|
||||
))
|
||||
: []
|
||||
)));
|
||||
const completionAuthorities = (dungeon.encounters ?? []).filter((encounter) => (
|
||||
encounter.source?.scope === "instance-orchestration"
|
||||
&& encounter.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
for (const objective of requiredEvents) {
|
||||
expect(
|
||||
baselineGatedObjectiveIds.has(objective.id) || completionAuthorities.length > 0,
|
||||
`${dungeon.id}:${objective.name}`,
|
||||
).toBe(true);
|
||||
if (progression && completionAuthorities.length) {
|
||||
const completionRules = completionAuthorities.flatMap((encounter) => (
|
||||
encounter.rules.filter((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)))
|
||||
));
|
||||
expect(completionRules.length, `${dungeon.id}:completion-authority`).toBeGreaterThan(0);
|
||||
expect(completionRules.every((rule) => (
|
||||
(rule.guards ?? []).some((guard) => (
|
||||
guard.kind === "objective-complete"
|
||||
&& guard.objectiveId === objective.id
|
||||
&& guard.equals
|
||||
))
|
||||
|| rule.actions.some((action) => (
|
||||
action.kind === "complete-objective" && action.objectiveId === objective.id
|
||||
))
|
||||
)), `${dungeon.id}:${objective.name}:premature-completion`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses difficulty guards that match declared dungeon variants", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => (
|
||||
campaign.dungeons.some((entry) => entry.slug === dungeon.id)
|
||||
));
|
||||
for (const dungeon of wowDungeons) {
|
||||
const difficultyIds = new Set(dungeon.difficultyVariants.map((variant) => variant.id));
|
||||
for (const encounter of dungeon.encounters ?? []) {
|
||||
for (const rule of encounter.rules) {
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind !== "difficulty") continue;
|
||||
expect(guard.difficultyIds.length, `${dungeon.id}:${rule.id}`).toBeGreaterThan(0);
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
expect(difficultyIds.has(difficultyId), `${dungeon.id}:${rule.id}:${difficultyId}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("completes ordinary dungeons regardless of required boss kill order", () => {
|
||||
const dungeon = requireDungeonDefinition("deadmines");
|
||||
const progression = dungeon.encounters?.find(
|
||||
(encounter) => encounter.id === "deadmines:boss-progression",
|
||||
);
|
||||
expect(progression).toBeDefined();
|
||||
const required = dungeon.objectiveOrder.filter((objective) => !objective.optional && !objective.eventOnly);
|
||||
expect(required.length).toBeGreaterThan(1);
|
||||
const killOrder = [required.at(-1)!, ...required.slice(0, -1)];
|
||||
const deadEntityIds: string[] = [];
|
||||
let snapshot = createDungeonEncounterSnapshot(progression!, 1_000);
|
||||
for (const boss of killOrder) {
|
||||
deadEntityIds.push(boss.id);
|
||||
snapshot = reduceDungeonEncounter(progression!, snapshot, {
|
||||
kind: "tick",
|
||||
now: 1_000 + deadEntityIds.length * 100,
|
||||
facts: {
|
||||
registeredEntityIds: required.map((objective) => objective.id),
|
||||
deadEntityIds,
|
||||
},
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("compiles every sourced WoW gameobject row with stable source provenance", () => {
|
||||
const wowDungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion !== "runewaker");
|
||||
const objects = wowDungeons.flatMap((dungeon) => dungeon.serverGameObjects);
|
||||
const sourceRows = objects.filter((object) => (
|
||||
object.source?.templateId !== undefined && object.source?.spawnId !== undefined
|
||||
));
|
||||
expect(wowDungeons).toHaveLength(54);
|
||||
expect(sourceRows).toHaveLength(3_943);
|
||||
expect(sourceRows.filter((object) => object.source?.clientDisplayId)).toHaveLength(3_905);
|
||||
expect(sourceRows.filter((object) => object.source?.clientModelPath)).toHaveLength(3_837);
|
||||
expect(sourceRows.filter((object) => object.source?.clientBounds)).toHaveLength(3_547);
|
||||
for (const dungeon of wowDungeons) {
|
||||
expect(new Set(dungeon.serverGameObjects.map((object) => object.id)).size)
|
||||
.toBe(dungeon.serverGameObjects.length);
|
||||
for (const object of dungeon.serverGameObjects) {
|
||||
expect(object.source?.fidelity).toBe("source-backed-adapted");
|
||||
expect(object.source?.files.length).toBeGreaterThan(0);
|
||||
if (object.source?.spawnId !== undefined) {
|
||||
expect(object.source.templateId).toBeGreaterThan(0);
|
||||
expect(object.source.spawnId).toBeGreaterThan(0);
|
||||
expect(object.source.gameObjectType).toBeGreaterThanOrEqual(0);
|
||||
expect(object.source.gameObjectType).toBeLessThanOrEqual(35);
|
||||
}
|
||||
if (object.visual?.hidden) {
|
||||
expect(object.interaction).toBeUndefined();
|
||||
expect(object.states.every((state) => !state.blocksMovement && !state.blocksSight)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes a deduplicated export manifest for source-backed gameobject visuals", () => {
|
||||
expect(gameObjectAssetManifest.summary).toEqual({
|
||||
typedInstances: 3_943,
|
||||
visualDisplayInstances: 3_905,
|
||||
nonVisualInstances: 38,
|
||||
resolvedModelInstances: 3_837,
|
||||
boundedInstances: 3_547,
|
||||
uniqueAssets: 458,
|
||||
unresolvedModelInstances: 68,
|
||||
unresolvedBoundInstances: 358,
|
||||
unresolvedModelDisplayIds: 9,
|
||||
unresolvedBoundDisplayIds: 68,
|
||||
});
|
||||
expect(gameObjectAssetManifest.assets).toHaveLength(458);
|
||||
expect(new Set(gameObjectAssetManifest.assets.map(
|
||||
(asset) => asset.sourceModelPath.toLowerCase(),
|
||||
)).size).toBe(gameObjectAssetManifest.assets.length);
|
||||
expect(gameObjectAssetManifest.assets.every((asset) => (
|
||||
["mdl", "mdx"].includes(asset.sourceType)
|
||||
&& asset.targetUrl.startsWith("/assets/game/world-objects/")
|
||||
&& asset.targetUrl.endsWith(".glb")
|
||||
&& asset.instances > 0
|
||||
&& asset.displayIds.length > 0
|
||||
&& asset.dungeons.length > 0
|
||||
))).toBe(true);
|
||||
expect(gameObjectAssetManifest.assets.reduce((total, asset) => total + asset.instances, 0))
|
||||
.toBe(gameObjectAssetManifest.summary.resolvedModelInstances);
|
||||
expect(gameObjectAssetManifest.nonVisualObjects.reduce(
|
||||
(total, object) => total + object.instances,
|
||||
0,
|
||||
)).toBe(gameObjectAssetManifest.summary.nonVisualInstances);
|
||||
expect(gameObjectAssetManifest.unresolvedModels.map((entry) => entry.displayId)).toEqual([
|
||||
9158, 9159, 9160, 9161, 9162, 9163, 9236, 9467, 9468,
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs Wailing Caverns with the complete server population and authored presentation", () => {
|
||||
const wailing = requireDungeonDefinition("wailing-caverns");
|
||||
const runtimePopulation = wailing.staticSpawns.length
|
||||
@@ -101,9 +417,17 @@ describe("generated dungeon campaign catalog", () => {
|
||||
const sourcePopulation = source.staticSpawns.length
|
||||
+ source.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
|
||||
const runtimePopulation = runtime.staticSpawns.length
|
||||
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0);
|
||||
+ runtime.roamingPacks.reduce((total, pack) => total + pack.members.length, 0)
|
||||
+ new Set((runtime.encounterSpawnGroups ?? [])
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id))
|
||||
.filter((id) => !runtime.staticSpawns.some((spawn) => spawn.id === id))).size;
|
||||
|
||||
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
|
||||
if (source.id === "frozen-reach") {
|
||||
expect(runtime.staticSpawns.some((spawn) => spawn.id.startsWith("generated-trash-"))).toBe(false);
|
||||
expect(runtime.validation.warnings.join(" ")).toMatch(/recovered Alva\/Arktos/i);
|
||||
} else {
|
||||
expect(runtimePopulation, source.id).toBeGreaterThanOrEqual(sourcePopulation);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +437,11 @@ describe("generated dungeon campaign catalog", () => {
|
||||
...dungeon.roamingPacks.filter((pack) => "spawnChance" in pack),
|
||||
]) as unknown as ReadonlyArray<{ readonly spawnChance: number }>;
|
||||
const optionalBosses = generated.definitions.flatMap((dungeon) => (
|
||||
dungeon.objectiveOrder.filter((objective) => "optional" in objective && objective.optional)
|
||||
dungeon.objectiveOrder.filter((objective) => {
|
||||
if (!("optional" in objective) || !objective.optional) return false;
|
||||
const spawn = dungeon.staticSpawns.find((candidate) => candidate.id === objective.id);
|
||||
return Boolean(spawn && "spawnChance" in spawn);
|
||||
})
|
||||
));
|
||||
|
||||
expect(chanceSpawns).toHaveLength(24);
|
||||
@@ -355,6 +683,44 @@ describe("generated dungeon campaign catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("makes every required boss objective available in every declared difficulty", () => {
|
||||
for (const dungeon of generated.definitions) {
|
||||
const spawns = new Map(dungeon.staticSpawns.map((spawn) => [spawn.id, spawn]));
|
||||
for (const boss of dungeon.objectiveOrder) {
|
||||
if (("eventOnly" in boss && boss.eventOnly) || ("optional" in boss && boss.optional)) continue;
|
||||
const spawn = spawns.get(boss.id);
|
||||
expect(spawn, `${dungeon.id}:${boss.name}`).toBeTruthy();
|
||||
for (const difficulty of dungeon.difficultyVariants) {
|
||||
expect(
|
||||
(spawn!.spawnMask & difficulty.spawnMask) !== 0,
|
||||
`${dungeon.id}:${boss.name}:${difficulty.id}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves heroic-only Epoch bosses without gating normal completion", () => {
|
||||
for (const [dungeonId, bossName] of [
|
||||
["the-nexus", "Frozen Commander"],
|
||||
["ahnkahet-old-kingdom", "Amanitar"],
|
||||
["gundrak", "Eck the Ferocious"],
|
||||
] as const) {
|
||||
const dungeon = generated.definitions.find((entry) => entry.id === dungeonId)!;
|
||||
const boss = dungeon.objectiveOrder.find((objective) => objective.name === bossName)!;
|
||||
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
expect("optional" in boss && boss.optional, `${dungeonId}:${bossName}`).toBe(true);
|
||||
expect(spawn.spawnMask, `${dungeonId}:${bossName}`).toBe(2);
|
||||
}
|
||||
|
||||
const blackMorass = generated.definitions.find((dungeon) => dungeon.id === "black-morass")!;
|
||||
for (const boss of blackMorass.objectiveOrder) {
|
||||
const spawn = blackMorass.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
expect("optional" in boss && boss.optional, boss.name).not.toBe(true);
|
||||
expect(spawn.spawnMask, boss.name).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("aligns the Deadmines source population to its packaged interior route", () => {
|
||||
const deadmines = requireDungeonDefinition("deadmines");
|
||||
const firstPack = deadmines.staticSpawns.find(
|
||||
|
||||
@@ -46,6 +46,30 @@ describe("dungeon combat fidelity integration", () => {
|
||||
expect(friendlyResult.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("cycles only living enemy targets that are in range and line of sight", () => {
|
||||
for (const [id, position] of [
|
||||
["blocked-near", [2, 0, 0]],
|
||||
["visible", [4, 0, 0]],
|
||||
["too-far", [41, 0, 0]],
|
||||
] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id });
|
||||
setMobPosition(id, position);
|
||||
}
|
||||
let blockVisibleTarget = false;
|
||||
registerCombatSpatialProvider({
|
||||
actor: (id) => ({ id, position: getMobPosition(id) ?? [0, 0, 0], yaw: 0 }),
|
||||
hasLineOfSight: (_origin, target) => target[0] !== 2 && (!blockVisibleTarget || target[0] !== 4),
|
||||
projectGroundPoint: (point) => point,
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBe("visible");
|
||||
|
||||
blockVisibleTarget = true;
|
||||
expect(useCombatStore.getState().cycleTarget("next", [0, 0, 0], 40)).toBeNull();
|
||||
expect(useCombatStore.getState().selectedTargetId).toBeNull();
|
||||
});
|
||||
|
||||
it("applies only the first two direct-damage pushbacks and ignores fully absorbed hits", () => {
|
||||
registerTarget();
|
||||
expect(useCombatStore.getState().castAbility("mage-frostbolt", { now: 1_000 }).ok).toBe(true);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveDungeonEncounterCombatEvents } from "./dungeonEncounterCombatEvents";
|
||||
|
||||
describe("dungeon encounter combat event bridge", () => {
|
||||
it("emits stable engage, descending health, and death edges once", () => {
|
||||
const engaged = deriveDungeonEncounterCombatEvents({}, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 1_000, maxHealth: 1_000 },
|
||||
}, 1);
|
||||
expect(engaged.events).toEqual([{ kind: "entity-engaged", entityId: "boss", now: 1 }]);
|
||||
|
||||
const damaged = deriveDungeonEncounterCombatEvents(engaged.cursor, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
|
||||
}, 2);
|
||||
expect(damaged.events).toEqual([{
|
||||
kind: "entity-health",
|
||||
entityId: "boss",
|
||||
healthPercent: 49,
|
||||
now: 2,
|
||||
}]);
|
||||
|
||||
const unchanged = deriveDungeonEncounterCombatEvents(damaged.cursor, {
|
||||
boss: { engaged: true, dead: false, despawned: false, health: 490, maxHealth: 1_000 },
|
||||
}, 3);
|
||||
expect(unchanged.events).toEqual([]);
|
||||
|
||||
const dead = deriveDungeonEncounterCombatEvents(unchanged.cursor, {
|
||||
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
|
||||
}, 4);
|
||||
expect(dead.events).toEqual([{ kind: "entity-death", entityId: "boss", now: 4 }]);
|
||||
expect(deriveDungeonEncounterCombatEvents(dead.cursor, {
|
||||
boss: { engaged: false, dead: true, despawned: false, health: 0, maxHealth: 1_000 },
|
||||
}, 5).events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat scripted despawn as death and permits a later respawn edge", () => {
|
||||
const first = deriveDungeonEncounterCombatEvents({}, {
|
||||
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
|
||||
}, 1);
|
||||
const despawned = deriveDungeonEncounterCombatEvents(first.cursor, {
|
||||
add: { engaged: false, dead: false, despawned: true, health: 100, maxHealth: 100 },
|
||||
}, 2);
|
||||
expect(despawned.events).toEqual([]);
|
||||
expect(despawned.cursor).toEqual({});
|
||||
expect(deriveDungeonEncounterCombatEvents(despawned.cursor, {
|
||||
add: { engaged: true, dead: false, despawned: false, health: 100, maxHealth: 100 },
|
||||
}, 3).events).toEqual([{ kind: "entity-engaged", entityId: "add", now: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DungeonEncounterEvent } from "./dungeonEncounterTypes";
|
||||
|
||||
export interface DungeonEncounterMobObservation {
|
||||
readonly engaged: boolean;
|
||||
readonly dead: boolean;
|
||||
readonly despawned: boolean;
|
||||
readonly health: number;
|
||||
readonly maxHealth: number;
|
||||
}
|
||||
|
||||
export interface DungeonEncounterMobCursorEntry {
|
||||
readonly engaged: boolean;
|
||||
readonly dead: boolean;
|
||||
readonly healthPercent: number;
|
||||
}
|
||||
|
||||
export type DungeonEncounterMobCursor = Readonly<Record<string, DungeonEncounterMobCursorEntry>>;
|
||||
|
||||
type CombatEdgeEvent = Extract<
|
||||
DungeonEncounterEvent,
|
||||
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
|
||||
>;
|
||||
|
||||
export interface DungeonEncounterCombatEventBatch {
|
||||
readonly cursor: DungeonEncounterMobCursor;
|
||||
readonly events: readonly CombatEdgeEvent[];
|
||||
}
|
||||
|
||||
function healthPercent(mob: DungeonEncounterMobObservation): number {
|
||||
if (!Number.isFinite(mob.maxHealth) || mob.maxHealth <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, (Math.max(0, mob.health) / mob.maxHealth) * 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts mutable combat-state edges into deterministic encounter events.
|
||||
* Despawned actors are deliberately omitted so scripted cleanup never looks
|
||||
* like a kill and a later respawn of the same stable id can engage normally.
|
||||
*/
|
||||
export function deriveDungeonEncounterCombatEvents(
|
||||
previous: DungeonEncounterMobCursor,
|
||||
mobs: Readonly<Record<string, DungeonEncounterMobObservation>>,
|
||||
now: number,
|
||||
): DungeonEncounterCombatEventBatch {
|
||||
const cursor: Record<string, DungeonEncounterMobCursorEntry> = {};
|
||||
const events: CombatEdgeEvent[] = [];
|
||||
|
||||
for (const entityId of Object.keys(mobs).sort()) {
|
||||
const mob = mobs[entityId];
|
||||
if (mob.despawned) continue;
|
||||
const percent = healthPercent(mob);
|
||||
const prior = previous[entityId];
|
||||
cursor[entityId] = Object.freeze({
|
||||
engaged: mob.engaged,
|
||||
dead: mob.dead,
|
||||
healthPercent: percent,
|
||||
});
|
||||
|
||||
if (mob.engaged && !mob.dead && !prior?.engaged) {
|
||||
events.push(Object.freeze({ kind: "entity-engaged", entityId, now }));
|
||||
}
|
||||
if (!mob.dead && percent < (prior?.healthPercent ?? 100)) {
|
||||
events.push(Object.freeze({ kind: "entity-health", entityId, healthPercent: percent, now }));
|
||||
}
|
||||
if (mob.dead && !prior?.dead) {
|
||||
events.push(Object.freeze({ kind: "entity-death", entityId, now }));
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ cursor: Object.freeze(cursor), events: Object.freeze(events) });
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonRunIsComplete,
|
||||
createDungeonEncounterSnapshot,
|
||||
dispatchActiveDungeonEntityEvent,
|
||||
dispatchActiveDungeonInteraction,
|
||||
dispatchDungeonEncounterSignal,
|
||||
drainDungeonEncounterEffects,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
importDungeonEncounterSnapshots,
|
||||
reduceDungeonEncounter,
|
||||
registerDungeonEncounterEffectHandler,
|
||||
resetDungeonEncounters,
|
||||
resetDungeonEncounterSnapshot,
|
||||
subscribeDungeonEncounterSnapshots,
|
||||
validateDungeonEncounterDefinition,
|
||||
@@ -104,14 +107,32 @@ const MANAGER_B = {
|
||||
{ id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "observe-boss-death",
|
||||
trigger: { kind: "entity-death", entityId: "boss" },
|
||||
actions: [{ id: "mark-boss-dead", kind: "set-flag", flagId: "boss-dead", value: true }],
|
||||
},
|
||||
],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
const MANAGER_DIFFICULTY = {
|
||||
id: "test:manager-difficulty",
|
||||
dungeonId: "manager-difficulty",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "heroic-signal",
|
||||
trigger: { kind: "signal", signalId: "test:heroic" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
|
||||
actions: [{ id: "mark-live-heroic", kind: "set-flag", flagId: "heroic", value: true }],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
afterEach(() => {
|
||||
activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]);
|
||||
useWailingEncounterStore.getState().reset();
|
||||
drainDungeonEncounterEffects("manager-a");
|
||||
drainDungeonEncounterEffects("manager-b");
|
||||
drainDungeonEncounterEffects("manager-difficulty");
|
||||
});
|
||||
|
||||
describe("dungeon encounter reducer", () => {
|
||||
@@ -195,6 +216,57 @@ describe("dungeon encounter reducer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reconstructs persisted state instead of retaining a temporary overwrite on reset", () => {
|
||||
const definition = {
|
||||
id: "test:persistent-reconstruction",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "locked",
|
||||
initialState: { worldObjectStates: { herald: "locked" } },
|
||||
resetPolicy: { bossWipePhase: "ready" },
|
||||
rules: [
|
||||
{
|
||||
id: "unlock-herald",
|
||||
trigger: { kind: "signal", signalId: "unlock" },
|
||||
actions: [{
|
||||
id: "persist-herald-ready",
|
||||
kind: "set-world-object-state",
|
||||
objectId: "herald",
|
||||
state: "ready",
|
||||
persistence: "run",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: "start-attempt",
|
||||
trigger: { kind: "interact", objectId: "herald" },
|
||||
guards: [{ kind: "world-object-state", objectId: "herald", states: ["ready"] }],
|
||||
actions: [{
|
||||
id: "temporarily-activate-herald",
|
||||
kind: "set-world-object-state",
|
||||
objectId: "herald",
|
||||
state: "active",
|
||||
}],
|
||||
},
|
||||
],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const unlocked = reduceDungeonEncounter(
|
||||
definition,
|
||||
createDungeonEncounterSnapshot(definition),
|
||||
{ kind: "signal", signalId: "unlock", now: 1 },
|
||||
).snapshot;
|
||||
const active = reduceDungeonEncounter(
|
||||
definition,
|
||||
unlocked,
|
||||
{ kind: "interact", objectId: "herald", now: 2 },
|
||||
).snapshot;
|
||||
expect(active.worldObjectStates.herald).toBe("active");
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(definition, active, "boss-wipe", 3);
|
||||
expect(reset).toMatchObject({
|
||||
phase: "ready",
|
||||
worldObjectStates: { herald: "ready" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate action ids at definition validation", () => {
|
||||
expect(() => validateDungeonEncounterDefinition({
|
||||
id: "test:invalid",
|
||||
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
|
||||
}],
|
||||
})).toThrow(/duplicate action id same/);
|
||||
});
|
||||
|
||||
it("rejects empty difficulty guards at definition validation", () => {
|
||||
expect(() => validateDungeonEncounterDefinition({
|
||||
id: "test:invalid-difficulty",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "empty-difficulty",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: [] }],
|
||||
actions: [{ id: "should-not-fire", kind: "set-phase", phase: "invalid" }],
|
||||
}],
|
||||
})).toThrow(/empty difficulty guard/);
|
||||
});
|
||||
|
||||
it("evaluates difficulty guards from ephemeral runtime context", () => {
|
||||
const definition = {
|
||||
id: "test:difficulty",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "idle",
|
||||
rules: [{
|
||||
id: "heroic-branch",
|
||||
trigger: { kind: "signal", signalId: "start" },
|
||||
guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }],
|
||||
actions: [{ id: "mark-heroic", kind: "set-flag", flagId: "heroic", value: true }],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const initial = createDungeonEncounterSnapshot(definition);
|
||||
const event = { kind: "signal", signalId: "start", now: 1 } as const;
|
||||
|
||||
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "normal" }).snapshot).toBe(initial);
|
||||
expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "heroic" }).snapshot.flags.heroic).toBe(true);
|
||||
});
|
||||
|
||||
it("never turns an entities-dead safety timeout into boss or dungeon credit", () => {
|
||||
const completion = {
|
||||
id: "test:timeout-completion",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "boss-active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "boss-cleared",
|
||||
trigger: { kind: "entities-dead", entityIds: ["boss"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "complete-timeout-boss", kind: "complete-objective", objectiveId: "boss", persistence: "run" },
|
||||
{ id: "checkpoint-timeout-boss", kind: "set-checkpoint", checkpointId: "boss", persistence: "run" },
|
||||
{ id: "complete-timeout-dungeon", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const initial = createDungeonEncounterSnapshot(completion, 0);
|
||||
const idled = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: ["boss"], deadEntityIds: [] },
|
||||
}).snapshot;
|
||||
const missing = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: [], deadEntityIds: [] },
|
||||
}).snapshot;
|
||||
expect(idled).toBe(initial);
|
||||
expect(missing).toBe(initial);
|
||||
|
||||
const killed = reduceDungeonEncounter(completion, initial, {
|
||||
kind: "tick",
|
||||
now: 1_001,
|
||||
facts: { registeredEntityIds: ["boss"], deadEntityIds: ["boss"] },
|
||||
}).snapshot;
|
||||
expect(killed.completedObjectiveIds).toEqual(["boss"]);
|
||||
expect(killed.flags["dungeon-complete"]).toBe(true);
|
||||
|
||||
const recovery = {
|
||||
id: "test:timeout-recovery",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "wave-active",
|
||||
rules: [{
|
||||
id: "missing-wave-recovery",
|
||||
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "despawn-missing-wave", kind: "deactivate-spawn-group", spawnGroupId: "missing-wave" },
|
||||
{ id: "leave-missing-wave", kind: "set-phase", phase: "recovered" },
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const recovered = reduceDungeonEncounter(
|
||||
recovery,
|
||||
createDungeonEncounterSnapshot(recovery, 0),
|
||||
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
|
||||
).snapshot;
|
||||
expect(recovered.phase).toBe("recovered");
|
||||
|
||||
const surrogateProgress = {
|
||||
id: "test:timeout-surrogate-progress",
|
||||
dungeonId: "test-dungeon",
|
||||
initialPhase: "wave-active",
|
||||
initialState: { flags: { "escort-complete": false } },
|
||||
rules: [{
|
||||
id: "missing-wave-cannot-persist",
|
||||
trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 },
|
||||
actions: [
|
||||
{ id: "leave-missing-persistent-wave", kind: "set-phase", phase: "recovered" },
|
||||
{
|
||||
id: "persist-fake-escort-credit",
|
||||
kind: "set-flag",
|
||||
flagId: "escort-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const surrogateInitial = createDungeonEncounterSnapshot(surrogateProgress, 0);
|
||||
const refused = reduceDungeonEncounter(
|
||||
surrogateProgress,
|
||||
surrogateInitial,
|
||||
{ kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } },
|
||||
).snapshot;
|
||||
expect(refused).toBe(surrogateInitial);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active dungeon encounter manager", () => {
|
||||
it("requires every declared completion authority before finishing a dungeon", () => {
|
||||
const baseline = {
|
||||
id: "test:completion-baseline",
|
||||
dungeonId: "completion-dungeon",
|
||||
initialPhase: "active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "complete-baseline",
|
||||
trigger: { kind: "signal", signalId: "baseline-complete" },
|
||||
actions: [{
|
||||
id: "mark-baseline-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
}],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
const instance = {
|
||||
id: "test:completion-instance",
|
||||
dungeonId: "completion-dungeon",
|
||||
initialPhase: "active",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
rules: [{
|
||||
id: "complete-instance",
|
||||
trigger: { kind: "signal", signalId: "instance-complete" },
|
||||
actions: [{
|
||||
id: "mark-instance-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "dungeon-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
}],
|
||||
}],
|
||||
} as const satisfies DungeonEncounterDefinition;
|
||||
|
||||
activateDungeonEncounterDefinitions("completion-dungeon", [baseline, instance]);
|
||||
expect(activeDungeonRunIsComplete()).toBe(false);
|
||||
dispatchDungeonEncounterSignal("baseline-complete", 1);
|
||||
expect(activeDungeonRunIsComplete()).toBe(false);
|
||||
dispatchDungeonEncounterSignal("instance-complete", 2);
|
||||
expect(activeDungeonRunIsComplete()).toBe(true);
|
||||
});
|
||||
|
||||
it("isolates shared signals to the active dungeon", () => {
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
dispatchDungeonEncounterSignal("test:shared", 1_000);
|
||||
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1);
|
||||
});
|
||||
|
||||
it("broadcasts live combat entity edges to the active dungeon", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
expect(dispatchActiveDungeonEntityEvent({
|
||||
kind: "entity-death",
|
||||
entityId: "boss",
|
||||
now: 2_500,
|
||||
})).toEqual([]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.flags["boss-dead"]).toBe(true);
|
||||
const managerBRevision = dungeonEncounterSnapshot(MANAGER_B.id)?.revision;
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_600 });
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.revision).toBe(managerBRevision);
|
||||
});
|
||||
|
||||
it("updates difficulty context when an existing dungeon provider is reactivated", () => {
|
||||
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "normal");
|
||||
dispatchDungeonEncounterSignal("test:heroic", 1);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).not.toBe(true);
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "heroic");
|
||||
dispatchDungeonEncounterSignal("test:heroic", 2);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).toBe(true);
|
||||
});
|
||||
|
||||
it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
const subscriber = vi.fn();
|
||||
const effectHandler = vi.fn();
|
||||
const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber);
|
||||
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
|
||||
actionId: "switch-cue",
|
||||
signalId: "test:switch-opened",
|
||||
});
|
||||
expect(effectHandler).toHaveBeenCalled();
|
||||
expect(effectHandler).toHaveBeenCalledTimes(3);
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
|
||||
const exported = exportDungeonEncounterSnapshots("manager-b");
|
||||
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
|
||||
expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
|
||||
|
||||
const olderAuthority = {
|
||||
...hydrated,
|
||||
phase: "authority-restored",
|
||||
revision: Math.max(0, hydrated.revision - 1),
|
||||
};
|
||||
expect(importDungeonEncounterSnapshots("manager-b", [olderAuthority])).toEqual([]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored");
|
||||
expect(importDungeonEncounterSnapshots(
|
||||
"manager-b",
|
||||
[olderAuthority],
|
||||
{ authoritative: true },
|
||||
)).toEqual([MANAGER_B.id]);
|
||||
expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("authority-restored");
|
||||
|
||||
unregisterEffect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps effects drainable without a handler and clears queued effects on reset", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 4_000)).toHaveLength(3);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
|
||||
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 4_100)).toHaveLength(3);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards undrained effects when the active dungeon changes", () => {
|
||||
activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]);
|
||||
resetDungeonEncounters("dungeon", "manager-b");
|
||||
expect(dispatchActiveDungeonInteraction("switch", "player", 5_000)).toHaveLength(3);
|
||||
|
||||
activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]);
|
||||
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,15 @@ function unique(values: readonly string[]): readonly string[] {
|
||||
return Object.freeze([...new Set(values)].sort());
|
||||
}
|
||||
|
||||
function orderedUnique(values: readonly string[]): readonly string[] {
|
||||
const seen = new Set<string>();
|
||||
return Object.freeze(values.filter((value) => {
|
||||
if (seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
export function createDungeonEncounterSnapshot(
|
||||
definition: DungeonEncounterDefinition,
|
||||
now = 0,
|
||||
@@ -53,12 +62,37 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
|
||||
validateStableId(definition.id, "Encounter");
|
||||
validateStableId(definition.dungeonId, `Encounter ${definition.id} dungeon`);
|
||||
validateStableId(definition.initialPhase, `Encounter ${definition.id} initial phase`);
|
||||
if (definition.source) {
|
||||
if (!definition.source.files.length || definition.source.files.some((file) => !file.trim())) {
|
||||
throw new Error(`Encounter ${definition.id} source review must name at least one source file`);
|
||||
}
|
||||
if (!definition.source.reviewedBehavior.trim()) {
|
||||
throw new Error(`Encounter ${definition.id} source review must describe the reviewed behavior`);
|
||||
}
|
||||
if (definition.source.fidelity === "source-exact"
|
||||
&& (definition.source.adaptations.length || definition.source.remainingGaps.length)) {
|
||||
throw new Error(`Source-exact encounter ${definition.id} cannot retain adaptations or gaps`);
|
||||
}
|
||||
}
|
||||
const ruleIds = new Set<string>();
|
||||
const actionIds = new Set<string>();
|
||||
for (const rule of definition.rules) {
|
||||
validateStableId(rule.id, `Encounter ${definition.id} rule`);
|
||||
if (ruleIds.has(rule.id)) throw new Error(`Encounter ${definition.id} has duplicate rule id ${rule.id}`);
|
||||
ruleIds.add(rule.id);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind === "difficulty") {
|
||||
if (!guard.difficultyIds.length) {
|
||||
throw new Error(`Encounter ${definition.id} rule ${rule.id} has an empty difficulty guard`);
|
||||
}
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
validateStableId(
|
||||
difficultyId,
|
||||
`Encounter ${definition.id} rule ${rule.id} difficulty`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
validateStableId(action.id, `Encounter ${definition.id} action`);
|
||||
if (actionIds.has(action.id)) {
|
||||
@@ -72,8 +106,19 @@ export function validateDungeonEncounterDefinition(definition: DungeonEncounterD
|
||||
}
|
||||
}
|
||||
|
||||
function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounterGuard): boolean {
|
||||
export interface DungeonEncounterRuntimeContext {
|
||||
readonly difficultyId?: string;
|
||||
}
|
||||
|
||||
function guardMatches(
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
guard: DungeonEncounterGuard,
|
||||
context: DungeonEncounterRuntimeContext,
|
||||
): boolean {
|
||||
if (guard.kind === "phase") return guard.phases.includes(snapshot.phase);
|
||||
if (guard.kind === "difficulty") {
|
||||
return context.difficultyId !== undefined && guard.difficultyIds.includes(context.difficultyId);
|
||||
}
|
||||
if (guard.kind === "flag") return (snapshot.flags[guard.flagId] ?? false) === guard.equals;
|
||||
if (guard.kind === "objective-complete") {
|
||||
return snapshot.completedObjectiveIds.includes(guard.objectiveId) === guard.equals;
|
||||
@@ -90,33 +135,55 @@ function guardMatches(snapshot: DungeonEncounterSnapshot, guard: DungeonEncounte
|
||||
return current <= guard.value;
|
||||
}
|
||||
|
||||
type DungeonEncounterTriggerMatch = "event" | "timeout" | false;
|
||||
|
||||
function triggerMatches(
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
trigger: DungeonEncounterRule["trigger"],
|
||||
event: DungeonEncounterEvent,
|
||||
): boolean {
|
||||
if (trigger.kind === "tick") return event.kind === "tick";
|
||||
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId;
|
||||
): DungeonEncounterTriggerMatch {
|
||||
if (trigger.kind === "tick") return event.kind === "tick" ? "event" : false;
|
||||
if (trigger.kind === "signal") return event.kind === "signal" && event.signalId === trigger.signalId ? "event" : false;
|
||||
if (trigger.kind === "entity-engaged" || trigger.kind === "entity-death") {
|
||||
return event.kind === trigger.kind && event.entityId === trigger.entityId;
|
||||
return event.kind === trigger.kind && event.entityId === trigger.entityId ? "event" : false;
|
||||
}
|
||||
if (trigger.kind === "entity-health") {
|
||||
return event.kind === "entity-health"
|
||||
&& event.entityId === trigger.entityId
|
||||
&& event.healthPercent <= trigger.atOrBelowPercent;
|
||||
&& event.healthPercent <= trigger.atOrBelowPercent
|
||||
? "event"
|
||||
: false;
|
||||
}
|
||||
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId;
|
||||
if (trigger.kind === "interact") return event.kind === "interact" && event.objectId === trigger.objectId ? "event" : false;
|
||||
if (trigger.kind === "elapsed") {
|
||||
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs;
|
||||
return event.kind === "tick" && event.now - snapshot.phaseStartedAt >= trigger.afterMs ? "event" : false;
|
||||
}
|
||||
if (event.kind !== "tick") return false;
|
||||
const registered = new Set(event.facts?.registeredEntityIds ?? []);
|
||||
const dead = new Set(event.facts?.deadEntityIds ?? []);
|
||||
const allDead = trigger.entityIds.length > 0
|
||||
&& trigger.entityIds.every((id) => registered.has(id) && dead.has(id));
|
||||
if (allDead) return "event";
|
||||
const hasAuthoritativeFacts = event.facts?.registeredEntityIds !== undefined
|
||||
&& event.facts.deadEntityIds !== undefined;
|
||||
const hasRegisteredLivingTarget = trigger.entityIds.some((id) => (
|
||||
registered.has(id) && !dead.has(id)
|
||||
));
|
||||
const timedOut = trigger.timeoutMs !== undefined
|
||||
&& hasAuthoritativeFacts
|
||||
&& !hasRegisteredLivingTarget
|
||||
&& event.now - snapshot.phaseStartedAt >= trigger.timeoutMs;
|
||||
return allDead || timedOut;
|
||||
return timedOut ? "timeout" : false;
|
||||
}
|
||||
|
||||
function ruleCanAdvanceOnMissingEntityTimeout(rule: DungeonEncounterRule): boolean {
|
||||
return !rule.actions.some((action) => (
|
||||
action.persistence === "checkpoint"
|
||||
|| action.persistence === "run"
|
||||
|| action.kind === "complete-objective"
|
||||
|| action.kind === "set-checkpoint"
|
||||
|| action.kind === "grant-loot"
|
||||
));
|
||||
}
|
||||
|
||||
function ruleAlreadyConsumed(snapshot: DungeonEncounterSnapshot, rule: DungeonEncounterRule): boolean {
|
||||
@@ -193,7 +260,7 @@ function applyAction(
|
||||
}
|
||||
return {
|
||||
snapshot: consume
|
||||
? { ...next, consumedActionIds: unique([...next.consumedActionIds, action.id]) }
|
||||
? { ...next, consumedActionIds: orderedUnique([...next.consumedActionIds, action.id]) }
|
||||
: next,
|
||||
effect: actionEffect(action),
|
||||
};
|
||||
@@ -203,6 +270,7 @@ export function reduceDungeonEncounter(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
context: DungeonEncounterRuntimeContext = {},
|
||||
): DungeonEncounterReduction {
|
||||
if (snapshot.encounterId !== definition.id) {
|
||||
throw new Error(`Snapshot ${snapshot.encounterId} does not belong to encounter ${definition.id}`);
|
||||
@@ -215,8 +283,10 @@ export function reduceDungeonEncounter(
|
||||
.sort((left, right) => (right.rule.priority ?? 0) - (left.rule.priority ?? 0) || left.index - right.index);
|
||||
for (const { rule } of rules) {
|
||||
if (ruleAlreadyConsumed(next, rule)) continue;
|
||||
if (!triggerMatches(next, rule.trigger, event)) continue;
|
||||
if (!rule.guards?.every((guard) => guardMatches(next, guard)) && rule.guards?.length) continue;
|
||||
const triggerMatch = triggerMatches(next, rule.trigger, event);
|
||||
if (!triggerMatch) continue;
|
||||
if (triggerMatch === "timeout" && !ruleCanAdvanceOnMissingEntityTimeout(rule)) continue;
|
||||
if (!rule.guards?.every((guard) => guardMatches(next, guard, context)) && rule.guards?.length) continue;
|
||||
let fired = false;
|
||||
for (const action of rule.actions) {
|
||||
const applied = applyAction(next, action, event.now, rule.once ?? true);
|
||||
@@ -246,44 +316,54 @@ function retainedActionIds(
|
||||
return Object.freeze(snapshot.consumedActionIds.filter((id) => retained.has(id)));
|
||||
}
|
||||
|
||||
function retainedStateKeys(definition: DungeonEncounterDefinition): {
|
||||
readonly flags: ReadonlySet<string>;
|
||||
readonly counters: ReadonlySet<string>;
|
||||
readonly worldObjects: ReadonlySet<string>;
|
||||
readonly transitions: ReadonlySet<string>;
|
||||
readonly objectives: ReadonlySet<string>;
|
||||
readonly checkpoints: boolean;
|
||||
function reconstructedPersistentState(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
initial: DungeonEncounterSnapshot,
|
||||
): {
|
||||
readonly flags: Readonly<Record<string, boolean>>;
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
readonly worldObjectStates: Readonly<Record<string, string>>;
|
||||
readonly transitionStates: Readonly<Record<string, boolean>>;
|
||||
readonly completedObjectiveIds: readonly string[];
|
||||
readonly checkpointId: string | null;
|
||||
} {
|
||||
const actions = definition.rules.flatMap((rule) => rule.actions)
|
||||
const actionsById = new Map(definition.rules
|
||||
.flatMap((rule) => rule.actions)
|
||||
.map((action) => [action.id, action] as const));
|
||||
const actions = snapshot.consumedActionIds
|
||||
.map((id) => actionsById.get(id))
|
||||
.filter((action): action is DungeonEncounterAction => action !== undefined)
|
||||
.filter((action) => action.persistence === "checkpoint" || action.persistence === "run");
|
||||
return {
|
||||
flags: new Set(actions.filter((action) => action.kind === "set-flag").map((action) => action.flagId)),
|
||||
counters: new Set(actions
|
||||
.filter((action) => action.kind === "set-counter" || action.kind === "increment-counter")
|
||||
.map((action) => action.counterId)),
|
||||
worldObjects: new Set(actions
|
||||
.filter((action) => action.kind === "set-world-object-state")
|
||||
.map((action) => action.objectId)),
|
||||
transitions: new Set(actions
|
||||
.filter((action) => action.kind === "set-transition-active")
|
||||
.map((action) => action.transitionId)),
|
||||
objectives: new Set(actions
|
||||
.filter((action) => action.kind === "complete-objective")
|
||||
.map((action) => action.objectiveId)),
|
||||
checkpoints: actions.some((action) => action.kind === "set-checkpoint"),
|
||||
};
|
||||
}
|
||||
|
||||
function retainedRecord<T>(
|
||||
initial: Readonly<Record<string, T>>,
|
||||
current: Readonly<Record<string, T>>,
|
||||
keys: ReadonlySet<string>,
|
||||
): Readonly<Record<string, T>> {
|
||||
const retained: Record<string, T> = { ...initial };
|
||||
for (const key of keys) {
|
||||
if (current[key] !== undefined) retained[key] = current[key];
|
||||
const flags = { ...initial.flags };
|
||||
const counters = { ...initial.counters };
|
||||
const worldObjectStates = { ...initial.worldObjectStates };
|
||||
const transitionStates = { ...initial.transitionStates };
|
||||
const completedObjectiveIds = new Set<string>();
|
||||
let checkpointId: string | null = null;
|
||||
for (const action of actions) {
|
||||
if (action.kind === "set-flag") flags[action.flagId] = action.value;
|
||||
else if (action.kind === "set-counter") counters[action.counterId] = action.value;
|
||||
else if (action.kind === "increment-counter") {
|
||||
counters[action.counterId] = (counters[action.counterId] ?? 0) + (action.amount ?? 1);
|
||||
} else if (action.kind === "set-world-object-state") {
|
||||
worldObjectStates[action.objectId] = action.state;
|
||||
} else if (action.kind === "set-transition-active") {
|
||||
transitionStates[action.transitionId] = action.active;
|
||||
} else if (action.kind === "complete-objective") {
|
||||
completedObjectiveIds.add(action.objectiveId);
|
||||
} else if (action.kind === "set-checkpoint") {
|
||||
checkpointId = action.checkpointId;
|
||||
}
|
||||
}
|
||||
return Object.freeze(retained);
|
||||
return {
|
||||
flags: Object.freeze(flags),
|
||||
counters: Object.freeze(counters),
|
||||
worldObjectStates: Object.freeze(worldObjectStates),
|
||||
transitionStates: Object.freeze(transitionStates),
|
||||
completedObjectiveIds: Object.freeze([...completedObjectiveIds].sort()),
|
||||
checkpointId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetDungeonEncounterSnapshot(
|
||||
@@ -297,27 +377,18 @@ export function resetDungeonEncounterSnapshot(
|
||||
const phase = scope === "boss-wipe"
|
||||
? definition.resetPolicy?.bossWipePhase ?? definition.initialPhase
|
||||
: definition.resetPolicy?.checkpointPhase ?? definition.initialPhase;
|
||||
const retained = retainedStateKeys(definition);
|
||||
const retained = reconstructedPersistentState(definition, snapshot, initial);
|
||||
return Object.freeze({
|
||||
...initial,
|
||||
phase,
|
||||
phaseStartedAt: finiteTime(now),
|
||||
flags: retainedRecord(initial.flags, snapshot.flags, retained.flags),
|
||||
counters: retainedRecord(initial.counters, snapshot.counters, retained.counters),
|
||||
worldObjectStates: retainedRecord(
|
||||
initial.worldObjectStates,
|
||||
snapshot.worldObjectStates,
|
||||
retained.worldObjects,
|
||||
),
|
||||
transitionStates: retainedRecord(
|
||||
initial.transitionStates,
|
||||
snapshot.transitionStates,
|
||||
retained.transitions,
|
||||
),
|
||||
completedObjectiveIds: Object.freeze(snapshot.completedObjectiveIds
|
||||
.filter((id) => retained.objectives.has(id))),
|
||||
flags: retained.flags,
|
||||
counters: retained.counters,
|
||||
worldObjectStates: retained.worldObjectStates,
|
||||
transitionStates: retained.transitionStates,
|
||||
completedObjectiveIds: retained.completedObjectiveIds,
|
||||
consumedActionIds: retainedActionIds(definition, snapshot, scope),
|
||||
checkpointId: retained.checkpoints ? snapshot.checkpointId : null,
|
||||
checkpointId: retained.checkpointId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,6 +401,8 @@ export interface DungeonEncounterProvider {
|
||||
readonly reset: (scope: DungeonEncounterResetScope, now?: number) => void;
|
||||
/** Hydrates authoritative online/save state without involving renderer state. */
|
||||
readonly replaceSnapshot?: (snapshot: DungeonEncounterSnapshot) => void;
|
||||
/** Updates ephemeral run context without polluting serialized snapshots. */
|
||||
readonly setContext?: (context: DungeonEncounterRuntimeContext) => void;
|
||||
}
|
||||
|
||||
const providers = new Map<string, DungeonEncounterProvider>();
|
||||
@@ -337,6 +410,14 @@ const pendingEffects: DungeonEncounterEffectEnvelope[] = [];
|
||||
const effectHandlers = new Set<(value: DungeonEncounterEffectEnvelope) => void>();
|
||||
let activeDungeonId: string | null = null;
|
||||
|
||||
function clearPendingDungeonEncounterEffects(dungeonId?: string): void {
|
||||
for (let index = pendingEffects.length - 1; index >= 0; index -= 1) {
|
||||
if (!dungeonId || pendingEffects[index].dungeonId === dungeonId) {
|
||||
pendingEffects.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueProviderEffects(
|
||||
provider: DungeonEncounterProvider,
|
||||
effects: readonly DungeonEncounterEffect[],
|
||||
@@ -347,8 +428,11 @@ function queueProviderEffects(
|
||||
encounterId: provider.encounterId,
|
||||
effect,
|
||||
});
|
||||
pendingEffects.push(value);
|
||||
for (const handler of effectHandlers) handler(value);
|
||||
if (effectHandlers.size) {
|
||||
for (const handler of effectHandlers) handler(value);
|
||||
} else {
|
||||
pendingEffects.push(value);
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
@@ -416,6 +500,22 @@ export function dispatchActiveDungeonInteraction(
|
||||
return Object.freeze(effects);
|
||||
}
|
||||
|
||||
export function dispatchActiveDungeonEntityEvent(
|
||||
event: Extract<
|
||||
DungeonEncounterEvent,
|
||||
{ readonly kind: "entity-engaged" | "entity-health" | "entity-death" }
|
||||
>,
|
||||
): readonly DungeonEncounterEffect[] {
|
||||
if (!activeDungeonId) return [];
|
||||
const effects: DungeonEncounterEffect[] = [];
|
||||
for (const provider of providers.values()) {
|
||||
if (provider.dungeonId !== activeDungeonId) continue;
|
||||
effects.push(...queueProviderEffects(provider, provider.dispatch(event)));
|
||||
}
|
||||
notifyEncounterSubscribers();
|
||||
return Object.freeze(effects);
|
||||
}
|
||||
|
||||
export function advanceDungeonEncounters(
|
||||
dungeonId: string,
|
||||
event: Extract<DungeonEncounterEvent, { readonly kind: "tick" }>,
|
||||
@@ -435,6 +535,7 @@ export function resetDungeonEncounters(
|
||||
dungeonId?: string,
|
||||
now = 0,
|
||||
): void {
|
||||
clearPendingDungeonEncounterEffects(dungeonId);
|
||||
for (const provider of providers.values()) {
|
||||
if (!dungeonId || provider.dungeonId === dungeonId) provider.reset(scope, now);
|
||||
}
|
||||
@@ -449,7 +550,11 @@ export function resetDungeonEncounters(
|
||||
export function activateDungeonEncounterDefinitions(
|
||||
dungeonId: string,
|
||||
definitions: readonly DungeonEncounterDefinition[],
|
||||
difficultyId?: string,
|
||||
): void {
|
||||
if (activeDungeonId && activeDungeonId !== dungeonId) {
|
||||
clearPendingDungeonEncounterEffects(activeDungeonId);
|
||||
}
|
||||
activeDungeonId = dungeonId;
|
||||
for (const definition of definitions) {
|
||||
validateDungeonEncounterDefinition(definition);
|
||||
@@ -459,16 +564,18 @@ export function activateDungeonEncounterDefinitions(
|
||||
const existing = providers.get(definition.id);
|
||||
if (existing) {
|
||||
if (existing.dungeonId !== dungeonId) throw new Error(`Encounter ${definition.id} has conflicting dungeon owners`);
|
||||
existing.setContext?.({ difficultyId });
|
||||
continue;
|
||||
}
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
let context: DungeonEncounterRuntimeContext = { difficultyId };
|
||||
const provider: DungeonEncounterProvider = {
|
||||
definition,
|
||||
encounterId: definition.id,
|
||||
dungeonId,
|
||||
getSnapshot: () => snapshot,
|
||||
dispatch: (event) => {
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event);
|
||||
const result = reduceDungeonEncounter(definition, snapshot, event, context);
|
||||
snapshot = result.snapshot;
|
||||
return result.effects;
|
||||
},
|
||||
@@ -481,6 +588,9 @@ export function activateDungeonEncounterDefinitions(
|
||||
}
|
||||
snapshot = cloneEncounterSnapshot(value);
|
||||
},
|
||||
setContext: (value) => {
|
||||
context = Object.freeze({ ...value });
|
||||
},
|
||||
};
|
||||
providers.set(definition.id, provider);
|
||||
}
|
||||
@@ -501,7 +611,7 @@ function cloneEncounterSnapshot(value: DungeonEncounterSnapshot): DungeonEncount
|
||||
transitionStates: Object.freeze({ ...value.transitionStates }),
|
||||
activeSpawnGroupIds: unique(value.activeSpawnGroupIds),
|
||||
completedObjectiveIds: unique(value.completedObjectiveIds),
|
||||
consumedActionIds: unique(value.consumedActionIds),
|
||||
consumedActionIds: orderedUnique(value.consumedActionIds),
|
||||
revision: Math.max(0, Math.trunc(value.revision)),
|
||||
});
|
||||
}
|
||||
@@ -542,13 +652,14 @@ export function exportDungeonEncounterCheckpointSnapshots(
|
||||
export function importDungeonEncounterSnapshots(
|
||||
dungeonId: string,
|
||||
snapshots: readonly DungeonEncounterSnapshot[],
|
||||
options: { readonly authoritative?: boolean } = {},
|
||||
): readonly string[] {
|
||||
const imported: string[] = [];
|
||||
for (const raw of snapshots) {
|
||||
const provider = providers.get(raw.encounterId);
|
||||
if (!provider || provider.dungeonId !== dungeonId || !provider.replaceSnapshot) continue;
|
||||
const snapshot = cloneEncounterSnapshot(raw);
|
||||
if (snapshot.revision < provider.getSnapshot().revision) continue;
|
||||
if (!options.authoritative && snapshot.revision < provider.getSnapshot().revision) continue;
|
||||
provider.replaceSnapshot(snapshot);
|
||||
imported.push(snapshot.encounterId);
|
||||
}
|
||||
@@ -567,7 +678,7 @@ export function drainDungeonEncounterEffects(
|
||||
return Object.freeze(drained);
|
||||
}
|
||||
|
||||
/** Immediate application hook; effects also remain drainable for frame-bound consumers. */
|
||||
/** Immediate application hook. While one is mounted, effects bypass the headless drain queue. */
|
||||
export function registerDungeonEncounterEffectHandler(
|
||||
handler: (value: DungeonEncounterEffectEnvelope) => void,
|
||||
): () => void {
|
||||
@@ -598,3 +709,28 @@ export function activeDungeonEncounterSpawnGroupIds(): readonly string[] {
|
||||
return Object.freeze([...new Set(exportDungeonEncounterSnapshots()
|
||||
.flatMap((snapshot) => snapshot.activeSpawnGroupIds))].sort());
|
||||
}
|
||||
|
||||
export function activeDungeonCompletedObjectiveIds(dungeonId = activeDungeonId): readonly string[] {
|
||||
return unique(exportDungeonEncounterSnapshots(dungeonId)
|
||||
.flatMap((snapshot) => snapshot.completedObjectiveIds));
|
||||
}
|
||||
|
||||
function definitionControlsDungeonCompletion(definition: DungeonEncounterDefinition): boolean {
|
||||
return definition.rules.some((rule) => rule.actions.some((action) => (
|
||||
action.kind === "set-flag"
|
||||
&& action.flagId === "dungeon-complete"
|
||||
&& action.value
|
||||
)));
|
||||
}
|
||||
|
||||
export function activeDungeonRunIsComplete(dungeonId = activeDungeonId): boolean {
|
||||
if (!dungeonId) return false;
|
||||
const completionProviders = [...providers.values()].filter((provider) => (
|
||||
provider.dungeonId === dungeonId
|
||||
&& definitionControlsDungeonCompletion(provider.definition)
|
||||
));
|
||||
return completionProviders.length > 0
|
||||
&& completionProviders.every(
|
||||
(provider) => provider.getSnapshot().flags["dungeon-complete"] === true,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
|
||||
import {
|
||||
activeEncounterSpawnGroupSpawns,
|
||||
suppressCompletedDungeonPopulation,
|
||||
} from "./dungeonEncounterSpawns";
|
||||
|
||||
const spawn = (id: string, spawnMask: number) => ({
|
||||
id,
|
||||
@@ -20,4 +23,24 @@ describe("encounter spawn groups", () => {
|
||||
{ id: "wave-a", spawns: [spawn("one", 1), spawn("two", 2)] },
|
||||
], ["wave-a"], 2).map((entry) => entry.id)).toEqual(["two"]);
|
||||
});
|
||||
|
||||
it("suppresses completed static and roaming boss runtime ids on checkpoint reload", () => {
|
||||
const result = suppressCompletedDungeonPopulation(
|
||||
[spawn("static-boss", 1), spawn("trash", 1)],
|
||||
[{
|
||||
id: "boss-patrol",
|
||||
name: "Boss Patrol",
|
||||
speed: 1,
|
||||
formationSource: "database-solo",
|
||||
waypoints: [[0, 0, 0], [1, 0, 0]],
|
||||
members: [
|
||||
{ id: "boss", entityId: "test-mob", role: "leader", formationOffset: [0, 0, 0] },
|
||||
{ id: "escort", entityId: "test-mob", role: "follower", formationOffset: [1, 0, 0] },
|
||||
],
|
||||
}],
|
||||
["static-boss", "boss-patrol:boss"],
|
||||
);
|
||||
expect(result.staticSpawns.map((entry) => entry.id)).toEqual(["trash"]);
|
||||
expect(result.roamingPacks[0].members.map((member) => member.id)).toEqual(["escort"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { spawnMatchesDifficulty, type StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
import {
|
||||
spawnMatchesDifficulty,
|
||||
type MobPackDefinition,
|
||||
type StaticMobSpawnDefinition,
|
||||
} from "./mobPopulation";
|
||||
import type { DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
|
||||
export function activeEncounterSpawnGroupSpawns(
|
||||
@@ -20,3 +24,24 @@ export function activeEncounterSpawnGroupSpawns(
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function suppressCompletedDungeonPopulation(
|
||||
staticSpawns: readonly StaticMobSpawnDefinition[],
|
||||
roamingPacks: readonly MobPackDefinition[],
|
||||
completedObjectiveIds: readonly string[],
|
||||
): {
|
||||
readonly staticSpawns: readonly StaticMobSpawnDefinition[];
|
||||
readonly roamingPacks: readonly MobPackDefinition[];
|
||||
} {
|
||||
if (!completedObjectiveIds.length) return { staticSpawns, roamingPacks };
|
||||
const completed = new Set(completedObjectiveIds);
|
||||
return {
|
||||
staticSpawns: staticSpawns.filter((spawn) => !completed.has(spawn.id)),
|
||||
roamingPacks: roamingPacks.flatMap((pack) => {
|
||||
const members = pack.members.filter(
|
||||
(member) => !completed.has(`${pack.id}:${member.id}`),
|
||||
);
|
||||
return members.length ? [{ ...pack, members }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Vector3Tuple } from "./dungeonTypes";
|
||||
|
||||
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 1;
|
||||
// Version 2 preserves consumed action execution order so durable state can be
|
||||
// reconstructed without reviving attempt-scoped object/flag overwrites.
|
||||
export const DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION = 2;
|
||||
|
||||
export type DungeonEncounterResetScope = "boss-wipe" | "checkpoint" | "dungeon";
|
||||
export type DungeonEncounterValue = string | number | boolean;
|
||||
@@ -52,12 +54,17 @@ export type DungeonEncounterTrigger =
|
||||
| {
|
||||
readonly kind: "entities-dead";
|
||||
readonly entityIds: readonly string[];
|
||||
/** A source-authored safety timeout prevents an encounter from soft locking. */
|
||||
/**
|
||||
* Missing-actor recovery window. It may advance presentation-only rules
|
||||
* when no listed target is registered alive, but can never award an
|
||||
* objective, checkpoint, loot, or dungeon completion.
|
||||
*/
|
||||
readonly timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type DungeonEncounterGuard =
|
||||
| { readonly kind: "phase"; readonly phases: readonly string[] }
|
||||
| { readonly kind: "difficulty"; readonly difficultyIds: readonly string[] }
|
||||
| { readonly kind: "flag"; readonly flagId: string; readonly equals: boolean }
|
||||
| {
|
||||
readonly kind: "counter";
|
||||
@@ -116,12 +123,34 @@ export interface DungeonEncounterResetPolicy {
|
||||
readonly checkpointPhase?: string;
|
||||
}
|
||||
|
||||
export type DungeonEncounterFidelity =
|
||||
| "source-exact"
|
||||
| "source-backed-adapted"
|
||||
| "healer-man-authored";
|
||||
|
||||
export type DungeonEncounterScope = "boss-progression" | "instance-orchestration";
|
||||
|
||||
/**
|
||||
* Review record kept beside executable encounter data. Adapted behavior must
|
||||
* remain explicit so a safe approximation is never mistaken for a finished
|
||||
* port of the original instance script.
|
||||
*/
|
||||
export interface DungeonEncounterSourceReview {
|
||||
readonly fidelity: DungeonEncounterFidelity;
|
||||
readonly scope: DungeonEncounterScope;
|
||||
readonly files: readonly string[];
|
||||
readonly reviewedBehavior: string;
|
||||
readonly adaptations: readonly string[];
|
||||
readonly remainingGaps: readonly string[];
|
||||
}
|
||||
|
||||
export interface DungeonEncounterDefinition {
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly initialPhase: string;
|
||||
readonly initialState?: DungeonEncounterInitialState;
|
||||
readonly resetPolicy?: DungeonEncounterResetPolicy;
|
||||
readonly source?: DungeonEncounterSourceReview;
|
||||
readonly rules: readonly DungeonEncounterRule[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@ import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { DEFAULT_DUNGEON_ID as DEFAULT_DUNGEON_KEY } from "./dungeonDefaults";
|
||||
import { withRunewakerEncounterContent } from "./runewakerEncounterCatalog";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import { withAscensionCustomEncounterContent } from "./ascensionCustomEncounterCatalog";
|
||||
import { withWowWrathEncounterContent } from "./wowWrathEncounterContentCatalog";
|
||||
import { withWowClassicEncounterContent } from "./wowClassicEncounterContentCatalog";
|
||||
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
|
||||
import { withWowWrathEncounterBatchTwoContent } from "./wowWrathEncounterBatchTwoCatalog";
|
||||
import { withWowWrathEncounterBatchThreeContent } from "./wowWrathEncounterBatchThreeCatalog";
|
||||
import { withWowVerifiedNoOpInstanceContent } from "./wowVerifiedNoOpInstanceCatalog";
|
||||
import { withWowClassicEncounterBatchTwo } from "./wowClassicEncounterBatchTwoCatalog";
|
||||
import { withWowClassicEncounterBatchThree } from "./wowClassicEncounterBatchThreeCatalog";
|
||||
import { withWowTbcEncounterBatchTwoContent } from "./wowTbcEncounterBatchTwoCatalog";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -60,6 +72,45 @@ function enrichAuthoredDefinition(
|
||||
.filter(([, entity]) => entity.kind === "boss")
|
||||
.map(([id]) => id),
|
||||
);
|
||||
const authoredObjectiveByName = new Map(
|
||||
authored.objectiveOrder.map((objective) => [normalizedName(objective.name), objective.id]),
|
||||
);
|
||||
const generatedObjectiveAliases = new Map(
|
||||
generated.objectiveOrder.map((objective) => [
|
||||
objective.id,
|
||||
authoredObjectiveByName.get(normalizedName(objective.name)) ?? objective.id,
|
||||
]),
|
||||
);
|
||||
const authoredId = (id: string): string => generatedObjectiveAliases.get(id) ?? id;
|
||||
const remappedGeneratedEncounters = (generated.encounters ?? []).map((encounter) => (
|
||||
encounter.source?.scope !== "boss-progression"
|
||||
? encounter
|
||||
: {
|
||||
...encounter,
|
||||
rules: encounter.rules.map((rule) => ({
|
||||
...rule,
|
||||
trigger: rule.trigger.kind === "entities-dead"
|
||||
? { ...rule.trigger, entityIds: rule.trigger.entityIds.map(authoredId) }
|
||||
: rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged"
|
||||
? { ...rule.trigger, entityId: authoredId(rule.trigger.entityId) }
|
||||
: rule.trigger,
|
||||
guards: rule.guards?.map((guard) => (
|
||||
guard.kind === "objective-complete"
|
||||
? { ...guard, objectiveId: authoredId(guard.objectiveId) }
|
||||
: guard
|
||||
)),
|
||||
actions: rule.actions.map((action) => {
|
||||
if (action.kind === "complete-objective") {
|
||||
return { ...action, objectiveId: authoredId(action.objectiveId) };
|
||||
}
|
||||
if (action.kind === "set-checkpoint") {
|
||||
return { ...action, checkpointId: authoredId(action.checkpointId) };
|
||||
}
|
||||
return action;
|
||||
}),
|
||||
})),
|
||||
}
|
||||
));
|
||||
return {
|
||||
...authored,
|
||||
entities: {
|
||||
@@ -76,6 +127,15 @@ function enrichAuthoredDefinition(
|
||||
pack.members.some((member) => authoredBossEntityIds.has(member.entityId))
|
||||
)),
|
||||
],
|
||||
encounters: [
|
||||
...remappedGeneratedEncounters,
|
||||
...(authored.encounters ?? []),
|
||||
],
|
||||
encounterSpawnGroups: [
|
||||
...(generated.encounterSpawnGroups ?? []),
|
||||
...(authored.encounterSpawnGroups ?? []),
|
||||
],
|
||||
serverGameObjects: generated.serverGameObjects,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +148,31 @@ if (authoredWailingCaverns.id !== DEFAULT_DUNGEON_KEY) {
|
||||
throw new Error(`Default dungeon id mismatch: ${authoredWailingCaverns.id}`);
|
||||
}
|
||||
|
||||
const DUNGEON_CONTENT_ADAPTERS: readonly (
|
||||
(definition: DungeonDefinition) => DungeonDefinition
|
||||
)[] = Object.freeze([
|
||||
withWowEncounterContent,
|
||||
withWowClassicEncounterContent,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
withWowClassicEncounterBatchThree,
|
||||
withWowVerifiedNoOpInstanceContent,
|
||||
withWowWrathEncounterContent,
|
||||
withWowWrathEncounterBatchTwoContent,
|
||||
withWowWrathEncounterBatchThreeContent,
|
||||
withWowProgressionObjectContent,
|
||||
withWowTbcEncounterContent,
|
||||
withWowTbcEncounterBatchTwoContent,
|
||||
withAscensionCustomEncounterContent,
|
||||
withRunewakerEncounterContent,
|
||||
]);
|
||||
|
||||
function applyDungeonContentAdapters(definition: DungeonDefinition): DungeonDefinition {
|
||||
return DUNGEON_CONTENT_ADAPTERS.reduce(
|
||||
(current, applyContent) => applyContent(current),
|
||||
definition,
|
||||
);
|
||||
}
|
||||
|
||||
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||
[authoredWailingCaverns.id, authoredWailingCaverns],
|
||||
[FORSAKEN_ABBEY_DEFINITION.id, FORSAKEN_ABBEY_DEFINITION],
|
||||
@@ -99,7 +184,10 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
|
||||
|
||||
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
||||
Object.fromEntries(dungeonRegistryEntries.map(
|
||||
([id, definition]) => [id, withRunewakerEncounterContent(definition)] as const,
|
||||
([id, definition]) => [
|
||||
id,
|
||||
applyDungeonContentAdapters(definition),
|
||||
] as const,
|
||||
)),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,4 +40,17 @@ describe("dungeon run checkpoints", () => {
|
||||
savedAt: 1,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed on legacy checkpoints whose sorted action history cannot be reconstructed", () => {
|
||||
const currentSnapshot = createDungeonEncounterSnapshot(definition);
|
||||
expect(normalizeDungeonRunCheckpoint({
|
||||
version: 1,
|
||||
dungeonId: "test",
|
||||
difficultyId: "normal",
|
||||
checkpointId: null,
|
||||
spawn: { footPosition: [1, 2, 3], yaw: 0 },
|
||||
encounters: [{ ...currentSnapshot, schemaVersion: 1 }],
|
||||
savedAt: 1,
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@ import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_ENCOUNTER_SNAPSHOT_SCHEMA_VERSION } from "./dungeonEncounterTypes";
|
||||
import type { Vector3Tuple, WorldSpawn } from "./dungeonTypes";
|
||||
|
||||
export const DUNGEON_RUN_CHECKPOINT_VERSION = 1;
|
||||
// Version 1 snapshots sorted consumed action ids and cannot be migrated
|
||||
// reliably when multiple durable actions write the same state slot. Failing
|
||||
// those active-run checkpoints closed is safer than resuming corrupted doors.
|
||||
export const DUNGEON_RUN_CHECKPOINT_VERSION = 2;
|
||||
|
||||
export interface DungeonRunCheckpoint {
|
||||
readonly version: typeof DUNGEON_RUN_CHECKPOINT_VERSION;
|
||||
|
||||
@@ -12,10 +12,12 @@ import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
import type { GmReviewTarget } from "./gmContentReview";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
exportDungeonEncounterSnapshots,
|
||||
importDungeonEncounterSnapshots,
|
||||
resetDungeonEncounters,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly gameMode?: GameMode;
|
||||
@@ -39,13 +41,17 @@ export function activateDungeonSession(
|
||||
options: DungeonSessionOptions = {},
|
||||
): boolean {
|
||||
const definition = dungeonDefinitionById(dungeonId);
|
||||
if (!definition || !dungeonCanEnter(dungeonId)) {
|
||||
if (!definition || !dungeonCanEnter(dungeonId) || !dungeonContentIsUsable(dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousDefinition = dungeonDefinitionById(useGameStore.getState().activeDungeonId);
|
||||
if (previousDefinition) releaseDungeonGltfCache(previousDefinition);
|
||||
activateDungeonEncounterDefinitions(dungeonId, definition.encounters ?? []);
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
difficultyId ?? definition.defaultDifficultyId,
|
||||
);
|
||||
resetDungeonEncounters("dungeon", dungeonId);
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
@@ -91,7 +97,7 @@ export function activateDungeonSession(
|
||||
gmReviewTarget: options.gmReviewTarget,
|
||||
});
|
||||
if (options.encounterSnapshots?.length) {
|
||||
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots);
|
||||
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
|
||||
}
|
||||
useCombatStore.getState().setPlayerPosition(useGameStore.getState().playerPosition);
|
||||
return true;
|
||||
@@ -103,11 +109,13 @@ export function activateDungeonSession(
|
||||
*/
|
||||
export function restartActiveDungeonSession(): boolean {
|
||||
const game = useGameStore.getState();
|
||||
const encounterSnapshots = exportDungeonEncounterSnapshots(game.activeDungeonId);
|
||||
return activateDungeonSession(game.activeDungeonId, game.activeDifficultyId, {
|
||||
gameMode: game.gameMode,
|
||||
spawn: game.activeSpawn,
|
||||
gmTestMode: game.gmTestMode,
|
||||
gmReviewTarget: game.activeGmReviewTarget,
|
||||
preserveCharacter: true,
|
||||
encounterSnapshots,
|
||||
});
|
||||
}
|
||||
@@ -7,12 +7,16 @@ import { usePartyStore } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { DEFAULT_MANASTORM_ADMIN_CONFIG } from "./manastormAdminConfig";
|
||||
import { useManastormAdminStore } from "./manastormAdminStore";
|
||||
import { dungeonEncounterSnapshot } from "./dungeonEncounterRuntime";
|
||||
import {
|
||||
dungeonEncounterSnapshot,
|
||||
importDungeonEncounterSnapshots,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore } from "./wailingCavernsEncounter";
|
||||
|
||||
describe("dungeon session asset eviction", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
useManastormAdminStore.setState({ config: DEFAULT_MANASTORM_ADMIN_CONFIG, draft: DEFAULT_MANASTORM_ADMIN_CONFIG, dirty: false });
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
});
|
||||
@@ -35,11 +39,26 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts the active dungeon in place while preserving its party", () => {
|
||||
const active = dungeonDefinitionById("deadmines")!;
|
||||
useGameStore.getState().activateDungeon(active.id);
|
||||
it("does not activate a direct production session before its dungeon pack is usable", () => {
|
||||
vi.stubEnv("DEV", false);
|
||||
useGameStore.getState().activateDungeon("wailing-caverns");
|
||||
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
|
||||
|
||||
expect(activateDungeonSession("deadmines")).toBe(false);
|
||||
expect(useGameStore.getState().activeDungeonId).toBe("wailing-caverns");
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts the active dungeon in place while preserving its party and encounter state", () => {
|
||||
const active = dungeonDefinitionById("wailing-caverns")!;
|
||||
expect(activateDungeonSession(active.id)).toBe(true);
|
||||
usePartyStore.getState().initializeParty("retry-party", 20, 600, "healer");
|
||||
const members = usePartyStore.getState().members.map((member) => member.id);
|
||||
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const progressed = { ...current, phase: "ready", revision: current.revision + 1 } as const;
|
||||
expect(importDungeonEncounterSnapshots(active.id, [progressed])).toEqual([
|
||||
WAILING_NARALEX_ENCOUNTER.id,
|
||||
]);
|
||||
const revision = useGameStore.getState().sessionRevision;
|
||||
const clear = vi.spyOn(useGLTF, "clear").mockImplementation(() => undefined);
|
||||
|
||||
@@ -48,6 +67,10 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(clear.mock.calls.map(([url]) => url)).toEqual(dungeonGltfUrls(active));
|
||||
expect(useGameStore.getState().sessionRevision).toBe(revision + 1);
|
||||
expect(usePartyStore.getState().members.map((member) => member.id)).toEqual(members);
|
||||
expect(dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)).toMatchObject({
|
||||
phase: "ready",
|
||||
revision: progressed.revision,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the published dungeon group anchor at the session boundary", () => {
|
||||
@@ -59,9 +82,9 @@ describe("dungeon session asset eviction", () => {
|
||||
expect(useGameStore.getState().activeSpawn).toEqual(spawn);
|
||||
});
|
||||
|
||||
it("hydrates a safe encounter checkpoint after the dungeon reset", () => {
|
||||
it("hydrates a same-process checkpoint even when the reset revision is newer", () => {
|
||||
const current = dungeonEncounterSnapshot(WAILING_NARALEX_ENCOUNTER.id)!;
|
||||
const saved = { ...current, phase: "ready", revision: current.revision + 10 } as const;
|
||||
const saved = { ...current, phase: "ready" } as const;
|
||||
expect(activateDungeonSession("wailing-caverns", "normal", {
|
||||
encounterSnapshots: [saved],
|
||||
})).toBe(true);
|
||||
|
||||
@@ -7,6 +7,20 @@ export type DungeonWorldObjectKind =
|
||||
| "switch"
|
||||
| "key"
|
||||
| "chest"
|
||||
| "resource"
|
||||
| "generic"
|
||||
| "chair"
|
||||
| "spell-focus"
|
||||
| "text"
|
||||
| "trap"
|
||||
| "transport"
|
||||
| "camera"
|
||||
| "ritual"
|
||||
| "mailbox"
|
||||
| "fishing-hole"
|
||||
| "aura-generator"
|
||||
| "spellcaster"
|
||||
| "destructible"
|
||||
| "npc"
|
||||
| "quest-object"
|
||||
| "trigger";
|
||||
@@ -21,6 +35,14 @@ export interface DungeonWorldObjectSource {
|
||||
readonly files: readonly string[];
|
||||
readonly templateId?: number;
|
||||
readonly spawnId?: number;
|
||||
readonly gameObjectType?: number;
|
||||
readonly clientDisplayId?: number;
|
||||
readonly clientModelPath?: string;
|
||||
/** Unscaled client x/y/z display bounds retained for asset/collision review. */
|
||||
readonly clientBounds?: {
|
||||
readonly min: MobVector3;
|
||||
readonly max: MobVector3;
|
||||
};
|
||||
readonly note?: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +67,8 @@ export interface DungeonWorldObjectInteraction {
|
||||
export interface DungeonWorldObjectVisual {
|
||||
/** Optional independently packaged GLB. Procedural geometry remains the safe fallback. */
|
||||
readonly assetUrl?: string;
|
||||
/** Source-adapted records stay hidden until their client display is reviewed. */
|
||||
readonly hidden?: boolean;
|
||||
readonly color?: string;
|
||||
readonly emissive?: string;
|
||||
readonly scale?: MobVector3;
|
||||
@@ -111,6 +135,24 @@ export function validateDungeonWorldObject(object: DungeonWorldObjectDefinition)
|
||||
if (object.interaction && (!object.interaction.signalId.trim() || object.interaction.range <= 0)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has an invalid interaction.`);
|
||||
}
|
||||
if (object.visual?.assetUrl && object.visual.hidden) {
|
||||
throw new Error(`Dungeon world object ${object.id} cannot hide an authored visual asset.`);
|
||||
}
|
||||
if (object.source?.gameObjectType !== undefined && (
|
||||
!Number.isInteger(object.source.gameObjectType)
|
||||
|| object.source.gameObjectType < 0
|
||||
|| object.source.gameObjectType > 35
|
||||
)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has an invalid source gameobject type.`);
|
||||
}
|
||||
const clientBounds = object.source?.clientBounds;
|
||||
if (clientBounds && (
|
||||
!clientBounds.min.every(Number.isFinite)
|
||||
|| !clientBounds.max.every(Number.isFinite)
|
||||
|| clientBounds.min.some((value, axis) => value >= clientBounds.max[axis])
|
||||
)) {
|
||||
throw new Error(`Dungeon world object ${object.id} has invalid client display bounds.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function nearestDungeonWorldInteraction(
|
||||
|
||||
@@ -20,7 +20,19 @@ export type EnemyDamageSchool =
|
||||
| "shadow"
|
||||
| "holy";
|
||||
|
||||
export type EnemyControlMechanic = "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
export type EnemyControlMechanic = "confuse" | "fear" | "sleep" | "root" | "stun" | "silence";
|
||||
|
||||
/**
|
||||
* A persistent enemy aura that procs a short-lived, charged critical-strike
|
||||
* modifier from successful main-hand auto attacks.
|
||||
*/
|
||||
export interface EnemyMeleeAutoCriticalProc {
|
||||
readonly chance: number;
|
||||
readonly triggeredAura: string;
|
||||
readonly durationMs: number;
|
||||
readonly criticalChancePercent: number;
|
||||
readonly charges: number;
|
||||
}
|
||||
|
||||
export type EnemyTelegraphShape = "circle" | "cone" | "line";
|
||||
export type EnemyTelegraphAnchor = "source" | "target";
|
||||
@@ -45,6 +57,7 @@ export type EnemyAbilityEffect =
|
||||
readonly aura: string;
|
||||
readonly durationMs: number;
|
||||
readonly magnitude?: number;
|
||||
readonly meleeAutoCriticalProc?: EnemyMeleeAutoCriticalProc;
|
||||
}
|
||||
| {
|
||||
readonly kind: "target-aura";
|
||||
@@ -55,6 +68,8 @@ export type EnemyAbilityEffect =
|
||||
readonly kind: "control";
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
readonly durationMs: number;
|
||||
/** Optional damage resolved against the same targets as the control. */
|
||||
readonly damageMultiplier?: number;
|
||||
}
|
||||
| { readonly kind: "transform"; readonly form: string }
|
||||
| {
|
||||
|
||||
@@ -250,6 +250,168 @@ describe("server-backed enemy ability runtime", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("resolves compound enemy damage and control against the same target", () => {
|
||||
useCombatStore.getState().registerMob("compound-hammer", {
|
||||
name: "Compound Hammer",
|
||||
maxHealth: 1_000,
|
||||
attacks: [{
|
||||
id: "hammer-pair",
|
||||
name: "Hammer Pair",
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "holy",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 20,
|
||||
cooldownMs: 25_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [25_000, 25_000],
|
||||
damageMultiplier: 1.3,
|
||||
effect: {
|
||||
kind: "control",
|
||||
mechanic: "stun",
|
||||
durationMs: 2_500,
|
||||
damageMultiplier: 1.3,
|
||||
},
|
||||
}],
|
||||
});
|
||||
setMobPosition("compound-hammer", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("compound-hammer", 1, 1_000);
|
||||
const healthBefore = useCombatStore.getState().health;
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||
|
||||
expect(useCombatStore.getState().health).toBeLessThan(healthBefore);
|
||||
expect(useCombatStore.getState()).toMatchObject({
|
||||
controlMechanic: "stun",
|
||||
controlledUntil: 3_500,
|
||||
});
|
||||
expect(useCombatStore.getState().auras).toContainEqual(expect.objectContaining({
|
||||
sourceId: "compound-hammer",
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
expiresAt: 3_500,
|
||||
}));
|
||||
});
|
||||
|
||||
it("arms, limits, and consumes a next-melee critical proc only from main-hand autos", () => {
|
||||
useCombatStore.getState().registerMob("melee-proc-boss-1", {
|
||||
name: "Melee Proc Boss",
|
||||
level: 20,
|
||||
maxHealth: 1_000,
|
||||
damageMultiplier: 0.05,
|
||||
attacks: [
|
||||
{
|
||||
id: "vengeance-driver",
|
||||
name: "Vengeance",
|
||||
delivery: "area",
|
||||
target: "self",
|
||||
school: "holy",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 0,
|
||||
cooldownMs: 1,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [1, 1],
|
||||
maximumCasts: 1,
|
||||
damageMultiplier: 0,
|
||||
effect: {
|
||||
kind: "aura",
|
||||
aura: "vengeance",
|
||||
durationMs: 0,
|
||||
meleeAutoCriticalProc: {
|
||||
chance: 1,
|
||||
triggeredAura: "vengeance-next-melee-critical",
|
||||
durationMs: 6_000,
|
||||
criticalChancePercent: 100,
|
||||
charges: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "melee-special",
|
||||
name: "Melee Special",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 5,
|
||||
cooldownMs: 1_000,
|
||||
initialCooldownMs: [500, 500],
|
||||
repeatCooldownMs: [1_000, 1_000],
|
||||
damageMultiplier: 1,
|
||||
},
|
||||
{
|
||||
id: "basic-attack",
|
||||
name: "Basic Attack",
|
||||
delivery: "melee",
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
castTimeMs: 0,
|
||||
range: 4.5,
|
||||
cooldownMs: 2_000,
|
||||
initialCooldownMs: [1_000, 1_000],
|
||||
repeatCooldownMs: [2_000, 2_000],
|
||||
damageMultiplier: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
setMobPosition("melee-proc-boss-1", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("melee-proc-boss-1", 1, 1_000);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(1_000, [0, 0, 0]);
|
||||
useCombatStore.getState().advanceMobCombat(1_500, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.map((aura) => aura.id))
|
||||
.toEqual(["vengeance"]);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(2_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult?.landed).toBe(true);
|
||||
expect(useCombatStore.getState().lastCombatResult?.attackTable.criticalPercent).toBe(5);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual({
|
||||
id: "vengeance-next-melee-critical",
|
||||
sourceAbilityId: "vengeance-driver",
|
||||
endsAt: 8_000,
|
||||
nextMeleeCriticalChancePercent: 100,
|
||||
charges: 1,
|
||||
});
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(2_500, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
||||
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 8_000, charges: 1 }),
|
||||
);
|
||||
|
||||
useCombatStore.getState().advanceMobCombat(4_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult).toMatchObject({
|
||||
outcome: "crit",
|
||||
critical: true,
|
||||
landed: true,
|
||||
});
|
||||
expect(useCombatStore.getState().lastCombatResult?.damageBeforeMitigation)
|
||||
.toBe((useCombatStore.getState().lastCombatResult?.rawDamage ?? 0) * 2);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras).toContainEqual(
|
||||
expect.objectContaining({ id: "vengeance-next-melee-critical", endsAt: 10_000, charges: 1 }),
|
||||
);
|
||||
|
||||
useCombatStore.setState((state) => ({
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
"melee-proc-boss-1": {
|
||||
...state.mobs["melee-proc-boss-1"],
|
||||
auras: state.mobs["melee-proc-boss-1"].auras.map((aura) => (
|
||||
aura.id === "vengeance" ? { ...aura, meleeAutoCriticalProc: undefined } : aura
|
||||
)),
|
||||
},
|
||||
},
|
||||
}));
|
||||
useCombatStore.getState().advanceMobCombat(5_000, [0, 0, 0]);
|
||||
useCombatStore.getState().advanceMobCombat(6_000, [0, 0, 0]);
|
||||
expect(useCombatStore.getState().lastCombatResult?.critical).toBe(true);
|
||||
expect(useCombatStore.getState().mobs["melee-proc-boss-1"].auras.some(
|
||||
(aura) => aura.id === "vengeance-next-melee-critical",
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors encounter-state conditions before selecting an ability", () => {
|
||||
useWailingEncounterStore.getState().reset();
|
||||
useCombatStore.getState().registerMob("event-caster", {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Generated by npm run dungeon:catalog. Do not edit.
|
||||
export default "H4sIAAAAAAACCuy9a5PbyJUo+FcY3N24MxFkKs/J10lF3A/9GI+96x57W147NhwdN0AgWcURH7UgS2p1h/77xskESJAASbBISaWy/EHuQhLIzJPn/crfh+v8Piyyv4dyPVsth69hNFyvHss8DF//PsyzxUM2u1v+lC1n07DeDF8Pi8flXVgtxw+zhzCfLcOr+kH9W/Hf69VyOBqG5btZuVouwnLz1yx/m92F9fD1P4frMn91ly3Cq7uwDGW2CcWr6exd+Os8+xDKH9O3/rR4WJWbdf2ljlcW2TJbb1bl4rv1Omy/H18Y/Nsm8PtZ+WEwzebzSZa//ffhL7ygfPW43ITyh2yTzVd3w9cnv/zz43IzW4Tqx/Vast9Cudrc/7Aqww+rxWLGINGFL6DQE+NQBQKfm1x5ObV5BuAckiom0wkFtf/6mwjlP6/yt90L6fzpFiQPYT7f7uP3YbbOwzId4FCIV3/Oitnb9U9//b//o5htVuWrfD4Ly804fyzLsNy8ysqQjQ2+esg2+f34x1c/fv+H2Tysf4i/evWGvy2KSc6H+LDK79M3/xiyeSh/ypbjN5tVmd2FVzUKjN+vyrc1HqxfxXfGacqjn/74cTTMV+8Cf2j4+p+/13j1p4KhcZ8Vq/fTbHk3fhvCw3A0XGQPPKJU4xjXw9eO/9zMNjPGLcWou8k2s/zNQ/Z+uR6+BulHw3KVLWbLO8aR9fA16h1+16eRr8ow/Dg6WAPjwPvZshivN6v8bVaExjL0/jJMcxnQWoZXh6uQPRdRhKxYzJZh3ZjbngABYgsErg0C1XP299lsPlvejfPsXSiXjTXog2OgvTXIwzWgwcM1uJ5LKLPfVuU0LMdvy+xx3liBO4UI1AMKgD2XMJln+dtptrlfLcZFCA9NOND+KuweHFwLDhJaq+iLCY/zIltky93cTl4EASXxyRC4W64WoQx3zen9wfT6NCUq19q6gr6U+Lh8G5Zj5urzBhFG4j6+BNWCvjaHS9CXYmGxet+kA0B/CgFMCw29bh1CX2Jc51k5D5vxYsXyKZQfGssgf4IfqRYeKsa6g2XYnsv47XE+zcoye7ubHuWp6RF6sAPQlxBjucrfjtcPs7KBDXh4FLC3CtciCGNtizHixasowsPmvoERqA7oAlRzGbZDROn2YZjeOHG/mq8W2TJvQuIQG+gMZ2BG9dQFbEpmjPNFUzgenoRvzm9a0kGrFmvgL/Saf5GV2WOxWjZF88ndt7iCwRZXQNmbLdyF6awM4/w+Wy8aa6BTnKmtH0D7BPrSQ8HTL/ZFY4sScI8SWlzJmRYlmL4HsL7PNptQhmJ8n83nDUIw+oAQ9uhAtfUUT09mjZP5alWMp4/lMmsSgtF4YgnQYoug/JOXcB/m84gKJVtB5WYPEgfa0t5xQAsSZJ4sqNebkC3eZY/zTXN6cxkUOthBX23tcVmEcrK6a85uT5BCh7pq4cnq6nqevQvjh7DcA747sXvsIMWWbFJ9oZ+VebYps98asxs8tXt//uj70uFktcmWszxrzq1OSWXfVpHhydi/CPl9tszK5uz6Iqwj/WSVIBmL43k2+VDOlpv75iLMZchHT2cA67C5D2/fttigsaeI33aoA0+2WNlzMd6sFpO9+U/hf4Tx/vzgn6wMZI/5fVhms3FefnjY44CGTgFBn7eXlesNg7sZa8fr8SaU5b44oJOMsG0z6hYjtJfoh+PFqszWTd3Q+hML6BBHT9YKwjrPHsJ4WrLJ+lgu71fzpvvC2FOyuc0aFLUPpO9SHjd3WVmEAz+OcafYQwdlAj5dKFUreJgtl1netCCNO8Uf+jCpvlDY3IfxMvz62CQKZ0/Zji3OgOrpLJKnX+WP8/356dTm+xxBX8GYP86jH2k1HXcZDMafZNNtdxI82Z8WuXNax2q5twR/ESY492QtpSizt5v7rFwtD2jCSnmRtPL+yUZ89tt/r+bjZSgfJ8354RR/bFlOTj4VH7anMJ/d3W+Ws+VdcxV4ETJ0oWVfx9Ld47LY82RYecqx256c3JP9ee9mK/bmMGduzk8njqBluPmnkkF2v3yb3YfNeDUvxm9ny7ti1TBfLZz057R1BqWerDNMV+VdiAS5emzqTVbhKZWhhYy6pbxSX95YzrI5r4CZZH6fLR5mTX+CNfIEMFpHYp56JA+zTQQDM4bm7KcQQrWpwdKTjcctVZZhOg/5Zh8M9qQK117Ik1n0ZrZ8G8pNyO/H6/vVe/a47pZBeMCjoLmMNmbaU6t4KFd5KB7LbD6u44Rt/CxXv4XluAxZft90ftNnXsaqvFttNmE55mhQw/Pr6FMD5JfRsAjT2XLGCLGOobp2xHjWHbXbzDZz/vCb7cjg/2rF8+bTuzrsW/D3iUO1vz5kdTgzn2fr9Szfjyj/tCoiIhWb8f2HSTljJlrMptNZ/jjffPh7Vs6y5SatNq5tuSoX2XzvR82na4bPT9n6bdzNPLwL85+zZYxMAo2U/KUCA7tWftx+4U97X87eZbN5NpnNZ/Hb1Z9R33wowzos+SR4R78P56u8+u8O0Gzuw4L39peHsOTI6rwYhF8fQjGrKHL9uFhkJU/xBxafg819uXq8ux8cfGk0KMI0ZJvBbLMebDFkUK7YPBoNsmUx4FMabO7DgD1WHwbZfPYuiOFoOF9lxWx591NYr2N0dvjXMjxk5Wx5dziJEPz7x2V+n5WbUHxXhuy/srj6/6d+NmjvkBHrrlw9Lhl+/5skWUg+3mkKZeer+aqMAxkgyOFouAxZmQKfU/4PtPLjaJgtJhxS/jPDYO+1jCbas14zW244Js6nIQXix9HwPixm64f7UIbta+u3H36o3yTnc2IXSlrc9rn0ciL94Ret/jiK/tfIKrN5eyUF5s4X++/BaLiaTtdhw6iFI5SjsfvlIxPEJrAkWscvPG5Wj5u/RtdqpLnh//oe/nfOYUgjDGN+/MtoOJmHZbH/S4y/jAONH2YFY9C7sP9bHX9bj21/Hhf0wEt5Pys29+yklwy+tEXFmuoDv7S8SwpBvlpuVo9l/OhPqAcozeAHsgMAGoDGAWo3QEkDcDB4o5QeOBhoaQeAdjga/qRogMoPfgDpB0BqAA4HaGmAWsbX3iitB94OlHcD54a/cPJAxokfEVrvZuvHbL6j9erv0XA6m4cKHdMzcTdnxHgs58PXw1fpCyntYpv18Uqp8Y6Rvdp7L78P+dv142L4eqhyHbDwwVPg5A7IOMsjKAjKAHqy0oSM9KQgpRDB5zKTTmUZaJr4aeYc0/Hst8CakienrR5FRWR5Nw8/MKUOX2uPEiTznXw1n88SM6y3uH00llLu73Rv6PINd73e2Lf009zmUgbMwE+nmnIoJrqYkA+T4CUYJSeFJ3A4Vd5NrA7gC6cyaeS0cFY29g3a29aurTHKsvRrbROObxOu2ya0t2ktkAOTZSorKLM48Y6M9ACZs2C9MVY6RVIVqAiNmgLISY45wlRikXvT3KZlb0vvbeLxbeJ128T2Np0Bg3pqaALOZgEmEzMp0BaZM65QuZMFOqMmhSNHuTdGZnqSmcxM86CkySaNbaJEumCb6vg21XXbVO1tThRkVk9Ro8llkRszdQCoEVE56ae+KEwWMm/kVFqTaeNCRvkUpJv4acizsHeadMk29fFt6uu2qdvbhAnoaeb1BCl3ekpSBt6KRE2apibTjrSSTmbTSV5MvJ9M8hCmE5t7yieozD5t4gXbNMe3aa7bpulAWvBeTzJyJptOXI5IOMXcQjYxmpT0IE1AyjLjC5oUk4CTKRT5xNhpluUQfGOb2ttLTtMe36a9bpu2vU2cTmGKucNCSVdYC9KaScjcdCpzNIoXDwABZdDewkTDRCmcknSoJ9aDbmzTkL5km+74Nt1123TtbRYTkzkpMcszlfssA5qCk8HKoPzUoso1gQm59cFa1MFMNbqQT3xRKOX9hBrbtFrqC7ZJx7dJ122TOgRKkU0Kq5SWhUVLE2szdFmWBYdSGjOZBphgEYLNvAEMGnNJdopTNSWrPTW3qaS5hDb98W3667bp29skI0kGnILLc0usA2WB8mmhKAtqkod84oxRAbR3xoapDEZq8pR5p5XLpk2BYgxeIDfhuBYE12lB0KEFeYkTRUVArSaTqQrZNBRBeyomU3TTibQKCg1eFlPlpgi5lLLQMGXVAGHidttU3pBs06azyvvObR7XguA6LQi6tCCkXNHEkM5tXlCGAQk9eG8okNOQyaktzGSaBQpWaydVbjn7uZAhBN0UKJ7gEi0IjmtBcJ0WBB1akMmtd56NZT+xNndOEoSJC84GnE40WAw6gNUyoJxKJVFhwFxPMum1dQ2BgqBUF6e1jnTnNo9rQXCdFgQdWpCHPLg8965wBq2ZoCzAW2WsNQCFl+TJoIdM5Xk2LShoZ6yc5jK3KsiparAgrUBDW3dnztV9nMfVILhODYIONciyPjuZaFYQlC2mE1TW2rzIMlJTkkCWILOeFPrC5BkG67Wy2dTiVJJ32e440akO4rROkenc5nE1CK5Tg6BDDZITRThRbI9YpCxMZK4zm0uVGQ2UhRyDchonoShwQhhkPvFTlRVukk3l1DXVIPDuAokCx9UguE4Ngg41iELIZeGDnErjvXVocEJK596aqWK1wYQJTOR0YmkSfI4ZepPUQm08qN02lVW2Y5tOe9lNnMfVILhODYIONchQbomCUQSTzOQYiDcqyWSFdXnQhLrwCjGfZkZrK3NrIASvtcYJaIc7pLWku1gtEXZv87gaBNepQdChBjkqiGzmC/Jg0Ml8okEHLVU2MSqnTAPK3E+mboo+R6Nt5q3BgIVVJoep2m7TSOl1W9vzDrBbcB5Xg+A6NQg61CBVFASQ68Jq5VloThypSZ6bSeE8SI9agVSZy6koKGM/yqQwWTEtMJdu6nfeIUVWdZyms1Kprm3icTUIr1ODsEMN0hOZFZnxRqLSLF2UdZkzaprlpjAmI0eZx1BIZbydkp3oQmkDhZtOpkiyYaI
Line truncated
|
||||
export default "H4sIAAAAAAACCuy9a5PjNpY2+FcY+e7GzkRIKODgXhP7wa5y237b1fa6PO3d6HBMQCSUySlKzKGUWa7uqP++cQBSgu5USk63s9kfqp2ESNzOec4VB/+4WeR3fub+6ptFWc9vXrPRzaJ+aHJ/8/ofN7mb3bvydv7OzcupXyxvXt8UD/NbX8/H9+W9r8q5f9U96H5L/ntRz29GN37+WDb1fObnyx9c/sHd+sXN67/dLJr81a2b+Ve3fu4bt/TFq2n56H+o3CffvI3f+nZ2XzfLRfelPa/M3NwtlnUz+2Kx8Kvvhxeyf1t6fN81n7Kpq6qJyz/8+80vOKC8fpgvffPGLV1V3968PvrlHx/my3Lm2x93Y3F/9029vHtTN/5NPZuVuCSisAUrxERq4N4wm8ucWzpVuWNMazC8mEwnxvPN19+HVf6uzj/sH8jen66W5N5X1Woe/7hxi9zP4wbeEPLqO1eUHxbvfvh/virKZd28yqvSz5fj/KFp/Hz5yjXejSW8unfL/G789tXbL/9UVn7xJvzq1Xv8NikmOW7ifZ3fxW9+413lm3duPn6/rBt36191JDD+WDcfOjpYvArvjGOXBz/9+fPoJq8fPX7o5vXf/tHR1bcFrsadK+qPUze/HX/w/v5mdDNz99jCebKNi5vXGv9clssSaYsj6S7dsszf37uP88XNa0bt6Kap3ayc3yKNLG5eg1jTd7cbed34m8+jrTEgDXws58V4sazzD67wyTDE5jBkOgy2MwzLt0dBew6i8K6YlXO/SPpWR5YAYGcJ9O4S8J69f3RlVc5vx7l79M08GYPY2gazMQa6PQaQsD0G3XMIjft73Uz9fPyhcQ9VMgJ9jBBMj1Vg0HMIk8rlH6ZueVfPxoX39+k6mM1RqI110DvrQNnOKPpSwkNVuJmbr/vW9KwV4BSevAK383rmG3+bdm+3uhfHOZHrnalz1pcTH+Yf/HyMqF4lTBiY+/AQ+M7qC7k9BHEuFRb1x5QPGNhjBCB3yNCKnU3oy4yL3DWVX45nNcon33xKhmHsETziO3TIkeq2hqF6DuPvD9XUNY37sO4e6LHugfWAAybOYcamzj+MF/dlk1ADbG8F2xiF3mEIqdQOMMLZoyj8/fIuoQjgW3zBeDoMtUdEid3NkL1p4q6u6pmb5+lKbFODOYEMCFRPHcCyQWCsZqlw3N4Jm/Yvd6SD4DvQgF/o1f/MNe6hqOepaD46+x1UkLCDCkB7w8Ktn5aNH+d3bjFLxmCOIdOufsB2d6AvPxTY/WxTNO5wAmxwwg4qabnDCbLvBizu3HLpG1+M71xVJYwgxRYjbPAB39VTrHkyNE6qui7G04dm7lJGkAKODIHtwCLj9slDuPNVFUihQSuoWW6sxJa2tLEdbGcljHyyoF4svZs9uodqmXYvz1uFPXDQV1t7mBe+mdS3ae/qCCvsUVcVe7K6uqjcox/f+/nG4usjs4c9rLgjm3jf1XdN7paN+3vSu4Rjs7ent74vH07qpZuXuUv75sekst1VkdmTqX/m8zs3d03auziL6ox4skoQjcVx5SafmnK+vEsHIc8jPvN0AFj45Z3/8GEHBqU6xvxqjzrwZIsVPRfjZT2bbPR/jP7DGm/2z+yTlQH3kN/5uSvHefPpfgMBpTm2COK0vcx17zW4LVE7XoyXvmk2xYE5CoS7NqPYAUJ1jn44ntWNW6S6obJHBrBHHD1ZK/CL3N378bRBk/Whmd/VVeq+kOqYbN6FBm52N6TvUB6Wt64p/JYfR+pj8LCHMxk8XSi1I7gv53OXpxak1MfwoQ9I9V2F5Z0fz/2vDylTaHXMdtxBBuBPh0jsvs4fqs3+zbHJ99mCvoIxf6iCH6mejvcZDNIehelddxJ7sj8toHMcRz3fGII9ixK0frKWUjTuw/LONfV8iycUpWdJK2ufbMS7v/93XY3nvnmYpP2zY/i4Yzlp+lR6WO1CVd7eLefl/DYdBZxFDPvIsq9j6fZhXmx4MhQ95tjd7dzoJ/vzHssavTmIzGn/5sgW7Bhu9qls4O7mH9ydX47rqhh/KOe3RZ2Yr4od9efs6gycP1lnmNbNrQ8MWT+kepPicExl2CFGsaO8mr7Y2JSuwhEgSOZ3bnZfpv4EJemRxdjZEvnULbkvl2EZEBjS3o8RBN/lBmWebDyuuLLx08rny81lUEdVuN2BPBmil+X8g2+WPr8bL+7qj+hxXQ/DwBZGsXQYu5Spjo3ivqlzXzw0rhp3ccJd+mzqv/v5uPEuv0ud3+aZh1E3t/Vy6edjjAYlnl9tfusF+WV0U/hpOS+RIBYhVLcbMS73R+2W5bLCD79ftWR/3onnVdPbLuxb4PcNhmp/vXddODOv3GJR5psR5Xd1EQipWI7vPk2aEkG0KKfTMn+olp/+6prSzZdxtGFs87qZuWrjR+nTBa7PO7f4EGZT+Udf/ejmITLJzIjTX9plQNfK29UXvt34snt0ZeUmZVWGb7d/Bn3zvvELP8edwBn946aq8/a/9yzN8s7PcG7f3/s5RlarIvO/3vuibDly8TCbuQa7+BOKz2x519QPt3fZ1pdGWeGn3i2zcrnIVhSSNTWaR6PMzYsMdylb3vkMPVafMleVj57cjG6q2hXl/PadXyxCdPbmh8bfu6ac3253Qgj+/mGe37lm6YsvGu/+4sLo/7N7lu3OEAnrtqkf5rh+/4saWlDc3mkMZed1VTehwTFg9GZ0M/euiYHPKf4HKPp5dONmEwwpf4drsPGaMxNhUa8p50uMieNuUALweXRz52fl4v7ON3712uLDpzfdm0bb3KALJQ5u9ZxaOqF2+4tKfB4F/2uASlftjqSAXNti8z02uqmn04VfImnBCOhorH/5jAyx9CiJFuELD8v6YflDcK0Gnrv5ry/Z/4E5DLEF1xgf/zK6mVR+Xmz+EsIvQ0PyQ1cgBT36zd+K8NuubfXzMKB7HMrHsljeoZOe4vLFKXLUVO/xpfltVAjyer6sH5rw0XcgMqAye2NUxpjJmIAMhM6Amoxplr3nXGSaZYKqjIG6Gd284yYDbrM3jNqMGZ4xDRkok4Gg4bX3XIjMqoxbnWl98wsmDzhM/Air9VguHly15vX279HNtKx8S47xGbmtkDAemurm9c2r+IWYdrHK+njF+XgNZK823svvfP5h8TC7eX3Dc+GhsN4aj8kdzGGWh+fMc8nAGkWld0ZMCsM5ALM5dVRz55gwEzt1WiMfl3/3qClZo4USo6CIzG8r/wY59ea1sEAZRdzJ66oqIxh2U1w9GlNKN2e60XT+hPe9nsyb2mmucko9OGanU2FyVkxEMTHWT7ylTHI6KaxhGqbc6okSntlCc0clnRZa0WTeTFi1M2slJVco/XamyQ5Pk102TbY7TaWY0Uw6x11hnIKJ1UZSy5jTiikrpaKaG8oL4AYknzJGJznkwKYUitzKdJoKvS29pwmHpwmXTRN2p6klkyCm0kyYVs6zyUROClCF01IXPNe0AC35pNBGm9xKSZ2YOOnkNPecSjdJpgkUzBnT5IenyS+bJt+d5oQzp8QUBMicFrmUU80YCADgmtqpLQrpvLOSTqmSTkjtncmnjOqJnfrc+Y3dNOdMUxyeprhsmmJ3mmzCxNRZMQGTazE1lHqcCgVhhJlKJ7QRnGrqppO8mFg7meTeTycqtyafAJebvAlnTFMenqa8bJpyD9Eya8XEGS3ddKJzAANTyBVzEykMp5ZR6cE4J21hJsXEw2TKinwi1dS5nHmbTFNYdc5uqsPTVJdNU+1OE6ZTNoVcQ8GpLpRiVMmJd3o6pTlIjoNnjHmgXljFJoJNOIepoRrERFkmkmlKI86Zpj48TX3ZNPXuNIuJdJpScLnjuXWOmSnT1CvquZ0q4LkwTPpcWa8UCC+nArTPJ7YoOLd2YpJpKkHFGdM0h6dpLpum2SNQCjcpFOeCFgqUmSjlQDvnvAZKpZxMPZtA4b1yVjLwAnJq1BSmfGqUsCadJqfyHN60h6dpL5um3Z2mkdRQD1Om81wZ1IGcN/m04MZ5Psl9PtFScs+E1VL5KfWSCmONs1pw7aapQJESzpCb7LAWxC7TgtgeLchSmHBTeBB8Mply76a+8MKaYjIFPZ1QxVkhmKXFlOspsJxSWgg2RdUA2ESvp8mtNHSXN7Xi1u6d5mEtiF2mBbF9WhCYnJuJNCJXeWEceDBgmbXSeKMFc3SqCjmZOm+8EkJTnivMfi6o916kAsUado4WxA5rQewyLYjt0YJkrqy2aCzbiVK51tQwP9FeKw/TiWAKvPBMCeqBTimnwMFDLiaOWqF0IlCAcb4PaZU2Yu80D2tB7DItiO3RgizLvc5zqwstQckJ0IJZxaVSkrHCUmONBMscz3M3LYwXWio6zWmuuKdTnkCQ4EywXd0dkWv/dh5Wg9hlahDbowYp1GcnE4EKAlfFdAJcKZUXzhk+NZQZZZhT1nCwhcwdeGUFV26qYEqN1W69naD5HuZUmhu5d5qH1SB2mRrE9qhBdMINTDjaIwqM8xOaC6dyyp0UzDifg+dawMQXBUwMeJpP7JS7Qk/clE51qgYxq8+QKOywGsQuU4PYHjXIeJ/Twno6pdJapUHCxHCRWyWnHNUG6SdsQqcTZSbe5uDAyqgWCmkZX0+TK672TFMLS/cz52E1iF2mBrE9apA0uTLGS27YxMkcvMGJUiNdoXTuhQFRWA6QT50UQtFcSea9FULAhAkNa6JVRuyDWmNg/zQPq0HsMjWI7VGDtCmMUc4WxjIJmuYTwYQXlLuJ5LlxggHN7WSqp2BzkEI5qyR4KBSXOZvy1TQlpVbsantWM9gvOA+rQewyNYjtUYN4URjGclEowS0KzYk2fJLnclJoy6gFwRnlTuemKIxDP8qkkK6YFpBTPbVr7xA3iu/ZTa0o5/umCYfVILhMDYI9apCYUFc4aSUFLlC6cKWdlnzqcllI6Yw2zoIvKJdWTY2aiIILyQo9nUzB0MRE0caKM/QDOKwGwWVLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
{"schemaVersion":1,"source":{"campaignManifest":"dungeon-pipeline/dungeon-campaign.json","environmentPackages":["src/game/generated/fivePlayerDungeonImports.json","src/game/generated/manastormAssetPackages.json (temporary fallback)"],"encounterCatalog":"src/game/generated/manastormRuntimeCatalog.json","azerothCoreCommit":"4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3","azerothCoreSourceLock":"src/game/generated/azerothCoreSourceLock.json","spellCatalog":{"ascension":"../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc","epoch":"../HealerMan-Storage/pipeline-work/dungeons/epoch-client/DBFilesClient/Spell.dbc"}},"coverage":[{"dungeonId":"shadowfang-keep","mapId":33,"encounters":7,"entities":31,"staticSpawns":109,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"stormwind-stockade","mapId":34,"encounters":5,"entities":11,"staticSpawns":93,"roamingPacks":0,"source":"azerothcore"},{"dungeonId":"deadmines","mapId":36,"encounters":7,"entities":22,"staticSpawns":179,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"wailing-caverns","mapId":43,"encounters":8,"entities":20,"staticSpawns":252,"roamingPacks":7,"source":"azerothcore"},{"dungeonId":"razorfen-kraul","mapId":47,"encounters":7,"entities":38,"staticSpawns":179,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"blackfathom-deeps","mapId":48,"encounters":6,"entities":27,"staticSpawns":201,"roamingPacks":10,"source":"azerothcore"},{"dungeonId":"uldaman","mapId":70,"encounters":7,"entities":38,"staticSpawns":302,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"gnomeregan","mapId":90,"encounters":4,"entities":31,"staticSpawns":371,"roamingPacks":31,"source":"azerothcore"},{"dungeonId":"sunken-temple","mapId":109,"encounters":4,"entities":33,"staticSpawns":245,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"razorfen-downs","mapId":129,"encounters":6,"entities":25,"staticSpawns":194,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"scarlet-monastery","mapId":189,"encounters":5,"entities":37,"staticSpawns":310,"roamingPacks":16,"source":"azerothcore"},{"dungeonId":"zulfarrak","mapId":209,"encounters":5,"entities":21,"staticSpawns":252,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"blackrock-spire","mapId":229,"encounters":15,"entities":78,"staticSpawns":566,"roamingPacks":22,"source":"azerothcore"},{"dungeonId":"blackrock-depths","mapId":230,"encounters":13,"entities":61,"staticSpawns":1040,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"scholomance","mapId":289,"encounters":8,"entities":38,"staticSpawns":327,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"stratholme","mapId":329,"encounters":9,"entities":50,"staticSpawns":431,"roamingPacks":29,"source":"azerothcore"},{"dungeonId":"maraudon","mapId":349,"encounters":8,"entities":33,"staticSpawns":525,"roamingPacks":20,"source":"azerothcore"},{"dungeonId":"ragefire-chasm","mapId":389,"encounters":4,"entities":11,"staticSpawns":127,"roamingPacks":4,"source":"azerothcore"},{"dungeonId":"dire-maul","mapId":429,"encounters":12,"entities":75,"staticSpawns":756,"roamingPacks":59,"source":"azerothcore"},{"dungeonId":"shattered-halls","mapId":540,"encounters":3,"entities":32,"staticSpawns":198,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"blood-furnace","mapId":542,"encounters":3,"entities":17,"staticSpawns":139,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"hellfire-ramparts","mapId":543,"encounters":2,"entities":12,"staticSpawns":85,"roamingPacks":12,"source":"azerothcore"},{"dungeonId":"steamvault","mapId":545,"encounters":3,"entities":17,"staticSpawns":127,"roamingPacks":17,"source":"azerothcore"},{"dungeonId":"underbog","mapId":546,"encounters":4,"entities":22,"staticSpawns":161,"roamingPacks":23,"source":"azerothcore"},{"dungeonId":"slave-pens","mapId":547,"encounters":3,"entities":21,"staticSpawns":122,"roamingPacks":32,"source":"azerothcore"},{"dungeonId":"arcatraz","mapId":552,"encounters":4,"entities":29,"staticSpawns":85,"roamingPacks":9,"source":"azerothcore"},{"dungeonId":"botanica","mapId":553,"encounters":5,"entities":29,"staticSpawns":171,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"mechanar","mapId":554,"encounters":3,"entities":17,"staticSpawns":84,"roamingPacks":14,"source":"azerothcore"},{"dungeonId":"shadow-labyrinth","mapId":555,"encounters":4,"entities":22,"staticSpawns":189,"roamingPacks":13,"source":"azerothcore"},{"dungeonId":"sethekk-halls","mapId":556,"encounters":2,"entities":16,"staticSpawns":104,"roamingPacks":24,"source":"azerothcore"},{"dungeonId":"mana-tombs","mapId":557,"encounters":3,"entities":14,"staticSpawns":119,"roamingPacks":15,"source":"azerothcore"},{"dungeonId":"auchenai-crypts","mapId":558,"encounters":2,"entities":14,"staticSpawns":93,"roamingPacks":37,"source":"azerothcore"},{"dungeonId":"magisters-terrace","mapId":585,"encounters":3,"entities":18,"staticSpawns":147,"roamingPacks":6,"source":"azerothcore"},{"dungeonId":"black-morass","mapId":269,"encounters":3,"entitiesLine truncated
|
||||
@@ -6,17 +6,27 @@ import {
|
||||
} from "./manastormStageAssets";
|
||||
|
||||
let initialized = false;
|
||||
const generatedPackages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
|
||||
|
||||
export const GENERATED_MANASTORM_STAGE_ASSET_PACKAGE_COUNT = registry.packages.length;
|
||||
|
||||
/** Resolves a runtime stage package to its independently downloadable content pack. */
|
||||
export function generatedManastormContentPackId(assetPackageId: string): string | null {
|
||||
const assetPackage = generatedPackages.find((candidate) => candidate.id === assetPackageId);
|
||||
const assetUrl = assetPackage?.visual[0]?.url
|
||||
?? assetPackage?.collision[0]?.url
|
||||
?? assetPackage?.navigation.url;
|
||||
const directory = /^\/assets\/game\/manastorm\/([^/]+)\//.exec(assetUrl ?? "")?.[1];
|
||||
return directory ? `manastorm:${directory}` : null;
|
||||
}
|
||||
|
||||
export function registerGeneratedManastormStageAssets(): readonly string[] {
|
||||
const packages = registry.packages as unknown as readonly ManastormStageAssetPackage[];
|
||||
if (initialized) return Object.freeze(packages.map((assetPackage) => assetPackage.id));
|
||||
if (initialized) return Object.freeze(generatedPackages.map((assetPackage) => assetPackage.id));
|
||||
if (registry.schemaVersion !== MANASTORM_STAGE_ASSET_SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported generated Manastorm asset registry schema ${registry.schemaVersion}.`);
|
||||
}
|
||||
const registered: string[] = [];
|
||||
for (const assetPackage of packages) {
|
||||
for (const assetPackage of generatedPackages) {
|
||||
registerManastormStageAssetPackage(assetPackage);
|
||||
registered.push(assetPackage.id);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
preloadManastormStage,
|
||||
releaseActiveManastormStage,
|
||||
} from "./manastormStageLoader";
|
||||
import { generatedManastormContentPackId } from "./manastormAssetRegistry";
|
||||
|
||||
const CHECKSUM = "a".repeat(64);
|
||||
|
||||
@@ -64,6 +65,12 @@ describe("Manastorm stage asset packages", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("maps generated stages to the content pack needed by the download prompt", () => {
|
||||
expect(generatedManastormContentPackId("manastorm:assets:43"))
|
||||
.toBe("manastorm:43-wailingcaverns");
|
||||
expect(generatedManastormContentPackId("missing-package")).toBeNull();
|
||||
});
|
||||
|
||||
it("preloads one package, activates the stage, and releases the previous package", async () => {
|
||||
registerManastormStageAssetPackage(assetPackage("first"));
|
||||
registerManastormStageAssetPackage(assetPackage("second"));
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BufferGeometry, Float32BufferAttribute, Group, Mesh } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
|
||||
function navigationScene(): Group {
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute([
|
||||
0, 0, 0,
|
||||
2, 0, 0,
|
||||
0, 0, 2,
|
||||
], 3));
|
||||
const scene = new Group();
|
||||
scene.add(new Mesh(geometry));
|
||||
return scene;
|
||||
}
|
||||
|
||||
describe("party navigation asset cache", () => {
|
||||
it("reuses the graph prepared during expedition loading", () => {
|
||||
const first = preparePartyNavigationGraph("/navigation/cache-test.glb", navigationScene(), []);
|
||||
const second = preparePartyNavigationGraph("/navigation/cache-test.glb", new Group(), []);
|
||||
expect(second).toBe(first);
|
||||
expect(first.sourceTriangleCount).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps navigation variants with different authored links separate", () => {
|
||||
const first = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), []);
|
||||
const second = preparePartyNavigationGraph("/navigation/link-test.glb", navigationScene(), [{
|
||||
id: "ledge",
|
||||
start: [0, 0, 0],
|
||||
end: [2, 0, 2],
|
||||
bidirectional: true,
|
||||
authoredReason: "Test link",
|
||||
}]);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BufferGeometry, type Group, type Mesh } from "three";
|
||||
import type { DungeonNavigationLink } from "./dungeonTypes";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
type PartyNavigationGraph,
|
||||
} from "./partyNavigation";
|
||||
import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPolicy";
|
||||
|
||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||
|
||||
function graphCacheKey(url: string, offMeshLinks: readonly DungeonNavigationLink[]): string {
|
||||
const links = offMeshLinks.map((link) => [
|
||||
link.id,
|
||||
link.start,
|
||||
link.end,
|
||||
link.bidirectional,
|
||||
]);
|
||||
return `${url}:${JSON.stringify(links)}`;
|
||||
}
|
||||
|
||||
function navigationGeometry(scene: Group): BufferGeometry {
|
||||
scene.updateMatrixWorld(true);
|
||||
let sourceMesh: Mesh | null = null;
|
||||
let largestVertexCount = 0;
|
||||
scene.traverse((node) => {
|
||||
const mesh = node as Mesh;
|
||||
const count = mesh.isMesh ? mesh.geometry.getAttribute("position")?.count ?? 0 : 0;
|
||||
if (count > largestVertexCount) {
|
||||
sourceMesh = mesh;
|
||||
largestVertexCount = count;
|
||||
}
|
||||
});
|
||||
if (!sourceMesh) throw new Error("The party navigation GLB has no triangle geometry.");
|
||||
const geometry = (sourceMesh as Mesh).geometry.clone();
|
||||
geometry.applyMatrix4((sourceMesh as Mesh).matrixWorld);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function preparePartyNavigationGraph(
|
||||
url: string,
|
||||
scene: Group,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
): PartyNavigationGraph {
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks);
|
||||
const cached = graphCache.get(cacheKey);
|
||||
if (cached) {
|
||||
graphCache.delete(cacheKey);
|
||||
graphCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const geometry = navigationGeometry(scene);
|
||||
let graph: PartyNavigationGraph;
|
||||
try {
|
||||
graph = buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
offMeshLinks,
|
||||
});
|
||||
} finally {
|
||||
geometry.dispose();
|
||||
}
|
||||
graphCache.set(cacheKey, graph);
|
||||
if (graphCache.size > MAX_CACHED_NAVIGATION_GRAPHS) {
|
||||
const oldest = graphCache.keys().next().value as string | undefined;
|
||||
if (oldest) graphCache.delete(oldest);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore";
|
||||
import { useGameStore } from "./store";
|
||||
import { useManastormStore } from "./manastormStore";
|
||||
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
|
||||
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
|
||||
import type { ActiveAura, DispelCategory } from "./combatAuras";
|
||||
import {
|
||||
@@ -121,13 +122,19 @@ export function activePartyBossObjectives(): readonly PartyBossObjective[] {
|
||||
}] : [];
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
const completed = new Set(activeDungeonCompletedObjectiveIds(game.activeDungeonId));
|
||||
const mobs = useCombatStore.getState().mobs;
|
||||
const objectives = requireDungeonDefinition(game.activeDungeonId).objectiveOrder
|
||||
.filter((objective) => !objective.optional);
|
||||
if (game.activeDungeonId !== "wailing-caverns") return objectives;
|
||||
const phase = useWailingEncounterStore.getState().phase;
|
||||
return phase === "mutanus" || phase === "complete"
|
||||
? objectives
|
||||
: objectives.filter((objective) => objective.id !== "mutanus-spawn");
|
||||
.filter((objective) => !objective.optional && !completed.has(objective.id));
|
||||
const wailingPhase = game.activeDungeonId === "wailing-caverns"
|
||||
? useWailingEncounterStore.getState().phase
|
||||
: null;
|
||||
return objectives.filter((objective) => (
|
||||
!objective.eventOnly
|
||||
|| Boolean(mobs[objective.id])
|
||||
|| (objective.id === "mutanus-spawn"
|
||||
&& (wailingPhase === "mutanus" || wailingPhase === "complete"))
|
||||
));
|
||||
}
|
||||
|
||||
export function resetPartyRuntimeClock(): void {
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface GameState {
|
||||
setAssetStatus: (status: AssetStatus, message?: string | null) => void;
|
||||
updatePlayerSnapshot: (snapshot: PlayerSnapshot) => void;
|
||||
relocatePlayer: (spawn: WorldSpawn, makeActiveSpawn?: boolean) => void;
|
||||
/** Returns the party to its saved run spawn without requesting a full dungeon reset. */
|
||||
resetAfterDefeat: () => void;
|
||||
resetAtActiveSpawn: () => void;
|
||||
resetAtEntrance: () => void;
|
||||
}
|
||||
@@ -227,6 +229,25 @@ export const useGameStore = create<GameState>((set) => ({
|
||||
relocationRevision: state.relocationRevision + 1,
|
||||
};
|
||||
}),
|
||||
resetAfterDefeat: () => set((state) => {
|
||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||
return {
|
||||
overlay: null,
|
||||
paused: false,
|
||||
mapOpen: false,
|
||||
cameraLookActive: false,
|
||||
playerPosition: state.activeSpawn.footPosition,
|
||||
playerNavigationPosition: state.activeSpawn.footPosition,
|
||||
playerGrounded: true,
|
||||
cameraYaw: state.activeSpawn.yaw,
|
||||
areaName: resolveAreaNameForDungeon(definition, state.activeSpawn.footPosition),
|
||||
distanceTravelled: 0,
|
||||
hasMoved: false,
|
||||
actionLayer: "primary",
|
||||
resetRevision: state.resetRevision + 1,
|
||||
relocationRevision: state.relocationRevision + 1,
|
||||
};
|
||||
}),
|
||||
resetAtActiveSpawn: () => set((state) => {
|
||||
const definition = requireDungeonDefinition(state.activeDungeonId);
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TALENT_NODES, TALENT_TREES } from "./talentCatalog";
|
||||
import { loadTalentUiCatalog, talentUiNodesForTree } from "./talentUiCatalog";
|
||||
import { loadedTalentUiCatalog, loadTalentUiCatalog, talentUiArtUrls, talentUiNodesForTree } from "./talentUiCatalog";
|
||||
|
||||
describe("talent UI catalog shards", () => {
|
||||
it.each(["warrior", "barbarian"] as const)("loads only the %s class projection", async (classId) => {
|
||||
@@ -17,4 +17,13 @@ describe("talent UI catalog shards", () => {
|
||||
expect(talentUiNodesForTree(catalog, tree.id).every((node) => node.treeId === tree.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns each Priest talent artwork URL once", async () => {
|
||||
const catalog = await loadTalentUiCatalog("priest");
|
||||
const urls = talentUiArtUrls(catalog);
|
||||
expect(loadedTalentUiCatalog("priest")).toBe(catalog);
|
||||
expect(urls).toHaveLength(new Set(urls).size);
|
||||
expect(urls).toContain("/assets/ui/talents/backgrounds/talent_priest1.png");
|
||||
expect(urls).toContain("/assets/ui/talents/icons/spell_holy_powerwordshield.png");
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,7 @@ interface TalentUiCatalogModule {
|
||||
|
||||
const shardLoaders = import.meta.glob<TalentUiCatalogModule>("./generated/talentUi/*.json");
|
||||
const loadCache = new Map<ClassId, Promise<TalentUiCatalog>>();
|
||||
const loadedCatalogs = new Map<ClassId, TalentUiCatalog>();
|
||||
|
||||
export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog> {
|
||||
const cached = loadCache.get(classId);
|
||||
@@ -61,6 +62,7 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
|
||||
if (module.default.schemaVersion !== 1 || module.default.classId !== classId) {
|
||||
throw new Error(`Talent UI catalog ${classId} has incompatible metadata.`);
|
||||
}
|
||||
loadedCatalogs.set(classId, module.default);
|
||||
return module.default;
|
||||
}).catch((error: unknown) => {
|
||||
loadCache.delete(classId);
|
||||
@@ -70,6 +72,10 @@ export function loadTalentUiCatalog(classId: ClassId): Promise<TalentUiCatalog>
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function loadedTalentUiCatalog(classId: ClassId): TalentUiCatalog | null {
|
||||
return loadedCatalogs.get(classId) ?? null;
|
||||
}
|
||||
|
||||
export function talentUiDescriptionForRank(node: TalentUiNode, rank: number): string {
|
||||
const normalizedRank = Math.min(
|
||||
node.maxRank,
|
||||
@@ -91,3 +97,10 @@ export function talentUiNodesForTree(catalog: TalentUiCatalog, treeId: string):
|
||||
.filter((node) => node.treeId === treeId)
|
||||
.sort((left, right) => left.row - right.row || left.index - right.index);
|
||||
}
|
||||
|
||||
export function talentUiArtUrls(catalog: TalentUiCatalog): readonly string[] {
|
||||
return [...new Set([
|
||||
...catalog.trees.flatMap((tree) => [tree.icon, tree.background]),
|
||||
...catalog.nodes.map((node) => node.icon),
|
||||
])];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import { requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import type { DungeonDifficultyVariant, DungeonSourceRecipe } from "./dungeonTypes";
|
||||
|
||||
const recipeModules = import.meta.glob<DungeonSourceRecipe>(
|
||||
"../../dungeon-pipeline/recipes/*.json",
|
||||
{ eager: true, import: "default" },
|
||||
);
|
||||
|
||||
const TBC_DUNGEON_IDS = campaign.dungeons
|
||||
.filter((dungeon) => dungeon.source === "burning-crusade")
|
||||
.map((dungeon) => dungeon.slug);
|
||||
|
||||
function requireRecipe(dungeonId: string): DungeonSourceRecipe {
|
||||
const path = `../../dungeon-pipeline/recipes/${dungeonId}.json`;
|
||||
const recipe = recipeModules[path];
|
||||
if (!recipe) throw new Error(`Missing Burning Crusade recipe: ${path}`);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
function expectSelectableDifficultyContract(
|
||||
label: string,
|
||||
variants: readonly DungeonDifficultyVariant[],
|
||||
): void {
|
||||
const normal = variants.find((variant) => variant.id === "normal");
|
||||
const heroic = variants.find((variant) => variant.id === "heroic");
|
||||
|
||||
expect(normal, `${label}:normal`).toMatchObject({
|
||||
difficulty: "normal",
|
||||
spawnMask: 1,
|
||||
});
|
||||
expect(heroic, `${label}:heroic`).toMatchObject({
|
||||
difficulty: "heroic",
|
||||
spawnMask: 2,
|
||||
levelRange: [70, 70],
|
||||
});
|
||||
expect(new Set(variants.map((variant) => variant.id)).size, `${label}:unique-ids`)
|
||||
.toBe(variants.length);
|
||||
}
|
||||
|
||||
describe("Burning Crusade difficulty contract", () => {
|
||||
it("advertises selectable normal and level-70 heroic variants in every recipe and compiled definition", () => {
|
||||
expect(TBC_DUNGEON_IDS).toHaveLength(14);
|
||||
|
||||
for (const dungeonId of TBC_DUNGEON_IDS) {
|
||||
const recipe = requireRecipe(dungeonId);
|
||||
const definition = requireDungeonDefinition(dungeonId);
|
||||
|
||||
expect(recipe.slug, dungeonId).toBe(dungeonId);
|
||||
expect(recipe.expansion, dungeonId).toBe("burning-crusade");
|
||||
expectSelectableDifficultyContract(`${dungeonId}:recipe`, recipe.difficultyVariants);
|
||||
expectSelectableDifficultyContract(`${dungeonId}:runtime`, definition.difficultyVariants);
|
||||
}
|
||||
});
|
||||
|
||||
it("only uses encounter difficulty guards that players can select", () => {
|
||||
for (const dungeonId of TBC_DUNGEON_IDS) {
|
||||
const definition = requireDungeonDefinition(dungeonId);
|
||||
const selectableDifficultyIds = new Set(
|
||||
definition.difficultyVariants.map((variant) => variant.id),
|
||||
);
|
||||
|
||||
for (const encounter of definition.encounters ?? []) {
|
||||
for (const rule of encounter.rules) {
|
||||
for (const guard of rule.guards ?? []) {
|
||||
if (guard.kind !== "difficulty") continue;
|
||||
expect(guard.difficultyIds.length, `${dungeonId}:${encounter.id}:${rule.id}`)
|
||||
.toBeGreaterThan(0);
|
||||
for (const difficultyId of guard.difficultyIds) {
|
||||
expect(
|
||||
selectableDifficultyIds.has(difficultyId),
|
||||
`${dungeonId}:${encounter.id}:${rule.id}:${difficultyId}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -114,12 +114,50 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
|
||||
dungeonId: "wailing-caverns",
|
||||
initialPhase: "locked",
|
||||
resetPolicy: { bossWipePhase: "locked", checkpointPhase: "locked" },
|
||||
source: {
|
||||
fidelity: "source-backed-adapted",
|
||||
scope: "instance-orchestration",
|
||||
files: [
|
||||
"src/game/generated/azerothCoreReference.json",
|
||||
"dungeon-pipeline/fixtures/wailing-caverns/source-snapshot.json",
|
||||
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/instance_wailing_caverns.cpp",
|
||||
"../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts/Kalimdor/WailingCaverns/wailing_caverns.h",
|
||||
],
|
||||
reviewedBehavior: "The four Fanglords unlock the Disciple of Naralex event, which advances through ravager, serpent, cleansing, and nightmare stages before summoning Mutanus and completing the dungeon.",
|
||||
adaptations: [
|
||||
"Disciple movement is deterministic waypoint interpolation using source positions and reviewed stage durations instead of server-side escort motion packets.",
|
||||
"Wave safety timeouts prevent a missing or unreachable summon from permanently soft locking the event.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Port the complete source dialogue/emote sequence and every waypoint pause trigger.",
|
||||
"Bind the Disciple interaction to a reviewed source-backed NPC/gossip presentation instead of the current encounter signal adapter.",
|
||||
"Record full traversal, wipe/reset, checkpoint reload, HUD, collision, and completion playtest evidence.",
|
||||
],
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
id: "fanglords-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: FANG_LORD_RUNTIME_IDS },
|
||||
guards: [{ kind: "phase", phases: ["locked"] }],
|
||||
actions: [{ id: "unlock-naralex", kind: "set-phase", phase: "ready" }],
|
||||
actions: [
|
||||
{
|
||||
id: "record-fanglords-complete",
|
||||
kind: "set-flag",
|
||||
flagId: "fanglords-complete",
|
||||
value: true,
|
||||
persistence: "run",
|
||||
},
|
||||
{ id: "unlock-naralex", kind: "set-phase", phase: "ready" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "restore-ready-after-checkpoint",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [
|
||||
{ kind: "phase", phases: ["locked"] },
|
||||
{ kind: "flag", flagId: "fanglords-complete", equals: true },
|
||||
],
|
||||
actions: [{ id: "restore-ready-phase", kind: "set-phase", phase: "ready" }],
|
||||
},
|
||||
{
|
||||
id: "start-escort",
|
||||
@@ -196,6 +234,8 @@ export const WAILING_NARALEX_ENCOUNTER = Object.freeze({
|
||||
actions: [
|
||||
{ id: "complete-naralex", kind: "set-phase", phase: "complete", persistence: "run" },
|
||||
{ id: "complete-mutanus-objective", kind: "complete-objective", objectiveId: "mutanus-spawn", persistence: "run" },
|
||||
{ id: "save-mutanus-checkpoint", kind: "set-checkpoint", checkpointId: "mutanus-spawn", persistence: "checkpoint" },
|
||||
{ id: "mark-wailing-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -136,5 +136,31 @@ describe("AzerothCore-backed Wailing Caverns parity slice", () => {
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("mutanus");
|
||||
useWailingEncounterStore.getState().advance({ "mutanus-spawn": deadMob }, 264_504);
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("complete");
|
||||
expect(useWailingEncounterStore.getState().flags["dungeon-complete"]).toBe(true);
|
||||
expect(useWailingEncounterStore.getState().checkpointId).toBe("mutanus-spawn");
|
||||
});
|
||||
|
||||
it("restores the Naralex unlock after a checkpoint reload suppresses the cleared Fanglords", () => {
|
||||
useWailingEncounterStore.getState().advance({
|
||||
"lady-anacondra-spawn": deadMob,
|
||||
"lord-cobrahn-spawn": deadMob,
|
||||
"lord-pythas-spawn": deadMob,
|
||||
"lord-serpentis-spawn": deadMob,
|
||||
}, 1_000);
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "ready",
|
||||
flags: { "fanglords-complete": true },
|
||||
});
|
||||
|
||||
useWailingEncounterStore.getState().reset("checkpoint", 2_000);
|
||||
expect(useWailingEncounterStore.getState()).toMatchObject({
|
||||
phase: "locked",
|
||||
flags: { "fanglords-complete": true },
|
||||
});
|
||||
|
||||
// A loaded checkpoint intentionally has no live Fanglord runtime ids: the
|
||||
// progression provider has already suppressed those completed bosses.
|
||||
useWailingEncounterStore.getState().advance({}, 2_100);
|
||||
expect(useWailingEncounterStore.getState().phase).toBe("ready");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,515 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS,
|
||||
withWowClassicEncounterBatchThree,
|
||||
} from "./wowClassicEncounterBatchThreeCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function original(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function preadapted(id: string): DungeonDefinition {
|
||||
return withWowEncounterContent(original(id));
|
||||
}
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
return withWowClassicEncounterBatchThree(preadapted(id));
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-three`);
|
||||
if (!value) throw new Error(`Missing Classic batch-three encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
|
||||
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
|
||||
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
scripted: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed encounter batch three", () => {
|
||||
it("pins sources, validates references, suppresses event actors, remains idempotent, and preserves Solakar", () => {
|
||||
const cases = [
|
||||
["gnomeregan", [7_998]],
|
||||
["dire-maul", [11_496]],
|
||||
["blackrock-spire", [9_816, 10_316, 10_429, 10_264]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId))
|
||||
.toEqual(cases.map(([id]) => id));
|
||||
|
||||
const checkedPaths = new Set<string>();
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
if (!checkedPaths.has(sourceFile.path)) {
|
||||
expect(createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex"), sourceFile.path)
|
||||
.toBe(sourceFile.sha256);
|
||||
checkedPaths.add(sourceFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priorBRS = preadapted("blackrock-spire");
|
||||
const priorSolakarEncounter = priorBRS.encounters?.find((value) => value.id === "blackrock-spire:wow-instance");
|
||||
const priorSolakarGroups = priorBRS.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar"));
|
||||
const priorFatherFlame = priorBRS.serverGameObjects.find((value) => value.source?.templateId === 175_245);
|
||||
expect(priorSolakarEncounter).toBeDefined();
|
||||
expect(priorSolakarGroups).toHaveLength(6);
|
||||
expect(priorFatherFlame).toBeDefined();
|
||||
|
||||
for (const [id, suppressed] of cases) {
|
||||
const definition = adapted(id);
|
||||
const scripted = encounter(definition);
|
||||
validateDungeonEncounterDefinition(scripted);
|
||||
expect(scripted.source?.scope).toBe("instance-orchestration");
|
||||
expect(withWowClassicEncounterBatchThree(definition)).toBe(definition);
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressed]));
|
||||
|
||||
const spawnIds = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const groupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
|
||||
expect(objectIds.size).toBe(definition.serverGameObjects.length);
|
||||
expect(groupIds.size).toBe(definition.encounterSpawnGroups?.length ?? 0);
|
||||
const actionIds = new Set<string>();
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) expect(definition.entities[spawn.entityId], `${id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
for (const rule of scripted.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
||||
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expect(actionIds.has(action.id), `${id}:duplicate action ${action.id}`).toBe(false);
|
||||
actionIds.add(action.id);
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
||||
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
|
||||
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourceSpawnIds = definition.serverGameObjects.flatMap((object) => (
|
||||
object.source?.spawnId === undefined ? [] : [object.source.spawnId]
|
||||
));
|
||||
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
|
||||
}
|
||||
|
||||
const brs = adapted("blackrock-spire");
|
||||
expect(brs.encounters?.find((value) => value.id === "blackrock-spire:wow-instance")).toEqual(priorSolakarEncounter);
|
||||
expect(brs.encounterSpawnGroups?.filter((value) => value.id.startsWith("blackrock-spire:solakar")))
|
||||
.toEqual(priorSolakarGroups);
|
||||
expect(brs.serverGameObjects.find((value) => value.source?.templateId === 175_245)).toEqual(priorFatherFlame);
|
||||
});
|
||||
|
||||
it("ports Gyth and Urok's pinned combat schedules instead of leaving event bosses basic-only", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const byEntry = (entry: number) => Object.values(definition.entities)
|
||||
.find((entity) => entity.combat?.serverEntry === entry)!;
|
||||
const gyth = byEntry(10_339);
|
||||
const urok = byEntry(10_584);
|
||||
|
||||
expect(gyth.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "corrosive-acid-16359", spellId: 16_359, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "freeze-16350", spellId: 16_350, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "flame-breath-16390", spellId: 16_390, initial: [8_000, 16_000], repeat: [10_000, 16_000] },
|
||||
{ id: "knock-away-10101", spellId: 10_101, initial: [12_000, 18_000], repeat: [14_000, 20_000] },
|
||||
]);
|
||||
expect(gyth.combat?.mechanicIds).toEqual([
|
||||
"corrosive-acid-16359",
|
||||
"freeze-16350",
|
||||
"flame-breath-16390",
|
||||
"knock-away-10101",
|
||||
]);
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "corrosive-acid-16359"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "nature",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "freeze-16350"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "frost",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 10_000, damageMultiplier: 0.8 },
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
expect(gyth.combat?.attacks.find((attack) => attack.id === "flame-breath-16390"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
school: "fire",
|
||||
range: 20,
|
||||
radius: 20,
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20, angleDegrees: 90 },
|
||||
});
|
||||
|
||||
expect(urok.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "rend-16509", spellId: 16_509, initial: [17_000, 20_000], repeat: [8_000, 10_000] },
|
||||
{ id: "strike-15580", spellId: 15_580, initial: [10_000, 12_000], repeat: [8_000, 10_000] },
|
||||
{ id: "intimidating-roar-16508", spellId: 16_508, initial: [25_000, 30_000], repeat: [40_000, 45_000] },
|
||||
]);
|
||||
expect(urok.combat?.attacks.find((attack) => attack.id === "intimidating-roar-16508"))
|
||||
.toMatchObject({
|
||||
target: "nearby-party",
|
||||
range: 8,
|
||||
radius: 8,
|
||||
effect: { kind: "control", mechanic: "fear", durationMs: 8_000 },
|
||||
telegraph: { shape: "circle", anchor: "source", radius: 8 },
|
||||
});
|
||||
|
||||
const review = WOW_CLASSIC_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
|
||||
candidate.dungeonId === "blackrock-spire"
|
||||
))!;
|
||||
expect(review.remainingGaps.join(" ")).toContain("Freeze periodic damage");
|
||||
expect(review.remainingGaps.join(" ")).toContain("split primary-root/nearby-fear targeting");
|
||||
});
|
||||
|
||||
it("ports Crystal Fang's exact death summon while keeping the source-faithful rare boss melee-only", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const scripted = encounter(definition);
|
||||
const crystalFang = definition.staticSpawns.find((spawn) => (
|
||||
definition.entities[spawn.entityId]?.combat?.serverEntry === 10_376
|
||||
))!;
|
||||
const spiderling = group(definition, "blackrock-spire:batch-three:crystal-fang-spiderling");
|
||||
expect(crystalFang.spawnChance).toBe(0.15);
|
||||
expect(spiderling.spawns).toHaveLength(1);
|
||||
expect(spiderling.spawns[0]).toMatchObject({
|
||||
entityId: expect.stringContaining("entry-10375"),
|
||||
position: crystalFang.position,
|
||||
});
|
||||
|
||||
const snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "entity-death",
|
||||
entityId: crystalFang.id,
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(spiderling.id);
|
||||
});
|
||||
|
||||
it("runs Gnomeregan's exact Emi group counts, cave-ins, retry, doors, and persistent Grubbis gate", () => {
|
||||
const definition = adapted("gnomeregan");
|
||||
const scripted = encounter(definition);
|
||||
const groups = ["ambushers-1", "ambushers-2", "ambushers-3a", "ambushers-3b", "grubbis"]
|
||||
.map((name) => group(definition, `gnomeregan:batch-three:${name}`));
|
||||
expect(groups.map((value) => value.spawns.length)).toEqual([10, 4, 5, 5, 2]);
|
||||
expect(groups[2].spawns.map((spawn) => spawn.position)).toEqual(groups[3].spawns.map((spawn) => spawn.position));
|
||||
const emi = definition.serverGameObjects.find((object) => object.id === "gnomeregan:batch-three:emi-shortfuse")!;
|
||||
const caveOne = objectBySpawn(definition, 6_447);
|
||||
const caveTwo = objectBySpawn(definition, 17_633);
|
||||
const workshop = objectBySpawn(definition, 12_003);
|
||||
const finalDoor = objectBySpawn(definition, 6_910);
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: workshop.id, now: 0 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: finalDoor.id, now: 1 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[0].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[0], 3);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 4);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[emi.id]).toBe("ready");
|
||||
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("closed");
|
||||
expect(snapshot.worldObjectStates[workshop.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[finalDoor.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emi.id, now: 10 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, groups[0], 11);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_011 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[1].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[1], 5_012);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 45_012 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[caveTwo.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[2].id);
|
||||
snapshot = killGroup(scripted, snapshot, groups[2], 45_013);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 48_013 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, groups[3], 48_014);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 108_014 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groups[4].id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "gnomeregan:batch-three:grubbis",
|
||||
now: 108_015,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
||||
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emi.id]).toBe("complete");
|
||||
expect(snapshot.checkpointId).toBe("gnomeregan-grubbis");
|
||||
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 108_016);
|
||||
expect(snapshot.completedObjectiveIds).toContain("gnomeregan:batch-three:grubbis");
|
||||
expect(snapshot.worldObjectStates[caveOne.id]).toBe("open");
|
||||
const fleet = definition.encounters?.find((value) => value.id === "gnomeregan:boss-progression")!;
|
||||
const completion = fleet.rules.find((rule) => rule.id === "all-required-bosses-defeated")!;
|
||||
expect(completion.guards).toContainEqual({
|
||||
kind: "objective-complete",
|
||||
objectiveId: "gnomeregan:batch-three:grubbis",
|
||||
equals: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs Dire Maul's pylons, Immol'thar recovery, east-wing wall, doors, and six tribute tiers", () => {
|
||||
const definition = adapted("dire-maul");
|
||||
const scripted = encounter(definition);
|
||||
const pylons = [29_742, 29_743, 29_744, 29_745, 29_746].map((id) => objectBySpawn(definition, id));
|
||||
const forcefield = objectBySpawn(definition, 29_747);
|
||||
const courtyard = objectBySpawn(definition, 29_750);
|
||||
const inner = objectBySpawn(definition, 29_756);
|
||||
const conservatory = objectBySpawn(definition, 268_275);
|
||||
const wall = objectBySpawn(definition, 268_276);
|
||||
const ironbark = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:old-ironbark")!;
|
||||
const tribute = definition.serverGameObjects.find((object) => object.id === "dire-maul:batch-three:gordok-tribute")!;
|
||||
const immol = group(definition, "dire-maul:batch-three:immolthar");
|
||||
const alzzin = group(definition, "dire-maul:batch-three:alzzin");
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
pylons.forEach((pylon, index) => {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pylon.id, now: index }).snapshot;
|
||||
});
|
||||
expect(snapshot.counters["pylons-off"]).toBe(5);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 10 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 11);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(immol.id);
|
||||
expect(snapshot.counters["pylons-off"]).toBe(5);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 12 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(immol.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: immol.spawns[0].id, now: 13 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain(immol.spawns[0].id);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248429", now: 20 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[ironbark.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: ironbark.id, now: 21 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_021 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[conservatory.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 51, now: 7_022 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[wall.id]).toBe("closed");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: alzzin.spawns[0].id, healthPercent: 50, now: 7_023 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[wall.id]).toBe("open");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 7_024);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 7_025 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(alzzin.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: alzzin.spawns[0].id, now: 7_026 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("dire-maul:batch-three:alzzin");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: courtyard.id, now: 8_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: inner.id, now: 8_001 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[courtyard.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[inner.id]).toBe("open");
|
||||
for (const id of ["creature-248079", "creature-248080"]) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: id, now: snapshot.revision + 8_010 }).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-248076", now: 9_000 }).snapshot;
|
||||
expect(snapshot.counters["tribute-penalties"]).toBe(2);
|
||||
expect(snapshot.worldObjectStates[tribute.id]).toBe("tier-4");
|
||||
|
||||
let perfect = createDungeonEncounterSnapshot(scripted);
|
||||
perfect = reduceDungeonEncounter(scripted, perfect, { kind: "entity-death", entityId: "creature-248076", now: 1 }).snapshot;
|
||||
expect(perfect.worldObjectStates[tribute.id]).toBe("tier-6");
|
||||
const optionalIds = new Set(["creature-248077", "creature-248079", "creature-248080", "creature-248081", "creature-248082"]);
|
||||
expect(definition.bosses.filter((boss) => optionalIds.has(boss.id)).every((boss) => boss.optional)).toBe(true);
|
||||
const fleetCompletion = definition.encounters?.find((value) => value.id === "dire-maul:boss-progression")
|
||||
?.rules.find((rule) => rule.id === "all-required-bosses-defeated");
|
||||
expect(fleetCompletion?.guards?.some((guard) => guard.kind === "objective-complete" && optionalIds.has(guard.objectiveId)))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it("runs Blackrock Spire's runes, Emberseer reset, exact stadium, Drakkisath doors, and Urok retry", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const scripted = encounter(definition);
|
||||
const hallRunes = [87_827, 87_829, 87_825, 87_830, 87_828, 87_826, 87_824].map((id) => objectBySpawn(definition, id));
|
||||
const emberRunes = [87_831, 87_832, 87_833, 87_834, 87_835, 87_836, 87_837].map((id) => objectBySpawn(definition, id));
|
||||
const emberIn = objectBySpawn(definition, 87_839);
|
||||
const emberOut = objectBySpawn(definition, 87_841);
|
||||
const emberDoors = objectBySpawn(definition, 87_842);
|
||||
const emberTrigger = definition.serverGameObjects.find((object) => object.id === "blackrock-spire:batch-three:emberseer-trigger")!;
|
||||
const incarcerators = group(definition, "blackrock-spire:batch-three:incarcerators");
|
||||
const emberseer = group(definition, "blackrock-spire:batch-three:emberseer");
|
||||
const runeMobs = [
|
||||
["creature-137869", "creature-137871", "creature-137928", "creature-137930"],
|
||||
["creature-137872", "creature-137896", "creature-137898"],
|
||||
["creature-137870", "creature-137886", "creature-137929"],
|
||||
["creature-137873", "creature-137892", "creature-137897", "creature-137903", "creature-137904", "creature-137905", "creature-137931", "creature-137934"],
|
||||
["creature-137878", "creature-137911", "creature-137912", "creature-137939"],
|
||||
["creature-137880", "creature-137881", "creature-137942", "creature-137943"],
|
||||
["creature-137877", "creature-137908", "creature-137909", "creature-137910", "creature-137938"],
|
||||
];
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
runeMobs.forEach((ids, index) => {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: index + 1,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
});
|
||||
expect(snapshot.counters["dragonspire-runes"]).toBe(7);
|
||||
expect(hallRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "inactive")).toBe(true);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 8 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emberDoors.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 10 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_010 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(incarcerators.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2_011);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2_012 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[emberTrigger.id]).toBe("ready");
|
||||
expect(snapshot.worldObjectStates[emberIn.id]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(incarcerators.id);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: emberTrigger.id, now: 3_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, incarcerators, 5_001);
|
||||
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "growing")).toBe(true);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 30_001 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(emberseer.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: emberseer.spawns[0].id, now: 30_002 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
||||
expect(snapshot.worldObjectStates[emberOut.id]).toBe("open");
|
||||
expect(emberRunes.every((rune) => snapshot.worldObjectStates[rune.id] === "released")).toBe(true);
|
||||
|
||||
const stadiumEntry = objectBySpawn(definition, 87_845);
|
||||
const stadiumWaveDoor = objectBySpawn(definition, 87_843);
|
||||
const stadiumExit = objectBySpawn(definition, 87_844);
|
||||
const waves = Array.from({ length: 6 }, (_, index) => group(definition, `blackrock-spire:batch-three:stadium-wave-${index + 1}`));
|
||||
expect(waves.map((value) => value.spawns.length)).toEqual([4, 3, 4, 4, 5, 5]);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: stadiumEntry.id, now: 31_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 42_000 }).snapshot;
|
||||
for (let index = 0; index < waves.length; index += 1) {
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(waves[index].id);
|
||||
snapshot = killGroup(scripted, snapshot, waves[index], snapshot.phaseStartedAt + 1);
|
||||
if (index < waves.length - 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 13_000 }).snapshot;
|
||||
}
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 18_000 }).snapshot;
|
||||
const gyth = group(definition, "blackrock-spire:batch-three:gyth");
|
||||
const rend = group(definition, "blackrock-spire:batch-three:rend");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(gyth.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 26, now: snapshot.phaseStartedAt + 1 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(rend.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-health", entityId: gyth.spawns[0].id, healthPercent: 25, now: snapshot.phaseStartedAt + 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(rend.id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: gyth.spawns[0].id, now: snapshot.phaseStartedAt + 3 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: rend.spawns[0].id, now: snapshot.phaseStartedAt + 4 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 5 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
||||
expect(snapshot.worldObjectStates[stadiumEntry.id]).toBe("complete");
|
||||
expect(snapshot.worldObjectStates[stadiumWaveDoor.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[stadiumExit.id]).toBe("open");
|
||||
|
||||
const drakkDoors = [objectBySpawn(definition, 87_854), objectBySpawn(definition, 87_855)];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-137971", now: snapshot.phaseStartedAt + 6 }).snapshot;
|
||||
expect(drakkDoors.every((door) => snapshot.worldObjectStates[door.id] === "open")).toBe(true);
|
||||
|
||||
const pile = objectBySpawn(definition, 99_791);
|
||||
const urok = group(definition, "blackrock-spire:batch-three:urok");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 7 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(urok.id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", snapshot.phaseStartedAt + 8);
|
||||
expect(snapshot.worldObjectStates[pile.id]).toBe("ready");
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137817");
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-137968");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: pile.id, now: snapshot.phaseStartedAt + 9 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: urok.spawns[0].id, now: snapshot.phaseStartedAt + 10 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-spire:batch-three:urok-doomhowl");
|
||||
expect(snapshot.worldObjectStates[pile.id]).toBe("complete");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,482 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS,
|
||||
withWowClassicEncounterBatchTwo,
|
||||
} from "./wowClassicEncounterBatchTwoCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function original(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
return withWowClassicEncounterBatchTwo(original(id));
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:classic-batch-two`);
|
||||
if (!value) throw new Error(`Missing Classic batch-two encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectBySpawn(definition: DungeonDefinition, spawnId: number) {
|
||||
const matches = definition.serverGameObjects.filter((object) => object.source?.spawnId === spawnId);
|
||||
if (matches.length !== 1) throw new Error(`${definition.id} expected one source object ${spawnId}, found ${matches.length}`);
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
) {
|
||||
const entityIds = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: entityIds, deadEntityIds: entityIds },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed encounter batch two", () => {
|
||||
it("pins exact source hashes, validates every reference, suppresses event actors, and stays idempotent", () => {
|
||||
const cases = [
|
||||
["sunken-temple", [8_580, 8_443, 5_709]],
|
||||
["blackrock-depths", [9_035, 9_038, 9_040, 9_037, 9_036, 9_034, 9_039]],
|
||||
["scholomance", [1_853]],
|
||||
["stratholme", [10_440]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId))
|
||||
.toEqual(cases.map(([id]) => id));
|
||||
|
||||
const verifiedPaths = new Set<string>();
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_BATCH_TWO_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
if (!verifiedPaths.has(sourceFile.path)) {
|
||||
const actualHash = createHash("sha256").update(readFileSync(resolve(sourceFile.path))).digest("hex");
|
||||
expect(actualHash, sourceFile.path).toBe(sourceFile.sha256);
|
||||
verifiedPaths.add(sourceFile.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, suppressedEntries] of cases) {
|
||||
const definition = adapted(id);
|
||||
const scripted = encounter(definition);
|
||||
validateDungeonEncounterDefinition(scripted);
|
||||
expect(withWowClassicEncounterBatchTwo(definition)).toBe(definition);
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
|
||||
|
||||
const spawnIds = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((objective) => objective.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const rule of scripted.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
||||
expect(spawnIds.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.entityIds.every((entityId) => spawnIds.has(entityId)), `${id}:${rule.id}`).toBe(true);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === action.objectId);
|
||||
expect(object, `${id}:${rule.id}:${action.id}`).toBeDefined();
|
||||
expect(object?.states.some((state) => state.id === action.state), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourceSpawnIds = definition.serverGameObjects
|
||||
.flatMap((object) => object.source?.spawnId === undefined ? [] : [object.source.spawnId]);
|
||||
expect(new Set(sourceSpawnIds).size).toBe(sourceSpawnIds.length);
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
scripted.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
expect(definition.validation.warnings.some((warning) => warning.includes("remain"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Sunken Temple's ordered statues, ritual, defender gate, and Eranikus recovery", () => {
|
||||
const definition = adapted("sunken-temple");
|
||||
const scripted = encounter(definition);
|
||||
const statues = [27_898, 27_899, 28_111, 28_112, 28_113, 28_114]
|
||||
.map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
const forcefield = objectBySpawn(definition, 268_932);
|
||||
const altar = objectBySpawn(definition, 11_922);
|
||||
const flames = [47_561, 47_562, 47_563, 47_564].map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: statues[1].id, now: 1 }).snapshot;
|
||||
expect(snapshot.counters["statue-progress"]).toBe(0);
|
||||
for (let index = 0; index < statues.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: statues[index].id,
|
||||
now: index + 2,
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "atal-alarion-active", counters: { "statue-progress": 6 } });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 20);
|
||||
expect(snapshot).toMatchObject({ phase: "recovery", counters: { "statue-progress": 6 }, activeSpawnGroupIds: [] });
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:atal-alarion");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:atal-alarion"), 21);
|
||||
const atalAlarion = definition.bosses.find((objective) => objective.name === "Atal'alarion")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(atalAlarion.id);
|
||||
|
||||
const defenderIds = ["creature-39843", "creature-39844", "creature-39845", "creature-39846", "creature-34522", "creature-39847"];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 22,
|
||||
facts: { registeredEntityIds: defenderIds, deadEntityIds: defenderIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[forcefield.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 30 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:hakkar-pack-1");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 31);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 31 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[altar.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: altar.id, now: 40 }).snapshot;
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const ritualGroup = group(definition, `sunken-temple:hakkar-pack-${index + 1}`);
|
||||
snapshot = killGroup(scripted, snapshot, ritualGroup, 41 + index * 2);
|
||||
expect(snapshot.worldObjectStates[flames[index].id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: flames[index].id,
|
||||
now: 42 + index * 2,
|
||||
}).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:avatar-of-hakkar");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:avatar-of-hakkar"), 60);
|
||||
const avatar = definition.bosses.find((objective) => objective.name === "Avatar of Hakkar")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(avatar.id);
|
||||
expect(flames.every((flame) => snapshot.worldObjectStates[flame.id] === "doused")).toBe(true);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: "creature-39737", now: 70 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 71);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 71 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("sunken-temple:shade-of-eranikus");
|
||||
snapshot = killGroup(scripted, snapshot, group(definition, "sunken-temple:shade-of-eranikus"), 72);
|
||||
const eranikus = definition.bosses.find((objective) => objective.name === "Shade of Eranikus")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(eranikus.id);
|
||||
expect(snapshot.checkpointId).toBe("sunken-temple-eranikus");
|
||||
});
|
||||
|
||||
it("runs BRD's deterministic arena, twelve-coffer vault, and exact seven-stage tomb with retry", () => {
|
||||
const definition = adapted("blackrock-depths");
|
||||
const scripted = encounter(definition);
|
||||
const arenaGate4 = objectBySpawn(definition, 15_309);
|
||||
const arenaGate3 = objectBySpawn(definition, 15_295);
|
||||
const arenaGroups = [
|
||||
group(definition, "blackrock-depths:ring-wave-1"),
|
||||
group(definition, "blackrock-depths:ring-wave-2"),
|
||||
group(definition, "blackrock-depths:ring-boss"),
|
||||
];
|
||||
expect(arenaGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([3, 3, 1]);
|
||||
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "interact",
|
||||
objectId: arenaGate4.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_001);
|
||||
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("ready");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: arenaGate4.id, now: 6_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 11_000 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[0], 11_001);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 16_001 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[1], 16_002);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 21_002 }).snapshot;
|
||||
snapshot = killGroup(scripted, snapshot, arenaGroups[2], 21_003);
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:ring-of-law-gorosh");
|
||||
expect(snapshot.worldObjectStates[arenaGate3.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[arenaGate4.id]).toBe("open");
|
||||
|
||||
const coffers = [15_306, 15_329, 15_330, 15_331, 15_363, 15_364, 15_365, 15_536, 17_904, 35_801, 35_864, 39_924]
|
||||
.map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 30 + index }).snapshot;
|
||||
}
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 40);
|
||||
expect(snapshot.counters["opened-coffers"]).toBe(6);
|
||||
expect(coffers.slice(0, 6).every((coffer) => snapshot.worldObjectStates[coffer.id] === "open")).toBe(true);
|
||||
for (let index = 6; index < coffers.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: coffers[index].id, now: 41 + index }).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 60 }).snapshot;
|
||||
const watchman = group(definition, "blackrock-depths:vault-watchman");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(watchman.id);
|
||||
snapshot = killGroup(scripted, snapshot, watchman, 61);
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:watchman-doomgrip");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 43_094).id]).toBe("open");
|
||||
|
||||
const marker = definition.serverGameObjects.find((object) => object.id === "blackrock-depths:doomrel-marker")!;
|
||||
const tombGroups = Array.from({ length: 7 }, (_, index) => group(definition, `blackrock-depths:tomb-spectral-${index + 1}`));
|
||||
expect(tombGroups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1, 1, 1, 1, 1]);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 100 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1_100 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(tombGroups[0].id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1_101);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", counters: { "tomb-kills": 0 }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[marker.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: marker.id, now: 2_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 3_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: tombGroups[0].spawns[0].id,
|
||||
now: 3_001,
|
||||
}).snapshot;
|
||||
for (let index = 1; index < tombGroups.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 30_000,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: tombGroups[index].spawns[0].id,
|
||||
now: snapshot.phaseStartedAt + 1,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: snapshot.phaseStartedAt + 2 }).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("blackrock-depths:tomb-of-seven");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 67_872).id]).toBe("available");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 15_334).id]).toBe("open");
|
||||
});
|
||||
|
||||
it("gates Scholomance completion on six minibosses and a wipe-safe Gandling reveal", () => {
|
||||
const definition = adapted("scholomance");
|
||||
const scripted = encounter(definition);
|
||||
const brazier = objectBySpawn(definition, 43_208);
|
||||
const kirtonosGate = objectBySpawn(definition, 18_892);
|
||||
const gandlingEntrance = objectBySpawn(definition, 43_201);
|
||||
const progression = definition.encounters?.find((candidate) => candidate.id === "scholomance:boss-progression")!;
|
||||
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
|
||||
|
||||
let snapshot = reduceDungeonEncounter(scripted, createDungeonEncounterSnapshot(scripted), {
|
||||
kind: "interact",
|
||||
objectId: brazier.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 1);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 1 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[brazier.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "interact", objectId: brazier.id, now: 2 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_002 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:kirtonos");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "scholomance:kirtonos-the-herald",
|
||||
now: 5_003,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("scholomance:kirtonos-the-herald");
|
||||
expect(snapshot.worldObjectStates[kirtonosGate.id]).toBe("open");
|
||||
|
||||
const miniBossIds = ["creature-48867", "creature-48925", "creature-48946", "creature-48863", "creature-48857", "creature-48854"];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 6_000,
|
||||
facts: { registeredEntityIds: miniBossIds, deadEntityIds: miniBossIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("gandling-reveal");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_001);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_001 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "gandling-ready", flags: { "gandling-unlocked": true } });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("scholomance:gandling");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-engaged",
|
||||
entityId: "creature-247104",
|
||||
now: 6_002,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 6_003);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 6_003 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[gandlingEntrance.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "creature-247104",
|
||||
now: 6_004,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-247104");
|
||||
|
||||
const fleetRequired = definition.bosses.filter((objective) => !objective.optional && !objective.eventOnly);
|
||||
for (const objective of fleetRequired) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: objective.id,
|
||||
now: snapshot.revision + 10_000,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20_000 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
|
||||
});
|
||||
|
||||
it("persists and resumes Stratholme's exact 3-to-13-to-1-to-33-to-5 slaughter chain", () => {
|
||||
const definition = adapted("stratholme");
|
||||
const scripted = encounter(definition);
|
||||
const acolytes = Array.from({ length: 3 }, (_, index) => group(definition, `stratholme:ziggurat-${index + 1}-acolyte`));
|
||||
const zigguratBosses = ["creature-54238", "creature-54239", "creature-54240"];
|
||||
const zigguratDoors = [11_415, 11_414, 11_416].map((spawnId) => objectBySpawn(definition, spawnId));
|
||||
const mindless = group(definition, "stratholme:mindless-undead");
|
||||
const guards = group(definition, "stratholme:black-guards");
|
||||
expect(acolytes.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([1, 1, 1]);
|
||||
expect(mindless.spawns).toHaveLength(33);
|
||||
expect(guards.spawns).toHaveLength(5);
|
||||
let snapshot = createDungeonEncounterSnapshot(scripted);
|
||||
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "entity-death", entityId: zigguratBosses[0], now: 1 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 2);
|
||||
expect(snapshot.worldObjectStates[zigguratDoors[0].id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 2 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(acolytes[0].id);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: acolytes[0].spawns[0].id,
|
||||
now: 3,
|
||||
}).snapshot;
|
||||
for (let index = 1; index < 3; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: zigguratBosses[index],
|
||||
now: 4 + index * 2,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: acolytes[index].spawns[0].id,
|
||||
now: 5 + index * 2,
|
||||
}).snapshot;
|
||||
}
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 20 }).snapshot;
|
||||
expect(snapshot.flags["ziggurats-complete"]).toBe(true);
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_911).id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 6_908).id]).toBe("open");
|
||||
|
||||
const slaughterIds = [
|
||||
"creature-53969", "creature-54002", "creature-54018", "creature-54019",
|
||||
"creature-54020", "creature-54021", "creature-54022", "creature-54026", "creature-54027",
|
||||
"creature-54039", "creature-54040", "creature-54041", "creature-54050",
|
||||
];
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "tick",
|
||||
now: 21,
|
||||
facts: { registeredEntityIds: slaughterIds, deadEntityIds: slaughterIds },
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 22);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 22 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:ramstein");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "stratholme:ramstein-the-gorger",
|
||||
now: 23,
|
||||
}).snapshot;
|
||||
expect(snapshot.phase).toBe("mindless-delay");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "checkpoint", 24);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 24 }).snapshot;
|
||||
expect(snapshot.phase).toBe("mindless-delay");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_024 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(mindless.id);
|
||||
snapshot = killGroup(scripted, snapshot, mindless, 5_025);
|
||||
expect(snapshot.phase).toBe("black-guard-delay");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 5_026);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 5_026 }).snapshot;
|
||||
expect(snapshot.phase).toBe("black-guard-delay");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_026 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(guards.id);
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 11_808).id]).toBe("open");
|
||||
snapshot = killGroup(scripted, snapshot, guards, 25_027);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("stratholme:baron-rivendare");
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-engaged",
|
||||
entityId: "creature-54241",
|
||||
now: 25_028,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(scripted, snapshot, "boss-wipe", 25_029);
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, { kind: "tick", now: 25_029 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "baron-ready", flags: { "guards-defeated": true } });
|
||||
expect(snapshot.worldObjectStates[objectBySpawn(definition, 35_848).id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(scripted, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: "creature-54241",
|
||||
now: 25_030,
|
||||
}).snapshot;
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-54241");
|
||||
expect(snapshot.checkpointId).toBe("creature-54241");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,334 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS,
|
||||
withWowClassicEncounterContent,
|
||||
} from "./wowClassicEncounterContentCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return withWowClassicEncounterContent(definition);
|
||||
}
|
||||
|
||||
function classicEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:classic-instance`);
|
||||
if (!encounter) throw new Error(`Missing Classic encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing spawn group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectByTemplate(definition: DungeonDefinition, templateId: number) {
|
||||
const value = definition.serverGameObjects.find((object) => object.source?.templateId === templateId);
|
||||
if (!value) throw new Error(`Missing gameobject template ${templateId}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
) {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
});
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
describe("Classic source-backed dungeon encounter content", () => {
|
||||
it("pins source reviews, validates references, suppresses event bosses, and is idempotent", () => {
|
||||
const cases = [
|
||||
["blackfathom-deeps", []],
|
||||
["uldaman", [2_748]],
|
||||
["razorfen-downs", [7_355, 7_356]],
|
||||
["zulfarrak", [7_273]],
|
||||
] as const;
|
||||
expect(WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(cases.map(([id]) => id));
|
||||
for (const review of WOW_CLASSIC_ENCOUNTER_CONTENT_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.sourceFiles.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
expect(sourceFile.anchors.every((anchor) => /lines? \d+/.test(anchor))).toBe(true);
|
||||
}
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
for (const [id, suppressedEntries] of cases) {
|
||||
const definition = adapted(id);
|
||||
const encounter = classicEncounter(definition);
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
expect(withWowClassicEncounterContent(definition)).toBe(definition);
|
||||
if (suppressedEntries.length > 0) {
|
||||
expect(staticEntries(definition)).not.toEqual(expect.arrayContaining([...suppressedEntries]));
|
||||
}
|
||||
const spawnGroupIds = new Set(definition.encounterSpawnGroups?.map((spawnGroup) => spawnGroup.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${id}:${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const rule of encounter.rules) {
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(spawnGroupIds.has(action.spawnGroupId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
expect(objectIds.has(action.objectId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "complete-objective") {
|
||||
expect(objectiveIds.has(action.objectiveId), `${id}:${rule.id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects.filter((candidate) => (
|
||||
encounter.initialState?.worldObjectStates?.[candidate.id] !== undefined
|
||||
))) {
|
||||
validateDungeonWorldObject(object);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
for (const entry of suppressedEntries) {
|
||||
const entityIds = new Set(Object.entries(definition.entities)
|
||||
.filter(([, entity]) => entity.combat?.serverEntry === entry)
|
||||
.map(([entityId]) => entityId));
|
||||
const objective = definition.bosses.find((boss) => definition.encounterSpawnGroups?.some(
|
||||
(spawnGroup) => spawnGroup.spawns.some((spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId)),
|
||||
));
|
||||
expect(objective, `${id}:${entry}`).toMatchObject({ eventOnly: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("runs all four Blackfathom fires, opens the physical portal, and persists its checkpoint", () => {
|
||||
const definition = adapted("blackfathom-deeps");
|
||||
const encounter = classicEncounter(definition);
|
||||
const fires = [21_118, 21_119, 21_120, 21_121].map((entry) => objectByTemplate(definition, entry));
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter);
|
||||
fires.forEach((fire, index) => {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: fire.id,
|
||||
now: index * 10,
|
||||
}).snapshot;
|
||||
const spawnGroup = group(definition, `blackfathom-deeps:fire-${index + 1}-pack`);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
|
||||
snapshot = killGroup(encounter, snapshot, spawnGroup, index * 10 + 1).snapshot;
|
||||
expect(snapshot.worldObjectStates[fire.id]).toBe("lit");
|
||||
});
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 100 }).snapshot;
|
||||
const portal = objectByTemplate(definition, 21_117);
|
||||
expect(snapshot).toMatchObject({ counters: { "completed-fires": 4 }, checkpointId: "blackfathom-fire-event" });
|
||||
expect(snapshot.worldObjectStates[portal.id]).toBe("open");
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 200);
|
||||
expect(reset.worldObjectStates[portal.id]).toBe("open");
|
||||
expect(fires.every((fire) => reset.worldObjectStates[fire.id] === "lit")).toBe(true);
|
||||
expect(reset.activeSpawnGroupIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("opens Uldaman's seal, awakens four keepers with wipe recovery, and source-gates Archaedas", () => {
|
||||
const definition = adapted("uldaman");
|
||||
const encounter = classicEncounter(definition);
|
||||
const keystone = objectByTemplate(definition, 124_371);
|
||||
const seal = objectByTemplate(definition, 124_372);
|
||||
const keeperAltar = objectByTemplate(definition, 130_511);
|
||||
const keeperDoor = objectByTemplate(definition, 124_367);
|
||||
const archaedasAltar = objectByTemplate(definition, 133_234);
|
||||
const archaedasRoom = objectByTemplate(definition, 141_869);
|
||||
const vault = objectByTemplate(definition, 124_369);
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: keystone.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[seal.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 1 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:stone-keeper-1"), 2).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: keeperAltar.id, now: 3 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["uldaman:stone-keeper-2"]);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 4);
|
||||
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 1 } });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
for (let number = 2; number <= 4; number += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: keeperAltar.id,
|
||||
now: number * 10,
|
||||
}).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, `uldaman:stone-keeper-${number}`), number * 10 + 1).snapshot;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "active", counters: { "keepers-defeated": 4 }, checkpointId: "uldaman-stone-keepers" });
|
||||
expect(snapshot.worldObjectStates[keeperDoor.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 50 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "archaedas-active", activeSpawnGroupIds: ["uldaman:archaedas"] });
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 51);
|
||||
expect(snapshot).toMatchObject({ phase: "active", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: archaedasAltar.id, now: 52 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "uldaman:archaedas"), 53).snapshot;
|
||||
const objective = definition.bosses.find((boss) => boss.name === "Archaedas")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(objective.id);
|
||||
expect(snapshot.worldObjectStates[vault.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[archaedasRoom.id]).toBe("open");
|
||||
});
|
||||
|
||||
it("recovers Razorfen's gong and runs the full timed Belnistrasz event", () => {
|
||||
const definition = adapted("razorfen-downs");
|
||||
const encounter = classicEncounter(definition);
|
||||
const gong = objectByTemplate(definition, 148_917);
|
||||
const gongGroupIds = [
|
||||
"razorfen-downs:gong-fiends",
|
||||
"razorfen-downs:gong-reavers",
|
||||
"razorfen-downs:tutenkash",
|
||||
];
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: gong.id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([gongGroupIds[0]]);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 1 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idle", counters: { "gong-round": 0 } });
|
||||
for (let index = 0; index < gongGroupIds.length; index += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: gong.id,
|
||||
now: 10 + index * 2,
|
||||
}).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, gongGroupIds[index]), 11 + index * 2).snapshot;
|
||||
}
|
||||
const tutenkash = definition.bosses.find((boss) => boss.name === "Tuten'kash")!;
|
||||
expect(snapshot).toMatchObject({ counters: { "gong-round": 3 } });
|
||||
expect(snapshot.completedObjectiveIds).toContain(tutenkash.id);
|
||||
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
||||
|
||||
const belnistrasz = definition.serverGameObjects.find((object) => object.id === "razorfen-downs:belnistrasz")!;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: belnistrasz.id, now: 100 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 8_100 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("razorfen-downs:idol-wave-1");
|
||||
const wiped = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 8_101);
|
||||
const recovered = reduceDungeonEncounter(encounter, wiped, { kind: "tick", now: 8_101 }).snapshot;
|
||||
expect(recovered).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(recovered.worldObjectStates[belnistrasz.id]).toBe("ready");
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, recovered, { kind: "interact", objectId: belnistrasz.id, now: 9_000 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 17_000 }).snapshot;
|
||||
for (let wave = 2; wave <= 8; wave += 1) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 35_000,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(`razorfen-downs:idol-wave-${wave}`);
|
||||
}
|
||||
const plaguemawGroup = group(definition, "razorfen-downs:idol-wave-8");
|
||||
snapshot = killGroup(encounter, snapshot, plaguemawGroup, snapshot.phaseStartedAt + 1).snapshot;
|
||||
const plaguemaw = definition.bosses.find((boss) => boss.name === "Plaguemaw the Rotting")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(plaguemaw.id);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: snapshot.phaseStartedAt + 55_000,
|
||||
}).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
||||
const fires = definition.serverGameObjects.filter((object) => [151_951, 151_952, 151_973].includes(object.source?.templateId ?? -1));
|
||||
expect(fires.every((fire) => snapshot.worldObjectStates[fire.id] === "extinguished")).toBe(true);
|
||||
const resetComplete = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", snapshot.phaseStartedAt + 1);
|
||||
const resumedComplete = reduceDungeonEncounter(encounter, resetComplete, { kind: "tick", now: snapshot.phaseStartedAt + 1 }).snapshot;
|
||||
expect(resumedComplete).toMatchObject({ phase: "idol-complete", flags: { "idol-extinguished": true } });
|
||||
});
|
||||
|
||||
it("runs Zul'Farrak's exact 23/24/7 pyramid and independently recovers the Gahz'rilla gong", () => {
|
||||
const definition = adapted("zulfarrak");
|
||||
const encounter = classicEncounter(definition);
|
||||
const groups = [1, 2, 3].map((wave) => group(definition, `zulfarrak:pyramid-wave-${wave}`));
|
||||
expect(groups.map((spawnGroup) => spawnGroup.spawns.length)).toEqual([23, 24, 7]);
|
||||
const cages = definition.serverGameObjects.filter((object) => object.name === "Troll Cage");
|
||||
const endDoor = objectByTemplate(definition, 146_084);
|
||||
const weegli = definition.serverGameObjects.find((object) => object.id === "zulfarrak:weegli-gate-charge")!;
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact",
|
||||
objectId: cages[0].id,
|
||||
now: 0,
|
||||
}).snapshot;
|
||||
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "open")).toBe(true);
|
||||
snapshot = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 1);
|
||||
expect(snapshot).toMatchObject({ phase: "idle", activeSpawnGroupIds: [] });
|
||||
expect(cages.every((cage) => snapshot.worldObjectStates[cage.id] === "closed")).toBe(true);
|
||||
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: cages[2].id, now: 2 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_002 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[0].id]);
|
||||
snapshot = killGroup(encounter, snapshot, groups[0], 5_003).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 15_003 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[1].id]);
|
||||
snapshot = killGroup(encounter, snapshot, groups[1], 15_004).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groups[2].id]);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_004 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, groups[2], 20_005).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "pyramid-aftermath", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[weegli.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: weegli.id, now: 20_006 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 24_006 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "pyramid-complete", flags: { "pyramid-complete": true }, checkpointId: "zulfarrak-pyramid" });
|
||||
expect(snapshot.worldObjectStates[endDoor.id]).toBe("open");
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", 24_007);
|
||||
expect(reset.worldObjectStates[endDoor.id]).toBe("open");
|
||||
expect(cages.every((cage) => reset.worldObjectStates[cage.id] === "open")).toBe(true);
|
||||
|
||||
const gong = objectByTemplate(definition, 141_832);
|
||||
snapshot = reduceDungeonEncounter(encounter, reset, { kind: "interact", objectId: gong.id, now: 25_000 }).snapshot;
|
||||
const gahzrillaGroup = group(definition, "zulfarrak:gahzrilla");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([gahzrillaGroup.id]);
|
||||
const gongWipe = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", 25_001);
|
||||
expect(gongWipe.worldObjectStates[gong.id]).toBe("ready");
|
||||
snapshot = reduceDungeonEncounter(encounter, gongWipe, { kind: "interact", objectId: gong.id, now: 25_002 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, gahzrillaGroup, 25_003).snapshot;
|
||||
const gahzrilla = definition.bosses.find((boss) => boss.name === "Ghaz'rilla")!;
|
||||
expect(snapshot.completedObjectiveIds).toContain(gahzrilla.id);
|
||||
expect(snapshot.worldObjectStates[gong.id]).toBe("complete");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import campaign from "../../dungeon-pipeline/dungeon-campaign.json";
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
import {
|
||||
WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS,
|
||||
WOW_DUNGEON_NO_TRASH_EXCEPTIONS,
|
||||
} from "./wowDungeonContentExceptions";
|
||||
import {
|
||||
createMobAnimationTransform,
|
||||
getMobAnimationPhase,
|
||||
@@ -15,14 +19,8 @@ import {
|
||||
|
||||
const publicRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../public");
|
||||
const campaignIds = new Set(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||
const basicOnlyBossExceptions = new Set([
|
||||
"wailing-caverns:kresh",
|
||||
"wailing-caverns:deviate-faerie-dragon",
|
||||
"blackrock-spire:entry-9218",
|
||||
"blackrock-spire:entry-9219",
|
||||
"blackrock-spire:entry-10376",
|
||||
]);
|
||||
const noTrashExceptions = new Set(["trial-of-the-champion"]);
|
||||
const basicOnlyBossExceptions = new Set(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.map(({ key }) => key));
|
||||
const noTrashExceptions = new Set(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.map(({ key }) => key));
|
||||
const animationCache = new Map<string, Promise<readonly { readonly name: string }[]>>();
|
||||
|
||||
async function readAnimations(url: string): Promise<readonly { readonly name: string }[]> {
|
||||
@@ -54,9 +52,23 @@ async function readAnimations(url: string): Promise<readonly { readonly name: st
|
||||
}
|
||||
|
||||
describe("WoW dungeon combat and animation coverage", () => {
|
||||
it("gives all 54 campaign dungeons complete executable populations", async () => {
|
||||
it("gives all 54 campaign dungeons runtime-spawnable combat populations", async () => {
|
||||
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => campaignIds.has(definition.id));
|
||||
expect(dungeons).toHaveLength(54);
|
||||
expect(basicOnlyBossExceptions.size).toBe(WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS.length);
|
||||
expect(noTrashExceptions.size).toBe(WOW_DUNGEON_NO_TRASH_EXCEPTIONS.length);
|
||||
const actualBasicOnlyBosses = dungeons.flatMap((dungeon) => Object.entries(dungeon.entities)
|
||||
.filter(([, entity]) => (
|
||||
entity.kind === "boss"
|
||||
&& !(entity.combat?.attacks ?? []).some((attack) => attack.id !== "basic-attack")
|
||||
))
|
||||
.map(([entityId]) => `${dungeon.id}:${entityId}`))
|
||||
.sort();
|
||||
expect(actualBasicOnlyBosses).toEqual([...basicOnlyBossExceptions].sort());
|
||||
expect(dungeons
|
||||
.filter((dungeon) => !Object.values(dungeon.entities).some((entity) => entity.kind === "mob"))
|
||||
.map((dungeon) => dungeon.id)
|
||||
.sort()).toEqual([...noTrashExceptions].sort());
|
||||
|
||||
for (const dungeon of dungeons) {
|
||||
const entities = Object.values(dungeon.entities);
|
||||
@@ -68,7 +80,9 @@ describe("WoW dungeon combat and animation coverage", () => {
|
||||
expect(mobs.length, dungeon.id + ": mobs").toBeGreaterThan(0);
|
||||
}
|
||||
expect(
|
||||
dungeon.staticSpawns.length + dungeon.roamingPacks.length,
|
||||
dungeon.staticSpawns.length
|
||||
+ dungeon.roamingPacks.length
|
||||
+ (dungeon.encounterSpawnGroups?.reduce((count, group) => count + group.spawns.length, 0) ?? 0),
|
||||
dungeon.id + ": population",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
@@ -86,6 +100,14 @@ describe("WoW dungeon combat and animation coverage", () => {
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const group of dungeon.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of group.spawns) {
|
||||
expect(
|
||||
dungeon.entities[spawn.entityId],
|
||||
dungeon.id + ":" + group.id + ":" + spawn.id,
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const key = dungeon.id + ":" + entity.id;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface WowDungeonContentException {
|
||||
readonly key: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
/** Exact runtime entity keys for source-verified bosses whose only attack is melee. */
|
||||
export const WOW_DUNGEON_BASIC_ONLY_BOSS_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([
|
||||
Object.freeze({
|
||||
key: "wailing-caverns:kresh",
|
||||
reason: "Kresh is melee-only in the pinned AzerothCore snapshot; the historical server has no spell or SmartAI row for entry 3653.",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "wailing-caverns:deviate-faerie-dragon",
|
||||
reason: "The server-faithful rare entry 5912 has no spell rows and is despawned by the Wailing Caverns initialization event.",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "gnomeregan:wow-classic-batch-three-entry-7361",
|
||||
reason: "Grubbis is melee-only in the pinned AzerothCore snapshot; entry 7361 has aggro dialogue and instance-completion rows but no combat spell cast.",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "blackrock-spire:entry-9218",
|
||||
reason: "Spirestone Battle Lord is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "blackrock-spire:entry-9219",
|
||||
reason: "Spirestone Butcher is a 15% rare spawn in the pinned database and has no combat spell cast beyond its basic melee attack.",
|
||||
}),
|
||||
Object.freeze({
|
||||
key: "blackrock-spire:entry-10376",
|
||||
reason: "Crystal Fang is a 15% rare spawn with no active combat spell in the pinned database; its exact on-death Spire Spiderling summon is implemented by the instance adapter.",
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Exact dungeon keys allowed to contain no mob-kind combat actors. */
|
||||
export const WOW_DUNGEON_NO_TRASH_EXCEPTIONS: readonly WowDungeonContentException[] = Object.freeze([]);
|
||||
@@ -0,0 +1,352 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { activeEncounterSpawnGroupSpawns } from "./dungeonEncounterSpawns";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition, DungeonEncounterSnapshot } from "./dungeonEncounterTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_ENCOUNTER_CONTENT_REVIEWS,
|
||||
WOW_ENCOUNTER_NEXT_BATCH,
|
||||
withWowEncounterContent,
|
||||
} from "./wowEncounterContentCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function adapted(id: string): DungeonDefinition {
|
||||
const definition = campaign.definitions.find((candidate) => candidate.id === id);
|
||||
if (!definition) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return withWowEncounterContent(definition);
|
||||
}
|
||||
|
||||
function wowEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((value) => value.id === `${definition.id}:wow-instance`);
|
||||
if (!encounter) throw new Error(`Missing WoW encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing spawn group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function staticEntries(definition: DungeonDefinition): readonly number[] {
|
||||
return definition.staticSpawns.flatMap((spawn) => {
|
||||
const entry = definition.entities[spawn.entityId]?.combat?.serverEntry;
|
||||
return entry === undefined ? [] : [entry];
|
||||
});
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
) {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
});
|
||||
}
|
||||
|
||||
describe("source-backed WoW encounter content", () => {
|
||||
it("removes every adapted event boss from static population and validates its encounter", () => {
|
||||
const cases = [
|
||||
["black-morass", [17_879, 17_880, 17_881]],
|
||||
["violet-hold", [29_315, 29_316, 31_134]],
|
||||
["culling-of-stratholme", [26_529, 26_530, 26_532, 26_533]],
|
||||
["escape-from-durnholde", [17_848, 17_862, 18_096]],
|
||||
["arcatraz", [20_912]],
|
||||
["blackrock-spire", [10_264]],
|
||||
] as const;
|
||||
for (const [id, entries] of cases) {
|
||||
const definition = adapted(id);
|
||||
const encounter = wowEncounter(definition);
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
expect(encounter.source?.fidelity).toBe("source-backed-adapted");
|
||||
expect(encounter.source?.scope).toBe("instance-orchestration");
|
||||
const remainingStaticEntries = staticEntries(definition);
|
||||
for (const entry of entries) {
|
||||
expect(remainingStaticEntries, `${id} static entry ${entry}`).not.toContain(entry);
|
||||
const entityIds = new Set(Object.entries(definition.entities)
|
||||
.filter(([, entity]) => entity.combat?.serverEntry === entry)
|
||||
.map(([entityId]) => entityId));
|
||||
const objective = definition.bosses.find((boss) => (
|
||||
definition.encounterSpawnGroups?.some((spawnGroup) => spawnGroup.spawns.some(
|
||||
(spawn) => spawn.id === boss.id && entityIds.has(spawn.entityId),
|
||||
))
|
||||
));
|
||||
expect(objective, `${id} entry ${entry}`).toMatchObject({ eventOnly: true });
|
||||
}
|
||||
expect(withWowEncounterContent(definition)).toBe(definition);
|
||||
}
|
||||
expect(staticEntries(adapted("violet-hold"))).not.toContain(29_395);
|
||||
expect(WOW_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(expect.arrayContaining(cases.map(([id]) => id)));
|
||||
expect(WOW_ENCOUNTER_NEXT_BATCH).toEqual([]);
|
||||
});
|
||||
|
||||
it("resumes Black Morass at rift 7 and 13 after run-persistent boss checkpoints", () => {
|
||||
const definition = adapted("black-morass");
|
||||
const encounter = wowEncounter(definition);
|
||||
let snapshot = reduceDungeonEncounter(
|
||||
encounter,
|
||||
createDungeonEncounterSnapshot(encounter),
|
||||
{ kind: "interact", objectId: "black-morass:medivh-defense-start", now: 0 },
|
||||
).snapshot;
|
||||
let now = 0;
|
||||
|
||||
for (let rift = 1; rift <= 6; rift += 1) {
|
||||
now = snapshot.phaseStartedAt + (rift === 1 ? 9_000 : 7_000);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(spawnGroup.id);
|
||||
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
|
||||
}
|
||||
const deja = definition.bosses.find((boss) => boss.name === "Chrono Lord Deja")!;
|
||||
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja" });
|
||||
expect(snapshot.completedObjectiveIds).toContain(deja.id);
|
||||
|
||||
now = snapshot.phaseStartedAt + 156_000;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-07"]);
|
||||
const resetAfterDeja = resetDungeonEncounterSnapshot(encounter, snapshot, "boss-wipe", now + 1);
|
||||
expect(resetAfterDeja.activeSpawnGroupIds).toEqual([]);
|
||||
expect(resetAfterDeja.completedObjectiveIds).toEqual([deja.id]);
|
||||
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], resetAfterDeja.activeSpawnGroupIds, 1)).toEqual([]);
|
||||
snapshot = reduceDungeonEncounter(encounter, resetAfterDeja, { kind: "tick", now: now + 1 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "rift-07-wait", checkpointId: "after-deja", counters: { rift: 6 } });
|
||||
|
||||
for (let rift = 7; rift <= 12; rift += 1) {
|
||||
now = snapshot.phaseStartedAt + (rift === 7 ? 156_000 : 7_000);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
const spawnGroup = group(definition, `black-morass:rift-${String(rift).padStart(2, "0")}`);
|
||||
snapshot = killGroup(encounter, snapshot, spawnGroup, now + 1).snapshot;
|
||||
}
|
||||
const temporus = definition.bosses.find((boss) => boss.name === "Temporus")!;
|
||||
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus" });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([deja.id, temporus.id]));
|
||||
|
||||
now = snapshot.phaseStartedAt + 156_000;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["black-morass:rift-13"]);
|
||||
const resetAfterTemporus = resetDungeonEncounterSnapshot(encounter, snapshot, "checkpoint", now + 1);
|
||||
expect(resetAfterTemporus.activeSpawnGroupIds).toEqual([]);
|
||||
snapshot = reduceDungeonEncounter(encounter, resetAfterTemporus, { kind: "tick", now: now + 1 }).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "rift-13-wait", checkpointId: "after-temporus", counters: { rift: 12 } });
|
||||
});
|
||||
|
||||
it("makes reviewed Violet Hold doors visible and physically stateful", () => {
|
||||
const definition = adapted("violet-hold");
|
||||
const reviewedTemplates = new Set([191_723, 191_564, 191_563, 191_562, 191_606]);
|
||||
const doors = definition.serverGameObjects.filter((object) => reviewedTemplates.has(object.source?.templateId ?? -1));
|
||||
expect(doors.length).toBeGreaterThan(0);
|
||||
for (const door of doors) {
|
||||
validateDungeonWorldObject(door);
|
||||
expect(door.states.find((state) => state.id === "closed")).toMatchObject({
|
||||
visible: true,
|
||||
blocksMovement: true,
|
||||
blocksSight: true,
|
||||
});
|
||||
expect(door.states.find((state) => state.id === "open")).toMatchObject({
|
||||
visible: true,
|
||||
blocksMovement: false,
|
||||
blocksSight: false,
|
||||
});
|
||||
expect(door.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs all 18 Violet Hold waves and persists its three objectives", () => {
|
||||
const definition = adapted("violet-hold");
|
||||
const encounter = wowEncounter(definition);
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact", objectId: "violet-hold:sinclari-start", now: 0,
|
||||
}).snapshot;
|
||||
let now = 0;
|
||||
for (let wave = 1; wave <= 18; wave += 1) {
|
||||
const id = `violet-hold:wave-${String(wave).padStart(2, "0")}`;
|
||||
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
||||
const delay = wave === 1 ? 19_000 : wave === 7 || wave === 13 ? 35_000 : 3_000;
|
||||
now = snapshot.phaseStartedAt + delay;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
||||
now += 1;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
|
||||
});
|
||||
|
||||
it("persists Black Morass completion and deactivates the final group", () => {
|
||||
const definition = adapted("black-morass");
|
||||
const encounter = wowEncounter(definition);
|
||||
const aeonus = definition.bosses.find((boss) => boss.name === "Aeonus")!;
|
||||
const finalGroup = group(definition, "black-morass:rift-18");
|
||||
const base = createDungeonEncounterSnapshot(encounter, 1_000);
|
||||
const readyToClear = {
|
||||
...base,
|
||||
phase: "rift-18-active",
|
||||
activeSpawnGroupIds: [finalGroup.id],
|
||||
} as DungeonEncounterSnapshot;
|
||||
const cleared = killGroup(encounter, readyToClear, finalGroup, 2_000);
|
||||
expect(cleared.snapshot).toMatchObject({
|
||||
phase: "complete",
|
||||
flags: { "dungeon-complete": true },
|
||||
activeSpawnGroupIds: [],
|
||||
});
|
||||
expect(cleared.snapshot.completedObjectiveIds).toContain(aeonus.id);
|
||||
expect(cleared.effects).toContainEqual({
|
||||
kind: "despawn-group",
|
||||
actionId: "deactivate-rift-18",
|
||||
spawnGroupId: finalGroup.id,
|
||||
});
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, cleared.snapshot, "boss-wipe", 3_000);
|
||||
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 3_000 }).snapshot;
|
||||
expect(recovered).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
|
||||
expect(recovered.completedObjectiveIds).toContain(aeonus.id);
|
||||
});
|
||||
|
||||
it("runs Culling's city, rift, Epoch, and Mal'Ganis groups to completion", () => {
|
||||
const definition = adapted("culling-of-stratholme");
|
||||
const encounter = wowEncounter(definition);
|
||||
const sequence = [
|
||||
...Array.from({ length: 10 }, (_, index) => `culling-city-${String(index + 1).padStart(2, "0")}`),
|
||||
"culling-town-01", "culling-town-02", "culling-town-03", "culling-epoch", "culling-malganis",
|
||||
];
|
||||
const delays = new Map<string, number>([[sequence[0], 10_000], ["culling-town-01", 5_000], ["culling-epoch", 32_000], ["culling-malganis", 5_000]]);
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact", objectId: "culling-of-stratholme:arthas-start", now: 0,
|
||||
}).snapshot;
|
||||
let now = 0;
|
||||
for (const id of sequence) {
|
||||
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
||||
now = snapshot.phaseStartedAt + (delays.get(id) ?? 0);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
||||
now += 1;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining(definition.bosses.map((boss) => boss.id)));
|
||||
});
|
||||
|
||||
it("uses five imported Durnholde barrels before Drake, then gates the Thrall sequence", () => {
|
||||
const definition = adapted("escape-from-durnholde");
|
||||
const encounter = wowEncounter(definition);
|
||||
const barrels = definition.serverGameObjects.filter((object) => object.source?.templateId === 182_589);
|
||||
expect(barrels.length).toBeGreaterThanOrEqual(5);
|
||||
expect(barrels.every((object) => object.interaction?.prompt === "Plant an incendiary bomb")).toBe(true);
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter);
|
||||
barrels.slice(0, 5).forEach((barrel, index) => {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: barrel.id, now: index }).snapshot;
|
||||
});
|
||||
expect(snapshot.counters.barrels).toBe(5);
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5 }).snapshot;
|
||||
expect(snapshot.phase).toBe("durnholde-drake-wait");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_005 }).snapshot;
|
||||
const drakeGroup = group(definition, "durnholde-drake");
|
||||
snapshot = killGroup(encounter, snapshot, drakeGroup, 12_006).snapshot;
|
||||
const drake = definition.bosses.find((boss) => boss.name === "Lieutenant Drake")!;
|
||||
expect(snapshot).toMatchObject({ phase: "durnholde-await-thrall", checkpointId: drake.id });
|
||||
const prisonDoor = definition.serverGameObjects.find((object) => object.source?.templateId === 184_393)!;
|
||||
expect(snapshot.worldObjectStates[prisonDoor.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId: "escape-from-durnholde:thrall-escort", now: 12_007 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 12_007 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("durnholde-skarloc");
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, killGroup(encounter, snapshot, group(definition, "durnholde-skarloc"), 12_008).snapshot, "boss-wipe", 12_009);
|
||||
const recovered = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: 12_009 }).snapshot;
|
||||
expect(recovered.phase).toBe("durnholde-barn-wait");
|
||||
expect(recovered.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
snapshot = recovered;
|
||||
let now = 12_009;
|
||||
const remainder = [
|
||||
["durnholde-barn", 3_000],
|
||||
["durnholde-church", 3_000],
|
||||
["durnholde-epoch-wave-1", 3_000],
|
||||
["durnholde-epoch-wave-2", 0],
|
||||
["durnholde-epoch-wave-3", 0],
|
||||
["durnholde-epoch", 8_000],
|
||||
] as const;
|
||||
for (const [id, delay] of remainder) {
|
||||
if (!snapshot.activeSpawnGroupIds.includes(id)) {
|
||||
now = snapshot.phaseStartedAt + delay;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds, id).toContain(id);
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, id), now + 1).snapshot;
|
||||
now += 1;
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
||||
});
|
||||
|
||||
it("opens reviewed Arcatraz pods in order before spawning Skyriss", () => {
|
||||
const definition = adapted("arcatraz");
|
||||
const encounter = wowEncounter(definition);
|
||||
const pods = [183_961, 183_963, 183_964, 183_962, 183_965].map((templateId) => (
|
||||
definition.serverGameObjects.find((object) => object.source?.templateId === templateId)!
|
||||
));
|
||||
for (const pod of pods) {
|
||||
validateDungeonWorldObject(pod);
|
||||
expect(pod.states.find((state) => state.id === "closed")).toMatchObject({ blocksMovement: true, blocksSight: true, visible: true });
|
||||
expect(pod.states.find((state) => state.id === "open")).toMatchObject({ blocksMovement: false, blocksSight: false, visible: true });
|
||||
expect(pod.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact", objectId: "arcatraz:warden-start", now: 0,
|
||||
}).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 5_000 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-alpha"), 5_001).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 10_001 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 20_001 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-delta"), 20_002).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 28_002 }).snapshot;
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-pod-gamma"), 28_003).snapshot;
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now: 40_003 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["arcatraz-skyriss"]);
|
||||
expect(pods.every((pod) => snapshot.worldObjectStates[pod.id] === "open")).toBe(true);
|
||||
snapshot = killGroup(encounter, snapshot, group(definition, "arcatraz-skyriss"), 40_004).snapshot;
|
||||
expect(snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
|
||||
});
|
||||
|
||||
it("keeps Solakar waves overlapping and removes surviving adds when Solakar dies", () => {
|
||||
const definition = adapted("blackrock-spire");
|
||||
const encounter = wowEncounter(definition);
|
||||
const fatherFlame = definition.serverGameObjects.find((object) => object.source?.templateId === 175_245)!;
|
||||
expect(fatherFlame.interaction?.prompt).toBe("Activate Father Flame");
|
||||
let snapshot = reduceDungeonEncounter(encounter, createDungeonEncounterSnapshot(encounter), {
|
||||
kind: "interact", objectId: fatherFlame.id, now: 0,
|
||||
}).snapshot;
|
||||
for (const now of [500, 30_500, 60_500, 90_500, 120_500, 150_500]) {
|
||||
snapshot = reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
expect(snapshot.activeSpawnGroupIds).toHaveLength(6);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("blackrock-spire:solakar");
|
||||
const solakarGroup = group(definition, "blackrock-spire:solakar");
|
||||
const cleared = killGroup(encounter, snapshot, solakarGroup, 150_501);
|
||||
expect(cleared.snapshot).toMatchObject({ phase: "complete", activeSpawnGroupIds: [] });
|
||||
expect(cleared.effects.filter((effect) => effect.kind === "despawn-group")).toHaveLength(6);
|
||||
expect(activeEncounterSpawnGroupSpawns(definition.encounterSpawnGroups ?? [], cleared.snapshot.activeSpawnGroupIds, 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import { WOW_PROGRESSION_OBJECT_REVIEWS } from "./wowProgressionObjectContentCatalog";
|
||||
|
||||
function encounter(dungeonId: string, suffix: string) {
|
||||
const definition = DUNGEON_REGISTRY[dungeonId];
|
||||
const result = definition.encounters?.find((candidate) => candidate.id === `${dungeonId}:${suffix}`);
|
||||
if (!result) throw new Error(`Missing ${dungeonId}:${suffix}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function actorId(dungeonId: string, entry: number): string {
|
||||
const definition = DUNGEON_REGISTRY[dungeonId];
|
||||
const staticSpawn = definition.staticSpawns.find(
|
||||
(spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry === entry,
|
||||
);
|
||||
if (staticSpawn) return staticSpawn.id;
|
||||
for (const pack of definition.roamingPacks) {
|
||||
const member = pack.members.find(
|
||||
(candidate) => definition.entities[candidate.entityId]?.combat?.serverEntry === entry,
|
||||
);
|
||||
if (member) return `${pack.id}:${member.id}`;
|
||||
}
|
||||
throw new Error(`${dungeonId} is missing entry ${entry}`);
|
||||
}
|
||||
|
||||
describe("source-backed progression objects", () => {
|
||||
it("pins source hashes and keeps every adaptation and remaining gap explicit", () => {
|
||||
expect(WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => review.dungeonId)).toEqual([
|
||||
"deadmines",
|
||||
"shadowfang-keep",
|
||||
"maraudon",
|
||||
"mechanar",
|
||||
"steamvault",
|
||||
"shadow-labyrinth",
|
||||
"scarlet-monastery",
|
||||
]);
|
||||
for (const review of WOW_PROGRESSION_OBJECT_REVIEWS) {
|
||||
expect(review.files.length).toBeGreaterThan(0);
|
||||
expect(review.files.every((file) => /sha256 [a-f0-9]{64}/.test(file))).toBe(true);
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
const runtime = DUNGEON_REGISTRY[review.dungeonId].encounters?.find(
|
||||
(candidate) => candidate.source?.reviewedBehavior === review.reviewedBehavior,
|
||||
);
|
||||
expect(runtime).toBeDefined();
|
||||
validateDungeonEncounterDefinition(runtime!);
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Deadmines factory, foundry, and cannon breach transitions", () => {
|
||||
const dungeon = DUNGEON_REGISTRY.deadmines;
|
||||
const definition = encounter("deadmines", "progression-objects");
|
||||
const factory = dungeon.serverGameObjects.find((object) => object.source?.templateId === 13_965)!;
|
||||
const foundry = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_399)!;
|
||||
const ironClad = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_397)!;
|
||||
const foundryLever = dungeon.serverGameObjects.find((object) => object.source?.templateId === 101_834)!;
|
||||
const cannon = dungeon.serverGameObjects.find((object) => object.source?.templateId === 16_398)!;
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("deadmines", 644),
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[factory.id]).toBe("open");
|
||||
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: foundryLever.id,
|
||||
now: 2,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[foundry.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: cannon.id,
|
||||
now: 3,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[ironClad.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[cannon.id]).toBe("used");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("deadmines:cannon-reinforcements");
|
||||
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === "deadmines:cannon-reinforcements")?.spawns).toHaveLength(2);
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
|
||||
expect(reset.worldObjectStates[factory.id]).toBe("open");
|
||||
expect(reset.worldObjectStates[foundry.id]).toBe("open");
|
||||
expect(reset.worldObjectStates[ironClad.id]).toBe("open");
|
||||
expect(reset.activeSpawnGroupIds).not.toContain("deadmines:cannon-reinforcements");
|
||||
});
|
||||
|
||||
it("opens each Shadowfang Keep progression door at its source milestone", () => {
|
||||
const dungeon = DUNGEON_REGISTRY["shadowfang-keep"];
|
||||
const definition = encounter("shadowfang-keep", "progression-objects");
|
||||
const courtyard = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_895)!;
|
||||
const sorcerer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_972)!;
|
||||
const arugal = dungeon.serverGameObjects.find((object) => object.source?.templateId === 18_971)!;
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
|
||||
for (const [entry, objectId, now] of [
|
||||
[3_914, courtyard.id, 1],
|
||||
[4_274, sorcerer.id, 2],
|
||||
[3_927, arugal.id, 3],
|
||||
] as const) {
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("shadowfang-keep", entry),
|
||||
now,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[objectId]).toBe("open");
|
||||
}
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "checkpoint", 4);
|
||||
expect(reset.worldObjectStates[courtyard.id]).toBe("open");
|
||||
expect(reset.worldObjectStates[sorcerer.id]).toBe("open");
|
||||
expect(reset.worldObjectStates[arugal.id]).toBe("open");
|
||||
});
|
||||
|
||||
it("opens Maraudon's Corruption Spewer when Noxxion dies", () => {
|
||||
const dungeon = DUNGEON_REGISTRY.maraudon;
|
||||
const definition = encounter("maraudon", "progression-objects");
|
||||
const spewer = dungeon.serverGameObjects.find((object) => object.source?.templateId === 178_570)!;
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
expect(snapshot.worldObjectStates[spewer.id]).toBe("closed");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("maraudon", 13_282),
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[spewer.id]).toBe("open");
|
||||
expect(resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2).worldObjectStates[spewer.id]).toBe("open");
|
||||
});
|
||||
|
||||
it("opens Mechanar passage doors and resets Sepethrea's room door safely", () => {
|
||||
const dungeon = DUNGEON_REGISTRY.mechanar;
|
||||
const definition = encounter("mechanar", "progression-objects");
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
const ironDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_632)!;
|
||||
const gyroDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_322)!;
|
||||
const sepethreaDoor = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_449)!;
|
||||
|
||||
expect(ironDoor.states.find((state) => state.id === "closed")?.blocksMovement).toBe(true);
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("mechanar", 19_710),
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("mechanar", 19_218),
|
||||
now: 2,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-engaged",
|
||||
entityId: actorId("mechanar", 19_221),
|
||||
now: 3,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("closed");
|
||||
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 4);
|
||||
expect(snapshot.worldObjectStates[ironDoor.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[gyroDoor.id]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[sepethreaDoor.id]).toBe("open");
|
||||
});
|
||||
|
||||
it("requires both Steamvault boss panels before opening the main chamber", () => {
|
||||
const dungeon = DUNGEON_REGISTRY.steamvault;
|
||||
const definition = encounter("steamvault", "access-panels");
|
||||
let snapshot = createDungeonEncounterSnapshot(definition);
|
||||
const hydro = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_125)!;
|
||||
const mek = dungeon.serverGameObjects.find((object) => object.source?.templateId === 184_126)!;
|
||||
const door = dungeon.serverGameObjects.find((object) => object.source?.templateId === 183_049)!;
|
||||
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("steamvault", 17_797),
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[hydro.id]).toBe("unlocked");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: hydro.id, now: 2 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[door.id]).toBe("closed");
|
||||
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("steamvault", 17_796),
|
||||
now: 4,
|
||||
}).snapshot;
|
||||
expect(snapshot.worldObjectStates[mek.id]).toBe("unlocked");
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "interact", objectId: mek.id, now: 5 }).snapshot;
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 6 }).snapshot;
|
||||
expect(snapshot.worldObjectStates[door.id]).toBe("open");
|
||||
const reset = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 7);
|
||||
expect(reset.worldObjectStates[door.id]).toBe("open");
|
||||
expect(reset.worldObjectStates[hydro.id]).toBe("used");
|
||||
expect(reset.worldObjectStates[mek.id]).toBe("used");
|
||||
});
|
||||
|
||||
it("holds Hellmaw out of static population until all common ritualists die", () => {
|
||||
const dungeon = DUNGEON_REGISTRY["shadow-labyrinth"];
|
||||
const definition = encounter("shadow-labyrinth", "progression-objects");
|
||||
const ritualists = dungeon.staticSpawns
|
||||
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_794 && (spawn.spawnMask ?? 3) === 3)
|
||||
.map((spawn) => spawn.id);
|
||||
const hellmawGroup = dungeon.encounterSpawnGroups?.find(
|
||||
(group) => group.id === "shadow-labyrinth:hellmaw",
|
||||
);
|
||||
expect(ritualists).toHaveLength(18);
|
||||
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 18_731)).toBe(false);
|
||||
expect(hellmawGroup?.spawns).toHaveLength(1);
|
||||
const result = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
|
||||
kind: "tick",
|
||||
now: 1,
|
||||
facts: { registeredEntityIds: ritualists, deadEntityIds: ritualists },
|
||||
});
|
||||
expect(result.snapshot.activeSpawnGroupIds).toContain("shadow-labyrinth:hellmaw");
|
||||
});
|
||||
|
||||
it("gates Whitemane behind Mograine and restores her attempt after a wipe", () => {
|
||||
const dungeon = DUNGEON_REGISTRY["scarlet-monastery"];
|
||||
const definition = encounter("scarlet-monastery", "cathedral-pair");
|
||||
const groupId = "scarlet-monastery:whitemane";
|
||||
expect(dungeon.staticSpawns.some((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 3_977)).toBe(false);
|
||||
expect(dungeon.encounterSpawnGroups?.find((group) => group.id === groupId)?.spawns).toHaveLength(1);
|
||||
|
||||
let snapshot = reduceDungeonEncounter(definition, createDungeonEncounterSnapshot(definition), {
|
||||
kind: "entity-death",
|
||||
entityId: actorId("scarlet-monastery", 3_976),
|
||||
now: 1,
|
||||
}).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
|
||||
snapshot = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", 2);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(groupId);
|
||||
snapshot = reduceDungeonEncounter(definition, snapshot, { kind: "tick", now: 3 }).snapshot;
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(groupId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import type {
|
||||
DungeonBossObjective,
|
||||
DungeonDefinition,
|
||||
DungeonEncounterSpawnGroupDefinition,
|
||||
} from "./dungeonTypes";
|
||||
import type {
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterRule,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonWorldObjectDefinition } from "./dungeonWorldObjects";
|
||||
import type { StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
|
||||
interface ProgressionSourceReview {
|
||||
readonly dungeonId: string;
|
||||
readonly files: readonly string[];
|
||||
readonly reviewedBehavior: string;
|
||||
readonly adaptations: readonly string[];
|
||||
readonly remainingGaps: readonly string[];
|
||||
}
|
||||
|
||||
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
|
||||
const WORLD_SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-world";
|
||||
|
||||
export const WOW_PROGRESSION_OBJECT_REVIEWS: readonly ProgressionSourceReview[] = Object.freeze([
|
||||
{
|
||||
dungeonId: "deadmines",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/instance_deadmines.cpp (sha256 c13065819443a70ec431b921edd2e32bcd6e0ec66c4c84c3f2e932e9fb1cbbc9; saved Rhahk'Zor and cannon state lines 31-95)`,
|
||||
`${SOURCE_ROOT}/EasternKingdoms/Deadmines/deadmines.h (sha256 a510bb170ac09b78c2f095742aa668c1aac8bf7ba8b457f6923807f37ae98045; encounter and gameobject entries lines 25-45)`,
|
||||
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; Rhahk'Zor rows 4777-4780, cannon rows 17399 and 43337-43343, lever rows 38147-38150)`,
|
||||
],
|
||||
reviewedBehavior: "Rhahk'Zor opens the Factory Door; the foundry lever opens the Foundry Door; the Defias Cannon breaches the Iron Clad Door and summons two Defias Squallshapers, with the nearby inside lever retaining its database-linked door control.",
|
||||
adaptations: [
|
||||
"The source cannon requires a Gunpowder spell hit; Healer Man exposes the cannon as a direct one-shot interaction because inventory quest-item spell casting is not part of the dungeon runtime.",
|
||||
"The cannon's custom animation and Mr. Smite dialogue are collapsed into the immediate breach and exact two-add reinforcement spawn.",
|
||||
"Source transforms and client bounds drive procedural doors, levers, and cannon collision while their native legacy models await export.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Heavy Door 1, Heavy Door 2, and the Mast Room Door are still left nonblocking because the reviewed source only persists their generic client/core state and does not bind an instance progression event.",
|
||||
"Cannon smoke, timed dialogue, native door/lever animation, and the original gunpowder item requirement remain unported presentation or inventory mechanics.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "shadowfang-keep",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/instance_shadowfang_keep.cpp (sha256 12d7568b35b30a7d2630de46729d4ffaa0d58de9c345c6c9143037bf88b59e07; saved courtyard, Fenrus, and Nandos door state lines 76-111)`,
|
||||
`${SOURCE_ROOT}/EasternKingdoms/ShadowfangKeep/shadowfang_keep.h (sha256 daa0ac298fe8c23c4e9353be898bec0fa6b7cdd9c35e821ef880aaa8600419fd; encounter and gameobject entries lines 25-48)`,
|
||||
`${WORLD_SOURCE_ROOT}/smart_scripts.sql (sha256 622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2; prisoner escort/unlock rows 7933-7945 and 40678-40693)`,
|
||||
],
|
||||
reviewedBehavior: "The courtyard route opens after the Rethilgore/prisoner section, Fenrus opens the Sorcerer Door, and Wolf Master Nandos opens the final Arugal Door.",
|
||||
adaptations: [
|
||||
"The two faction-specific prisoner escorts are collapsed into an immediate courtyard-door transition when Rethilgore dies, avoiding a mandatory faction/gossip branch that the current runtime cannot represent safely.",
|
||||
"The exact imported door transforms and display bounds provide procedural blockers while native legacy door animations await export.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Deathstalker Adamant and Sorcerer Ashcrombe dialogue, waypoint movement, spell visuals, faction-specific gossip, and despawn presentation remain unported.",
|
||||
"The seasonal Apothecary Hummel event is intentionally outside the ordinary dungeon route and has not been made a required objective.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "maraudon",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/Kalimdor/Maraudon/instance_maraudon.cpp (sha256 edc4b2c0ba055f9953186bda5009ac990e1b18d206ae8d9aa60dcbf24f259d09; saved Noxxion state and spewer transition lines 31-79)`,
|
||||
`${SOURCE_ROOT}/Kalimdor/Maraudon/maraudon.h (sha256 81939372ae6bb38d879cda349992c1895e7281f5364ddca4e0fc235140e6cbfa; encounter and Corruption Spewer entries lines 20-33)`,
|
||||
],
|
||||
reviewedBehavior: "Defeating Noxxion permanently opens the Corruption Spewer route transition.",
|
||||
adaptations: [
|
||||
"The imported transform and display bounds drive a conservative procedural barrier that disappears when opened; the legacy spewer animation and particles await native model export.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Corruption spray/particle presentation, the native gameobject animation, optional quest interactions, and Celebras's Scepter shortcut are not part of this mandatory route gate.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "mechanar",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/instance_mechanar.cpp (sha256 6dac8f562fe69dd5ec06ef94ffea5399ae08c9b502eec3fa8790390a0fde90b7; DoorData lines 22-27)`,
|
||||
`${SOURCE_ROOT}/Outland/TempestKeep/Mechanar/mechanar.h (sha256 9c5baf35ca4b6126901d39278a289a42efce0b3d6c56d1ac1231cd8ddb6d9283; data and gameobject entries lines 29-52)`,
|
||||
],
|
||||
reviewedBehavior: "Gatewatcher Iron-Hand and Gatewatcher Gyro-Kill each open their bound Mo'arg passage door; Sepethrea's room door closes while she is engaged and opens after her defeat.",
|
||||
adaptations: [
|
||||
"The exact imported door transforms and display bounds drive procedural blockers while the three native MDX door models await export.",
|
||||
"Door motion is represented by visible/blocked and hidden/unblocked states rather than the client door animation.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Pathaleon's bridge trash death counter, bridge dialogue, Cache of the Legion rules, and elevator operation are not yet ported.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "steamvault",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/instance_steam_vault.cpp (sha256 55b1bd5fd6a01233ca18a35eb9b0c098bbb996cfd220d8f4c3e51f992e3c60cf; access panels lines 36-91 and boss state lines 154-174)`,
|
||||
`${SOURCE_ROOT}/Outland/CoilfangReservoir/SteamVault/steam_vault.h (sha256 94d142e428a796cf4c0d36ec6b9b7fe70f059790d6378bbb55c7803bf9cda393; object entries lines 31-50)`,
|
||||
],
|
||||
reviewedBehavior: "Hydromancer Thespia and Mekgineer Steamrigger each unlock their nearby access panel; using both panels opens the Main Chambers door to Warlord Kalithresh.",
|
||||
adaptations: [
|
||||
"Source positions identify both panels and the main door, with compact procedural consoles and a procedural collision blocker until their client models are exported.",
|
||||
"The original door-controller creature's movement and emote are collapsed into the immediate two-panel door transition.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Door-controller presentation, distiller reset coupling, access-panel animation, and native object visuals remain unported.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "shadow-labyrinth",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/instance_shadow_labyrinth.cpp (sha256 b51d655ee5918d0bcb89b4ada5d9af752c8c0395032d99e4148b9edaaf34a834; ritualist release lines 58-89 and DoorData lines 24-29)`,
|
||||
`${SOURCE_ROOT}/Outland/Auchindoun/ShadowLabyrinth/shadow_labyrinth.h (sha256 11f6bf85abe38fab9d4182325cc6a4693e3ea724e49f211355756345a7d44046; ritualist, Hellmaw, and door entries lines 28-44)`,
|
||||
],
|
||||
reviewedBehavior: "The common Cabal Ritualists keep Ambassador Hellmaw banished; Blackheart and Vorpil open the Refectory and Screaming Hall passage doors on defeat.",
|
||||
adaptations: [
|
||||
"Hellmaw is removed from the unsafe static spawn and appears only after the 18 normal/common ritualists die.",
|
||||
"Heroic-only duplicate ritualists do not delay release because the encounter reducer has no active-difficulty guard; they remain ordinary combat population.",
|
||||
"The exact imported door transforms and bounds use procedural blockers until native door models are exported.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Hellmaw's banish channel presentation, heroic-only ritualist contribution, Blackheart room aggro choreography, and native door animations remain unported.",
|
||||
],
|
||||
},
|
||||
{
|
||||
dungeonId: "scarlet-monastery",
|
||||
files: [
|
||||
`${SOURCE_ROOT}/EasternKingdoms/ScarletMonastery/instance_scarlet_monastery.cpp (sha256 1bada7d72e38197e6d8d202d39940a27b69e6dbbf795a53e97744227aa8ca01e; cathedral pair and doors lines 38-69 and 132-197)`,
|
||||
],
|
||||
reviewedBehavior: "Commander Mograine gates High Inquisitor Whitemane behind the High Inquisitor's Door as one linked cathedral encounter.",
|
||||
adaptations: [
|
||||
"Whitemane is removed from the unsafe static spawn, the imported inner door opens on Mograine's first defeat, and Whitemane then spawns at her exact database position.",
|
||||
"The source resurrection duet is represented as two ordered lives because the current encounter action vocabulary cannot revive an existing combat actor.",
|
||||
"The Chapel Door is left open; its source Ashbringer event is optional external narrative content and must not block the ordinary cathedral route.",
|
||||
],
|
||||
remainingGaps: [
|
||||
"Whitemane's sleep/resurrection of Mograine, Mograine's post-resurrection phase, cathedral-wide social aggro, Ashbringer scene, and native door animation remain unported.",
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const REVIEW_BY_DUNGEON = new Map(
|
||||
WOW_PROGRESSION_OBJECT_REVIEWS.map((review) => [review.dungeonId, review] as const),
|
||||
);
|
||||
|
||||
function entityEntry(definition: DungeonDefinition, entityId: string): number | undefined {
|
||||
return definition.entities[entityId]?.combat?.serverEntry;
|
||||
}
|
||||
|
||||
function staticSpawnForEntry(definition: DungeonDefinition, entry: number): StaticMobSpawnDefinition {
|
||||
const spawn = definition.staticSpawns.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
|
||||
if (!spawn) throw new Error(`${definition.id} is missing static creature entry ${entry}`);
|
||||
return spawn;
|
||||
}
|
||||
|
||||
function actorIdForEntry(definition: DungeonDefinition, entry: number): string {
|
||||
const staticSpawn = definition.staticSpawns.find(
|
||||
(candidate) => entityEntry(definition, candidate.entityId) === entry,
|
||||
);
|
||||
if (staticSpawn) return staticSpawn.id;
|
||||
for (const pack of definition.roamingPacks) {
|
||||
const member = pack.members.find((candidate) => entityEntry(definition, candidate.entityId) === entry);
|
||||
if (member) return `${pack.id}:${member.id}`;
|
||||
}
|
||||
throw new Error(`${definition.id} is missing runtime actor for creature entry ${entry}`);
|
||||
}
|
||||
|
||||
function objectiveForEntry(definition: DungeonDefinition, entry: number): DungeonBossObjective {
|
||||
const actorIds = new Set<string>();
|
||||
for (const spawn of definition.staticSpawns) {
|
||||
if (entityEntry(definition, spawn.entityId) === entry) actorIds.add(spawn.id);
|
||||
}
|
||||
for (const pack of definition.roamingPacks) {
|
||||
for (const member of pack.members) {
|
||||
if (entityEntry(definition, member.entityId) === entry) actorIds.add(`${pack.id}:${member.id}`);
|
||||
}
|
||||
}
|
||||
const objective = definition.bosses.find((candidate) => actorIds.has(candidate.id));
|
||||
if (!objective) throw new Error(`${definition.id} is missing boss objective for creature entry ${entry}`);
|
||||
return objective;
|
||||
}
|
||||
|
||||
function sourceObject(definition: DungeonDefinition, templateId: number): DungeonWorldObjectDefinition {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
|
||||
if (!object) throw new Error(`${definition.id} is missing source gameobject template ${templateId}`);
|
||||
return object;
|
||||
}
|
||||
|
||||
function reviewedDoor(
|
||||
definition: DungeonDefinition,
|
||||
templateId: number,
|
||||
initialState: "closed" | "open",
|
||||
halfExtents?: readonly [number, number, number],
|
||||
): DungeonWorldObjectDefinition {
|
||||
const source = sourceObject(definition, templateId);
|
||||
const provenance = source.source;
|
||||
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
|
||||
return Object.freeze({
|
||||
...source,
|
||||
kind: "door",
|
||||
...(halfExtents ? { halfExtents } : {}),
|
||||
initialState,
|
||||
states: Object.freeze([
|
||||
{
|
||||
id: "closed",
|
||||
label: "Closed",
|
||||
visible: true,
|
||||
blocksMovement: true,
|
||||
blocksSight: true,
|
||||
interactionEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "open",
|
||||
label: "Open",
|
||||
visible: false,
|
||||
blocksMovement: false,
|
||||
blocksSight: false,
|
||||
interactionEnabled: false,
|
||||
},
|
||||
]),
|
||||
visual: Object.freeze({ color: "#544a3f", emissive: "#130f0a" }),
|
||||
source: Object.freeze({
|
||||
...provenance,
|
||||
fidelity: "source-backed-adapted",
|
||||
note: "Source transform and client bounds are retained. A procedural blocker provides reviewed gameplay collision until the native legacy door model is exported.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function reviewedPanel(
|
||||
definition: DungeonDefinition,
|
||||
templateId: number,
|
||||
signalId: string,
|
||||
): DungeonWorldObjectDefinition {
|
||||
const source = sourceObject(definition, templateId);
|
||||
const provenance = source.source;
|
||||
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
|
||||
return Object.freeze({
|
||||
...source,
|
||||
kind: "switch",
|
||||
halfExtents: [0.85, 1.1, 0.45] as const,
|
||||
initialState: "locked",
|
||||
states: Object.freeze([
|
||||
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "unlocked", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
|
||||
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
]),
|
||||
interaction: Object.freeze({
|
||||
prompt: "Activate access panel",
|
||||
range: 3,
|
||||
signalId,
|
||||
allowedStates: ["unlocked"],
|
||||
oneShot: true,
|
||||
}),
|
||||
visual: Object.freeze({ color: "#2f6d75", emissive: "#173f46" }),
|
||||
source: Object.freeze({
|
||||
...provenance,
|
||||
fidelity: "source-backed-adapted",
|
||||
note: "Source placement identifies the console. Compact procedural geometry supplies a readable interaction target until its native legacy model is exported.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function reviewedInteractable(
|
||||
definition: DungeonDefinition,
|
||||
templateId: number,
|
||||
options: {
|
||||
readonly signalId: string;
|
||||
readonly prompt: string;
|
||||
readonly kind?: DungeonWorldObjectDefinition["kind"];
|
||||
readonly initialState?: "ready" | "locked";
|
||||
readonly range?: number;
|
||||
},
|
||||
): DungeonWorldObjectDefinition {
|
||||
const source = sourceObject(definition, templateId);
|
||||
const provenance = source.source;
|
||||
if (!provenance) throw new Error(`${definition.id} source gameobject ${source.id} has no provenance`);
|
||||
const initialState = options.initialState ?? "ready";
|
||||
return Object.freeze({
|
||||
...source,
|
||||
kind: options.kind ?? "switch",
|
||||
initialState,
|
||||
states: Object.freeze([
|
||||
{ id: "locked", label: "Locked", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
{ id: "ready", label: "Ready", visible: true, blocksMovement: false, interactionEnabled: true },
|
||||
{ id: "used", label: "Activated", visible: true, blocksMovement: false, interactionEnabled: false },
|
||||
]),
|
||||
interaction: Object.freeze({
|
||||
prompt: options.prompt,
|
||||
range: options.range ?? 3.2,
|
||||
signalId: options.signalId,
|
||||
allowedStates: ["ready"],
|
||||
oneShot: true,
|
||||
}),
|
||||
visual: Object.freeze({ color: "#786344", emissive: "#2b2114" }),
|
||||
source: Object.freeze({
|
||||
...provenance,
|
||||
fidelity: "source-backed-adapted",
|
||||
note: "The source transform and client display bounds are retained. Procedural geometry supplies a readable interaction target until the native legacy model is exported.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceObjects(
|
||||
definition: DungeonDefinition,
|
||||
replacements: readonly DungeonWorldObjectDefinition[],
|
||||
): readonly DungeonWorldObjectDefinition[] {
|
||||
const byId = new Map(replacements.map((object) => [object.id, object] as const));
|
||||
return Object.freeze(definition.serverGameObjects.map((object) => byId.get(object.id) ?? object));
|
||||
}
|
||||
|
||||
function sourceReview(definition: DungeonDefinition): DungeonEncounterDefinition["source"] {
|
||||
const review = REVIEW_BY_DUNGEON.get(definition.id);
|
||||
if (!review) throw new Error(`Missing progression-object source review for ${definition.id}`);
|
||||
return Object.freeze({
|
||||
fidelity: "source-backed-adapted",
|
||||
scope: "instance-orchestration",
|
||||
files: review.files,
|
||||
reviewedBehavior: review.reviewedBehavior,
|
||||
adaptations: review.adaptations,
|
||||
remainingGaps: review.remainingGaps,
|
||||
});
|
||||
}
|
||||
|
||||
function appendWarning(definition: DungeonDefinition, warning: string): DungeonDefinition["validation"] {
|
||||
return Object.freeze({
|
||||
...definition.validation,
|
||||
warnings: Object.freeze([...new Set([...definition.validation.warnings, warning])]),
|
||||
});
|
||||
}
|
||||
|
||||
function buildDeadmines(definition: DungeonDefinition): DungeonDefinition {
|
||||
const rhahkZor = actorIdForEntry(definition, 644);
|
||||
const squallshaperSource = staticSpawnForEntry(definition, 1_732);
|
||||
const factoryDoor = reviewedDoor(definition, 13_965, "closed");
|
||||
const foundryDoor = reviewedDoor(definition, 16_399, "closed");
|
||||
const ironCladDoor = reviewedDoor(definition, 16_397, "closed");
|
||||
const factoryLever = reviewedInteractable(definition, 101_831, {
|
||||
signalId: "deadmines:factory-lever",
|
||||
prompt: "Use the factory lever",
|
||||
initialState: "locked",
|
||||
});
|
||||
const ironCladLever = reviewedInteractable(definition, 101_833, {
|
||||
signalId: "deadmines:iron-clad-lever",
|
||||
prompt: "Use the iron-clad lever",
|
||||
});
|
||||
const foundryLever = reviewedInteractable(definition, 101_834, {
|
||||
signalId: "deadmines:foundry-lever",
|
||||
prompt: "Use the foundry lever",
|
||||
});
|
||||
const cannon = reviewedInteractable(definition, 16_398, {
|
||||
signalId: "deadmines:fire-cannon",
|
||||
prompt: "Load and fire the Defias Cannon",
|
||||
kind: "quest-object",
|
||||
range: 4.5,
|
||||
});
|
||||
const cannonReinforcements: DungeonEncounterSpawnGroupDefinition = Object.freeze({
|
||||
id: "deadmines:cannon-reinforcements",
|
||||
spawns: Object.freeze([
|
||||
{
|
||||
id: "deadmines:cannon-squallshaper-one",
|
||||
entityId: squallshaperSource.entityId,
|
||||
position: [-709.2917, 9.0828, -100.7318] as const,
|
||||
yaw: -1.58,
|
||||
spawnMask: 1,
|
||||
},
|
||||
{
|
||||
id: "deadmines:cannon-squallshaper-two",
|
||||
entityId: squallshaperSource.entityId,
|
||||
position: [-708.6717, 9.0028, -90.2018] as const,
|
||||
yaw: -1.74,
|
||||
spawnMask: 1,
|
||||
},
|
||||
]),
|
||||
});
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "rhahk-zor-opens-factory",
|
||||
trigger: { kind: "entity-death", entityId: rhahkZor },
|
||||
actions: [
|
||||
{ id: "open-factory-door", kind: "set-world-object-state", objectId: factoryDoor.id, state: "open", persistence: "run" },
|
||||
{ id: "mark-factory-lever-used", kind: "set-world-object-state", objectId: factoryLever.id, state: "used", persistence: "run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "foundry-lever-opens-door",
|
||||
trigger: { kind: "interact", objectId: foundryLever.id },
|
||||
guards: [{ kind: "world-object-state", objectId: foundryLever.id, states: ["ready"] }],
|
||||
actions: [
|
||||
{ id: "open-foundry-door", kind: "set-world-object-state", objectId: foundryDoor.id, state: "open", persistence: "run" },
|
||||
{ id: "mark-foundry-lever-used", kind: "set-world-object-state", objectId: foundryLever.id, state: "used", persistence: "run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "iron-clad-lever-opens-door",
|
||||
trigger: { kind: "interact", objectId: ironCladLever.id },
|
||||
guards: [{ kind: "world-object-state", objectId: ironCladLever.id, states: ["ready"] }],
|
||||
actions: [
|
||||
{ id: "lever-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
|
||||
{ id: "mark-iron-clad-lever-used", kind: "set-world-object-state", objectId: ironCladLever.id, state: "used", persistence: "run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cannon-breaches-iron-clad-door",
|
||||
trigger: { kind: "interact", objectId: cannon.id },
|
||||
guards: [{ kind: "world-object-state", objectId: cannon.id, states: ["ready"] }],
|
||||
actions: [
|
||||
{ id: "mark-defias-cannon-used", kind: "set-world-object-state", objectId: cannon.id, state: "used", persistence: "run" },
|
||||
{ id: "cannon-opens-iron-clad-door", kind: "set-world-object-state", objectId: ironCladDoor.id, state: "open", persistence: "run" },
|
||||
{ id: "spawn-cannon-reinforcements", kind: "activate-spawn-group", spawnGroupId: cannonReinforcements.id },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "deadmines:progression-objects",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
initialState: {
|
||||
worldObjectStates: {
|
||||
[factoryDoor.id]: "closed",
|
||||
[foundryDoor.id]: "closed",
|
||||
[ironCladDoor.id]: "closed",
|
||||
[factoryLever.id]: "locked",
|
||||
[foundryLever.id]: "ready",
|
||||
[ironCladLever.id]: "ready",
|
||||
[cannon.id]: "ready",
|
||||
},
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), cannonReinforcements]),
|
||||
serverGameObjects: replaceObjects(definition, [
|
||||
factoryDoor,
|
||||
foundryDoor,
|
||||
ironCladDoor,
|
||||
factoryLever,
|
||||
foundryLever,
|
||||
ironCladLever,
|
||||
cannon,
|
||||
]),
|
||||
validation: appendWarning(definition, "Deadmines now runs the source Rhahk'Zor, foundry-lever, and cannon/iron-clad transitions; generic heavy and mast doors remain explicitly nonblocking."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildShadowfangKeep(definition: DungeonDefinition): DungeonDefinition {
|
||||
const rethilgore = actorIdForEntry(definition, 3_914);
|
||||
const fenrus = actorIdForEntry(definition, 4_274);
|
||||
const nandos = actorIdForEntry(definition, 3_927);
|
||||
const courtyardDoor = reviewedDoor(definition, 18_895, "closed");
|
||||
const sorcererDoor = reviewedDoor(definition, 18_972, "closed");
|
||||
const arugalDoor = reviewedDoor(definition, 18_971, "closed");
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "rethilgore-section-opens-courtyard",
|
||||
trigger: { kind: "entity-death", entityId: rethilgore },
|
||||
actions: [{ id: "open-courtyard-door", kind: "set-world-object-state", objectId: courtyardDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "fenrus-opens-sorcerer-door",
|
||||
trigger: { kind: "entity-death", entityId: fenrus },
|
||||
actions: [{ id: "open-sorcerer-door", kind: "set-world-object-state", objectId: sorcererDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "nandos-opens-arugal-door",
|
||||
trigger: { kind: "entity-death", entityId: nandos },
|
||||
actions: [{ id: "open-arugal-door", kind: "set-world-object-state", objectId: arugalDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "shadowfang-keep:progression-objects",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
initialState: {
|
||||
worldObjectStates: {
|
||||
[courtyardDoor.id]: "closed",
|
||||
[sorcererDoor.id]: "closed",
|
||||
[arugalDoor.id]: "closed",
|
||||
},
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
serverGameObjects: replaceObjects(definition, [courtyardDoor, sorcererDoor, arugalDoor]),
|
||||
validation: appendWarning(definition, "Shadowfang Keep now binds all three source progression doors; the faction-specific prisoner escort is an explicit immediate post-Rethilgore adaptation."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMaraudon(definition: DungeonDefinition): DungeonDefinition {
|
||||
const noxxion = actorIdForEntry(definition, 13_282);
|
||||
const corruptionSpewer = reviewedDoor(definition, 178_570, "closed", [1.2, 7.7, 13.9]);
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "noxxion-opens-corruption-spewer",
|
||||
trigger: { kind: "entity-death", entityId: noxxion },
|
||||
actions: [{
|
||||
id: "open-corruption-spewer",
|
||||
kind: "set-world-object-state",
|
||||
objectId: corruptionSpewer.id,
|
||||
state: "open",
|
||||
persistence: "run",
|
||||
}],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "maraudon:progression-objects",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
initialState: { worldObjectStates: { [corruptionSpewer.id]: "closed" } },
|
||||
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
serverGameObjects: replaceObjects(definition, [corruptionSpewer]),
|
||||
validation: appendWarning(definition, "Maraudon's source Noxxion-to-Corruption-Spewer route gate is executable; native spewer presentation remains an explicit asset adaptation."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMechanar(definition: DungeonDefinition): DungeonDefinition {
|
||||
const ironHand = actorIdForEntry(definition, 19_710);
|
||||
const gyroKill = actorIdForEntry(definition, 19_218);
|
||||
const sepethrea = actorIdForEntry(definition, 19_221);
|
||||
const ironDoor = reviewedDoor(definition, 184_632, "closed", [4.65, 7.98, 1.1]);
|
||||
const gyroDoor = reviewedDoor(definition, 184_322, "closed", [4.65, 7.98, 1.1]);
|
||||
const sepethreaDoor = reviewedDoor(definition, 184_449, "open", [4.65, 7.98, 1.1]);
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "iron-hand-opens-moarg-one",
|
||||
trigger: { kind: "entity-death", entityId: ironHand },
|
||||
actions: [{ id: "open-moarg-one", kind: "set-world-object-state", objectId: ironDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "gyro-kill-opens-moarg-two",
|
||||
trigger: { kind: "entity-death", entityId: gyroKill },
|
||||
actions: [{ id: "open-moarg-two", kind: "set-world-object-state", objectId: gyroDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "sepethrea-closes-room",
|
||||
trigger: { kind: "entity-engaged", entityId: sepethrea },
|
||||
actions: [{ id: "close-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "closed" }],
|
||||
},
|
||||
{
|
||||
id: "sepethrea-opens-room",
|
||||
trigger: { kind: "entity-death", entityId: sepethrea },
|
||||
actions: [{ id: "open-sepethrea-room", kind: "set-world-object-state", objectId: sepethreaDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "mechanar:progression-objects",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
initialState: {
|
||||
worldObjectStates: {
|
||||
[ironDoor.id]: "closed",
|
||||
[gyroDoor.id]: "closed",
|
||||
[sepethreaDoor.id]: "open",
|
||||
},
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
serverGameObjects: replaceObjects(definition, [ironDoor, gyroDoor, sepethreaDoor]),
|
||||
validation: appendWarning(definition, "Mechanar source doors are executable; bridge scripting, elevator operation, and native door assets remain documented adaptations."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildSteamvault(definition: DungeonDefinition): DungeonDefinition {
|
||||
const thespia = actorIdForEntry(definition, 17_797);
|
||||
const steamrigger = actorIdForEntry(definition, 17_796);
|
||||
const hydroPanel = reviewedPanel(definition, 184_125, "steamvault:hydromancer-panel");
|
||||
const mekPanel = reviewedPanel(definition, 184_126, "steamvault:mekgineer-panel");
|
||||
const mainDoor = reviewedDoor(definition, 183_049, "closed", [7.2, 5.95, 0.9]);
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "thespia-unlocks-panel",
|
||||
trigger: { kind: "entity-death", entityId: thespia },
|
||||
actions: [{ id: "unlock-hydromancer-panel", kind: "set-world-object-state", objectId: hydroPanel.id, state: "unlocked", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "steamrigger-unlocks-panel",
|
||||
trigger: { kind: "entity-death", entityId: steamrigger },
|
||||
actions: [{ id: "unlock-mekgineer-panel", kind: "set-world-object-state", objectId: mekPanel.id, state: "unlocked", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "use-hydromancer-panel",
|
||||
trigger: { kind: "interact", objectId: hydroPanel.id },
|
||||
guards: [{ kind: "world-object-state", objectId: hydroPanel.id, states: ["unlocked"] }],
|
||||
actions: [
|
||||
{ id: "mark-hydromancer-panel-used", kind: "set-flag", flagId: "hydromancer-panel-used", value: true, persistence: "run" },
|
||||
{ id: "set-hydromancer-panel-used", kind: "set-world-object-state", objectId: hydroPanel.id, state: "used", persistence: "run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "use-mekgineer-panel",
|
||||
trigger: { kind: "interact", objectId: mekPanel.id },
|
||||
guards: [{ kind: "world-object-state", objectId: mekPanel.id, states: ["unlocked"] }],
|
||||
actions: [
|
||||
{ id: "mark-mekgineer-panel-used", kind: "set-flag", flagId: "mekgineer-panel-used", value: true, persistence: "run" },
|
||||
{ id: "set-mekgineer-panel-used", kind: "set-world-object-state", objectId: mekPanel.id, state: "used", persistence: "run" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "both-panels-open-main-door",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [
|
||||
{ kind: "flag", flagId: "hydromancer-panel-used", equals: true },
|
||||
{ kind: "flag", flagId: "mekgineer-panel-used", equals: true },
|
||||
],
|
||||
actions: [{ id: "open-main-chambers-door", kind: "set-world-object-state", objectId: mainDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "steamvault:access-panels",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
initialState: {
|
||||
flags: { "hydromancer-panel-used": false, "mekgineer-panel-used": false },
|
||||
worldObjectStates: {
|
||||
[hydroPanel.id]: "locked",
|
||||
[mekPanel.id]: "locked",
|
||||
[mainDoor.id]: "closed",
|
||||
},
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "active", checkpointPhase: "active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
serverGameObjects: replaceObjects(definition, [hydroPanel, mekPanel, mainDoor]),
|
||||
validation: appendWarning(definition, "Steamvault's two boss-gated access panels and Kalithresh door are executable with documented procedural presentation."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildShadowLabyrinth(definition: DungeonDefinition): DungeonDefinition {
|
||||
const hellmawObjective = objectiveForEntry(definition, 18_731);
|
||||
const hellmawSpawn = staticSpawnForEntry(definition, 18_731);
|
||||
const ritualists = definition.staticSpawns
|
||||
.filter((spawn) => entityEntry(definition, spawn.entityId) === 18_794 && (spawn.spawnMask ?? 3) === 3)
|
||||
.map((spawn) => spawn.id);
|
||||
if (ritualists.length !== 18) {
|
||||
throw new Error(`shadow-labyrinth expected 18 common Cabal Ritualists, found ${ritualists.length}`);
|
||||
}
|
||||
const blackheart = actorIdForEntry(definition, 18_667);
|
||||
const vorpil = actorIdForEntry(definition, 18_732);
|
||||
const refectoryDoor = reviewedDoor(definition, 183_296, "closed", [3.5, 6, 0.8]);
|
||||
const screamingHallDoor = reviewedDoor(definition, 183_295, "closed", [3.5, 6, 0.8]);
|
||||
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
|
||||
id: "shadow-labyrinth:hellmaw",
|
||||
spawns: Object.freeze([hellmawSpawn]),
|
||||
});
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "release-hellmaw",
|
||||
trigger: { kind: "entities-dead", entityIds: ritualists },
|
||||
actions: [
|
||||
{ id: "spawn-released-hellmaw", kind: "activate-spawn-group", spawnGroupId: group.id },
|
||||
{ id: "enter-hellmaw-active", kind: "set-phase", phase: "hellmaw-active" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "blackheart-opens-refectory",
|
||||
trigger: { kind: "entity-death", entityId: blackheart },
|
||||
actions: [{ id: "open-refectory-door", kind: "set-world-object-state", objectId: refectoryDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
{
|
||||
id: "vorpil-opens-screaming-hall",
|
||||
trigger: { kind: "entity-death", entityId: vorpil },
|
||||
actions: [{ id: "open-screaming-hall-door", kind: "set-world-object-state", objectId: screamingHallDoor.id, state: "open", persistence: "run" }],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "shadow-labyrinth:progression-objects",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "ritualists-active",
|
||||
initialState: {
|
||||
worldObjectStates: {
|
||||
[refectoryDoor.id]: "closed",
|
||||
[screamingHallDoor.id]: "closed",
|
||||
},
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "ritualists-active", checkpointPhase: "ritualists-active" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
const eventObjective = Object.freeze({ ...hellmawObjective, eventOnly: true });
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== hellmawSpawn.id)),
|
||||
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
|
||||
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
|
||||
serverGameObjects: replaceObjects(definition, [refectoryDoor, screamingHallDoor]),
|
||||
validation: appendWarning(definition, "Shadow Labyrinth now gates Hellmaw on the common ritualists and binds both passage doors; heroic ritualist gating and presentation remain documented adaptations."),
|
||||
});
|
||||
}
|
||||
|
||||
function buildScarletMonastery(definition: DungeonDefinition): DungeonDefinition {
|
||||
const mograine = actorIdForEntry(definition, 3_976);
|
||||
const whitemaneObjective = objectiveForEntry(definition, 3_977);
|
||||
const whitemaneSpawn = staticSpawnForEntry(definition, 3_977);
|
||||
const chapelDoor = reviewedDoor(definition, 104_591, "open", [4.3, 5.5, 0.8]);
|
||||
const inquisitorDoor = reviewedDoor(definition, 104_600, "closed", [3.6, 4.6, 0.8]);
|
||||
const group: DungeonEncounterSpawnGroupDefinition = Object.freeze({
|
||||
id: "scarlet-monastery:whitemane",
|
||||
spawns: Object.freeze([whitemaneSpawn]),
|
||||
});
|
||||
const rules: readonly DungeonEncounterRule[] = Object.freeze([
|
||||
{
|
||||
id: "mograine-releases-whitemane",
|
||||
trigger: { kind: "entity-death", entityId: mograine },
|
||||
actions: [
|
||||
{ id: "remember-mograine-defeat", kind: "set-flag", flagId: "mograine-defeated", value: true, persistence: "run" },
|
||||
{ id: "open-inquisitor-door", kind: "set-world-object-state", objectId: inquisitorDoor.id, state: "open", persistence: "run" },
|
||||
{ id: "spawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
|
||||
{ id: "enter-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resume-whitemane-after-wipe",
|
||||
trigger: { kind: "tick" },
|
||||
guards: [
|
||||
{ kind: "phase", phases: ["resume"] },
|
||||
{ kind: "flag", flagId: "mograine-defeated", equals: true },
|
||||
{ kind: "objective-complete", objectiveId: whitemaneObjective.id, equals: false },
|
||||
],
|
||||
actions: [
|
||||
{ id: "respawn-whitemane", kind: "activate-spawn-group", spawnGroupId: group.id },
|
||||
{ id: "resume-whitemane-active", kind: "set-phase", phase: "whitemane-active" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const encounter: DungeonEncounterDefinition = Object.freeze({
|
||||
id: "scarlet-monastery:cathedral-pair",
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "mograine-active",
|
||||
initialState: {
|
||||
flags: { "mograine-defeated": false },
|
||||
worldObjectStates: { [chapelDoor.id]: "open", [inquisitorDoor.id]: "closed" },
|
||||
},
|
||||
resetPolicy: { bossWipePhase: "resume", checkpointPhase: "resume" },
|
||||
source: sourceReview(definition),
|
||||
rules,
|
||||
});
|
||||
const eventObjective = Object.freeze({ ...whitemaneObjective, eventOnly: true });
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
staticSpawns: Object.freeze(definition.staticSpawns.filter((spawn) => spawn.id !== whitemaneSpawn.id)),
|
||||
bosses: Object.freeze(definition.bosses.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
|
||||
objectiveOrder: Object.freeze(definition.objectiveOrder.map((boss) => boss.id === eventObjective.id ? eventObjective : boss)),
|
||||
encounters: Object.freeze([...(definition.encounters ?? []), encounter]),
|
||||
encounterSpawnGroups: Object.freeze([...(definition.encounterSpawnGroups ?? []), group]),
|
||||
serverGameObjects: replaceObjects(definition, [chapelDoor, inquisitorDoor]),
|
||||
validation: appendWarning(definition, "Scarlet Monastery now gates Whitemane behind Mograine and the source inner door; resurrection remains a documented two-stage adaptation."),
|
||||
});
|
||||
}
|
||||
|
||||
export function withWowProgressionObjectContent(definition: DungeonDefinition): DungeonDefinition {
|
||||
if (definition.encounters?.some((encounter) => encounter.id === `${definition.id}:progression-objects`)
|
||||
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:access-panels`)
|
||||
|| definition.encounters?.some((encounter) => encounter.id === `${definition.id}:cathedral-pair`)) {
|
||||
return definition;
|
||||
}
|
||||
if (definition.id === "deadmines") return buildDeadmines(definition);
|
||||
if (definition.id === "shadowfang-keep") return buildShadowfangKeep(definition);
|
||||
if (definition.id === "maraudon") return buildMaraudon(definition);
|
||||
if (definition.id === "mechanar") return buildMechanar(definition);
|
||||
if (definition.id === "steamvault") return buildSteamvault(definition);
|
||||
if (definition.id === "shadow-labyrinth") return buildShadowLabyrinth(definition);
|
||||
if (definition.id === "scarlet-monastery") return buildScarletMonastery(definition);
|
||||
return definition;
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterEvent,
|
||||
DungeonEncounterSnapshot,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import { withWowTbcEncounterContent } from "./wowTbcEncounterContentCatalog";
|
||||
import {
|
||||
WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS,
|
||||
withWowTbcEncounterBatchTwoContent,
|
||||
} from "./wowTbcEncounterBatchTwoCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"underbog",
|
||||
"slave-pens",
|
||||
"mana-tombs",
|
||||
"auchenai-crypts",
|
||||
"magisters-terrace",
|
||||
] as const;
|
||||
|
||||
const EXPECTED_SOURCE_HASHES = Object.freeze({
|
||||
underbog: [
|
||||
"89a7406ee90f7a068a9ca0e3c4d8b9f8995325a704bbf9e15dfa75a17c78deca",
|
||||
"57aa7cbbdeef9b2bf6bf911e99a118f8b8daa51b9d71e305c673c15b0d101dca",
|
||||
"c10c6e02bc9af2e9c8c876f77cda8abf295f79c3240e789315afd156511a25d9",
|
||||
"28a3e2b836b349b2104d187fdf8b424455ded2b067f221b209b636c509933ac3",
|
||||
"875f6aadf0a44d3119de5cc5751fba232d156ece30bfa5e522bb24f4c005884d",
|
||||
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
|
||||
"5109b88955dfd4f718e581ca88f0f9ec478fc64cdf0904cea07e17157bd68ccb",
|
||||
],
|
||||
"slave-pens": [
|
||||
"c1dc38b81d430d16b7a55ff128bb8989fa8ef487bc033540cb7cd5bf5d2f2967",
|
||||
"1e83c465f3cf74797474ee51e3b8a7f6682ede63ba9b2fdd18841ce1b4bf91e8",
|
||||
"09c7f29871b5f2e429c5f643db6f645f0c33a163b143e48e4806ac7231d174d6",
|
||||
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
|
||||
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
|
||||
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
|
||||
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
|
||||
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
|
||||
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
|
||||
],
|
||||
"mana-tombs": [
|
||||
"f243c4ee42d272202023b0c81762221b96e62029006fcfbafbcd5d2bf7b40c7d",
|
||||
"dbb6971658836e2137e6c7a590ce13a790b3d75f79a97edc84e6c8ac8624de1b",
|
||||
"490d328811925cb815949e55ba88919512ae11fed6ce7e0c243947a6014a342c",
|
||||
"f9407bd388db7434746b6f650ae98772933ef47908fb484e19a8460a16e0e783",
|
||||
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
|
||||
"f9130f5df57a62c4037dbd21b004537ffc6a7651cf9efe64785998d8fbe0828f",
|
||||
"23ef87c37ae66698a2b1aedd23b30b139b5be3bf6040220e6cd45cdcb28b9e67",
|
||||
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
|
||||
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
|
||||
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
|
||||
],
|
||||
"auchenai-crypts": [
|
||||
"5a5192f9f89726bf0d73a9b92fccd5e684c47ef22a53465a1ccc6153dabf490c",
|
||||
"ee1e867c866a9c023d1257f90c99925975a321a67f2303e83a8373e2da3315e0",
|
||||
"970edcd88536186ea675f84ef28cdb3f99b19554f289c5fbb1b42e895d13c8be",
|
||||
"6e34fbe51bed87ca4495a8e0b006017ea2de52673d00d6d5148b00e138ebf752",
|
||||
"622b5865f171b0b7a4dd93ecba55afd673c393c579485c17f827b31cb6e47dd2",
|
||||
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
|
||||
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
|
||||
],
|
||||
"magisters-terrace": [
|
||||
"8aa316ceaa914b64e88fe420a0f37537bf016bf5635f8106bf32f82b3eb25728",
|
||||
"43ff46dc05da63bfc4ed49cc5fce8451656857cce544f4ed146736285d4f626d",
|
||||
"458daa44853b6a94a058ab8ccfe08ae4c8ccd34a0387cb766544e12bcbf45dee",
|
||||
"9f5b4a05eb6b6e2acc361f764fd1e472ec255bb9a32369c16e7cc8303e5693e2",
|
||||
"25884cccec0c8bf123c73294bbcdcaee7f6da9a7a0a4af8b3e4d79b66c6b3024",
|
||||
"ed4a734dcdf8b7e186dd4ceeb0ee7d890784aa18edfeab5acf03180a8da1d47f",
|
||||
"3e5713c2a0fa480c82b1753bfea6f1b94a4dec4f29a052ba979c049915a90a42",
|
||||
],
|
||||
});
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function adapted(id: typeof TARGETS[number]): DungeonDefinition {
|
||||
const generated = campaign.definitions.find((definition) => definition.id === id);
|
||||
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
|
||||
const baseline = withWowTbcEncounterContent(withWowProgressionObjectContent(withWowEncounterContent(generated)));
|
||||
return withWowTbcEncounterBatchTwoContent(baseline);
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-batch-two-instance`);
|
||||
if (!value) throw new Error(`Missing TBC batch-two encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing spawn group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectIdForSpawn(definition: DungeonDefinition, spawnId: number): string {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId);
|
||||
if (!object) throw new Error(`${definition.id} missing object spawn ${spawnId}`);
|
||||
return object.id;
|
||||
}
|
||||
|
||||
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
|
||||
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
|
||||
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
|
||||
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
|
||||
}
|
||||
|
||||
function entityForEntry(definition: DungeonDefinition, entry: number) {
|
||||
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
|
||||
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
|
||||
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
|
||||
if (!spawn) throw new Error(`${definition.id} missing static boss entry ${entry}`);
|
||||
return spawn.id;
|
||||
}
|
||||
|
||||
function dispatch(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
difficultyId?: "normal" | "heroic",
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
difficultyId?: "normal" | "heroic",
|
||||
): DungeonEncounterSnapshot {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return dispatch(definition, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}, difficultyId);
|
||||
}
|
||||
|
||||
describe("source-backed adapted TBC encounter batch two", () => {
|
||||
it("pins every reviewed source hash/anchor and publishes explicit adaptations and gaps", () => {
|
||||
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const source of review.sourceFiles) {
|
||||
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
|
||||
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(source.anchors.length).toBeGreaterThan(0);
|
||||
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
|
||||
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
|
||||
.toBe(source.sha256);
|
||||
}
|
||||
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
|
||||
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives each reviewed event actor its source schedule and keeps Frozen Core passive", () => {
|
||||
const auchenai = adapted("auchenai-crypts");
|
||||
const avatar = entityForEntry(auchenai, 18_478);
|
||||
expect(avatar.combat?.attacks).toMatchObject([
|
||||
{ id: "basic-attack", cooldownMs: 1_391 },
|
||||
{
|
||||
id: "mortal-strike-16856",
|
||||
name: "Mortal Strike",
|
||||
spellId: 16_856,
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
initialCooldownMs: [8_500, 16_800],
|
||||
repeatCooldownMs: [10_900, 24_100],
|
||||
damageMultiplier: 2,
|
||||
source: { entry: 18_478, rowId: 0, eventType: 0, actionType: 11, targetType: 2 },
|
||||
},
|
||||
{
|
||||
id: "sunder-armor-16145",
|
||||
name: "Sunder Armor",
|
||||
spellId: 16_145,
|
||||
target: "primary",
|
||||
initialCooldownMs: [6_500, 11_500],
|
||||
repeatCooldownMs: [6_200, 15_700],
|
||||
effect: { kind: "target-aura", aura: "sunder-armor", durationMs: 30_000 },
|
||||
source: { entry: 18_478, rowId: 1, eventType: 0, actionType: 11, targetType: 2 },
|
||||
},
|
||||
]);
|
||||
|
||||
const manaTombs = adapted("mana-tombs");
|
||||
const yor = entityForEntry(manaTombs, 22_930);
|
||||
expect(yor.combat?.attacks).toMatchObject([
|
||||
{ id: "basic-attack", cooldownMs: 2_000 },
|
||||
{
|
||||
id: "double-breath-38361",
|
||||
name: "Double Breath",
|
||||
spellId: 38_361,
|
||||
target: "nearby-party",
|
||||
school: "fire",
|
||||
range: 5,
|
||||
initialCooldownMs: [25_500, 30_500],
|
||||
repeatCooldownMs: [10_000, 20_000],
|
||||
telegraph: { shape: "cone", anchor: "source", length: 20 },
|
||||
},
|
||||
{
|
||||
id: "stomp-36405",
|
||||
name: "Stomp",
|
||||
spellId: 36_405,
|
||||
target: "nearby-party",
|
||||
school: "physical",
|
||||
initialCooldownMs: [12_000, 18_000],
|
||||
repeatCooldownMs: [14_000, 24_000],
|
||||
},
|
||||
]);
|
||||
|
||||
const slavePens = adapted("slave-pens");
|
||||
const ahune = entityForEntry(slavePens, 25_740);
|
||||
const frozenCore = entityForEntry(slavePens, 25_865);
|
||||
expect(ahune).toMatchObject({ kind: "boss", hasLoot: true });
|
||||
expect(ahune.combat?.attacks).toMatchObject([
|
||||
{
|
||||
id: "ahunes-shield-45954",
|
||||
name: "Ahune's Shield",
|
||||
spellId: 45_954,
|
||||
target: "self",
|
||||
castTimeMs: 0,
|
||||
initialCooldownMs: [4, 4],
|
||||
maximumCasts: 1,
|
||||
effect: { kind: "aura", aura: "ahunes-shield", durationMs: 0, magnitude: 0.75 },
|
||||
},
|
||||
{
|
||||
id: "ahune-spanky-hands-46146",
|
||||
name: "[PH] Ahune Spanky Hands",
|
||||
spellId: 46_146,
|
||||
target: "self",
|
||||
castTimeMs: 0,
|
||||
initialCooldownMs: [4, 4],
|
||||
maximumCasts: 1,
|
||||
},
|
||||
]);
|
||||
expect(frozenCore).toMatchObject({ kind: "mob", hasLoot: false });
|
||||
expect(frozenCore.combat?.healthMultiplier).toBe(4.3);
|
||||
expect(frozenCore.combat?.attacks).toMatchObject([
|
||||
{
|
||||
id: "ice-spear-control-aura-46371",
|
||||
name: "Ice Spear Control Aura",
|
||||
spellId: 46_371,
|
||||
target: "self",
|
||||
castTimeMs: 0,
|
||||
maximumCasts: 1,
|
||||
},
|
||||
{
|
||||
id: "synch-health-46430",
|
||||
name: "Synch Health",
|
||||
spellId: 46_430,
|
||||
target: "self",
|
||||
castTimeMs: 0,
|
||||
initialCooldownMs: [3_000, 3_000],
|
||||
repeatCooldownMs: [3_000, 3_000],
|
||||
effect: { kind: "encounter-event", eventId: "ahune-synch-health" },
|
||||
},
|
||||
]);
|
||||
|
||||
for (const entity of [avatar, yor, ahune, frozenCore]) {
|
||||
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
|
||||
expect(entity.combat?.mechanicIds).toEqual(
|
||||
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
|
||||
);
|
||||
}
|
||||
expect(WOW_TBC_ENCOUNTER_BATCH_TWO_REVIEWS.find((review) => review.dungeonId === "slave-pens")
|
||||
?.remainingGaps.join(" ")).toContain("proc-driven Cold Slap");
|
||||
});
|
||||
|
||||
it("validates definitions, stable action ids, references, timeouts, and wrapper idempotency", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adapted(id);
|
||||
const value = encounter(definition);
|
||||
validateDungeonEncounterDefinition(value);
|
||||
definition.serverGameObjects.forEach(validateDungeonWorldObject);
|
||||
const groups = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
|
||||
const objects = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
|
||||
const spawns = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
|
||||
expect(new Set(actionIds).size, id).toBe(actionIds.length);
|
||||
for (const rule of value.rules) {
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
|
||||
rule.trigger.entityIds.forEach((entityId) => expect(spawns.has(entityId), entityId).toBe(true));
|
||||
}
|
||||
if (rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health" || rule.trigger.kind === "entity-death") {
|
||||
expect(spawns.has(rule.trigger.entityId), `${id}:${rule.id}:${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "interact") expect(objects.has(rule.trigger.objectId), rule.id).toBe(true);
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groups.has(action.spawnGroupId), action.id).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") expect(objects.has(action.objectId), action.id).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(withWowTbcEncounterBatchTwoContent(definition)).toBe(definition);
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses event-only/static duplicates and preserves source order and cardinality", () => {
|
||||
const underbog = adapted("underbog");
|
||||
expect(underbog.staticSpawns.some((spawn) => [18_105, 17_827].includes(entryForSpawn(underbog, spawn.id) ?? -1))).toBe(false);
|
||||
expect(group(underbog, "underbog:ghazan-platform").spawns).toHaveLength(1);
|
||||
expect(group(underbog, "underbog:claw").spawns).toHaveLength(1);
|
||||
expect(underbog.bosses.find((boss) => boss.name === "Ghaz'an")).toMatchObject({ eventOnly: true });
|
||||
|
||||
const slavePens = adapted("slave-pens");
|
||||
expect(slavePens.staticSpawns.some((spawn) => entryForSpawn(slavePens, spawn.id) === 17_893)).toBe(false);
|
||||
expect(group(slavePens, "slave-pens:naturalist-attackers").spawns).toHaveLength(3);
|
||||
expect(group(slavePens, "slave-pens:ahune-coldwaves").spawns).toHaveLength(2);
|
||||
expect(slavePens.objectiveOrder.at(-1)).toMatchObject({ id: "slave-pens:ahune", optional: true, eventOnly: true });
|
||||
|
||||
const manaTombs = adapted("mana-tombs");
|
||||
expect(group(manaTombs, "mana-tombs:pandemonius-room-pull").spawns).toHaveLength(12);
|
||||
expect(group(manaTombs, "mana-tombs:shaheen-wave-1").spawns).toHaveLength(4);
|
||||
expect(group(manaTombs, "mana-tombs:shaheen-wave-2").spawns).toHaveLength(4);
|
||||
expect(group(manaTombs, "mana-tombs:shaheen-wave-3").spawns).toHaveLength(1);
|
||||
expect(manaTombs.staticSpawns.some((spawn) => /^creature-833(0[9]|1[0-9]|21)$/.test(spawn.id))).toBe(false);
|
||||
expect(manaTombs.objectiveOrder.at(-1)).toMatchObject({ id: "mana-tombs:yor", optional: true, eventOnly: true });
|
||||
|
||||
const auchenai = adapted("auchenai-crypts");
|
||||
expect(group(auchenai, "auchenai-crypts:stolen-soul").spawns).toHaveLength(1);
|
||||
expect(group(auchenai, "auchenai-crypts:avatar").spawns).toHaveLength(1);
|
||||
|
||||
const magisters = adapted("magisters-terrace");
|
||||
expect(magisters.staticSpawns.some((spawn) => entryForSpawn(magisters, spawn.id) === 24_560)).toBe(false);
|
||||
expect(group(magisters, "magisters-terrace:selin-crystals").spawns).toHaveLength(5);
|
||||
expect(group(magisters, "magisters-terrace:delrissa-party").spawns).toHaveLength(5);
|
||||
expect(group(magisters, "magisters-terrace:kael-spheres").spawns).toHaveLength(3);
|
||||
expect(magisters.objectiveOrder.map((boss) => boss.name)).toEqual([
|
||||
"Selin Fireheart", "Vexallus", "Priestess Delrissa", "Kael'thas Sunstrider",
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces only reviewed GO rows by exact source spawnId with visible procedural states", () => {
|
||||
const expected = Object.freeze({
|
||||
underbog: [],
|
||||
"slave-pens": [50_458, 1_267],
|
||||
"mana-tombs": [43_146, 2_134_514],
|
||||
"auchenai-crypts": [],
|
||||
"magisters-terrace": [99_800, 99_802, 99_807, 99_808, 99_809, 99_813],
|
||||
} as const);
|
||||
for (const id of TARGETS) {
|
||||
const definition = adapted(id);
|
||||
for (const spawnId of expected[id]) {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
|
||||
expect(object.source?.fidelity).toBe("source-backed-adapted");
|
||||
expect(object.source?.files.length).toBeGreaterThan(0);
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
}
|
||||
}
|
||||
const magisters = adapted("magisters-terrace");
|
||||
for (const spawnId of [99_802, 99_807, 99_808, 99_809, 99_813]) {
|
||||
const door = magisters.serverGameObjects.find((candidate) => candidate.source?.spawnId === spawnId)!;
|
||||
expect(door.states.find((state) => state.id === "closed")).toMatchObject({
|
||||
visible: true,
|
||||
blocksMovement: true,
|
||||
blocksSight: true,
|
||||
});
|
||||
expect(door.states.find((state) => state.id === "open")).toMatchObject({
|
||||
visible: false,
|
||||
blocksMovement: false,
|
||||
blocksSight: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Underbog difficulty branches, Ghaz'an release, Claw rescue, and wipe retry", () => {
|
||||
const definition = adapted("underbog");
|
||||
const value = encounter(definition);
|
||||
const hungarfen = bossIdForEntry(definition, 17_770);
|
||||
let normal = createDungeonEncounterSnapshot(value);
|
||||
normal = dispatch(value, normal, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "normal");
|
||||
normal = dispatch(value, normal, { kind: "tick", now: 9_999 }, "normal");
|
||||
expect(normal.activeSpawnGroupIds).toEqual([]);
|
||||
normal = dispatch(value, normal, { kind: "tick", now: 10_000 }, "normal");
|
||||
expect(normal.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-normal"]);
|
||||
|
||||
let heroic = createDungeonEncounterSnapshot(value);
|
||||
heroic = dispatch(value, heroic, { kind: "entity-engaged", entityId: hungarfen, now: 0 }, "heroic");
|
||||
heroic = dispatch(value, heroic, { kind: "tick", now: 3_000 }, "heroic");
|
||||
expect(heroic.activeSpawnGroupIds).toEqual(["underbog:hungarfen-mushroom-heroic"]);
|
||||
heroic = resetDungeonEncounterSnapshot(value, heroic, "boss-wipe", 3_001);
|
||||
heroic = dispatch(value, heroic, { kind: "tick", now: 3_002 }, "heroic");
|
||||
expect(heroic).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
|
||||
|
||||
let snapshot = dispatch(value, heroic, { kind: "interact", objectId: "underbog:ghazan-platform-trigger", now: 4_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["underbog:ghazan-platform"]);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: "creature-138392", now: 4_001 });
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-138392");
|
||||
expect(snapshot.flags["ghazan-complete"]).toBe(true);
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 4_002);
|
||||
expect(reset.worldObjectStates["underbog:ghazan-platform-trigger"]).toBe("complete");
|
||||
|
||||
snapshot = dispatch(value, reset, { kind: "tick", now: 4_003 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 17_826), now: 4_004 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("underbog:claw");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "creature-138499", healthPercent: 20, now: 4_005 });
|
||||
expect(snapshot.flags["claw-rescued"]).toBe(true);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain("underbog:claw");
|
||||
expect(snapshot.worldObjectStates["underbog:rescued-claw"]).toBe("complete");
|
||||
});
|
||||
|
||||
it("runs Naturalist and Ahune as finite, persistent, retryable Slave Pens events", () => {
|
||||
const definition = adapted("slave-pens");
|
||||
const value = encounter(definition);
|
||||
const cage = objectIdForSpawn(definition, 50_458);
|
||||
const iceStone = objectIdForSpawn(definition, 1_267);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: cage, now: 0 });
|
||||
snapshot = killGroup(value, snapshot, group(definition, "slave-pens:naturalist-attackers"), 1);
|
||||
expect(snapshot.phase).toBe("naturalist-handoff");
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_441 });
|
||||
expect(snapshot.flags["naturalist-complete"]).toBe(true);
|
||||
expect(snapshot.worldObjectStates[cage]).toBe("complete");
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 4_000 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["slave-pens:ahune"]);
|
||||
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 14_001);
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 14_002 });
|
||||
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
|
||||
expect(snapshot.flags["naturalist-complete"]).toBe(true);
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: iceStone, now: 15_000 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 29_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-hailstone");
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 37_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:ahune-coldwaves");
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 123_000 });
|
||||
expect(snapshot.phase).toBe("ahune-core");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("slave-pens:frozen-core");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "slave-pens:frozen-core").spawns[0].id, now: 123_001 });
|
||||
expect(snapshot).toMatchObject({ phase: "ready", flags: { "ahune-complete": true }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.completedObjectiveIds).toContain("slave-pens:ahune");
|
||||
});
|
||||
|
||||
it("runs Mana-Tombs room pull, Sha'heen ambushes, and heroic-only Yor", () => {
|
||||
const definition = adapted("mana-tombs");
|
||||
const value = encounter(definition);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, 18_341), now: 0 });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["mana-tombs:pandemonius-room-pull"]);
|
||||
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 1);
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 2 }, "normal");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
const panel = objectIdForSpawn(definition, 43_146);
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: panel, now: 3 });
|
||||
for (let index = 1; index <= 3; index += 1) {
|
||||
snapshot = killGroup(value, snapshot, group(definition, `mana-tombs:shaheen-wave-${index}`), 3 + index);
|
||||
}
|
||||
expect(snapshot.flags["shaheen-complete"]).toBe(true);
|
||||
expect(snapshot.worldObjectStates[panel]).toBe("complete");
|
||||
const persisted = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 7);
|
||||
expect(persisted.flags["shaheen-complete"]).toBe(true);
|
||||
|
||||
const stasis = objectIdForSpawn(definition, 2_134_514);
|
||||
let normal = createDungeonEncounterSnapshot(value);
|
||||
normal = dispatch(value, normal, { kind: "tick", now: 0 }, "normal");
|
||||
expect(normal.worldObjectStates[stasis]).toBe("hidden");
|
||||
normal = dispatch(value, normal, { kind: "interact", objectId: stasis, now: 1 }, "normal");
|
||||
expect(normal.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
let heroic = createDungeonEncounterSnapshot(value);
|
||||
heroic = dispatch(value, heroic, { kind: "tick", now: 0 }, "heroic");
|
||||
expect(heroic.worldObjectStates[stasis]).toBe("ready");
|
||||
heroic = dispatch(value, heroic, { kind: "interact", objectId: stasis, now: 1 }, "heroic");
|
||||
expect(heroic.activeSpawnGroupIds).toEqual(["mana-tombs:yor"]);
|
||||
heroic = dispatch(value, heroic, { kind: "entity-death", entityId: group(definition, "mana-tombs:yor").spawns[0].id, now: 2 }, "heroic");
|
||||
expect(heroic.flags["yor-complete"]).toBe(true);
|
||||
expect(heroic.completedObjectiveIds).toContain("mana-tombs:yor");
|
||||
});
|
||||
|
||||
it("runs Maladaar's finite soul/avatar transition and keeps the avatar independently killable", () => {
|
||||
const definition = adapted("auchenai-crypts");
|
||||
const value = encounter(definition);
|
||||
const maladaar = bossIdForEntry(definition, 18_373);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: maladaar, now: 0 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 25_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:stolen-soul");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: maladaar, healthPercent: 25, now: 25_001 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: maladaar, now: 25_002 });
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain("auchenai-crypts:stolen-soul");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("auchenai-crypts:avatar");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: group(definition, "auchenai-crypts:avatar").spawns[0].id, now: 25_003 });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 25_004);
|
||||
expect(dispatch(value, reset, { kind: "tick", now: 25_005 }).phase).toBe("ready");
|
||||
});
|
||||
|
||||
it("runs Magisters' exact doors, difficulty branches, Delrissa recovery, and reachable completion", () => {
|
||||
const definition = adapted("magisters-terrace");
|
||||
const value = encounter(definition);
|
||||
const selin = bossIdForEntry(definition, 24_723);
|
||||
const vexallus = bossIdForEntry(definition, 24_744);
|
||||
const kael = bossIdForEntry(definition, 24_664);
|
||||
const selinPassage = objectIdForSpawn(definition, 99_802);
|
||||
const selinRoom = objectIdForSpawn(definition, 99_809);
|
||||
const vexPassage = objectIdForSpawn(definition, 99_813);
|
||||
const delrissaPassage = objectIdForSpawn(definition, 99_807);
|
||||
const kaelRoom = objectIdForSpawn(definition, 99_808);
|
||||
const orb = objectIdForSpawn(definition, 99_800);
|
||||
|
||||
let normal = createDungeonEncounterSnapshot(value);
|
||||
normal = dispatch(value, normal, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "normal");
|
||||
expect(normal.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-normal"]);
|
||||
expect(group(definition, normal.activeSpawnGroupIds[0]).spawns).toHaveLength(1);
|
||||
let heroic = createDungeonEncounterSnapshot(value);
|
||||
heroic = dispatch(value, heroic, { kind: "entity-health", entityId: vexallus, healthPercent: 84, now: 1 }, "heroic");
|
||||
expect(heroic.activeSpawnGroupIds).toEqual(["magisters-terrace:vexallus-85-heroic"]);
|
||||
expect(group(definition, heroic.activeSpawnGroupIds[0]).spawns).toHaveLength(2);
|
||||
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: selin, now: 0 });
|
||||
expect(snapshot.worldObjectStates[selinRoom]).toBe("closed");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:selin-crystals");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: selin, now: 1 });
|
||||
expect(snapshot.worldObjectStates[selinPassage]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[selinRoom]).toBe("open");
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: vexallus, now: 2 });
|
||||
expect(snapshot.worldObjectStates[vexPassage]).toBe("open");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:delrissa-party");
|
||||
const prematureKael = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 2.5 });
|
||||
expect(prematureKael.flags["dungeon-complete"]).not.toBe(true);
|
||||
expect(prematureKael.completedObjectiveIds).not.toContain("creature-96966");
|
||||
snapshot = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 4 });
|
||||
expect(snapshot.phase).toBe("delrissa-active");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["magisters-terrace:delrissa-party"]);
|
||||
snapshot = killGroup(value, snapshot, group(definition, "magisters-terrace:delrissa-party"), 5);
|
||||
expect(snapshot.completedObjectiveIds).toContain("creature-96966");
|
||||
expect(snapshot.worldObjectStates[delrissaPassage]).toBe("open");
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kael, now: 6 });
|
||||
expect(snapshot.worldObjectStates[kaelRoom]).toBe("closed");
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 15_006 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-phoenix");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: kael, healthPercent: 50, now: 15_007 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("magisters-terrace:kael-spheres");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kael, now: 15_008 });
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true } });
|
||||
expect(snapshot.worldObjectStates[kaelRoom]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[orb]).toBe("unlocked");
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "checkpoint", 15_009);
|
||||
const resumed = dispatch(value, reset, { kind: "tick", now: 15_010 });
|
||||
expect(resumed.phase).toBe("complete");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,518 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterEvent,
|
||||
DungeonEncounterSnapshot,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonDefinition, DungeonEncounterSpawnGroupDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import { withWowEncounterContent } from "./wowEncounterContentCatalog";
|
||||
import { withWowProgressionObjectContent } from "./wowProgressionObjectContentCatalog";
|
||||
import {
|
||||
WOW_TBC_ENCOUNTER_CONTENT_REVIEWS,
|
||||
withWowTbcEncounterContent,
|
||||
} from "./wowTbcEncounterContentCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"blood-furnace",
|
||||
"shattered-halls",
|
||||
"hellfire-ramparts",
|
||||
"botanica",
|
||||
"sethekk-halls",
|
||||
] as const;
|
||||
|
||||
const EXPECTED_SOURCE_HASHES = Object.freeze({
|
||||
"blood-furnace": [
|
||||
"ec11055881a2cb1a01c299ab9999f91f732ddefe2ed79e3b817734179694e398",
|
||||
"d1c55db2400e0dd5563f5832028dce474ad55ff0fc21471442bee7af3a10750e",
|
||||
"3ddeb8493dd32d30f76ca1612ee768d78783842cc3d187c48cc66861b92e0e36",
|
||||
],
|
||||
"shattered-halls": [
|
||||
"8e1e6178a4c36d5bfd245529625df928a3c1e9178562bf7c03daf29cee725dbc",
|
||||
"8edd2a23afb4d6db7c72d63b16d86a860c68bd6bee2dc21ece7d09e307092dfd",
|
||||
"1d4eae585fc0084c50e5d8b6caf1502643cc2f5739b42e79d668439afdf7341a",
|
||||
"6fee37fc2e07515aa887155701c83bb0383ba8a41bb5549e90da4a83e95dd94e",
|
||||
"c4c2e6724b0d7ca9daa659a2f4e64df53e1eae67e913fe901ceb74865d29f850",
|
||||
],
|
||||
"hellfire-ramparts": [
|
||||
"0b70541937db116dfd1d9eba75c0f9b662679edf5f61ca52c88d3198319ebb45",
|
||||
"32fa87c630d4512b62b4f7fb7f22ea82b8e4d93c9cfd5bd17e452e0b6245357c",
|
||||
"5365a041c9bacdb8c1a46f25b8ed90f2a8ff06e1925f7e8a34cd77ee1ea8df93",
|
||||
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
|
||||
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
|
||||
],
|
||||
botanica: [
|
||||
"58e3f612984d30d765d4ebe98016e530629d7cd5a66499b3ee1c541f16cb378f",
|
||||
"062c751d365cb5d205c47a5dca93c64fa46cfda7629456e8e58096dfe8aeaa22",
|
||||
"b350507ced2b94f6a9178db0102176c98e130cfe5b844ce11f649b62f94cae83",
|
||||
"a428f3f216e1e91a93339b837c9e1049f4d0eca4fa735312c574fcc388971dc7",
|
||||
"484bebc3e3211ab624bb933fd0b59bb1e9f6c2891b66d36c318dac8df7705399",
|
||||
],
|
||||
"sethekk-halls": [
|
||||
"223f84da402f20cfb5d99cbdee8b1bdf4cd22bf2f74eed1516cb078b9759250f",
|
||||
"2d44ec532e3d62d804cf0b4c26304cc9ad12b38f626ef5876e5e02b0b99e5bb8",
|
||||
"93ecd4af480331bc4026d24431c6db9288a17f9502966795e4318615eec774ca",
|
||||
"b15531207673a2b908e263acb6e5c3360810ffc682cc93f6cadaaad67f01a703",
|
||||
"73fdb3c48d6a1763600a1a3293639849f6a61cc465e1c186958c1322e3003f3f",
|
||||
"0066e4fd35b809986dd325487515087f5a95f4ffe50ea03846c8d8bf5196870a",
|
||||
"d5cce1a83550dcfa9eb2f0251dbb11fd24c272534b2b1a9b230924a44d817ab3",
|
||||
],
|
||||
});
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const campaign = JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
};
|
||||
|
||||
function adapted(id: typeof TARGETS[number]): DungeonDefinition {
|
||||
const generated = campaign.definitions.find((definition) => definition.id === id);
|
||||
if (!generated) throw new Error(`Missing generated dungeon ${id}`);
|
||||
const baseline = withWowProgressionObjectContent(withWowEncounterContent(generated));
|
||||
return withWowTbcEncounterContent(baseline);
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:tbc-instance`);
|
||||
if (!value) throw new Error(`Missing TBC encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string): DungeonEncounterSpawnGroupDefinition {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`Missing spawn group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectIdForTemplate(definition: DungeonDefinition, templateId: number): string {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId);
|
||||
if (!object) throw new Error(`${definition.id} missing object template ${templateId}`);
|
||||
return object.id;
|
||||
}
|
||||
|
||||
function entryForSpawn(definition: DungeonDefinition, spawnId: string): number | undefined {
|
||||
const spawn = definition.staticSpawns.find((candidate) => candidate.id === spawnId)
|
||||
?? definition.encounterSpawnGroups?.flatMap((candidate) => candidate.spawns).find((candidate) => candidate.id === spawnId);
|
||||
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
|
||||
}
|
||||
|
||||
function entityForEntry(definition: DungeonDefinition, entry: number) {
|
||||
const entity = Object.values(definition.entities).find((candidate) => candidate.combat?.serverEntry === entry);
|
||||
if (!entity) throw new Error(`${definition.id} missing entity entry ${entry}`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function bossIdForEntry(definition: DungeonDefinition, entry: number): string {
|
||||
const spawn = definition.staticSpawns.find((candidate) => entryForSpawn(definition, candidate.id) === entry);
|
||||
if (!spawn) throw new Error(`${definition.id} missing boss entry ${entry}`);
|
||||
return spawn.id;
|
||||
}
|
||||
|
||||
function dispatch(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(definition, snapshot, event).snapshot;
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
spawnGroup: DungeonEncounterSpawnGroupDefinition,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
const ids = spawnGroup.spawns.map((spawn) => spawn.id);
|
||||
return dispatch(definition, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
});
|
||||
}
|
||||
|
||||
describe("source-backed adapted TBC dungeon orchestration", () => {
|
||||
it("pins reviewed source hashes, symbol/line anchors, and explicit unsupported behavior", () => {
|
||||
expect(WOW_TBC_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_TBC_ENCOUNTER_CONTENT_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.sourceFiles.map((source) => source.sha256)).toEqual(EXPECTED_SOURCE_HASHES[review.dungeonId]);
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.join(" ")).toMatch(/remain|require|need|unported|incomplete/i);
|
||||
for (const source of review.sourceFiles) {
|
||||
expect(source.path).toMatch(/azerothcore-(?:scripts|world).+\.(?:cpp|h|sql)$|epoch-client.+\.dbc$/);
|
||||
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(source.anchors.length).toBeGreaterThan(0);
|
||||
expect(source.anchors.every((anchor) => /lines? \d+|records? \d+/.test(anchor))).toBe(true);
|
||||
expect(createHash("sha256").update(readFileSync(resolve(source.path))).digest("hex"), source.path)
|
||||
.toBe(source.sha256);
|
||||
}
|
||||
const sourceText = encounter(adapted(review.dungeonId)).source?.files.join(" ") ?? "";
|
||||
for (const hash of EXPECTED_SOURCE_HASHES[review.dungeonId]) expect(sourceText).toContain(hash);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives the event-only Ramparts and Sethekk bosses their pinned source combat schedules", () => {
|
||||
const ramparts = adapted("hellfire-ramparts");
|
||||
const vazruden = entityForEntry(ramparts, 17_537);
|
||||
const nazan = entityForEntry(ramparts, 17_536);
|
||||
const sethekk = adapted("sethekk-halls");
|
||||
const anzu = entityForEntry(sethekk, 23_035);
|
||||
|
||||
expect(vazruden.combat?.attacks).toMatchObject([
|
||||
{ id: "basic-attack", cooldownMs: 1_300 },
|
||||
{
|
||||
id: "revenge-19130",
|
||||
name: "Revenge",
|
||||
spellId: 19_130,
|
||||
target: "primary",
|
||||
school: "physical",
|
||||
initialCooldownMs: [4_000, 4_000],
|
||||
repeatCooldownMs: [6_000, 6_000],
|
||||
},
|
||||
]);
|
||||
expect(nazan.combat?.attacks).toMatchObject([
|
||||
{ id: "basic-attack", cooldownMs: 2_000 },
|
||||
{
|
||||
id: "fireball-33793",
|
||||
name: "Fireball",
|
||||
spellId: 33_793,
|
||||
target: "random-party",
|
||||
school: "fire",
|
||||
initialCooldownMs: [5_000, 5_000],
|
||||
repeatCooldownMs: [4_000, 6_000],
|
||||
},
|
||||
{
|
||||
id: "cone-of-fire-30926",
|
||||
name: "Cone of Fire",
|
||||
spellId: 30_926,
|
||||
target: "nearby-party",
|
||||
school: "fire",
|
||||
initialCooldownMs: [5_000, 5_000],
|
||||
repeatCooldownMs: [12_000, 12_000],
|
||||
},
|
||||
]);
|
||||
expect(anzu.combat?.attacks).toMatchObject([
|
||||
{ id: "basic-attack", cooldownMs: 1_400 },
|
||||
{
|
||||
id: "paralyzing-screech-40184",
|
||||
name: "Paralyzing Screech",
|
||||
spellId: 40_184,
|
||||
target: "nearby-party",
|
||||
school: "shadow",
|
||||
initialCooldownMs: [14_000, 14_000],
|
||||
repeatCooldownMs: [23_000, 23_000],
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
|
||||
},
|
||||
{
|
||||
id: "spell-bomb-40303",
|
||||
name: "Spell Bomb",
|
||||
spellId: 40_303,
|
||||
target: "random-party",
|
||||
initialCooldownMs: [5_000, 5_000],
|
||||
repeatCooldownMs: [16_000, 24_500],
|
||||
effect: { kind: "target-aura", aura: "spell-bomb", durationMs: 8_000 },
|
||||
},
|
||||
{
|
||||
id: "cyclone-of-feathers-40321",
|
||||
name: "Cyclone of Feathers",
|
||||
spellId: 40_321,
|
||||
target: "random-party",
|
||||
school: "shadow",
|
||||
initialCooldownMs: [8_000, 8_000],
|
||||
repeatCooldownMs: [22_000, 27_000],
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 6_000 },
|
||||
},
|
||||
]);
|
||||
|
||||
for (const entity of [vazruden, nazan, anzu]) {
|
||||
expect(entity.kind).toBe("boss");
|
||||
expect(entity.combat?.attacks.some((attack) => attack.id !== "basic-attack")).toBe(true);
|
||||
expect(entity.combat?.mechanicIds).toEqual(
|
||||
entity.combat?.attacks.filter((attack) => attack.id !== "basic-attack").map((attack) => attack.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("validates every encounter, stable action id, spawn/object reference, and safety timeout", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adapted(id);
|
||||
const value = encounter(definition);
|
||||
validateDungeonEncounterDefinition(value);
|
||||
definition.serverGameObjects.forEach(validateDungeonWorldObject);
|
||||
|
||||
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((candidate) => candidate.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((candidate) => candidate.id));
|
||||
const spawnIds = new Set([
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((candidate) => candidate.spawns.map((spawn) => spawn.id)),
|
||||
]);
|
||||
const actionIds = value.rules.flatMap((rule) => rule.actions.map((action) => action.id));
|
||||
expect(new Set(actionIds).size, id).toBe(actionIds.length);
|
||||
for (const rule of value.rules) {
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
|
||||
for (const entityId of rule.trigger.entityIds) expect(spawnIds.has(entityId), `${id}:${rule.id}:${entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "interact") expect(objectIds.has(rule.trigger.objectId), `${id}:${rule.id}`).toBe(true);
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds.has(action.spawnGroupId), `${id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
expect(objectIds.has(action.objectId), `${id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(withWowTbcEncounterContent(definition)).toBe(definition);
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes collision only for source-reviewed imported objects", () => {
|
||||
const reviewedTemplates = Object.freeze({
|
||||
"blood-furnace": [181_811, 181_812, 181_822, 181_819, 181_823, 181_766, 181_821, 181_818, 181_820, 181_817, 181_982],
|
||||
"shattered-halls": [182_539, 182_540],
|
||||
"hellfire-ramparts": [185_168],
|
||||
botanica: [],
|
||||
"sethekk-halls": [183_398, 187_372],
|
||||
} as const);
|
||||
for (const id of TARGETS) {
|
||||
const definition = adapted(id);
|
||||
for (const templateId of reviewedTemplates[id]) {
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.source?.templateId === templateId)!;
|
||||
expect(object.source?.fidelity).toBe("source-backed-adapted");
|
||||
expect(object.visual?.hidden).not.toBe(true);
|
||||
expect(object.source?.files.length).toBeGreaterThan(0);
|
||||
}
|
||||
const allowed = new Set<number>(reviewedTemplates[id]);
|
||||
const untouchedImports = definition.serverGameObjects.filter((object) => (
|
||||
object.source?.spawnId !== undefined && !allowed.has(object.source.templateId ?? -1)
|
||||
));
|
||||
expect(untouchedImports.every((object) => object.visual?.hidden === true)).toBe(true);
|
||||
expect(untouchedImports.every((object) => object.states.every((state) => !state.blocksMovement && !state.blocksSight))).toBe(true);
|
||||
}
|
||||
|
||||
for (const [id, templateId] of [
|
||||
["blood-furnace", 181_811],
|
||||
["shattered-halls", 182_539],
|
||||
["sethekk-halls", 183_398],
|
||||
] as const) {
|
||||
const definition = adapted(id);
|
||||
const object = definition.serverGameObjects.find((candidate) => candidate.id === objectIdForTemplate(definition, templateId))!;
|
||||
expect(object.states.find((state) => state.id === "closed")).toMatchObject({
|
||||
visible: true,
|
||||
blocksMovement: true,
|
||||
blocksSight: true,
|
||||
});
|
||||
expect(object.states.find((state) => state.id === "open")).toMatchObject({
|
||||
visible: false,
|
||||
blocksMovement: false,
|
||||
blocksSight: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("removes unsafe static event actors and preserves finite source-shaped cardinality", () => {
|
||||
const blood = adapted("blood-furnace");
|
||||
expect(blood.staticSpawns.some((spawn) => entryForSpawn(blood, spawn.id) === 17_380)).toBe(false);
|
||||
expect([1, 2, 3, 4].map((index) => group(blood, `blood-furnace:broggok-cell-${index}`).spawns.length).sort()).toEqual([3, 4, 5, 6]);
|
||||
expect(group(blood, "blood-furnace:broggok").spawns).toHaveLength(1);
|
||||
expect(blood.staticSpawns.some((spawn) => (
|
||||
entryForSpawn(blood, spawn.id) === 17_398
|
||||
&& spawn.position[0] < -400
|
||||
&& spawn.position[2] >= 75
|
||||
&& spawn.position[2] <= 125
|
||||
))).toBe(false);
|
||||
|
||||
const shattered = adapted("shattered-halls");
|
||||
expect(shattered.staticSpawns.some((spawn) => entryForSpawn(shattered, spawn.id) === 16_807)).toBe(false);
|
||||
expect(group(shattered, "shattered-halls:nethekurse-converts").spawns).toHaveLength(4);
|
||||
expect(group(shattered, "shattered-halls:nethekurse").spawns).toHaveLength(1);
|
||||
expect(group(shattered, "shattered-halls:kargath-reinforcements").spawns).toHaveLength(7);
|
||||
|
||||
const ramparts = adapted("hellfire-ramparts");
|
||||
expect(ramparts.staticSpawns.some((spawn) => [17_536, 17_537].includes(entryForSpawn(ramparts, spawn.id) ?? -1))).toBe(false);
|
||||
expect(group(ramparts, "hellfire-ramparts:vazruden-sentries").spawns).toHaveLength(2);
|
||||
expect(group(ramparts, "hellfire-ramparts:vazruden-and-nazan").spawns).toHaveLength(2);
|
||||
|
||||
const botanica = adapted("botanica");
|
||||
expect(group(botanica, "botanica:freywinn-frayers").spawns).toHaveLength(3);
|
||||
expect(group(botanica, "botanica:laj-thorns").spawns).toHaveLength(2);
|
||||
expect(group(botanica, "botanica:warp-saplings").spawns).toHaveLength(6);
|
||||
|
||||
const sethekk = adapted("sethekk-halls");
|
||||
expect([90, 50, 10].map((threshold) => group(sethekk, `sethekk-halls:syth-elementals-${threshold}`).spawns.length)).toEqual([4, 4, 4]);
|
||||
expect(group(sethekk, "sethekk-halls:anzu").spawns).toHaveLength(1);
|
||||
expect([66, 33].map((threshold) => group(sethekk, `sethekk-halls:anzu-broods-${threshold}`).spawns.length)).toEqual([5, 5]);
|
||||
expect(sethekk.bosses.find((boss) => boss.id === "sethekk-halls:anzu")).toMatchObject({ optional: true, eventOnly: true });
|
||||
});
|
||||
|
||||
it("runs Blood Furnace cells and recovers the Broggok checkpoint without duplication", () => {
|
||||
const definition = adapted("blood-furnace");
|
||||
const value = encounter(definition);
|
||||
const leverId = objectIdForTemplate(definition, 181_982);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: leverId, now: 1 });
|
||||
expect(snapshot.phase).toBe("cell-1-active");
|
||||
for (let index = 1; index <= 4; index += 1) {
|
||||
snapshot = killGroup(value, snapshot, group(definition, `blood-furnace:broggok-cell-${index}`), index + 1);
|
||||
}
|
||||
expect(snapshot).toMatchObject({ phase: "broggok-active", activeSpawnGroupIds: ["blood-furnace:broggok"] });
|
||||
snapshot = killGroup(value, snapshot, group(definition, "blood-furnace:broggok"), 7);
|
||||
expect(snapshot.phase).toBe("post-broggok");
|
||||
expect(snapshot.completedObjectiveIds).toContain(group(definition, "blood-furnace:broggok").spawns[0].id);
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 8);
|
||||
expect(reset).toMatchObject({ phase: "resume", activeSpawnGroupIds: [] });
|
||||
const resumed = dispatch(value, reset, { kind: "tick", now: 9 });
|
||||
expect(resumed.phase).toBe("post-broggok");
|
||||
expect(resumed.activeSpawnGroupIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("runs Nethekurse's convert gate, wipe recovery, and bounded Kargath adds", () => {
|
||||
const definition = adapted("shattered-halls");
|
||||
const value = encounter(definition);
|
||||
const entrance = objectIdForTemplate(definition, 182_539);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: entrance, now: 1 });
|
||||
expect(snapshot.phase).toBe("nethekurse-converts-active");
|
||||
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 2);
|
||||
expect(snapshot.phase).toBe("nethekurse-active");
|
||||
|
||||
const wiped = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3);
|
||||
const ready = dispatch(value, wiped, { kind: "tick", now: 4 });
|
||||
expect(ready).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
|
||||
snapshot = dispatch(value, ready, { kind: "interact", objectId: entrance, now: 5 });
|
||||
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse-converts"), 6);
|
||||
snapshot = killGroup(value, snapshot, group(definition, "shattered-halls:nethekurse"), 7);
|
||||
expect(snapshot.phase).toBe("post-nethekurse");
|
||||
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 182_540)]).toBe("open");
|
||||
|
||||
const kargathId = bossIdForEntry(definition, 16_808);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: kargathId, now: 8 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain("shattered-halls:kargath-reinforcements");
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: kargathId, now: 9 });
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain("shattered-halls:kargath-reinforcements");
|
||||
});
|
||||
|
||||
it("makes the previously missing Ramparts finale the only completion path and recovers it", () => {
|
||||
const definition = adapted("hellfire-ramparts");
|
||||
const value = encounter(definition);
|
||||
const progression = definition.encounters?.find((candidate) => candidate.id === "hellfire-ramparts:boss-progression")!;
|
||||
expect(progression.rules.some((rule) => rule.id === "all-required-bosses-defeated")).toBe(false);
|
||||
expect(definition.bosses.at(-1)).toMatchObject({ id: "hellfire-ramparts:vazruden-and-nazan", eventOnly: true });
|
||||
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_306), now: 1 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 17_308), now: 2 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3 });
|
||||
expect(snapshot.phase).toBe("ready");
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "hellfire-ramparts:vazruden-herald", now: 4 });
|
||||
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 5);
|
||||
expect(snapshot.phase).toBe("herald-descent");
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 3_005 });
|
||||
expect(snapshot.phase).toBe("bosses-active");
|
||||
|
||||
const interrupted = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 3_006);
|
||||
const retryReady = dispatch(value, interrupted, { kind: "tick", now: 3_007 });
|
||||
expect(retryReady).toMatchObject({
|
||||
phase: "ready",
|
||||
worldObjectStates: { "hellfire-ramparts:vazruden-herald": "ready" },
|
||||
});
|
||||
snapshot = dispatch(value, retryReady, {
|
||||
kind: "interact",
|
||||
objectId: "hellfire-ramparts:vazruden-herald",
|
||||
now: 3_008,
|
||||
});
|
||||
expect(snapshot.phase).toBe("sentries-active");
|
||||
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-sentries"), 3_009);
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 6_009 });
|
||||
expect(snapshot.phase).toBe("bosses-active");
|
||||
snapshot = killGroup(value, snapshot, group(definition, "hellfire-ramparts:vazruden-and-nazan"), 6_010);
|
||||
expect(snapshot).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
||||
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 185_168)]).toBe("unlocked");
|
||||
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 6_011);
|
||||
const resumed = dispatch(value, reset, { kind: "tick", now: 6_012 });
|
||||
expect(resumed).toMatchObject({ phase: "complete", flags: { "dungeon-complete": true }, activeSpawnGroupIds: [] });
|
||||
});
|
||||
|
||||
it("runs each Botanica summon once and resets an interrupted finite cycle", () => {
|
||||
const definition = adapted("botanica");
|
||||
const value = encounter(definition);
|
||||
const cases = [
|
||||
[17_975, "freywinn", "botanica:freywinn-frayers", 30_000],
|
||||
[17_980, "laj", "botanica:laj-thorns", 20_000],
|
||||
[17_977, "warp-splinter", "botanica:warp-saplings", 40_000],
|
||||
] as const;
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
let now = 1;
|
||||
for (const [entry, prefix, groupId, delay] of cases) {
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now });
|
||||
expect(snapshot.phase).toBe(`${prefix}-wait`);
|
||||
if (prefix === "freywinn") {
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", now + 1);
|
||||
snapshot = dispatch(value, reset, { kind: "entity-engaged", entityId: bossIdForEntry(definition, entry), now: now + 2 });
|
||||
now += 2;
|
||||
}
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: now + delay });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual([groupId]);
|
||||
snapshot = killGroup(value, snapshot, group(definition, groupId), now + delay + 1);
|
||||
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
|
||||
now += delay + 2;
|
||||
}
|
||||
});
|
||||
|
||||
it("runs Syth thresholds, Ikiss objects, and optional Anzu lifecycle with recovery", () => {
|
||||
const definition = adapted("sethekk-halls");
|
||||
const value = encounter(definition);
|
||||
let snapshot = createDungeonEncounterSnapshot(value);
|
||||
const sythId = bossIdForEntry(definition, 18_472);
|
||||
for (const [threshold, health] of [[90, 89], [50, 49], [10, 9]] as const) {
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: sythId, healthPercent: health, now: 100 - health });
|
||||
const wave = group(definition, `sethekk-halls:syth-elementals-${threshold}`);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(wave.id);
|
||||
snapshot = killGroup(value, snapshot, wave, 101 - health);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(wave.id);
|
||||
}
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-death", entityId: bossIdForEntry(definition, 18_473), now: 100 });
|
||||
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 183_398)]).toBe("open");
|
||||
expect(snapshot.worldObjectStates[objectIdForTemplate(definition, 187_372)]).toBe("unlocked");
|
||||
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 101 });
|
||||
expect(snapshot.phase).toBe("anzu-intro");
|
||||
const reset = resetDungeonEncounterSnapshot(value, snapshot, "boss-wipe", 102);
|
||||
snapshot = dispatch(value, reset, { kind: "tick", now: 103 });
|
||||
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [] });
|
||||
snapshot = dispatch(value, snapshot, { kind: "interact", objectId: "sethekk-halls:anzu-ritual", now: 104 });
|
||||
snapshot = dispatch(value, snapshot, { kind: "tick", now: 16_104 });
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(["sethekk-halls:anzu"]);
|
||||
for (const [threshold, health] of [[66, 65], [33, 32]] as const) {
|
||||
snapshot = dispatch(value, snapshot, { kind: "entity-health", entityId: "sethekk-halls:anzu", healthPercent: health, now: 17_000 - health });
|
||||
const broods = group(definition, `sethekk-halls:anzu-broods-${threshold}`);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(broods.id);
|
||||
snapshot = killGroup(value, snapshot, broods, 17_001 - health);
|
||||
}
|
||||
snapshot = killGroup(value, snapshot, group(definition, "sethekk-halls:anzu"), 18_000);
|
||||
expect(snapshot).toMatchObject({ phase: "ready", activeSpawnGroupIds: [], flags: { "anzu-complete": true } });
|
||||
expect(snapshot.completedObjectiveIds).toContain("sethekk-halls:anzu");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDungeonEncounterDefinition } from "./dungeonEncounterRuntime";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS,
|
||||
withWowVerifiedNoOpInstanceContent,
|
||||
} from "./wowVerifiedNoOpInstanceCatalog";
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const generatedDefinitions = (
|
||||
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
}
|
||||
).definitions;
|
||||
|
||||
describe("verified no-op WoW instance scripts", () => {
|
||||
it("matches every pinned source file by SHA-256", async () => {
|
||||
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
|
||||
const source = await readFile(new URL(`../../${review.sourceFile}`, import.meta.url));
|
||||
expect(createHash("sha256").update(source).digest("hex"), review.dungeonId).toBe(review.sha256);
|
||||
}
|
||||
});
|
||||
|
||||
it("adds a source-exact empty orchestration review without altering population", () => {
|
||||
expect(WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.map((review) => review.dungeonId)).toEqual([
|
||||
"stormwind-stockade",
|
||||
"razorfen-kraul",
|
||||
"ragefire-chasm",
|
||||
]);
|
||||
for (const review of WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS) {
|
||||
const source = generatedDefinitions.find((definition) => definition.id === review.dungeonId);
|
||||
if (!source) throw new Error(`Missing generated dungeon ${review.dungeonId}`);
|
||||
const adapted = withWowVerifiedNoOpInstanceContent(source);
|
||||
const encounter = adapted.encounters?.find(
|
||||
(candidate) => candidate.id === `${review.dungeonId}:source-reviewed-instance`,
|
||||
);
|
||||
expect(encounter).toBeDefined();
|
||||
validateDungeonEncounterDefinition(encounter!);
|
||||
expect(encounter?.source).toMatchObject({
|
||||
fidelity: "source-exact",
|
||||
scope: "instance-orchestration",
|
||||
adaptations: [],
|
||||
remainingGaps: [],
|
||||
});
|
||||
expect(encounter?.rules).toEqual([]);
|
||||
expect(adapted.entities).toBe(source.entities);
|
||||
expect(adapted.staticSpawns).toBe(source.staticSpawns);
|
||||
expect(adapted.roamingPacks).toBe(source.roamingPacks);
|
||||
expect(withWowVerifiedNoOpInstanceContent(adapted)).toBe(adapted);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
|
||||
export interface WowVerifiedNoOpInstanceReview {
|
||||
readonly dungeonId: "stormwind-stockade" | "razorfen-kraul" | "ragefire-chasm";
|
||||
readonly sourceFile: string;
|
||||
readonly sha256: string;
|
||||
readonly reviewedBehavior: string;
|
||||
}
|
||||
|
||||
const SOURCE_ROOT = "../HealerMan-Storage/pipeline-work/dungeons/azerothcore-scripts/src/server/scripts";
|
||||
|
||||
/**
|
||||
* These pinned instance scripts only register an empty InstanceScript. Keeping
|
||||
* that fact executable prevents the production audit from inventing doors,
|
||||
* waves, or gates that the authoritative source does not define.
|
||||
*/
|
||||
export const WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS: readonly WowVerifiedNoOpInstanceReview[] = Object.freeze([
|
||||
{
|
||||
dungeonId: "stormwind-stockade",
|
||||
sourceFile: `${SOURCE_ROOT}/EasternKingdoms/TheStockade/instance_the_stockade.cpp`,
|
||||
sha256: "5e86a879ea7919840567607e6fbc1b6df2c50581f23574295430b46701f54a49",
|
||||
reviewedBehavior: "The pinned Stockade instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
|
||||
},
|
||||
{
|
||||
dungeonId: "razorfen-kraul",
|
||||
sourceFile: `${SOURCE_ROOT}/Kalimdor/RazorfenKraul/instance_razorfen_kraul.cpp`,
|
||||
sha256: "50309cf599020ca404d3230f134919349177a98aa572bf61132dd0e14504da54",
|
||||
reviewedBehavior: "The pinned Razorfen Kraul instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
|
||||
},
|
||||
{
|
||||
dungeonId: "ragefire-chasm",
|
||||
sourceFile: `${SOURCE_ROOT}/Kalimdor/RagefireChasm/instance_ragefire_chasm.cpp`,
|
||||
sha256: "294d043e5e6f02f3fbe82f369129659617da30727e048ffa4a333237e58a22f6",
|
||||
reviewedBehavior: "The pinned Ragefire Chasm instance script only constructs and registers an empty InstanceScript; it defines no saved encounter state, gates, waves, summons, or route transitions.",
|
||||
},
|
||||
]);
|
||||
|
||||
function reviewedNoOpEncounter(
|
||||
definition: DungeonDefinition,
|
||||
review: WowVerifiedNoOpInstanceReview,
|
||||
): DungeonEncounterDefinition {
|
||||
return Object.freeze({
|
||||
id: `${definition.id}:source-reviewed-instance`,
|
||||
dungeonId: definition.id,
|
||||
initialPhase: "active",
|
||||
source: Object.freeze({
|
||||
fidelity: "source-exact",
|
||||
scope: "instance-orchestration",
|
||||
files: Object.freeze([review.sourceFile]),
|
||||
reviewedBehavior: review.reviewedBehavior,
|
||||
adaptations: Object.freeze([]),
|
||||
remainingGaps: Object.freeze([]),
|
||||
}),
|
||||
rules: Object.freeze([]),
|
||||
});
|
||||
}
|
||||
|
||||
export function withWowVerifiedNoOpInstanceContent(
|
||||
definition: DungeonDefinition,
|
||||
): DungeonDefinition {
|
||||
const review = WOW_VERIFIED_NO_OP_INSTANCE_REVIEWS.find(
|
||||
(candidate) => candidate.dungeonId === definition.id,
|
||||
);
|
||||
if (!review) return definition;
|
||||
const encounterId = `${definition.id}:source-reviewed-instance`;
|
||||
if (definition.encounters?.some((encounter) => encounter.id === encounterId)) return definition;
|
||||
return Object.freeze({
|
||||
...definition,
|
||||
encounters: Object.freeze([
|
||||
...(definition.encounters ?? []),
|
||||
reviewedNoOpEncounter(definition, review),
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterEvent,
|
||||
DungeonEncounterSnapshot,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS,
|
||||
withWowWrathEncounterBatchThreeContent,
|
||||
type WowWrathEncounterBatchThreeDungeonId,
|
||||
} from "./wowWrathEncounterBatchThreeCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"utgarde-keep",
|
||||
"the-nexus",
|
||||
"draktharon-keep",
|
||||
"azjol-nerub",
|
||||
"halls-of-lightning",
|
||||
"ahnkahet-old-kingdom",
|
||||
"forge-of-souls",
|
||||
] as const satisfies readonly WowWrathEncounterBatchThreeDungeonId[];
|
||||
|
||||
const REVIEWED_TEMPLATES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
|
||||
"utgarde-keep": [186_756, 186_694],
|
||||
"the-nexus": [188_526, 188_527, 188_528],
|
||||
"draktharon-keep": [189_299, 189_300, 189_301, 189_302],
|
||||
"azjol-nerub": [192_395, 192_396, 192_397, 192_398],
|
||||
"halls-of-lightning": [191_325, 191_326],
|
||||
"ahnkahet-old-kingdom": [192_236, 193_093, 193_094, 193_564],
|
||||
"forge-of-souls": [],
|
||||
});
|
||||
|
||||
const SUPPRESSED_ENTRIES: Readonly<Record<WowWrathEncounterBatchThreeDungeonId, readonly number[]>> = Object.freeze({
|
||||
"utgarde-keep": [24_200, 24_201, 23_954],
|
||||
"the-nexus": [26_731, 26_763, 26_723],
|
||||
"draktharon-keep": [26_631, 26_632],
|
||||
"azjol-nerub": [28_684, 28_921, 29_120, 28_729, 28_730, 28_731],
|
||||
"halls-of-lightning": [28_546],
|
||||
"ahnkahet-old-kingdom": [29_309, 29_308, 29_310, 29_311],
|
||||
"forge-of-souls": [36_497],
|
||||
});
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const generatedDefinitions = (
|
||||
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
}
|
||||
).definitions;
|
||||
|
||||
function sourceDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
|
||||
const source = generatedDefinitions.find((definition) => definition.id === id);
|
||||
if (!source) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return source;
|
||||
}
|
||||
|
||||
function adaptedDefinition(id: WowWrathEncounterBatchThreeDungeonId): DungeonDefinition {
|
||||
return withWowWrathEncounterBatchThreeContent(sourceDefinition(id));
|
||||
}
|
||||
|
||||
function encounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const value = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-batch-three-instance`);
|
||||
if (!value) throw new Error(`Missing batch-three encounter for ${definition.id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalized(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
|
||||
const fragment = normalized(nameFragment);
|
||||
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
|
||||
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string) {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function send(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
event: DungeonEncounterEvent,
|
||||
difficultyId: "normal" | "heroic" = "normal",
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(definition, snapshot, event, { difficultyId }).snapshot;
|
||||
}
|
||||
|
||||
function killGroup(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
actorIds: readonly string[],
|
||||
now: number,
|
||||
difficultyId: "normal" | "heroic" = "normal",
|
||||
): DungeonEncounterSnapshot {
|
||||
return send(definition, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: actorIds, deadEntityIds: actorIds },
|
||||
}, difficultyId);
|
||||
}
|
||||
|
||||
function assertRecoverable(
|
||||
definition: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
now: number,
|
||||
difficultyId: "normal" | "heroic" = "normal",
|
||||
): DungeonEncounterSnapshot {
|
||||
const completed = snapshot.completedObjectiveIds;
|
||||
const checkpoint = snapshot.checkpointId;
|
||||
expect(checkpoint).not.toBeNull();
|
||||
let recovered = resetDungeonEncounterSnapshot(definition, snapshot, "boss-wipe", now);
|
||||
expect(recovered.phase).toBe("resume");
|
||||
recovered = send(definition, recovered, { kind: "tick", now: now + 1 }, difficultyId);
|
||||
expect(recovered.phase).not.toBe("resume");
|
||||
expect(recovered.completedObjectiveIds).toEqual(completed);
|
||||
expect(recovered.checkpointId).toBe(checkpoint);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
|
||||
return [
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...definition.roamingPacks.flatMap((pack) => pack.members.map((member) => `${pack.id}:${member.id}`)),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id)),
|
||||
];
|
||||
}
|
||||
|
||||
describe("Wrath encounter batch three source contract", () => {
|
||||
it("pins every reviewed AzerothCore source file by SHA-256", async () => {
|
||||
expect(WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const sourceFile of review.sourceFiles) {
|
||||
expect(sourceFile.anchors.length).toBeGreaterThan(0);
|
||||
const bytes = await readFile(sourceFile.path);
|
||||
expect(createHash("sha256").update(bytes).digest("hex"), sourceFile.path).toBe(sourceFile.sha256);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent and keeps all definitions, references, and source-row replacements valid", () => {
|
||||
for (const id of TARGETS) {
|
||||
const source = sourceDefinition(id);
|
||||
const adapted = adaptedDefinition(id);
|
||||
const controller = encounter(adapted);
|
||||
expect(withWowWrathEncounterBatchThreeContent(adapted)).toBe(adapted);
|
||||
expect(controller.source?.scope).toBe("instance-orchestration");
|
||||
validateDungeonEncounterDefinition(controller);
|
||||
adapted.serverGameObjects.forEach(validateDungeonWorldObject);
|
||||
|
||||
const objectiveIds = adapted.bosses.map((boss) => boss.id);
|
||||
expect(new Set(objectiveIds).size, `${id} objective ids`).toBe(objectiveIds.length);
|
||||
const orderedObjectiveIds = adapted.objectiveOrder.map((boss) => boss.id);
|
||||
expect(new Set(orderedObjectiveIds).size, `${id} objective-order ids`).toBe(orderedObjectiveIds.length);
|
||||
const spawnIds = runtimeSpawnIds(adapted);
|
||||
expect(new Set(spawnIds).size, `${id} runtime spawn ids`).toBe(spawnIds.length);
|
||||
const objectIds = adapted.serverGameObjects.map((object) => object.id);
|
||||
expect(new Set(objectIds).size, `${id} object ids`).toBe(objectIds.length);
|
||||
const sourceObjectSpawnIds = adapted.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined);
|
||||
expect(new Set(sourceObjectSpawnIds).size, `${id} source object rows`).toBe(sourceObjectSpawnIds.length);
|
||||
|
||||
for (const templateId of REVIEWED_TEMPLATES[id]) {
|
||||
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
||||
const adaptedRows = adapted.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
||||
expect(adaptedRows.length, `${id} template ${templateId}`).toBe(originalRows.length);
|
||||
expect(adaptedRows.map((object) => object.source?.spawnId)).toEqual(originalRows.map((object) => object.source?.spawnId));
|
||||
expect(adaptedRows.every((object) => object.source?.fidelity === "source-backed-adapted")).toBe(true);
|
||||
expect(adaptedRows.some((object) => originalRows.some((original) => original.id === object.id))).toBe(false);
|
||||
}
|
||||
|
||||
const remainingEntries = new Set(adapted.staticSpawns.map((spawn) => adapted.entities[spawn.entityId]?.combat?.serverEntry));
|
||||
for (const entry of SUPPRESSED_ENTRIES[id]) expect(remainingEntries.has(entry), `${id} static entry ${entry}`).toBe(false);
|
||||
|
||||
const spawnGroupIds = new Set((adapted.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id));
|
||||
const worldObjectIds = new Set(adapted.serverGameObjects.map((object) => object.id));
|
||||
const validObjectiveIds = new Set(adapted.bosses.map((boss) => boss.id));
|
||||
const validEntityIds = new Set(runtimeSpawnIds(adapted));
|
||||
for (const rule of controller.rules) {
|
||||
if (rule.trigger.kind === "entity-death" || rule.trigger.kind === "entity-engaged" || rule.trigger.kind === "entity-health") {
|
||||
expect(validEntityIds.has(rule.trigger.entityId), `${id} trigger entity ${rule.trigger.entityId}`).toBe(true);
|
||||
}
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
rule.trigger.entityIds.forEach((entityId) => expect(validEntityIds.has(entityId), `${id} dead-set entity ${entityId}`).toBe(true));
|
||||
}
|
||||
if (rule.trigger.kind === "interact") expect(worldObjectIds.has(rule.trigger.objectId), `${id} interaction ${rule.trigger.objectId}`).toBe(true);
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(spawnGroupIds.has(action.spawnGroupId), `${id} action group ${action.spawnGroupId}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") expect(worldObjectIds.has(action.objectId), `${id} action object ${action.objectId}`).toBe(true);
|
||||
if (action.kind === "complete-objective") expect(validObjectiveIds.has(action.objectiveId), `${id} action objective ${action.objectiveId}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Wrath encounter batch three lifecycle", () => {
|
||||
it("runs Utgarde Keep's order-safe duo ghosts and timed Ingvar resurrection with wipe recovery", () => {
|
||||
const dungeon = adaptedDefinition("utgarde-keep");
|
||||
const controller = encounter(dungeon);
|
||||
const keleseth = objective(dungeon, "Keleseth");
|
||||
const duo = objective(dungeon, "Skarvold");
|
||||
const ingvar = objective(dungeon, "Ingvar");
|
||||
const skarvald = group(dungeon, "utgarde-keep:skarvald");
|
||||
const dalronn = group(dungeon, "utgarde-keep:dalronn");
|
||||
const skarvaldGhost = group(dungeon, "utgarde-keep:skarvald-ghost");
|
||||
const dalronnGhost = group(dungeon, "utgarde-keep:dalronn-ghost");
|
||||
const ingvarLiving = group(dungeon, "utgarde-keep:ingvar-living");
|
||||
const ingvarUndead = group(dungeon, "utgarde-keep:ingvar-undead");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keleseth.id, now: ++now });
|
||||
expect(snapshot.phase).toBe("duo-active");
|
||||
expect(snapshot.activeSpawnGroupIds).toEqual(expect.arrayContaining([skarvald.id, dalronn.id]));
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: ++now });
|
||||
expect(snapshot.flags["dalronn-dead"]).toBe(true);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(dalronnGhost.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(duo.id);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(skarvaldGhost.id);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(dalronnGhost.id);
|
||||
expect(snapshot.phase).toBe("ingvar-living-active");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarLiving.spawns[0].id, now: ++now });
|
||||
expect(snapshot.phase).toBe("ingvar-resurrection");
|
||||
const resurrectionStarted = now;
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 19_999 });
|
||||
expect(snapshot.phase).toBe("ingvar-resurrection");
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_000 });
|
||||
expect(snapshot.phase).toBe("ingvar-undead-active");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(ingvarUndead.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ingvarUndead.spawns[0].id, now: resurrectionStarted + 20_001 });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([keleseth.id, duo.id, ingvar.id]));
|
||||
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-one"]).toBe("open");
|
||||
expect(snapshot.worldObjectStates["utgarde-keep:reviewed-portcullis-two"]).toBe("open");
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: resurrectionStarted + 20_002 });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
|
||||
let alternate = createDungeonEncounterSnapshot(controller, 0);
|
||||
alternate = send(controller, alternate, { kind: "entity-death", entityId: keleseth.id, now: 1 });
|
||||
alternate = send(controller, alternate, { kind: "entity-death", entityId: skarvald.spawns[0].id, now: 2 });
|
||||
expect(alternate.activeSpawnGroupIds).toContain(skarvaldGhost.id);
|
||||
alternate = send(controller, alternate, { kind: "entity-death", entityId: dalronn.spawns[0].id, now: 3 });
|
||||
expect(alternate.completedObjectiveIds).toContain(duo.id);
|
||||
});
|
||||
|
||||
it("keeps the source combat schedules on Utgarde Keep's ghost and undead replacement actors", () => {
|
||||
const dungeon = adaptedDefinition("utgarde-keep");
|
||||
const entityForGroup = (groupId: string) => {
|
||||
const entityId = group(dungeon, groupId).spawns[0].entityId;
|
||||
return dungeon.entities[entityId];
|
||||
};
|
||||
const attacksForGroup = (groupId: string) => {
|
||||
return entityForGroup(groupId).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
|
||||
};
|
||||
|
||||
expect(entityForGroup("utgarde-keep:dalronn-ghost").combat?.attacks[0]).toMatchObject({
|
||||
id: "basic-attack",
|
||||
name: "Basic Attack",
|
||||
delivery: "melee",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
range: 4.5,
|
||||
});
|
||||
|
||||
expect(attacksForGroup("utgarde-keep:skarvald-ghost").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "charge-43651", spellId: 43_651, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
|
||||
{ id: "stone-strike-48583", spellId: 48_583, initial: [10_000, 10_000], repeat: [5_000, 10_000] },
|
||||
]);
|
||||
expect(attacksForGroup("utgarde-keep:dalronn-ghost").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "shadow-bolt-43649", spellId: 43_649, initial: [1_000, 1_000], repeat: [2_000, 2_000] },
|
||||
{ id: "debilitate-43650", spellId: 43_650, initial: [5_000, 5_000], repeat: [5_000, 10_000] },
|
||||
]);
|
||||
expect(attacksForGroup("utgarde-keep:dalronn-ghost")[1].effect)
|
||||
.toEqual({ kind: "target-aura", aura: "debilitate", durationMs: 8_000 });
|
||||
expect(attacksForGroup("utgarde-keep:ingvar-undead").map((attack) => ({
|
||||
id: attack.id,
|
||||
spellId: attack.spellId,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "dreadful-roar-42729", spellId: 42_729, initial: [15_000, 15_000], repeat: [15_000, 20_000] },
|
||||
{ id: "woe-strike-42730", spellId: 42_730, initial: [2_000, 2_000], repeat: [3_000, 7_000] },
|
||||
{ id: "dark-smash-42723", spellId: 42_723, initial: [5_000, 5_000], repeat: [9_000, 11_000] },
|
||||
{ id: "shadow-axe-42749", spellId: 42_749, initial: [10_000, 10_000], repeat: [35_000, 35_000] },
|
||||
]);
|
||||
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
|
||||
attack.id === "woe-strike-42730"
|
||||
))?.school).toBe("physical");
|
||||
expect(attacksForGroup("utgarde-keep:ingvar-undead").find((attack) => (
|
||||
attack.id === "shadow-axe-42749"
|
||||
))?.school).toBe("physical");
|
||||
|
||||
const review = WOW_WRATH_ENCOUNTER_BATCH_THREE_REVIEWS.find((candidate) => (
|
||||
candidate.dungeonId === "utgarde-keep"
|
||||
))!;
|
||||
expect(review.remainingGaps.join(" ")).toContain("Woe Strike's damage-on-heal reaction");
|
||||
expect(review.remainingGaps.join(" ")).toContain("Shadow Axe's outbound/returning actor");
|
||||
});
|
||||
|
||||
it("runs Nexus spheres, normal Telestra split, Keristrasza release, and checkpoint recovery", () => {
|
||||
const dungeon = adaptedDefinition("the-nexus");
|
||||
const controller = encounter(dungeon);
|
||||
const anomalus = objective(dungeon, "Anomalus");
|
||||
const ormorok = objective(dungeon, "Ormorok");
|
||||
const telestra = objective(dungeon, "Telestra");
|
||||
const keristrasza = objective(dungeon, "Keristrasza");
|
||||
const opening = group(dungeon, "the-nexus:telestra-opening");
|
||||
const anomalusOpening = group(dungeon, "the-nexus:anomalus-opening");
|
||||
const anomalusRift = group(dungeon, "the-nexus:anomalus-empowered-rift");
|
||||
const anomalusFinal = group(dungeon, "the-nexus:anomalus-final");
|
||||
const clones = group(dungeon, "the-nexus:telestra-first-clones");
|
||||
const normalFinal = group(dungeon, "the-nexus:telestra-normal-final");
|
||||
const keristraszaActor = group(dungeon, "the-nexus:keristrasza");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
||||
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: anomalusOpening.spawns[0].id, healthPercent: 51, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(anomalusRift.id);
|
||||
snapshot = killGroup(controller, snapshot, anomalusRift.spawns.map((spawn) => spawn.id), ++now);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(anomalusFinal.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: anomalusFinal.spawns[0].id, now: ++now });
|
||||
expect(snapshot.worldObjectStates["the-nexus:reviewed-anomalus-sphere"]).toBe("ready");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: ormorok.id, now: ++now });
|
||||
expect(snapshot.worldObjectStates["the-nexus:reviewed-ormorok-sphere"]).toBe("ready");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: ++now });
|
||||
expect(snapshot.phase).toBe("telestra-first-clones");
|
||||
snapshot = killGroup(controller, snapshot, clones.spawns.map((spawn) => spawn.id), ++now);
|
||||
expect(snapshot.phase).toBe("telestra-normal-final");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(normalFinal.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: normalFinal.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(telestra.id);
|
||||
expect(snapshot.worldObjectStates["the-nexus:reviewed-telestra-sphere"]).toBe("ready");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
|
||||
for (const sphere of [
|
||||
"the-nexus:reviewed-telestra-sphere",
|
||||
"the-nexus:reviewed-anomalus-sphere",
|
||||
"the-nexus:reviewed-ormorok-sphere",
|
||||
]) snapshot = send(controller, snapshot, { kind: "interact", objectId: sphere, now: ++now });
|
||||
expect(snapshot.counters["spheres-used"]).toBe(3);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(keristraszaActor.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: keristraszaActor.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(keristrasza.id);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("selects Telestra's second split only on heroic", () => {
|
||||
const dungeon = adaptedDefinition("the-nexus");
|
||||
const controller = encounter(dungeon);
|
||||
const opening = group(dungeon, "the-nexus:telestra-opening");
|
||||
const firstClones = group(dungeon, "the-nexus:telestra-first-clones");
|
||||
const middle = group(dungeon, "the-nexus:telestra-heroic-middle");
|
||||
const secondClones = group(dungeon, "the-nexus:telestra-second-clones");
|
||||
const final = group(dungeon, "the-nexus:telestra-heroic-final");
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, 0);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: 1 }, "heroic");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 51, now: 2 }, "heroic");
|
||||
snapshot = killGroup(controller, snapshot, firstClones.spawns.map((spawn) => spawn.id), 3, "heroic");
|
||||
expect(snapshot.phase).toBe("telestra-heroic-middle");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(middle.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: middle.spawns[0].id, healthPercent: 11, now: 4 }, "heroic");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(secondClones.id);
|
||||
snapshot = killGroup(controller, snapshot, secondClones.spawns.map((spawn) => spawn.id), 5, "heroic");
|
||||
expect(snapshot.phase).toBe("telestra-heroic-final");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
|
||||
});
|
||||
|
||||
it("runs Drak'Tharon's four crystal waves and Tharon'ja stage with each checkpoint recoverable", () => {
|
||||
const dungeon = adaptedDefinition("draktharon-keep");
|
||||
const controller = encounter(dungeon);
|
||||
const trollgore = objective(dungeon, "Trollgore");
|
||||
const novos = objective(dungeon, "Novos");
|
||||
const dred = objective(dungeon, "King Dred");
|
||||
const tharon = objective(dungeon, "Tharon'ja");
|
||||
const novosActor = group(dungeon, "draktharon-keep:novos");
|
||||
const tharonOpening = group(dungeon, "draktharon-keep:tharon-opening");
|
||||
const tharonFlesh = group(dungeon, "draktharon-keep:tharon-flesh");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: trollgore.id, now: ++now });
|
||||
expect(snapshot.phase).toBe("novos-handler-1");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
for (let waveIndex = 1; waveIndex <= 4; waveIndex += 1) {
|
||||
const wave = group(dungeon, `draktharon-keep:novos-handler-wave-${waveIndex}`);
|
||||
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now);
|
||||
expect(snapshot.worldObjectStates[`draktharon-keep:reviewed-novos-crystal-${waveIndex}`]).toBe("complete");
|
||||
}
|
||||
expect(snapshot.phase).toBe("novos-active");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: novosActor.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(novos.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.phase).toBe("dred-active");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: dred.id, now: ++now });
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(tharonOpening.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: tharonOpening.spawns[0].id, healthPercent: 55, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(tharonFlesh.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: tharonFlesh.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([trollgore.id, novos.id, dred.id, tharon.id]));
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
function advanceAzjolToAnubarak(difficultyId: "normal" | "heroic") {
|
||||
const dungeon = adaptedDefinition("azjol-nerub");
|
||||
const controller = encounter(dungeon);
|
||||
const krik = objective(dungeon, "Krik'thir");
|
||||
const hadronox = objective(dungeon, "Hadronox");
|
||||
const watchers = group(dungeon, "azjol-nerub:krikthir-watchers");
|
||||
const krikActor = group(dungeon, "azjol-nerub:krikthir");
|
||||
const hadronoxActor = group(dungeon, "azjol-nerub:hadronox");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now }, difficultyId);
|
||||
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), ++now, difficultyId);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(krikActor.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: krikActor.spawns[0].id, now: ++now }, difficultyId);
|
||||
expect(snapshot.completedObjectiveIds).toContain(krik.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
|
||||
expect(snapshot.worldObjectStates["azjol-nerub:reviewed-krikthir-passage"]).toBe("open");
|
||||
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:hadronox-start", now: ++now }, difficultyId);
|
||||
for (let waveIndex = 1; waveIndex <= 3; waveIndex += 1) {
|
||||
const wave = group(dungeon, `azjol-nerub:crusher-wave-${waveIndex}`);
|
||||
snapshot = killGroup(controller, snapshot, wave.spawns.map((spawn) => spawn.id), ++now, difficultyId);
|
||||
}
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: hadronoxActor.spawns[0].id, now: ++now }, difficultyId);
|
||||
expect(snapshot.completedObjectiveIds).toContain(hadronox.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now, difficultyId);
|
||||
snapshot = send(controller, snapshot, { kind: "interact", objectId: "azjol-nerub:anubarak-start", now: ++now }, difficultyId);
|
||||
expect(snapshot.phase).toBe("anubarak-stage-1");
|
||||
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
|
||||
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("closed");
|
||||
}
|
||||
return { dungeon, controller, snapshot, now };
|
||||
}
|
||||
|
||||
describe("Wrath encounter batch three route gates", () => {
|
||||
it("runs Azjol-Nerub's normal watcher, crusher, and three-submerge route", () => {
|
||||
const prepared = advanceAzjolToAnubarak("normal");
|
||||
const { dungeon, controller } = prepared;
|
||||
const anub = objective(dungeon, "Anub'arak");
|
||||
let { snapshot, now } = prepared;
|
||||
const thresholds = [75, 50, 25] as const;
|
||||
for (let index = 0; index < thresholds.length; index += 1) {
|
||||
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
|
||||
const adds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
|
||||
snapshot = send(controller, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: stage.spawns[0].id,
|
||||
healthPercent: thresholds[index],
|
||||
now: ++now,
|
||||
});
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(adds.id);
|
||||
expect(adds.spawns.length).toBe([7, 8, 13][index]);
|
||||
snapshot = killGroup(controller, snapshot, adds.spawns.map((spawn) => spawn.id), ++now);
|
||||
expect(snapshot.phase).toBe(`anubarak-stage-${index + 2}`);
|
||||
}
|
||||
const finalStage = group(dungeon, "azjol-nerub:anubarak-stage-4");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: finalStage.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(anub.id);
|
||||
for (let doorIndex = 1; doorIndex <= 3; doorIndex += 1) {
|
||||
expect(snapshot.worldObjectStates[`azjol-nerub:reviewed-anubarak-door-${doorIndex}`]).toBe("open");
|
||||
}
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("selects every larger Anub'arak intermission on heroic", () => {
|
||||
const prepared = advanceAzjolToAnubarak("heroic");
|
||||
const { dungeon, controller } = prepared;
|
||||
let { snapshot, now } = prepared;
|
||||
const thresholds = [75, 50, 25] as const;
|
||||
for (let index = 0; index < thresholds.length; index += 1) {
|
||||
const stage = group(dungeon, `azjol-nerub:anubarak-stage-${index + 1}`);
|
||||
const heroicAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-heroic`);
|
||||
const normalAdds = group(dungeon, `azjol-nerub:anubarak-intermission-${index + 1}-normal`);
|
||||
snapshot = send(controller, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: stage.spawns[0].id,
|
||||
healthPercent: thresholds[index],
|
||||
now: ++now,
|
||||
}, "heroic");
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(heroicAdds.id);
|
||||
expect(snapshot.activeSpawnGroupIds).not.toContain(normalAdds.id);
|
||||
expect(heroicAdds.spawns.length).toBe([8, 10, 15][index]);
|
||||
expect(heroicAdds.spawns.every((spawn) => spawn.spawnMask === 2)).toBe(true);
|
||||
snapshot = killGroup(controller, snapshot, heroicAdds.spawns.map((spawn) => spawn.id), ++now, "heroic");
|
||||
}
|
||||
expect(snapshot.phase).toBe("anubarak-stage-4");
|
||||
});
|
||||
|
||||
it("runs Halls of Lightning's timed Ionar split and mandatory watcher checkpoint", () => {
|
||||
const dungeon = adaptedDefinition("halls-of-lightning");
|
||||
const controller = encounter(dungeon);
|
||||
const bjarngrim = objective(dungeon, "Bjarngrim");
|
||||
const volkhan = objective(dungeon, "Volkhan");
|
||||
const ionar = objective(dungeon, "Ionar");
|
||||
const loken = objective(dungeon, "Loken");
|
||||
const opening = group(dungeon, "halls-of-lightning:ionar-opening");
|
||||
const sparks = group(dungeon, "halls-of-lightning:ionar-sparks");
|
||||
const final = group(dungeon, "halls-of-lightning:ionar-final");
|
||||
const watchers = group(dungeon, "halls-of-lightning:awakened-watchers");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: bjarngrim.id, now: ++now });
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.phase).toBe("volkhan-active");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volkhan.id, now: ++now });
|
||||
expect(snapshot.worldObjectStates["halls-of-lightning:reviewed-volkhan-door"]).toBe("open");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 50, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(sparks.id);
|
||||
const splitStarted = now;
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 19_999 });
|
||||
expect(snapshot.phase).toBe("ionar-sparks-active");
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_000 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(final.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: final.spawns[0].id, now: splitStarted + 20_001 });
|
||||
expect(snapshot.completedObjectiveIds).toContain(ionar.id);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_002);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(watchers.id);
|
||||
snapshot = killGroup(controller, snapshot, watchers.spawns.map((spawn) => spawn.id), splitStarted + 20_003);
|
||||
expect(snapshot.flags["watchers-cleared"]).toBe(true);
|
||||
expect(snapshot.phase).toBe("loken-active");
|
||||
snapshot = assertRecoverable(controller, snapshot, splitStarted + 20_004);
|
||||
expect(snapshot.phase).toBe("loken-active");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: loken.id, now: splitStarted + 20_005 });
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: splitStarted + 20_006 });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
});
|
||||
|
||||
it("runs Ahn'kahet's spheres, guardian, ritual, insanity stages, and recovers each boss checkpoint", () => {
|
||||
const dungeon = adaptedDefinition("ahnkahet-old-kingdom");
|
||||
const controller = encounter(dungeon);
|
||||
const nadox = objective(dungeon, "Nadox");
|
||||
const taldaram = objective(dungeon, "Taldaram");
|
||||
const jedoga = objective(dungeon, "Jedoga");
|
||||
const volazj = objective(dungeon, "Volazj");
|
||||
const nadoxOpening = group(dungeon, "ahnkahet-old-kingdom:nadox-opening");
|
||||
const nadoxGuardian = group(dungeon, "ahnkahet-old-kingdom:nadox-guardian");
|
||||
const nadoxFinal = group(dungeon, "ahnkahet-old-kingdom:nadox-final");
|
||||
const taldaramActor = group(dungeon, "ahnkahet-old-kingdom:taldaram");
|
||||
const initiates = group(dungeon, "ahnkahet-old-kingdom:jedoga-initiates");
|
||||
const jedogaOpening = group(dungeon, "ahnkahet-old-kingdom:jedoga-opening");
|
||||
const volunteer = group(dungeon, "ahnkahet-old-kingdom:jedoga-volunteer");
|
||||
const jedogaFinal = group(dungeon, "ahnkahet-old-kingdom:jedoga-final");
|
||||
let now = 0;
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, now);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
for (let sphereIndex = 1; sphereIndex <= 2; sphereIndex += 1) {
|
||||
snapshot = send(controller, snapshot, { kind: "interact", objectId: `ahnkahet-old-kingdom:reviewed-sphere-${sphereIndex}`, now: ++now });
|
||||
}
|
||||
expect(snapshot.counters["spheres-used"]).toBe(2);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: nadoxOpening.spawns[0].id, healthPercent: 50, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(nadoxGuardian.id);
|
||||
snapshot = killGroup(controller, snapshot, nadoxGuardian.spawns.map((spawn) => spawn.id), ++now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: nadoxFinal.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(nadox.id);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
|
||||
expect(snapshot.worldObjectStates["ahnkahet-old-kingdom:reviewed-taldaram-platform"]).toBe("open");
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(taldaramActor.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: taldaramActor.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(taldaram.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(initiates.id);
|
||||
snapshot = killGroup(controller, snapshot, initiates.spawns.map((spawn) => spawn.id), ++now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: jedogaOpening.spawns[0].id, healthPercent: 55, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(volunteer.id);
|
||||
snapshot = killGroup(controller, snapshot, volunteer.spawns.map((spawn) => spawn.id), ++now);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: jedogaFinal.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toContain(jedoga.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, ++now);
|
||||
|
||||
for (const [index, threshold] of [66, 33].entries()) {
|
||||
const stage = group(dungeon, `ahnkahet-old-kingdom:volazj-stage-${index + 1}`);
|
||||
const visages = group(dungeon, `ahnkahet-old-kingdom:volazj-visages-${index + 1}`);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: stage.spawns[0].id, healthPercent: threshold, now: ++now });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(visages.id);
|
||||
snapshot = killGroup(controller, snapshot, visages.spawns.map((spawn) => spawn.id), ++now);
|
||||
}
|
||||
const volazjFinal = group(dungeon, "ahnkahet-old-kingdom:volazj-stage-3");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: volazjFinal.spawns[0].id, now: ++now });
|
||||
expect(snapshot.completedObjectiveIds).toEqual(expect.arrayContaining([nadox.id, taldaram.id, jedoga.id, volazj.id]));
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: ++now });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
|
||||
const amanitarEntry = dungeon.staticSpawns
|
||||
.filter((spawn) => dungeon.entities[spawn.entityId]?.combat?.serverEntry === 30_258);
|
||||
expect(amanitarEntry).toHaveLength(1);
|
||||
expect(amanitarEntry[0].spawnMask).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps Forge of Souls doorless while staging Bronjahm's 35-percent Soulstorm", () => {
|
||||
const dungeon = adaptedDefinition("forge-of-souls");
|
||||
const controller = encounter(dungeon);
|
||||
const bronjahm = objective(dungeon, "Bronjahm");
|
||||
const devourer = objective(dungeon, "Devourer");
|
||||
const opening = group(dungeon, "forge-of-souls:bronjahm-opening");
|
||||
const soulstorm = group(dungeon, "forge-of-souls:bronjahm-soulstorm");
|
||||
let snapshot = createDungeonEncounterSnapshot(controller, 0);
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: 1 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(opening.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-health", entityId: opening.spawns[0].id, healthPercent: 35, now: 2 });
|
||||
expect(snapshot.activeSpawnGroupIds).toContain(soulstorm.id);
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: soulstorm.spawns[0].id, now: 3 });
|
||||
expect(snapshot.completedObjectiveIds).toContain(bronjahm.id);
|
||||
snapshot = assertRecoverable(controller, snapshot, 4);
|
||||
expect(snapshot.phase).toBe("devourer-active");
|
||||
snapshot = send(controller, snapshot, { kind: "entity-death", entityId: devourer.id, now: 5 });
|
||||
snapshot = send(controller, snapshot, { kind: "tick", now: 6 });
|
||||
expect(snapshot.phase).toBe("complete");
|
||||
expect(REVIEWED_TEMPLATES["forge-of-souls"]).toEqual([]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,673 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { gunzipSync, strFromU8 } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterAction,
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterGuard,
|
||||
DungeonEncounterSnapshot,
|
||||
DungeonEncounterTrigger,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import type { DungeonBossObjective, DungeonDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import compressedCampaign from "./generated/dungeonCampaignCatalog.compressed";
|
||||
import {
|
||||
WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS,
|
||||
withWowWrathEncounterBatchTwoContent,
|
||||
} from "./wowWrathEncounterBatchTwoCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"halls-of-stone",
|
||||
"pit-of-saron",
|
||||
"utgarde-pinnacle",
|
||||
"gundrak",
|
||||
] as const;
|
||||
|
||||
type TargetId = typeof TARGETS[number];
|
||||
|
||||
const REVIEWED_TEMPLATES: Readonly<Record<TargetId, readonly number[]>> = Object.freeze({
|
||||
"halls-of-stone": [193_907, 193_906, 191_296],
|
||||
"pit-of-saron": [201_885, 201_848],
|
||||
"utgarde-pinnacle": [191_745, 188_593, 192_173, 192_174],
|
||||
gundrak: [
|
||||
192_518, 192_519, 192_520,
|
||||
192_564, 192_565, 192_567, 192_566,
|
||||
193_188, 192_633, 192_632, 192_569,
|
||||
192_568, 193_208, 193_209,
|
||||
],
|
||||
});
|
||||
|
||||
const EVENT_BOSS_NAMES: Readonly<Record<TargetId, readonly string[]>> = Object.freeze({
|
||||
"halls-of-stone": ["Tribunal of Ages"],
|
||||
"pit-of-saron": ["Tyrannus"],
|
||||
"utgarde-pinnacle": ["Svala", "Gortok", "Skadi"],
|
||||
gundrak: ["Eck"],
|
||||
});
|
||||
|
||||
const FINAL_BOSS_NAME: Readonly<Record<TargetId, string>> = Object.freeze({
|
||||
"halls-of-stone": "Sjonnir",
|
||||
"pit-of-saron": "Tyrannus",
|
||||
"utgarde-pinnacle": "Ymiron",
|
||||
gundrak: "Gal'darah",
|
||||
});
|
||||
|
||||
const EXPECTED_NONFINAL_CHECKPOINTS: Readonly<Record<TargetId, number>> = Object.freeze({
|
||||
"halls-of-stone": 3,
|
||||
"pit-of-saron": 3,
|
||||
"utgarde-pinnacle": 3,
|
||||
gundrak: 4,
|
||||
});
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) bytes[index] = decoded.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
const generatedDefinitions = (
|
||||
JSON.parse(strFromU8(gunzipSync(decodeBase64(compressedCampaign)))) as {
|
||||
readonly definitions: readonly DungeonDefinition[];
|
||||
}
|
||||
).definitions;
|
||||
|
||||
function sourceDefinition(id: TargetId): DungeonDefinition {
|
||||
const source = generatedDefinitions.find((definition) => definition.id === id);
|
||||
if (!source) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return source;
|
||||
}
|
||||
|
||||
function adaptedDefinition(id: TargetId): DungeonDefinition {
|
||||
return withWowWrathEncounterBatchTwoContent(sourceDefinition(id));
|
||||
}
|
||||
|
||||
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((candidate) => (
|
||||
candidate.id === `${definition.id}:wrath-batch-two-instance`
|
||||
));
|
||||
if (!encounter) throw new Error(`Missing batch-two encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
function normalized(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function objective(definition: DungeonDefinition, nameFragment: string): DungeonBossObjective {
|
||||
const fragment = normalized(nameFragment);
|
||||
const value = definition.bosses.find((candidate) => normalized(candidate.name).includes(fragment));
|
||||
if (!value) throw new Error(`${definition.id} is missing objective matching ${nameFragment}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function group(definition: DungeonDefinition, id: string) {
|
||||
const value = definition.encounterSpawnGroups?.find((candidate) => candidate.id === id);
|
||||
if (!value) throw new Error(`${definition.id} is missing encounter group ${id}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function runtimeSpawnIds(definition: DungeonDefinition): readonly string[] {
|
||||
return [
|
||||
...definition.staticSpawns.map((spawn) => spawn.id),
|
||||
...definition.roamingPacks.flatMap((pack) => (
|
||||
pack.members.map((member) => `${pack.id}:${member.id}`)
|
||||
)),
|
||||
...(definition.encounterSpawnGroups ?? []).flatMap((spawnGroup) => (
|
||||
spawnGroup.spawns.map((spawn) => spawn.id)
|
||||
)),
|
||||
];
|
||||
}
|
||||
|
||||
function activeSpawnIds(
|
||||
definition: DungeonDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
ignoredGroupIds: ReadonlySet<string> = new Set(),
|
||||
): readonly string[] {
|
||||
const active = new Set(snapshot.activeSpawnGroupIds);
|
||||
return (definition.encounterSpawnGroups ?? [])
|
||||
.filter((spawnGroup) => active.has(spawnGroup.id) && !ignoredGroupIds.has(spawnGroup.id))
|
||||
.flatMap((spawnGroup) => spawnGroup.spawns.map((spawn) => spawn.id));
|
||||
}
|
||||
|
||||
function killIds(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
ids: readonly string[],
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: ids, deadEntityIds: ids },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function interact(
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
objectId: string,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "interact", objectId, now }).snapshot;
|
||||
}
|
||||
|
||||
function incompleteObjective(
|
||||
definition: DungeonDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
names: readonly string[],
|
||||
): DungeonBossObjective | undefined {
|
||||
return names
|
||||
.map((name) => objective(definition, name))
|
||||
.find((candidate) => !snapshot.completedObjectiveIds.includes(candidate.id));
|
||||
}
|
||||
|
||||
function advanceOneStep(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
if (snapshot.phase === "resume") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
if (definition.id === "halls-of-stone") {
|
||||
if (snapshot.phase === "tribunal-ready") {
|
||||
const remaining = incompleteObjective(definition, snapshot, ["Krystallus", "Maiden"]);
|
||||
return remaining
|
||||
? killIds(encounter, snapshot, [remaining.id], now)
|
||||
: interact(encounter, snapshot, "halls-of-stone:reviewed-tribunal-console", now);
|
||||
}
|
||||
if (snapshot.phase === "sjonnir-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Sjonnir").id], now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "pit-of-saron") {
|
||||
if (snapshot.phase === "progression") {
|
||||
const remaining = incompleteObjective(definition, snapshot, ["Garfrost", "Ick"]);
|
||||
return remaining
|
||||
? killIds(encounter, snapshot, [remaining.id], now)
|
||||
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gauntlet-ready") {
|
||||
return interact(encounter, snapshot, "pit-of-saron:gauntlet-start", now);
|
||||
}
|
||||
if (snapshot.phase === "tunnel-ready") {
|
||||
return interact(encounter, snapshot, "pit-of-saron:tunnel-start", now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "utgarde-pinnacle") {
|
||||
if (snapshot.phase === "svala-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:svala-start", now);
|
||||
}
|
||||
if (snapshot.phase === "svala-phase-one-active") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "entity-health",
|
||||
entityId: "utgarde-pinnacle:svala-phase-one-actor",
|
||||
healthPercent: 50,
|
||||
now,
|
||||
}).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gortok-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:reviewed-stasis-sphere", now);
|
||||
}
|
||||
if (snapshot.phase === "skadi-ready") {
|
||||
return interact(encounter, snapshot, "utgarde-pinnacle:skadi-start", now);
|
||||
}
|
||||
const harpoonByPhase: Readonly<Record<string, string>> = {
|
||||
"harpoon-one-ready": "utgarde-pinnacle:harpoon-1",
|
||||
"harpoon-two-ready": "utgarde-pinnacle:harpoon-2",
|
||||
"harpoon-three-ready": "utgarde-pinnacle:harpoon-3",
|
||||
};
|
||||
const harpoon = harpoonByPhase[snapshot.phase];
|
||||
if (harpoon) return interact(encounter, snapshot, harpoon, now);
|
||||
if (snapshot.phase === "ymiron-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Ymiron").id], now);
|
||||
}
|
||||
}
|
||||
|
||||
if (definition.id === "gundrak") {
|
||||
if (snapshot.phase === "altar-progression") {
|
||||
const remaining = incompleteObjective(
|
||||
definition,
|
||||
snapshot,
|
||||
["Slad'ran", "Moorabi", "Drakkari Colossus"],
|
||||
);
|
||||
if (remaining) return killIds(encounter, snapshot, [remaining.id], now);
|
||||
const altar = definition.serverGameObjects.find((object) => (
|
||||
object.id.startsWith("gundrak:reviewed-altar-")
|
||||
&& snapshot.worldObjectStates[object.id] === "ready"
|
||||
));
|
||||
return altar
|
||||
? interact(encounter, snapshot, altar.id, now)
|
||||
: reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "bridge-activating") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: Math.max(now, snapshot.phaseStartedAt + 5_000),
|
||||
}).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "gal-darah-ready") {
|
||||
return killIds(encounter, snapshot, [objective(definition, "Gal'darah").id], now);
|
||||
}
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
const spawnIds = activeSpawnIds(definition, snapshot);
|
||||
if (spawnIds.length) return killIds(encounter, snapshot, spawnIds, now);
|
||||
return reduceDungeonEncounter(encounter, snapshot, { kind: "tick", now }).snapshot;
|
||||
}
|
||||
|
||||
interface FinishedEncounter {
|
||||
readonly snapshot: DungeonEncounterSnapshot;
|
||||
readonly phases: readonly string[];
|
||||
readonly checkpoints: readonly DungeonEncounterSnapshot[];
|
||||
}
|
||||
|
||||
function finishEncounter(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
initial = createDungeonEncounterSnapshot(encounter),
|
||||
): FinishedEncounter {
|
||||
let snapshot = initial;
|
||||
const phases = [snapshot.phase];
|
||||
const checkpoints: DungeonEncounterSnapshot[] = [];
|
||||
const seenCheckpoints = new Set<string>();
|
||||
const finalObjectiveId = objective(definition, FINAL_BOSS_NAME[definition.id as TargetId]).id;
|
||||
let now = snapshot.phaseStartedAt + 1;
|
||||
for (let step = 0; step < 100 && snapshot.phase !== "complete"; step += 1) {
|
||||
const previous = snapshot;
|
||||
snapshot = advanceOneStep(definition, encounter, snapshot, now);
|
||||
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
|
||||
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
|
||||
if (snapshot.checkpointId
|
||||
&& snapshot.checkpointId !== finalObjectiveId
|
||||
&& !seenCheckpoints.has(snapshot.checkpointId)) {
|
||||
seenCheckpoints.add(snapshot.checkpointId);
|
||||
checkpoints.push(snapshot);
|
||||
}
|
||||
if (snapshot === previous) throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
|
||||
}
|
||||
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
|
||||
return { snapshot, phases, checkpoints };
|
||||
}
|
||||
|
||||
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
|
||||
let cursor = -1;
|
||||
for (const phase of expected) {
|
||||
cursor = phases.indexOf(phase, cursor + 1);
|
||||
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
function expectTriggerReferences(
|
||||
trigger: DungeonEncounterTrigger,
|
||||
spawnIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (trigger.kind === "entity-death" || trigger.kind === "entity-engaged" || trigger.kind === "entity-health") {
|
||||
expect(spawnIds, `trigger entity ${trigger.entityId}`).toContain(trigger.entityId);
|
||||
}
|
||||
if (trigger.kind === "entities-dead") {
|
||||
for (const entityId of trigger.entityIds) expect(spawnIds, `trigger entity ${entityId}`).toContain(entityId);
|
||||
}
|
||||
if (trigger.kind === "interact") expect(objectIds, `trigger object ${trigger.objectId}`).toContain(trigger.objectId);
|
||||
}
|
||||
|
||||
function expectGuardReferences(
|
||||
guard: DungeonEncounterGuard,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectIds: ReadonlySet<string>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (guard.kind === "spawn-group-active") expect(groupIds).toContain(guard.spawnGroupId);
|
||||
if (guard.kind === "world-object-state") expect(objectIds).toContain(guard.objectId);
|
||||
if (guard.kind === "objective-complete") expect(objectiveIds).toContain(guard.objectiveId);
|
||||
}
|
||||
|
||||
function expectActionReferences(
|
||||
action: DungeonEncounterAction,
|
||||
groupIds: ReadonlySet<string>,
|
||||
objectsById: ReadonlyMap<string, DungeonDefinition["serverGameObjects"][number]>,
|
||||
objectiveIds: ReadonlySet<string>,
|
||||
): void {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds, `action group ${action.spawnGroupId}`).toContain(action.spawnGroupId);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
const object = objectsById.get(action.objectId);
|
||||
expect(object, `action object ${action.objectId}`).toBeDefined();
|
||||
expect(object?.states.map((state) => state.id), `${action.objectId}:${action.state}`)
|
||||
.toContain(action.state);
|
||||
}
|
||||
if (action.kind === "complete-objective") expect(objectiveIds).toContain(action.objectiveId);
|
||||
}
|
||||
|
||||
describe("source-reviewed Wrath encounter batch two", () => {
|
||||
it("pins every reviewed AzerothCore source file by actual SHA-256 and line/symbol anchors", async () => {
|
||||
expect(WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_WRATH_ENCOUNTER_BATCH_TWO_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const source of review.sourceFiles) {
|
||||
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(source.anchors.length).toBeGreaterThan(0);
|
||||
expect(source.anchors.every((anchor) => /lines? \d+/i.test(anchor))).toBe(true);
|
||||
const digest = createHash("sha256").update(await readFile(source.path)).digest("hex");
|
||||
expect(digest, source.path).toBe(source.sha256);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches idempotently with unique rows, valid references, and finite event groups", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
expect(withWowWrathEncounterBatchTwoContent(definition)).toBe(definition);
|
||||
expect(encounter.source).toMatchObject({
|
||||
fidelity: "source-backed-adapted",
|
||||
scope: "instance-orchestration",
|
||||
});
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
|
||||
const spawnIdList = runtimeSpawnIds(definition);
|
||||
expect(new Set(spawnIdList).size, `${id} duplicate runtime spawn id`).toBe(spawnIdList.length);
|
||||
const groupIdList = (definition.encounterSpawnGroups ?? []).map((spawnGroup) => spawnGroup.id);
|
||||
expect(new Set(groupIdList).size, `${id} duplicate group id`).toBe(groupIdList.length);
|
||||
const objectIdList = definition.serverGameObjects.map((object) => object.id);
|
||||
expect(new Set(objectIdList).size, `${id} duplicate object id`).toBe(objectIdList.length);
|
||||
const encounterIdList = (definition.encounters ?? []).map((candidate) => candidate.id);
|
||||
expect(new Set(encounterIdList).size, `${id} duplicate encounter id`).toBe(encounterIdList.length);
|
||||
const sourceSpawnIds = definition.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined);
|
||||
expect(new Set(sourceSpawnIds).size, `${id} duplicate source gameobject row`).toBe(sourceSpawnIds.length);
|
||||
|
||||
const spawnIds = new Set(spawnIdList);
|
||||
const groupIds = new Set(groupIdList);
|
||||
const objectIds = new Set(objectIdList);
|
||||
const objectsById = new Map(definition.serverGameObjects.map((object) => [object.id, object] as const));
|
||||
const objectiveIds = new Set(definition.bosses.map((boss) => boss.id));
|
||||
for (const spawnGroup of definition.encounterSpawnGroups ?? []) {
|
||||
for (const spawn of spawnGroup.spawns) {
|
||||
expect(definition.entities[spawn.entityId], `${spawnGroup.id}:${spawn.id}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
for (const object of definition.serverGameObjects) validateDungeonWorldObject(object);
|
||||
for (const rule of encounter.rules) {
|
||||
expectTriggerReferences(rule.trigger, spawnIds, objectIds);
|
||||
for (const guard of rule.guards ?? []) {
|
||||
expectGuardReferences(guard, groupIds, objectIds, objectiveIds);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
expectActionReferences(action, groupIds, objectsById, objectiveIds);
|
||||
}
|
||||
}
|
||||
|
||||
const batchGroups = (definition.encounterSpawnGroups ?? [])
|
||||
.filter((spawnGroup) => spawnGroup.id.startsWith(`${id}:`));
|
||||
for (const spawnGroup of batchGroups) {
|
||||
const completion = encounter.rules.find((rule) => (
|
||||
rule.trigger.kind === "entities-dead"
|
||||
&& rule.trigger.entityIds.length === spawnGroup.spawns.length
|
||||
&& rule.trigger.entityIds.every((spawnId) => spawnGroup.spawns.some((spawn) => spawn.id === spawnId))
|
||||
));
|
||||
const isHealthGatedSvala = spawnGroup.id === "utgarde-pinnacle:svala-phase-one";
|
||||
expect(Boolean(completion) || isHealthGatedSvala, `${id} finite completion for ${spawnGroup.id}`).toBe(true);
|
||||
if (completion?.trigger.kind === "entities-dead") {
|
||||
expect(completion.trigger.timeoutMs, `${id} timeout for ${spawnGroup.id}`).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces each reviewed imported object by spawnId without dropping or duplicating source rows", () => {
|
||||
for (const id of TARGETS) {
|
||||
const source = sourceDefinition(id);
|
||||
const definition = adaptedDefinition(id);
|
||||
const sourceRowIds = source.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined)
|
||||
.sort((left, right) => left - right);
|
||||
const adaptedRowIds = definition.serverGameObjects
|
||||
.map((object) => object.source?.spawnId)
|
||||
.filter((spawnId): spawnId is number => spawnId !== undefined)
|
||||
.sort((left, right) => left - right);
|
||||
expect(adaptedRowIds, `${id} source-row inventory`).toEqual(sourceRowIds);
|
||||
|
||||
for (const templateId of REVIEWED_TEMPLATES[id]) {
|
||||
const originalRows = source.serverGameObjects.filter((object) => object.source?.templateId === templateId);
|
||||
expect(originalRows, `${id} template ${templateId}`).toHaveLength(1);
|
||||
const original = originalRows[0];
|
||||
expect(original.source?.spawnId).toEqual(expect.any(Number));
|
||||
const replacements = definition.serverGameObjects.filter((object) => (
|
||||
object.source?.spawnId === original.source?.spawnId
|
||||
));
|
||||
expect(replacements, `${id} spawn ${original.source?.spawnId}`).toHaveLength(1);
|
||||
const replacement = replacements[0];
|
||||
expect(replacement.id).not.toBe(original.id);
|
||||
expect(replacement.id).toContain(":reviewed-");
|
||||
expect(replacement.position).toEqual(original.position);
|
||||
expect(replacement.halfExtents).toEqual(original.halfExtents);
|
||||
expect(replacement.yaw).toEqual(original.yaw);
|
||||
expect(replacement.visual?.hidden).not.toBe(true);
|
||||
expect(replacement.source?.fidelity).toBe("source-backed-adapted");
|
||||
}
|
||||
}
|
||||
|
||||
expect(adaptedDefinition("halls-of-stone").serverGameObjects
|
||||
.find((object) => object.id === "halls-of-stone:reviewed-tribunal-console")?.interaction)
|
||||
.toBeDefined();
|
||||
expect(adaptedDefinition("utgarde-pinnacle").serverGameObjects
|
||||
.find((object) => object.id === "utgarde-pinnacle:reviewed-stasis-sphere")?.interaction)
|
||||
.toBeDefined();
|
||||
expect(adaptedDefinition("gundrak").serverGameObjects
|
||||
.filter((object) => object.id.startsWith("gundrak:reviewed-altar-")).every((object) => object.interaction))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("suppresses only source-gated event objectives and preserves exact finite cardinalities", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const staticIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
|
||||
for (const name of EVENT_BOSS_NAMES[id]) {
|
||||
const eventBoss = objective(definition, name);
|
||||
expect(eventBoss.eventOnly, `${id}:${eventBoss.name}`).toBe(true);
|
||||
expect(staticIds, `${id}:${eventBoss.name}`).not.toContain(eventBoss.id);
|
||||
}
|
||||
}
|
||||
|
||||
const halls = adaptedDefinition("halls-of-stone");
|
||||
expect([
|
||||
group(halls, "halls-of-stone:tribunal-protectors").spawns.length,
|
||||
group(halls, "halls-of-stone:tribunal-stormcallers").spawns.length,
|
||||
group(halls, "halls-of-stone:tribunal-custodian").spawns.length,
|
||||
]).toEqual([3, 2, 1]);
|
||||
expect(incompleteObjective(halls, createDungeonEncounterSnapshot(adaptedEncounter(halls)), ["Krystallus", "Maiden"]))
|
||||
.toBeDefined();
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
expect(group(pit, "pit-of-saron:first-ambush").spawns).toHaveLength(10);
|
||||
expect(group(pit, "pit-of-saron:second-ambush-normal").spawns).toHaveLength(6);
|
||||
expect(group(pit, "pit-of-saron:tyrannus").spawns).toHaveLength(1);
|
||||
expect(pit.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
|
||||
objective(pit, "Garfrost").id,
|
||||
objective(pit, "Ick").id,
|
||||
]));
|
||||
|
||||
const pinnacle = adaptedDefinition("utgarde-pinnacle");
|
||||
expect(group(pinnacle, "utgarde-pinnacle:svala-ritual-channelers").spawns).toHaveLength(3);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-first-wave").spawns).toHaveLength(13);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-one").spawns).toHaveLength(3);
|
||||
expect(group(pinnacle, "utgarde-pinnacle:skadi-reinforcement-two").spawns).toHaveLength(3);
|
||||
expect(pinnacle.staticSpawns.map((spawn) => spawn.id)).toContain(objective(pinnacle, "Ymiron").id);
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
expect(group(gundrak, "gundrak:eck").spawns).toHaveLength(1);
|
||||
expect(group(gundrak, "gundrak:eck").spawns[0].spawnMask).toBe(2);
|
||||
expect(objective(gundrak, "Eck").optional).toBe(true);
|
||||
expect(gundrak.staticSpawns.map((spawn) => spawn.id)).toEqual(expect.arrayContaining([
|
||||
objective(gundrak, "Slad'ran").id,
|
||||
objective(gundrak, "Moorabi").id,
|
||||
objective(gundrak, "Drakkari Colossus").id,
|
||||
objective(gundrak, "Gal'darah").id,
|
||||
]));
|
||||
});
|
||||
|
||||
it("runs every required lifecycle through its reviewed gates and completes without renderer state", () => {
|
||||
const halls = adaptedDefinition("halls-of-stone");
|
||||
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
|
||||
expectOrderedPhases(hallsRun.phases, [
|
||||
"tribunal-protectors-active",
|
||||
"tribunal-stormcallers-active",
|
||||
"tribunal-custodian-active",
|
||||
"sjonnir-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(hallsRun.snapshot.worldObjectStates["halls-of-stone:reviewed-sjonnir-door"]).toBe("open");
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
const pitRun = finishEncounter(pit, adaptedEncounter(pit));
|
||||
expectOrderedPhases(pitRun.phases, [
|
||||
"gauntlet-ready",
|
||||
"first-ambush-active",
|
||||
"second-ambush-active",
|
||||
"tunnel-ready",
|
||||
"tyrannus-active",
|
||||
"complete",
|
||||
]);
|
||||
expect(pitRun.snapshot.flags["gauntlet-complete"]).toBe(true);
|
||||
expect(pitRun.snapshot.worldObjectStates["pit-of-saron:reviewed-portcullis"]).toBe("open");
|
||||
|
||||
const pinnacle = adaptedDefinition("utgarde-pinnacle");
|
||||
const pinnacleRun = finishEncounter(pinnacle, adaptedEncounter(pinnacle));
|
||||
expectOrderedPhases(pinnacleRun.phases, [
|
||||
"svala-phase-one-active",
|
||||
"svala-ritual-active",
|
||||
"svala-phase-two-active",
|
||||
"gortok-ready",
|
||||
"stasis-worgen-active",
|
||||
"stasis-furbolg-active",
|
||||
"gortok-active",
|
||||
"skadi-ready",
|
||||
"skadi-first-wave-active",
|
||||
"harpoon-one-ready",
|
||||
"skadi-reinforcement-one-active",
|
||||
"harpoon-two-ready",
|
||||
"skadi-reinforcement-two-active",
|
||||
"harpoon-three-ready",
|
||||
"skadi-ground-active",
|
||||
"ymiron-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(pinnacleRun.snapshot.counters["harpoon-hits"]).toBe(3);
|
||||
expect(pinnacleRun.snapshot.worldObjectStates["utgarde-pinnacle:reviewed-ymiron-door"]).toBe("open");
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
const gundrakRun = finishEncounter(gundrak, adaptedEncounter(gundrak));
|
||||
expectOrderedPhases(gundrakRun.phases, [
|
||||
"altar-progression",
|
||||
"bridge-activating",
|
||||
"gal-darah-ready",
|
||||
"complete",
|
||||
]);
|
||||
expect(gundrakRun.snapshot.counters["keys-in"]).toBe(3);
|
||||
expect(gundrakRun.snapshot.flags["bridge-open"]).toBe(true);
|
||||
expect(gundrakRun.snapshot.worldObjectStates["gundrak:reviewed-bridge"]).toBe("open");
|
||||
expect(gundrakRun.snapshot.completedObjectiveIds).not.toContain(objective(gundrak, "Eck").id);
|
||||
expect(gundrakRun.snapshot.activeSpawnGroupIds).toContain("gundrak:eck");
|
||||
|
||||
for (const [definition, result] of [
|
||||
[halls, hallsRun],
|
||||
[pit, pitRun],
|
||||
[pinnacle, pinnacleRun],
|
||||
[gundrak, gundrakRun],
|
||||
] as const) {
|
||||
expect(result.snapshot.flags["dungeon-complete"], definition.id).toBe(true);
|
||||
expect(result.snapshot.completedObjectiveIds, definition.id).toEqual(expect.arrayContaining(
|
||||
definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from both wipe scopes at every nonfinal checkpoint, including optional Eck", () => {
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
const run = finishEncounter(definition, encounter);
|
||||
expect(run.checkpoints, id).toHaveLength(EXPECTED_NONFINAL_CHECKPOINTS[id]);
|
||||
for (const checkpoint of run.checkpoints) {
|
||||
for (const scope of ["boss-wipe", "checkpoint"] as const) {
|
||||
const reset = resetDungeonEncounterSnapshot(
|
||||
encounter,
|
||||
checkpoint,
|
||||
scope,
|
||||
checkpoint.phaseStartedAt + 10_000,
|
||||
);
|
||||
expect(reset.phase, `${id}:${checkpoint.checkpointId}:${scope}`).toBe("resume");
|
||||
expect(reset.activeSpawnGroupIds).toEqual([]);
|
||||
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
|
||||
expect(reset.checkpointId).toBe(checkpoint.checkpointId);
|
||||
expect(finishEncounter(definition, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gundrak = adaptedDefinition("gundrak");
|
||||
const encounter = adaptedEncounter(gundrak);
|
||||
let eckCheckpoint = killIds(
|
||||
encounter,
|
||||
createDungeonEncounterSnapshot(encounter),
|
||||
[objective(gundrak, "Moorabi").id],
|
||||
1,
|
||||
);
|
||||
eckCheckpoint = killIds(
|
||||
encounter,
|
||||
eckCheckpoint,
|
||||
group(gundrak, "gundrak:eck").spawns.map((spawn) => spawn.id),
|
||||
2,
|
||||
);
|
||||
expect(eckCheckpoint.checkpointId).toBe(objective(gundrak, "Eck").id);
|
||||
for (const scope of ["boss-wipe", "checkpoint"] as const) {
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, eckCheckpoint, scope, 3);
|
||||
expect(reset.completedObjectiveIds).toContain(objective(gundrak, "Eck").id);
|
||||
expect(finishEncounter(gundrak, encounter, reset).snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps completion order-safe when a static final boss is reported dead before its event gate", () => {
|
||||
for (const id of ["halls-of-stone", "utgarde-pinnacle", "gundrak"] as const) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
const finalBoss = objective(definition, FINAL_BOSS_NAME[id]);
|
||||
const premature = killIds(encounter, createDungeonEncounterSnapshot(encounter), [finalBoss.id], 1);
|
||||
expect(premature.flags["dungeon-complete"], id).toBe(false);
|
||||
const completed = finishEncounter(definition, encounter, premature).snapshot;
|
||||
expect(completed.flags["dungeon-complete"], id).toBe(true);
|
||||
if (id === "gundrak") expect(completed.flags["bridge-open"]).toBe(true);
|
||||
}
|
||||
|
||||
const pit = adaptedDefinition("pit-of-saron");
|
||||
const pitEncounter = adaptedEncounter(pit);
|
||||
const tyrannusGroup = group(pit, "pit-of-saron:tyrannus");
|
||||
const prematureTyrannus = killIds(
|
||||
pitEncounter,
|
||||
createDungeonEncounterSnapshot(pitEncounter),
|
||||
tyrannusGroup.spawns.map((spawn) => spawn.id),
|
||||
1,
|
||||
);
|
||||
expect(prematureTyrannus.completedObjectiveIds).not.toContain(objective(pit, "Tyrannus").id);
|
||||
expect(prematureTyrannus.phase).toBe("progression");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,406 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDungeonEncounterSnapshot,
|
||||
reduceDungeonEncounter,
|
||||
resetDungeonEncounterSnapshot,
|
||||
validateDungeonEncounterDefinition,
|
||||
} from "./dungeonEncounterRuntime";
|
||||
import type {
|
||||
DungeonEncounterDefinition,
|
||||
DungeonEncounterSnapshot,
|
||||
} from "./dungeonEncounterTypes";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import { validateDungeonWorldObject } from "./dungeonWorldObjects";
|
||||
import {
|
||||
WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS,
|
||||
withWowWrathEncounterContent,
|
||||
} from "./wowWrathEncounterContentCatalog";
|
||||
|
||||
const TARGETS = [
|
||||
"halls-of-reflection",
|
||||
"the-oculus",
|
||||
"trial-of-the-champion",
|
||||
] as const;
|
||||
|
||||
const START_OBJECT_BY_DUNGEON = Object.freeze({
|
||||
"halls-of-reflection": "halls-of-reflection:wave-start",
|
||||
"the-oculus": "the-oculus:expedition-start",
|
||||
"trial-of-the-champion": "trial-of-the-champion:announcer-start",
|
||||
});
|
||||
|
||||
function adaptedDefinition(id: typeof TARGETS[number]): DungeonDefinition {
|
||||
const source = DUNGEON_REGISTRY[id];
|
||||
if (!source) throw new Error(`Missing generated dungeon ${id}`);
|
||||
return withWowWrathEncounterContent(source);
|
||||
}
|
||||
|
||||
function adaptedEncounter(definition: DungeonDefinition): DungeonEncounterDefinition {
|
||||
const encounter = definition.encounters?.find((candidate) => candidate.id === `${definition.id}:wrath-instance`);
|
||||
if (!encounter) throw new Error(`Missing Wrath encounter for ${definition.id}`);
|
||||
return encounter;
|
||||
}
|
||||
|
||||
function activeSpawnIds(
|
||||
definition: DungeonDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
): readonly string[] {
|
||||
const active = new Set(snapshot.activeSpawnGroupIds);
|
||||
return (definition.encounterSpawnGroups ?? [])
|
||||
.filter((group) => active.has(group.id))
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id));
|
||||
}
|
||||
|
||||
function advanceOneStep(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
snapshot: DungeonEncounterSnapshot,
|
||||
now: number,
|
||||
): DungeonEncounterSnapshot {
|
||||
if (snapshot.phase === "ready") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: START_OBJECT_BY_DUNGEON[definition.id as keyof typeof START_OBJECT_BY_DUNGEON],
|
||||
now,
|
||||
}).snapshot;
|
||||
}
|
||||
if (snapshot.phase === "escape-ready") {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "interact",
|
||||
objectId: "halls-of-reflection:escape-start",
|
||||
now,
|
||||
}).snapshot;
|
||||
}
|
||||
const spawnIds = activeSpawnIds(definition, snapshot);
|
||||
if (spawnIds.length) {
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now,
|
||||
facts: { registeredEntityIds: spawnIds, deadEntityIds: spawnIds },
|
||||
}).snapshot;
|
||||
}
|
||||
return reduceDungeonEncounter(encounter, snapshot, {
|
||||
kind: "tick",
|
||||
now: Math.max(now, snapshot.phaseStartedAt + 1_000_000),
|
||||
facts: { registeredEntityIds: [], deadEntityIds: [] },
|
||||
}).snapshot;
|
||||
}
|
||||
|
||||
function finishEncounter(
|
||||
definition: DungeonDefinition,
|
||||
encounter: DungeonEncounterDefinition,
|
||||
initial = createDungeonEncounterSnapshot(encounter),
|
||||
): { readonly snapshot: DungeonEncounterSnapshot; readonly phases: readonly string[] } {
|
||||
let snapshot = initial;
|
||||
const phases = [snapshot.phase];
|
||||
let now = snapshot.phaseStartedAt + 1;
|
||||
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
|
||||
const previous = snapshot;
|
||||
snapshot = advanceOneStep(definition, encounter, snapshot, now);
|
||||
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
|
||||
if (snapshot.phase !== phases.at(-1)) phases.push(snapshot.phase);
|
||||
if (snapshot === previous) {
|
||||
throw new Error(`${definition.id} soft locked in ${snapshot.phase}`);
|
||||
}
|
||||
}
|
||||
if (snapshot.phase !== "complete") throw new Error(`${definition.id} did not complete`);
|
||||
return { snapshot, phases };
|
||||
}
|
||||
|
||||
function expectOrderedPhases(phases: readonly string[], expected: readonly string[]): void {
|
||||
let cursor = -1;
|
||||
for (const phase of expected) {
|
||||
cursor = phases.indexOf(phase, cursor + 1);
|
||||
expect(cursor, `missing ordered phase ${phase}`).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
|
||||
describe("source-reviewed Wrath instance adapters", () => {
|
||||
it("keeps immutable file hashes and source anchors beside every adapter", async () => {
|
||||
expect(WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.map((review) => review.dungeonId)).toEqual(TARGETS);
|
||||
for (const review of WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS) {
|
||||
expect(review.fidelity).toBe("source-backed-adapted");
|
||||
expect(review.sourceFiles.length).toBeGreaterThanOrEqual(3);
|
||||
expect(review.adaptations.length).toBeGreaterThan(0);
|
||||
expect(review.remainingGaps.length).toBeGreaterThan(0);
|
||||
for (const source of review.sourceFiles) {
|
||||
expect(source.sha256).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(source.anchors.length).toBeGreaterThan(0);
|
||||
expect(createHash("sha256").update(await readFile(source.path)).digest("hex"), source.path)
|
||||
.toBe(source.sha256);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("validates definitions, group references, safety timeouts, and reviewed blocker geometry", () => {
|
||||
for (const id of TARGETS) {
|
||||
const source = DUNGEON_REGISTRY[id];
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
validateDungeonEncounterDefinition(encounter);
|
||||
definition.serverGameObjects.forEach(validateDungeonWorldObject);
|
||||
|
||||
const groupIds = new Set((definition.encounterSpawnGroups ?? []).map((group) => group.id));
|
||||
const objectIds = new Set(definition.serverGameObjects.map((object) => object.id));
|
||||
for (const rule of encounter.rules) {
|
||||
if (rule.trigger.kind === "entities-dead") {
|
||||
expect(rule.trigger.timeoutMs, `${id}:${rule.id}`).toBeGreaterThan(0);
|
||||
}
|
||||
for (const action of rule.actions) {
|
||||
if (action.kind === "activate-spawn-group" || action.kind === "deactivate-spawn-group") {
|
||||
expect(groupIds.has(action.spawnGroupId), `${id}:${action.id}`).toBe(true);
|
||||
}
|
||||
if (action.kind === "set-world-object-state") {
|
||||
expect(objectIds.has(action.objectId), `${id}:${action.id}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reviewedBlockers = definition.serverGameObjects.filter((object) => (
|
||||
object.id.includes(":reviewed-") || object.id.includes(":ice-wall-")
|
||||
));
|
||||
expect(reviewedBlockers.length).toBeGreaterThan(0);
|
||||
for (const blocker of reviewedBlockers) {
|
||||
const closed = blocker.states.find((state) => state.id === "closed");
|
||||
const open = blocker.states.find((state) => state.id === "open");
|
||||
expect(closed).toMatchObject({ visible: true, blocksMovement: true });
|
||||
expect(open).toMatchObject({ blocksMovement: false });
|
||||
}
|
||||
|
||||
const originalImportedObjects = definition.serverGameObjects.filter((object) => (
|
||||
object.source?.spawnId !== undefined && !object.id.includes(":reviewed-")
|
||||
));
|
||||
const originalImportedObjectIds = new Set(originalImportedObjects.map((object) => object.id));
|
||||
for (const object of source.serverGameObjects.filter((candidate) => (
|
||||
candidate.source?.spawnId !== undefined && !candidate.id.includes(":reviewed-")
|
||||
))) {
|
||||
expect(originalImportedObjectIds.has(object.id), `${id} dropped imported object ${object.id}`).toBe(true);
|
||||
}
|
||||
expect(originalImportedObjects.every((object) => object.visual?.hidden === true)).toBe(true);
|
||||
expect(originalImportedObjects.every((object) => object.states.every((state) => !state.blocksMovement))).toBe(true);
|
||||
expect(withWowWrathEncounterContent(definition)).toBe(definition);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes every unsafe static event boss and preserves exact sourced stage cardinality", () => {
|
||||
const expectations = {
|
||||
"halls-of-reflection": [38_112, 38_113],
|
||||
"the-oculus": [27_447, 27_654, 27_655, 27_656],
|
||||
"trial-of-the-champion": [30_188, 35_451, 35_569, 35_571, 35_572],
|
||||
} as const;
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const staticEntries = definition.staticSpawns.map((spawn) => definition.entities[spawn.entityId]?.combat?.serverEntry);
|
||||
for (const entry of expectations[id]) expect(staticEntries).not.toContain(entry);
|
||||
expect(definition.bosses.every((boss) => boss.eventOnly)).toBe(true);
|
||||
const staticSpawnIds = new Set(definition.staticSpawns.map((spawn) => spawn.id));
|
||||
for (const boss of definition.bosses) {
|
||||
expect(staticSpawnIds.has(boss.id), `${id} left event boss ${boss.name} statically spawned`).toBe(false);
|
||||
}
|
||||
}
|
||||
|
||||
const halls = adaptedDefinition("halls-of-reflection");
|
||||
expect([1, 2, 3, 4, 6, 7, 8, 9].map((wave) => (
|
||||
halls.encounterSpawnGroups?.find((group) => group.id === `halls-of-reflection:wave-${String(wave).padStart(2, "0")}`)?.spawns.length
|
||||
))).toEqual([3, 3, 4, 4, 5, 5, 5, 5]);
|
||||
|
||||
const oculus = adaptedDefinition("the-oculus");
|
||||
expect(oculus.encounterSpawnGroups?.find((group) => group.id === "the-oculus:centrifuge-constructs")?.spawns).toHaveLength(10);
|
||||
expect([1, 2, 3].map((wave) => (
|
||||
oculus.encounterSpawnGroups?.find((group) => group.id === `the-oculus:urom-menagerie-${wave}`)?.spawns.length
|
||||
))).toEqual([4, 4, 4]);
|
||||
|
||||
const trial = adaptedDefinition("trial-of-the-champion");
|
||||
expect([1, 2, 3].map((wave) => (
|
||||
trial.encounterSpawnGroups?.find((group) => group.id.startsWith(`trial-of-the-champion:minions-${wave}-`))?.spawns.length
|
||||
))).toEqual([3, 3, 3]);
|
||||
expect(trial.encounterSpawnGroups?.find((group) => group.id === "trial-of-the-champion:argent-soldiers")?.spawns).toHaveLength(9);
|
||||
expect([1, 2, 3].map((phase) => (
|
||||
trial.encounterSpawnGroups?.find((group) => group.id === `trial-of-the-champion:black-knight-phase-${phase}`)?.spawns.length
|
||||
))).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("gives every Trial event boss its pinned phase-specific combat schedule", () => {
|
||||
const trial = adaptedDefinition("trial-of-the-champion");
|
||||
const trialReview = WOW_WRATH_ENCOUNTER_CONTENT_REVIEWS.find(
|
||||
(review) => review.dungeonId === "trial-of-the-champion",
|
||||
);
|
||||
expect(trialReview?.adaptations.join(" ")).toContain(
|
||||
"six-second, one-charge +100% critical chance modifier",
|
||||
);
|
||||
const entity = (id: string) => trial.entities[id];
|
||||
const specials = (id: string) => entity(id).combat?.attacks.filter((attack) => attack.id !== "basic-attack") ?? [];
|
||||
|
||||
for (const id of [
|
||||
"wrath-event-mounted-mokra",
|
||||
"wrath-event-mounted-eressea",
|
||||
"wrath-event-mounted-runok",
|
||||
]) {
|
||||
expect(specials(id).map((attack) => attack.id)).toEqual([
|
||||
"mounted-charge-63010",
|
||||
"shield-breaker-68504",
|
||||
"thrust-68505",
|
||||
]);
|
||||
expect(specials(id).map((attack) => [attack.initialCooldownMs, attack.repeatCooldownMs])).toEqual([
|
||||
[[2_500, 4_000], [4_500, 6_000]],
|
||||
[[5_000, 8_000], [6_000, 8_000]],
|
||||
[[3_000, 5_000], [3_000, 5_000]],
|
||||
]);
|
||||
}
|
||||
|
||||
expect(specials("wrath-event-eadric").map((attack) => attack.spellId)).toEqual([
|
||||
66_865,
|
||||
66_935,
|
||||
66_867,
|
||||
]);
|
||||
expect(specials("wrath-event-eadric").find((attack) => attack.id === "vengeance-66865")?.effect)
|
||||
.toEqual({
|
||||
kind: "aura",
|
||||
aura: "vengeance",
|
||||
durationMs: 0,
|
||||
meleeAutoCriticalProc: {
|
||||
chance: 1,
|
||||
triggeredAura: "vengeance-next-melee-critical-66889",
|
||||
durationMs: 6_000,
|
||||
criticalChancePercent: 100,
|
||||
charges: 1,
|
||||
},
|
||||
});
|
||||
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935")?.repeatCooldownMs)
|
||||
.toEqual([16_000, 16_000]);
|
||||
expect(specials("wrath-event-eadric").find((attack) => attack.id === "radiance-66935"))
|
||||
.toMatchObject({
|
||||
school: "holy",
|
||||
damageMultiplier: 0.9,
|
||||
effect: { kind: "control", mechanic: "confuse", durationMs: 2_000, damageMultiplier: 0.9 },
|
||||
});
|
||||
expect(specials("wrath-event-eadric").find((attack) => attack.id === "hammer-of-justice-and-righteous-66863-66867"))
|
||||
.toMatchObject({
|
||||
name: "Hammer of Justice + Hammer of the Righteous",
|
||||
target: "random-party",
|
||||
initialCooldownMs: [25_000, 25_000],
|
||||
repeatCooldownMs: [25_000, 25_000],
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 6_000, damageMultiplier: 1.3 },
|
||||
});
|
||||
expect(specials("wrath-event-eadric").some((attack) => attack.id === "hammer-of-justice-66863")).toBe(false);
|
||||
|
||||
for (const id of ["wrath-event-mounted-eressea", "wrath-event-black-knight-phase-3"]) {
|
||||
expect(entity(id).combat?.attacks[0]).toMatchObject({
|
||||
id: "basic-attack",
|
||||
name: "Basic Attack",
|
||||
delivery: "melee",
|
||||
school: "physical",
|
||||
animation: "attack",
|
||||
range: 4.5,
|
||||
});
|
||||
}
|
||||
|
||||
expect(specials("wrath-event-black-knight-phase-1").map((attack) => attack.id)).toEqual([
|
||||
"plague-strike-67724",
|
||||
"icy-touch-67718",
|
||||
"death-respite-67745",
|
||||
"obliterate-67725",
|
||||
]);
|
||||
expect(specials("wrath-event-black-knight-phase-1")[0]).toMatchObject({
|
||||
id: "plague-strike-67724",
|
||||
school: "physical",
|
||||
});
|
||||
expect(specials("wrath-event-black-knight-phase-2").map((attack) => attack.id)).toEqual([
|
||||
"plague-strike-67724",
|
||||
"icy-touch-67718",
|
||||
"obliterate-67725",
|
||||
"desecration-67778",
|
||||
]);
|
||||
expect(specials("wrath-event-black-knight-phase-2")[0]).toMatchObject({
|
||||
id: "plague-strike-67724",
|
||||
school: "physical",
|
||||
});
|
||||
expect(specials("wrath-event-black-knight-phase-3").map((attack) => ({
|
||||
id: attack.id,
|
||||
initial: attack.initialCooldownMs,
|
||||
repeat: attack.repeatCooldownMs,
|
||||
}))).toEqual([
|
||||
{ id: "death-bite-67808", initial: [2_000, 2_000], repeat: [2_000, 4_000] },
|
||||
{ id: "marked-for-death-67823", initial: [1_000, 1_000], repeat: [9_000, 9_000] },
|
||||
]);
|
||||
expect(specials("wrath-event-black-knight-phase-3")[1].effect)
|
||||
.toEqual({ kind: "target-aura", aura: "marked-for-death", durationMs: 9_000 });
|
||||
});
|
||||
|
||||
it("reduces every complete source-ordered lifecycle without renderer state", () => {
|
||||
const halls = adaptedDefinition("halls-of-reflection");
|
||||
const hallsRun = finishEncounter(halls, adaptedEncounter(halls));
|
||||
expectOrderedPhases(hallsRun.phases, [
|
||||
"wave-01-active", "wave-05-active", "wave-06-source-pause", "wave-06-active",
|
||||
"wave-10-active", "general-active", "escape-ready", "escape-1-active", "escape-4-active", "complete",
|
||||
]);
|
||||
|
||||
const oculus = adaptedDefinition("the-oculus");
|
||||
const oculusRun = finishEncounter(oculus, adaptedEncounter(oculus));
|
||||
expectOrderedPhases(oculusRun.phases, [
|
||||
"drakos-active", "constructs-active", "varos-active", "urom-menagerie-1-active",
|
||||
"urom-menagerie-3-active", "urom-active", "eregos-active", "complete",
|
||||
]);
|
||||
|
||||
const trial = adaptedDefinition("trial-of-the-champion");
|
||||
const trialRun = finishEncounter(trial, adaptedEncounter(trial));
|
||||
expectOrderedPhases(trialRun.phases, [
|
||||
"champion-minions-1-active", "champion-minions-3-active", "mounted-champions-active",
|
||||
"foot-champions-active", "argent-soldiers-active", "eadric-active",
|
||||
"black-knight-phase-1-active", "black-knight-phase-3-active", "complete",
|
||||
]);
|
||||
|
||||
for (const [definition, result] of [
|
||||
[halls, hallsRun],
|
||||
[oculus, oculusRun],
|
||||
[trial, trialRun],
|
||||
] as const) {
|
||||
expect(result.snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
expect(result.snapshot.completedObjectiveIds).toEqual(
|
||||
expect.arrayContaining(definition.bosses.filter((boss) => !boss.optional).map((boss) => boss.id)),
|
||||
);
|
||||
expect(result.snapshot.activeSpawnGroupIds).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("recovers from every non-final persistent checkpoint without a soft lock", () => {
|
||||
const expectedCheckpointCounts = {
|
||||
"halls-of-reflection": 3,
|
||||
"the-oculus": 3,
|
||||
"trial-of-the-champion": 2,
|
||||
} as const;
|
||||
for (const id of TARGETS) {
|
||||
const definition = adaptedDefinition(id);
|
||||
const encounter = adaptedEncounter(definition);
|
||||
let snapshot = createDungeonEncounterSnapshot(encounter);
|
||||
let now = 1;
|
||||
const checkpointSnapshots: DungeonEncounterSnapshot[] = [];
|
||||
const seenCheckpoints = new Set<string>();
|
||||
for (let step = 0; step < 80 && snapshot.phase !== "complete"; step += 1) {
|
||||
snapshot = advanceOneStep(definition, encounter, snapshot, now);
|
||||
now = Math.max(now + 1, snapshot.phaseStartedAt + 1);
|
||||
if (snapshot.checkpointId && !seenCheckpoints.has(snapshot.checkpointId) && snapshot.phase !== "complete") {
|
||||
seenCheckpoints.add(snapshot.checkpointId);
|
||||
checkpointSnapshots.push(snapshot);
|
||||
}
|
||||
}
|
||||
expect(checkpointSnapshots, id).toHaveLength(expectedCheckpointCounts[id]);
|
||||
for (const checkpoint of checkpointSnapshots) {
|
||||
const reset = resetDungeonEncounterSnapshot(encounter, checkpoint, "boss-wipe", now + 1);
|
||||
expect(reset.phase).toBe("resume");
|
||||
expect(reset.activeSpawnGroupIds).toEqual([]);
|
||||
expect(reset.completedObjectiveIds).toEqual(checkpoint.completedObjectiveIds);
|
||||
|
||||
const resumed = reduceDungeonEncounter(encounter, reset, { kind: "tick", now: now + 2 }).snapshot;
|
||||
expect(resumed.phase).not.toBe("resume");
|
||||
expect(
|
||||
resumed.activeSpawnGroupIds.length > 0 || resumed.phase === "escape-ready",
|
||||
`${id}:${checkpoint.checkpointId}`,
|
||||
).toBe(true);
|
||||
expect(finishEncounter(definition, encounter, resumed).snapshot.flags["dungeon-complete"]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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%.",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user