Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 18:57:44 -04:00
1 parent bd073fb00f
commit 83eac6d476
15 files changed
+438 -272

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ export default function GameRuntime() {
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => {
const race = useDungeonRaceStore.getState();
if (race.inGame && ["preparing", "countdown", "active"].includes(race.status)) void race.leave();
if (race.inGame && useShellStore.getState().phase !== "game") void race.leave();
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon);
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
import type { CharacterProfile, PlayerSession } from "./types";
const request = vi.hoisted(() => vi.fn());
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
const session: PlayerSession = {
kind: "account", ownerId: "player-1", displayName: "Racer", accessToken: "race-token",
};
const character = { id: "character-1" } as CharacterProfile;
const queued: RaceServerState = { status: "queued", serverTime: Date.now(), joinedAt: Date.now() };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
describe("dungeon race exit", () => {
beforeEach(() => {
request.mockReset();
useDungeonRaceStore.getState().reset();
});
afterEach(() => useDungeonRaceStore.getState().reset());
it("clears an active race immediately and waits for the server leave before requeuing", async () => {
const deleteRequest = deferred<RaceServerState>();
request.mockImplementation((path: string, init: RequestInit) =>
path === "/api/dungeon-races/queue" && init.method === "DELETE"
? deleteRequest.promise : Promise.resolve(queued));
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
useDungeonRaceStore.setState({ status: "active", inGame: true, matchId: "old-match",
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 10_000 });
const leaving = useDungeonRaceStore.getState().leave();
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
matchId: null, startAt: null });
const requeue = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
await Promise.resolve();
expect(request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1);
deleteRequest.resolve(queued);
await Promise.all([leaving, requeue]);
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE", "POST"]);
expect(useDungeonRaceStore.getState().status).toBe("queued");
});
it("cancels a queue request in flight without restoring its late response", async () => {
const firstQueue = deferred<RaceServerState>();
request.mockImplementation((path: string, init: RequestInit) =>
path === "/api/dungeon-races/queue" && init.method === "POST"
? firstQueue.promise : Promise.resolve(queued));
const starting = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
const leaving = useDungeonRaceStore.getState().leave();
expect(useDungeonRaceStore.getState().mode).toBeNull();
firstQueue.resolve(queued);
await Promise.all([starting, leaving]);
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", matchId: null });
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE"]);
});
it("ignores an older preparation response after the match has started", async () => {
const stale = deferred<RaceServerState>();
request.mockImplementation((path: string) => path === "/api/dungeon-races/state"
? stale.promise : Promise.resolve(queued));
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
useDungeonRaceStore.setState({ status: "active", matchId: "race-1", inGame: true });
const syncing = useDungeonRaceStore.getState().sync();
stale.resolve({ status: "preparing", matchId: "race-1", serverTime: Date.now() });
await syncing;
expect(useDungeonRaceStore.getState().status).toBe("active");
});
it("ends offline practice locally without a server request", async () => {
useDungeonRaceStore.setState({ mode: "practice", status: "settled", inGame: true,
matchId: "practice-1", startAt: Date.now() - 30_000, result: "loss" });
await useDungeonRaceStore.getState().leave();
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
matchId: null, startAt: null, result: null });
expect(request).not.toHaveBeenCalled();
});
});
+76 -29
View File
@@ -11,6 +11,9 @@ import type { PartyRole } from "../game/partyRoles";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
const RACE_STATUS_ORDER: Record<RaceStatus, number> = {
idle: -1, queued: 0, preparing: 1, countdown: 2, active: 3, settled: 4, void: 5,
};
export interface RaceHumanOpponent { readonly type: "human"; readonly name: string;
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
export type RaceOpponent = RaceHumanOpponent | RaceBot;
@@ -70,6 +73,10 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
let preparingSeed: string | null = null;
let readyPending = false;
let finishPromise: Promise<void> | null = null;
let queueRequest: Promise<RaceServerState> | null = null;
let pendingLeave: Promise<void> | null = null;
let pendingCleanupToken: string | null = null;
let raceRevision = 0;
function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; }
@@ -86,12 +93,14 @@ async function prepareContent(dungeonId: string, seed: string): Promise<void> {
export const useDungeonRaceStore = create<RaceState>((set, get) => {
async function enterPreparedRun(dungeonId: string, seed: string, difficultyId?: string) {
if (get().inGame || preparingSeed === seed) return;
const revision = raceRevision;
preparingSeed = seed;
set({ preparingContent: true, error: "" });
try {
await prepareContent(dungeonId, seed);
if (get().seed !== seed || get().status === "void") return;
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
if (raceRevision !== revision || get().seed !== seed) return;
installDungeonShellRuntime();
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
@@ -99,9 +108,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
set({ inGame: true, preparingContent: false });
} catch (error) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
} finally { preparingSeed = null; }
if (raceRevision === revision) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
}
} finally { if (preparingSeed === seed) preparingSeed = null; }
}
function applyServerState(response: RaceServerState) {
const previous = get();
@@ -110,6 +121,8 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
stopPolling();
return;
}
if (response.matchId && response.matchId === previous.matchId
&& RACE_STATUS_ORDER[response.status] < RACE_STATUS_ORDER[previous.status]) return;
const result = response.result ?? null;
const offset = response.serverTime - Date.now();
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
@@ -132,16 +145,35 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
startOnline: async (session, character, role, pool, dungeonId) => {
if (!session.accessToken) throw new Error("Sign in online to enter a ranked race.");
get().reset();
const revision = raceRevision;
const token = session.accessToken;
selectedRole = role;
sessionToken = session.accessToken;
sessionToken = token;
set({ mode: "online", status: "queued" });
let request: Promise<RaceServerState> | null = null;
try {
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
if (pendingLeave) try { await pendingLeave; } catch { /* Retry cleanup before queuing. */ }
if (raceRevision !== revision) return;
if (pendingCleanupToken) {
const cleanupToken = pendingCleanupToken;
await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, cleanupToken);
if (pendingCleanupToken === cleanupToken) pendingCleanupToken = null;
}
if (raceRevision !== revision) return;
request = requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
method: "POST", body: JSON.stringify({ characterId: character.id, role, pool, ...(dungeonId ? { dungeonId } : {}) }),
}, sessionToken);
}, token);
queueRequest = request;
const response = await request;
if (queueRequest === request) queueRequest = null;
if (raceRevision !== revision) return;
applyServerState(response);
pollTimer = setInterval(() => { void get().sync(); }, 750);
} catch (error) { set({ status: "idle", error: errorMessage(error) }); }
if (!["settled", "void", "idle"].includes(response.status))
pollTimer = setInterval(() => { void get().sync(); }, 750);
} catch (error) {
if (queueRequest === request) queueRequest = null;
if (raceRevision === revision) set({ status: "idle", error: errorMessage(error) });
}
},
startPractice: async (character, role, dungeonId, tier, source) => {
get().reset();
@@ -162,8 +194,12 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
},
sync: async () => {
if (get().mode !== "online" || !sessionToken) return;
try { applyServerState(await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
const revision = raceRevision;
const token = sessionToken;
try {
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, token);
if (raceRevision === revision) applyServerState(response);
} catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); }
},
readyUp: async () => {
const state = get();
@@ -172,11 +208,15 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
set({ ready: true, status: "countdown", startAt: Date.now() + 3_000 }); return;
}
if (!sessionToken) return;
const revision = raceRevision;
const token = sessionToken;
readyPending = true;
try { applyServerState(await requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
finally { readyPending = false; }
try {
const response = await requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, token);
if (raceRevision === revision) applyServerState(response);
} catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); }
finally { if (raceRevision === revision) readyPending = false; }
},
reportClear: (bossIds) => {
const state = get();
@@ -192,14 +232,15 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
}
if (!sessionToken) return Promise.resolve();
const matchId = state.matchId;
finishPromise = requestOnlineJson<RaceServerState>(
const report = requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
}, sessionToken)
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
.finally(() => { finishPromise = null; });
return finishPromise;
.finally(() => { if (finishPromise === report) finishPromise = null; });
finishPromise = report;
return report;
},
tick: () => {
const state = get();
@@ -211,19 +252,25 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
},
leave: async () => {
stopPolling();
if (get().mode === "online" && sessionToken) {
try { await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, sessionToken); }
catch (error) { set({ error: errorMessage(error) }); }
}
if (!get().inGame) {
const message = get().error;
get().reset();
set({ error: message });
}
const token = get().mode === "online" ? sessionToken : null;
const request = queueRequest;
get().reset();
const revision = raceRevision;
if (!token) return;
pendingCleanupToken = token;
const closing = (async () => {
try { await request; } catch { /* A failed queue request needs no server cleanup. */ }
await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, token);
if (pendingCleanupToken === token) pendingCleanupToken = null;
})();
pendingLeave = closing;
try { await closing; }
catch (error) { if (raceRevision === revision) set({ error: errorMessage(error) }); }
finally { if (pendingLeave === closing) pendingLeave = null; }
},
reset: () => {
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false;
raceRevision += 1;
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
+5 -3
View File
@@ -1,5 +1,6 @@
import type { DungeonAssetChunk, DungeonDefinition } from "./dungeonTypes";
import type { ManastormStageAssetPackage } from "./manastormStageAssets";
import { dungeonContentSourceId } from "./dungeonContentSource";
import { dungeonModules } from "@dungeonAssetModules";
function findAsset(dungeonId: string, chunk: DungeonAssetChunk | undefined): string | null {
@@ -19,10 +20,11 @@ export interface ResolvedDungeonAssets {
}
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
const sourceId = dungeonContentSourceId(definition.id);
return {
visual: definition.assets.visual.flatMap((chunk) => findAsset(definition.id, chunk) ?? []),
collision: definition.assets.collision.flatMap((chunk) => findAsset(definition.id, chunk) ?? []),
navigation: findAsset(definition.id, definition.assets.navigation),
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
navigation: findAsset(sourceId, definition.assets.navigation),
};
}
+113 -172
View File
@@ -7,27 +7,17 @@ import type {
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "../dungeonEncounterTypes";
import type {
MobPackDefinition,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import {
WAILING_NARALEX_ENCOUNTER,
WAILING_NARALEX_SPAWN_GROUPS,
WAILING_NARALEX_START_POSITION,
WAILING_NARALEX_ESCORT_WAYPOINTS,
} from "../wailingCavernsEncounter";
const WAILING_ROUTE_WIDTH = 36;
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
@@ -133,114 +123,104 @@ function appendPrototypeWarning(source: DungeonDefinition, note: string): Dungeo
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const sourceFinalBoss = bossByName(source, "Mutanus the Devourer");
const route = [WAILING_NARALEX_START_POSITION, ...WAILING_NARALEX_ESCORT_WAYPOINTS];
const staticSpawns = routeSpawns(source, route, WAILING_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, WAILING_ROUTE_WIDTH);
const mutanusSpawn: StaticMobSpawnDefinition = {
...sourceFinalBoss.spawn,
position: sourceFinalBoss.objective.position,
};
const mutanusGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:mutanus`;
const mutanusGroup: DungeonEncounterSpawnGroupDefinition = {
id: mutanusGroupId,
spawns: [mutanusSpawn],
};
const encounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
const encounter: DungeonEncounterDefinition = {
...WAILING_NARALEX_ENCOUNTER,
id: encounterId,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "ready",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" },
source: {
...WAILING_NARALEX_ENCOUNTER.source,
reviewedBehavior: "A standalone run begins at the source escort start, follows the Naralex waypoint route, defends the Disciple through the source waves, and finishes with Mutanus.",
adaptations: [
...WAILING_NARALEX_ENCOUNTER.source.adaptations,
"The four Fanglords are skipped; the bite-sized run begins at the actual Naralex escort start.",
"Mutanus is activated by the encounter runtime as the final event spawn in this derived definition.",
],
},
rules: WAILING_NARALEX_ENCOUNTER.rules
.filter((rule) => rule.id !== "fanglords-defeated" && rule.id !== "restore-ready-after-checkpoint")
.map((rule) => {
if (rule.id === "start-escort") {
return {
...rule,
trigger: { kind: "signal", signalId: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start` },
};
}
if (rule.id === "nightmare-wave-cleared") {
return {
...rule,
actions: [
...rule.actions,
{ id: "activate-mutanus", kind: "activate-spawn-group", spawnGroupId: mutanusGroupId },
],
};
}
return rule;
}),
};
const encounterGroups = [
...WAILING_NARALEX_SPAWN_GROUPS,
mutanusGroup,
const verdan = bossByName(source, "Verdan the Everliving");
const startPosition: Vector3Tuple = [82, -30.92, 8];
// These points sit on the shipped Wailing Caverns navigation surface in Verdan's chamber.
const staticSpawns: readonly StaticMobSpawnDefinition[] = [
{ id: "verdan-room-druid", entityId: "druid-of-the-fang", position: [78, -31.01, 16], yaw: Math.PI },
{ id: "verdan-room-guardian", entityId: "deviate-guardian", position: [90, -31, 16], yaw: Math.PI },
{ id: "verdan-room-ravager", entityId: "deviate-ravager", position: [86, -31.05, 24], yaw: Math.PI },
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
const verdanGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan`;
const verdanGroup: DungeonEncounterSpawnGroupDefinition = {
id: verdanGroupId,
spawns: [verdan.spawn],
};
const encounter: DungeonEncounterDefinition = {
id: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:verdan-room`,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "room-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "room-mobs", checkpointPhase: "room-mobs" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/dungeons/wailingCaverns.ts"],
reviewedBehavior: "Clear three mobs in Verdan's chamber, then defeat Verdan the Everliving.",
adaptations: [
"The Fanglords, Naralex escort, and Mutanus event are outside this short run.",
"The room mobs use source Wailing Caverns creatures at floor-checked chamber positions.",
"Verdan is held until the room mobs are defeated.",
],
remainingGaps: ["The room encounter still needs an in-game combat and completion pass."],
},
rules: [
{
id: "verdan-room-cleared",
trigger: { kind: "entities-dead", entityIds: staticSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["room-mobs"] }],
actions: [
{ id: "verdan-room-activate-boss", kind: "activate-spawn-group", spawnGroupId: verdanGroupId },
{ id: "verdan-room-enter-boss-phase", kind: "set-phase", phase: "verdan" },
],
},
{
id: "verdan-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [verdan.spawn.id] },
guards: [{ kind: "phase", phases: ["verdan"] }],
actions: runCompletionActions("verdan-room", verdan.objective.id),
},
],
};
const finalObjective: DungeonBossObjective = { ...verdan.objective, eventOnly: true };
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
title: "Wailing Caverns: Verdan’s Lair",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
name: "Crag of the Everliving",
footPosition: startPosition,
forward: [0, 0, 1],
yaw: 0,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
location: "Wailing Caverns · Verdan’s Chamber",
theme: "Bite-Sized Dungeon",
summary: "Follow the winding escort route, defend Naralex’s Disciple through the awakening waves, and defeat Mutanus.",
loadingMessage: "Opening the path to the Dreamer’s Rock...",
unchartedAreaName: "Naralex’s Route",
summary: "Clear the creatures in Verdan’s chamber, then defeat Verdan the Everliving.",
loadingMessage: "Entering Verdan’s chamber...",
unchartedAreaName: "Crag of the Everliving",
},
areas: [
routeArea("entrance", "Disciple of Naralex’s Camp", WAILING_NARALEX_START_POSITION, 26),
routeArea("naralex-route", "The Winding Caverns", WAILING_NARALEX_ESCORT_WAYPOINTS[12], 64),
routeArea("mutanus", "Mutanus’s Chamber", sourceFinalBoss.objective.position, 42),
routeArea("verdan", "Crag of the Everliving", verdan.objective.position, 46),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
entities: entitiesFor(source, staticSpawns, [], [verdanGroup]),
staticSpawns,
roamingPacks,
roamingPacks: [],
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
encounterSpawnGroups: [verdanGroup],
serverGameObjects: [],
validation: appendPrototypeWarning(
source,
"Naralex’s route corridor and bite-sized objective list are Healer Man-authored; the source cave environment, escort waypoints, wave rosters, and Mutanus combat data are shared with Wailing Caverns.",
"Verdan’s room fight is Healer Man-authored; the cave environment, creature assets, and Verdan combat data are shared with Wailing Caverns.",
),
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const smite = bossByName(source, "Mr. Smite");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const startPosition = smite.spawn.position;
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
startPosition,
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
@@ -267,7 +247,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start at the ship ramp, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
reviewedBehavior: "Start on Mr. Smite's floor-snapped deck position, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
@@ -303,8 +283,6 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
@@ -312,7 +290,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
name: "Mr. Smite’s Deck",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
@@ -326,7 +304,7 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("entrance", "Mr. Smite’s Deck", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
@@ -348,101 +326,64 @@ export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): D
};
}
const SKADI_GROUP_IDS = new Set([
"utgarde-pinnacle:skadi-first-wave",
"utgarde-pinnacle:skadi-reinforcement-one",
"utgarde-pinnacle:skadi-reinforcement-two",
"utgarde-pinnacle:skadi-ground",
]);
const SKADI_RULE_IDS = new Set([
"utgarde-pinnacle-start-skadi",
"utgarde-pinnacle-first-wave-cleared",
"utgarde-pinnacle-fire-harpoon-one",
"utgarde-pinnacle-reinforcement-one-cleared",
"utgarde-pinnacle-fire-harpoon-two",
"utgarde-pinnacle-reinforcement-two-cleared",
"utgarde-pinnacle-fire-harpoon-three",
"utgarde-pinnacle-skadi-defeated",
]);
function skadiSliceRule(rule: DungeonEncounterRule, objectiveId: string): DungeonEncounterRule {
if (rule.id !== "utgarde-pinnacle-skadi-defeated") return rule;
return {
...rule,
trigger: { kind: "entities-dead", entityIds: [objectiveId], timeoutMs: 300_000 },
actions: [
...rule.actions.filter((action) => (
action.kind === "deactivate-spawn-group"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
)),
{ id: "utgarde-pinnacle-skadi-slice-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "utgarde-pinnacle-skadi-slice-finish", kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceEncounter = source.encounters?.find((encounter) => (
encounter.rules.some((rule) => rule.id === "utgarde-pinnacle-start-skadi")
));
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const firstWave = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-first-wave");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
const harpoons = [1, 2, 3].map((index) => source.serverGameObjects.find((object) => (
object.id === `utgarde-pinnacle:harpoon-${index}`
)));
if (!skadi || !sourceEncounter || !sourceSkadiGroup || !skadiStart || harpoons.some((object) => !object)) {
throw new Error("Utgarde Pinnacle is missing the reviewed Skadi gauntlet content.");
if (!skadi || !sourceSkadiGroup?.spawns[0] || !firstWave?.spawns[0] || !skadiStart) {
throw new Error("Utgarde Pinnacle is missing the Skadi room content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const bossPosition = firstWave.spawns[0].position;
const route: readonly Vector3Tuple[] = [skadiStart.position, bossPosition];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
if (staticSpawns.length === 0) throw new Error("Skadi's approach has no room mobs.");
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
position: bossPosition,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
};
const groups = source.encounterSpawnGroups!.filter((group) => SKADI_GROUP_IDS.has(group.id))
.map((group) => group.id === skadiGroup.id ? skadiGroup : group);
if (!groups.some((group) => group.id === skadiGroup.id)) groups.push(skadiGroup);
const encounter: DungeonEncounterDefinition = {
...sourceEncounter,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-gauntlet`,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-room`,
dungeonId: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
initialPhase: "skadi-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[skadiStart.id]: "ready",
...Object.fromEntries(harpoons.map((object) => [object!.id, "unavailable"])),
},
},
resetPolicy: { bossWipePhase: "skadi-ready", checkpointPhase: "skadi-ready" },
initialPhase: "approach-mobs",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "approach-mobs", checkpointPhase: "approach-mobs" },
source: {
...sourceEncounter.source!,
reviewedBehavior: "Begin at Skadi’s gauntlet, clear the source first wave, fire three harpoons through the bounded reinforcement packs, then defeat Skadi.",
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/wowWrathEncounterBatchTwoCatalog.ts"],
reviewedBehavior: "Clear the three imported mobs near Eagle's Eye, then confront Skadi on the same walkable approach.",
adaptations: [
...sourceEncounter.source!.adaptations,
"The Svala and Gortok stages are skipped; the source Skadi event begins ready at its gauntlet trigger.",
"Skadi’s event actor uses the source boss objective ID so party targeting, loot, and completion stay aligned.",
"The start interaction, flying phase, harpoons, and long wave sequence are omitted from this short run.",
"Skadi uses the source boss combat data and objective ID at a floor-snapped source wave position near the entrance.",
],
remainingGaps: ["The shortened encounter still needs an in-game combat and completion pass."],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
rules: [
{
id: "skadi-approach-cleared",
trigger: { kind: "entities-dead", entityIds: staticSpawns.map((spawn) => spawn.id) },
guards: [{ kind: "phase", phases: ["approach-mobs"] }],
actions: [
{ id: "skadi-approach-activate-boss", kind: "activate-spawn-group", spawnGroupId: skadiGroup.id },
{ id: "skadi-approach-enter-boss-phase", kind: "set-phase", phase: "skadi" },
],
},
{
id: "skadi-room-boss-defeated",
trigger: { kind: "entities-dead", entityIds: [skadiSpawn.id] },
guards: [{ kind: "phase", phases: ["skadi"] }],
actions: runCompletionActions("skadi-room", skadi.id),
},
],
};
const objective: DungeonBossObjective = { ...skadi, eventOnly: true };
const routeNames = new Map<string, DungeonWorldObjectDefinition>([
[skadiStart.id, skadiStart],
...harpoons.map((object) => [object!.id, object!] as const),
]);
const objective: DungeonBossObjective = { ...skadi, position: bossPosition, eventOnly: true };
return {
...source,
@@ -460,26 +401,26 @@ export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefiniti
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Enter Skadi’s gauntlet, push through the moving waves, fire the harpoons, and finish the fight on the ground.",
loadingMessage: "Preparing Skadi’s gauntlet...",
summary: "Defeat the three guards on Eagle’s Eye, then fight Skadi on the approach.",
loadingMessage: "Entering Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("skadi", "Skadi’s Arena", skadi.position, 34),
routeArea("skadi", "Skadi’s Arena", bossPosition, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
entities: entitiesFor(source, staticSpawns, [], [skadiGroup]),
staticSpawns,
roamingPacks,
roamingPacks: [],
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
encounterSpawnGroups: [skadiGroup],
serverGameObjects: [],
validation: appendPrototypeWarning(
source,
"The standalone Skadi route is Healer Man-authored; source gauntlet waves, harpoon controls, Skadi combat, and the Utgarde Pinnacle environment are reused. Grauf’s flying vehicle phase remains adapted to ground-based harpoon interactions.",
"The short Skadi approach is Healer Man-authored; the imported guards, source boss combat, and Utgarde Pinnacle environment are reused.",
),
};
}
@@ -324,12 +324,12 @@
},
{
"id": "bite-sized-wailing-caverns",
"title": "Wailing Caverns: Naralex’s Rescue",
"finalBossId": "mutanus-spawn",
"title": "Wailing Caverns: Verdan’s Lair",
"finalBossId": "verdan-spawn",
"bosses": [
{
"id": "mutanus-spawn",
"name": "Mutanus the Devourer"
"id": "verdan-spawn",
"name": "Verdan the Everliving"
}
],
"difficulties": [
@@ -337,9 +337,9 @@
"id": "normal",
"name": "normal",
"bossIds": [
"mutanus-spawn"
"verdan-spawn"
],
"finalBossId": "mutanus-spawn"
"finalBossId": "verdan-spawn"
}
],
"defaultDifficultyId": "normal"
+2 -2
View File
@@ -48,8 +48,8 @@ interface WailingReferenceShape {
}
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
export const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
export const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
REFERENCE.disciple.escortWaypoints.map((waypoint) => waypoint.position),
);
const FANG_LORD_RUNTIME_IDS = Object.freeze([
+10 -45
View File
@@ -1,17 +1,10 @@
import { useFrame } from "@react-three/fiber";
import { useRef, useSyncExternalStore } from "react";
import { useRef } from "react";
import type { Group } from "three";
import { BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID } from "../game/dungeonContentSource";
import {
dispatchDungeonEncounterSignal,
dungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { useGameStore } from "../game/store";
import {
useWailingEncounterStore,
wailingDisciplePosition,
type WailingEncounterPhase,
} from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt";
import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
@@ -19,44 +12,23 @@ import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const sourcePhase = useWailingEncounterStore((state) => state.phase);
const sourcePhaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt);
const biteEncounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
() => dungeonEncounterSnapshot(biteEncounterId)?.revision ?? 0,
() => 0,
);
const biteSnapshot = dungeonEncounterSnapshot(biteEncounterId);
const phase = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phase ?? "ready"
: sourcePhase;
const phaseStartedAt = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phaseStartedAt ?? 0
: sourcePhaseStartedAt;
const isBiteSized = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID;
const phase = useWailingEncounterStore((state) => state.phase);
const phaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt);
const groupRef = useRef<Group>(null);
useFrame(() => {
if (
(activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return;
const liveSnapshot = isBiteSized ? dungeonEncounterSnapshot(biteEncounterId) : null;
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return;
const now = dungeonSimulationNow();
const position = wailingDisciplePosition(
(liveSnapshot?.phase ?? useWailingEncounterStore.getState().phase) as Parameters<typeof wailingDisciplePosition>[0],
liveSnapshot?.phaseStartedAt ?? useWailingEncounterStore.getState().phaseStartedAt,
useWailingEncounterStore.getState().phase,
useWailingEncounterStore.getState().phaseStartedAt,
now,
);
groupRef.current?.position.set(...position);
});
if (
(activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return null;
const initialPosition = wailingDisciplePosition(phase as WailingEncounterPhase, phaseStartedAt);
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return null;
const initialPosition = wailingDisciplePosition(phase, phaseStartedAt);
return (
<group ref={groupRef} name="disciple-of-naralex-event" position={initialPosition}>
<mesh position={[0, 0.9, 0]} castShadow>
@@ -76,21 +48,14 @@ export function WailingNaralexEvent() {
type="button"
onClick={(event) => {
event.stopPropagation();
if (isBiteSized) {
dispatchDungeonEncounterSignal(
`${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start`,
dungeonSimulationNow(),
);
} else {
useWailingEncounterStore.getState().start();
}
useWailingEncounterStore.getState().start();
}}
>
Begin the awakening
</button>
) : null}
{phase === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null}
{phase === "complete" ? <small>{isBiteSized ? "Naralex has been rescued" : "Naralex has awakened"}</small> : null}
{phase === "complete" ? <small>Naralex has awakened</small> : null}
</div>
</WorldNpcPrompt>
</group>
+3 -1
View File
@@ -1,16 +1,18 @@
import { useEffect, useState } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { useGameStore } from "../game/store";
import "./dungeonRace.css";
export function DungeonRaceHud() {
const race = useDungeonRaceStore();
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 100);
return () => window.clearInterval(interval);
}, []);
if (!race.inGame || !race.opponent) return null;
if (!race.inGame || !race.opponent || race.dungeonId !== activeDungeonId) return null;
const serverNow = now + race.serverOffset;
const remaining = race.startAt ? Math.max(0, Math.ceil((race.startAt - serverNow) / 1000)) : null;
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
+1 -1
View File
@@ -159,6 +159,6 @@ export function DungeonRunResults() {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay();
useShellStore.getState().returnToMainMenu();
if (raceRun) useDungeonRaceStore.getState().reset();
if (raceRun) void useDungeonRaceStore.getState().leave();
})(); }} />;
}
+6
View File
@@ -11,6 +11,7 @@ import { specializationById, specializationIconUrl } from "../game/specializatio
import { dungeonBossCards } from "../game/dungeonBossCards";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { DungeonBossCards } from "./DungeonBossCards";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
function PauseDialog() {
const closeOverlay = useGameStore((state) => state.closeOverlay);
@@ -31,9 +32,14 @@ function PauseDialog() {
closeOverlay();
};
const returnToEntrance = () => {
if (useDungeonRaceStore.getState().inGame) {
void useDungeonRaceStore.getState().leave();
useGameStore.setState({ raceStartBlocked: false });
}
reset();
};
const leaveDungeon = () => {
if (useDungeonRaceStore.getState().inGame) void useDungeonRaceStore.getState().leave();
closeOverlay();
returnToMainMenu();
};