Update Healer Man server
This commit is contained in:
1 parent
bd073fb00f
commit
83eac6d476
15 files changed
+438
-272
No files matched your search
+1
-1
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
})(); }} />;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user