Update Healer Man server
This commit is contained in:
1 parent
bd073fb00f
commit
83eac6d476
15 files changed
+438
-272
No files matched your search
@@ -4001,3 +4001,16 @@ Vite's existing large-chunk advisory remains informational.
|
||||
showed its reward window (`05-card-after-loss-fixture.png`). It did not
|
||||
traverse and defeat the final boss through live combat. Server tests verify
|
||||
that a losing clear is accepted for the dungeon time board.
|
||||
|
||||
## Dungeon Race exit regression (2026-09-26)
|
||||
|
||||
- `src/app/dungeonRaceStore.test.ts` covers immediate cleanup of active and
|
||||
practice races, cancellation during an in-flight queue request, ordered
|
||||
server leave before requeue, and an old preparation response after start.
|
||||
- `scripts/playtest-dungeon-race-exit.cjs` used headless Edge with a fresh
|
||||
online account. It left an NPC race through the pause menu, entered an
|
||||
ordinary Bite-Sized dungeon without a race HUD, and requeued into a new
|
||||
online match without an “already in a race” error. Screenshots are in
|
||||
`playtest-artifacts/dungeon-race-exit/`. The offline practice playtest also
|
||||
left after a modeled loss, confirmed the local race state cleared, and
|
||||
started practice again. Browser page errors were absent in both passes.
|
||||
@@ -12,31 +12,31 @@ Ratings:
|
||||
- **B — promising:** a useful slice exists, but topology, gates, optional routes, or event handling need more design.
|
||||
- **C — keep whole / no clean cut:** the full dungeon is already compact or event-led, or a cut would lose what makes the instance work.
|
||||
|
||||
The three first-pass candidates below are now registered as derived Bite-Sized dungeons. This is a content prototype, not a claim that the routes have passed live traversal: the corridor filters, collision, companion navigation, encounter starts, wipes, completion, and results still need an in-game pass.
|
||||
The three first-pass candidates below are registered as derived Bite-Sized dungeons. After the first play report, the Wailing escort was replaced with Verdan’s room fight, the Deadmines entry was moved onto Mr. Smite’s source spawn, and Skadi’s hidden start interaction was replaced with a guard clear. Collision, companion navigation, wipes, completion, and results still need an in-game pass.
|
||||
|
||||
| Prototype | Implemented slice | Open playtest question |
|
||||
| --- | --- | --- |
|
||||
| Wailing Caverns — Naralex’s Rescue | Starts at the Disciple’s camp, follows the imported escort waypoints, runs the source ravager/serpent/nightmare waves, then activates Mutanus. The four Fanglords are skipped. | Confirm the filtered trash corridor tracks the cave path and the moving Disciple stays reachable through the bends. |
|
||||
| The Deadmines — Take the Ship | Starts at Mr. Smite’s ship ramp, clears the ship population, gates VanCleef behind Captain Greenskin, then ends in the cabin. | Check the ship corridor boundary and make sure the omitted Cookie branch does not invite a misleading detour. |
|
||||
| Utgarde Pinnacle — Skadi’s Gauntlet | Starts at Eagle’s Eye, runs the source first wave, three harpoon interactions and finite reinforcements, then grounds and completes Skadi. | Check the long approach, interaction distances, and companion behavior around the event’s ground-based harpoon adaptation. |
|
||||
| Wailing Caverns — Verdan’s Lair | Starts inside Verdan’s chamber at a navigation-surface point. Three source cave mobs spawn in the room; defeating all three activates Verdan. Naralex and Mutanus remain only in the full dungeon. | Confirm the room population, Verdan activation, party targeting, and completion in play. |
|
||||
| The Deadmines — Take the Ship | Starts at Mr. Smite’s source floor position, clears the ship population, gates VanCleef behind Captain Greenskin, then ends in the cabin. | Confirm the new start is grounded and the ship route remains walkable. |
|
||||
| Utgarde Pinnacle — Skadi’s Gauntlet | Starts at Eagle’s Eye. Defeating the three nearby imported guards activates Skadi at a source wave position on the same approach. The long waves and harpoons are omitted from this short run. | Confirm Skadi appears within the approach and the fight completes without an interaction. |
|
||||
|
||||
Each derived ID resolves to its source dungeon’s content pack; the original full-length dungeon remains unchanged. The new entries are labeled Bite-Sized and beta until those playtests are recorded.
|
||||
Each derived ID resolves to its source dungeon’s content pack and map asset directory through `dungeonContentSourceId`; the original full-length dungeon remains unchanged. The new entries are labeled Bite-Sized and beta until those playtests are recorded.
|
||||
|
||||
## Best first prototypes
|
||||
|
||||
| Rank | Dungeon slice | Why it fits | Main implementation question |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | **Wailing Caverns — Naralex’s Rescue:** start at the Disciple’s event point, escort through the lower caves, defend Naralex through the waves, then fight Mutanus. | It already has the “journey to the final boss” shape: a route through recognizable cave spaces that builds to a protected NPC and a final monster. This matches the example you gave. | In the original route the event is unlocked after the Fang leaders and starts back near the entrance. For a short version, provide a clean event start/checkpoint and preserve the escort plus wave sequence. [Dungeon overview](https://warcraft.wiki.gg/wiki/Wailing_Caverns) |
|
||||
| 2 | **The Deadmines — Take the Ship:** enter at Mr. Smite’s ramp, clear the ship, fight Captain Greenskin, then finish Edwin VanCleef in the cabin. | One strong visual set piece, a straightforward forward route, and a natural boss finale. The optional Cookie detour can be left out. [Classic guide](https://warcraft.wiki.gg/wiki/Deadmines_%28Classic%29) |
|
||||
| 1 | **Wailing Caverns — Verdan’s Lair:** clear the chamber mobs, then fight Verdan the Everliving. | One navigable cave room and a clear boss reveal keep the run short. The Naralex escort remains a future, more complex candidate. | Verify the room floor, combat spacing, and boss activation in play. [Dungeon overview](https://warcraft.wiki.gg/wiki/Wailing_Caverns) |
|
||||
| 2 | **The Deadmines — Take the Ship:** enter at Mr. Smite’s deck position, clear the ship, fight Captain Greenskin, then finish Edwin VanCleef in the cabin. | One strong visual set piece, a straightforward forward route, and a natural boss finale. The optional Cookie detour can be left out. [Classic guide](https://warcraft.wiki.gg/wiki/Deadmines_%28Classic%29) |
|
||||
| 3 | **Zul’Farrak — The Staircase:** start at the pyramid event, survive Sergeant Bly’s descending waves, and finish at Chief Ukorz’s chamber. | The stair defense is already a compact wave encounter with a clear spatial progression; it could be a bite-sized run by itself or a short prelude into a boss. | Choose whether Bly and his group are protected participants or replace the event with a controlled wave sequence; the event’s outcome opens the Ukorz route. [Instance map and event guide](https://ptgmedia.pearsoncmg.com/imprint_downloads/brady/wow/zulfarrak/zulfarrak_lr.pdf) |
|
||||
| 4 | **The Blood Furnace — Broggok’s Breakout:** begin at the prison cells, release the waves, then push through the final corridor to Keli’dan. | A tight industrial route with a built-in escalation and a boss payoff. The map is compact and the three encounters already form a clean progression. [Dungeon overview](https://warcraft.wiki.gg/wiki/Blood_Furnace) |
|
||||
| 5 | **Utgarde Pinnacle — Skadi’s Gauntlet:** start as Skadi retreats, fight down Eagle’s Eye through the advancing packs, collect harpoons, and bring Skadi down. | One of WoW’s clearest self-contained dungeon set pieces: moving waves, a visible target, a hazard lane, an objective pickup, and a boss. It can end at Skadi without carrying the whole dungeon. [Encounter guide](https://www.icy-veins.com/wotlk-classic/utgarde-pinnacle-dungeon-guide) |
|
||||
| 5 | **Utgarde Pinnacle — Skadi’s Gauntlet:** defeat the guards at Eagle’s Eye, then fight Skadi on the approach. | The short implementation gives the route an immediate, visible boss payoff. A full moving-wave and harpoon event remains a separate candidate. [Encounter guide](https://www.icy-veins.com/wotlk-classic/utgarde-pinnacle-dungeon-guide) |
|
||||
| 6 | **Pit of Saron — The Quarry Breakout:** free the enslaved workers, clear the ambushes and tunnel, then confront Tyrannus. | A linear outdoor quarry gives the route space to breathe, and the rescue objective gives a strong reason to advance. The original instance already frames the final boss around freeing the slaves. [Dungeon overview](https://warcraft.wiki.gg/wiki/Pit_of_Saron) |
|
||||
| 7 | **The Shattered Halls — Kargath’s Gauntlet:** start around Warbringer O’Mrogg, push through the final gauntlet, and finish in Kargath’s arena. | A direct late-dungeon route with a clear arena payoff. It works best if the final hallway pressure is the feature, not just a boss rush. |
|
||||
| 8 | **Shadow Labyrinth — The Screaming Hall:** begin around Grandmaster Vorpil’s chamber and advance along the final hall to Murmur. | The project map shows a long, distinct finale corridor, and the two-boss ending provides a natural short arc without needing to reproduce the whole labyrinth. [Dungeon overview](https://warcraft.wiki.gg/wiki/Shadow_Labyrinth) |
|
||||
| 9 | **Halls of Lightning — Ionar to Loken:** use the storm overlook and the route into Loken’s sanctum as the final ascent. | Strong visual progression from open storm space to titan interior; Ionar already guards the entrance to Loken’s sanctum. [Ionar’s encounter context](https://warcraft.wiki.gg/wiki/Ionar) |
|
||||
|
||||
**My pick for the first batch:** Wailing Caverns, Deadmines, and Skadi’s Gauntlet. Together they give three distinct forms—escort-and-defense, a compact boss route, and an event gauntlet—while staying close to routes the source game already supports.
|
||||
**Current first batch:** Verdan’s Lair, Take the Ship, and Skadi’s Gauntlet. These now use short room and route progressions with explicit boss activation.
|
||||
|
||||
## Full 51-dungeon scan
|
||||
|
||||
@@ -45,7 +45,7 @@ Each derived ID resolves to its source dungeon’s content pack; the original fu
|
||||
| Classic | Shadowfang Keep | Fenrus through Wolf Master Nandos and up to Arugal’s tower. | B |
|
||||
| Classic | Stormwind Stockade | The existing central Cellblock Rush is already the reference design. | C |
|
||||
| Classic | The Deadmines | Mr. Smite’s ship ramp through Greenskin to VanCleef’s cabin. | A |
|
||||
| Classic | Wailing Caverns | Naralex escort route, defense waves, and Mutanus. | A |
|
||||
| Classic | Wailing Caverns | Verdan’s chamber fight now; Naralex escort is deferred. | A |
|
||||
| Classic | Razorfen Kraul | The deeper boar warrens and Charlga’s area; the open, branching route needs a firm start boundary. | B |
|
||||
| Classic | Blackfathom Deeps | Kelris’s altar ritual and the opened route to Aku’mai. | B |
|
||||
| Classic | Uldaman | The late titan-vault approach to Archaedas. | B |
|
||||
@@ -78,7 +78,7 @@ Each derived ID resolves to its source dungeon’s content pack; the original fu
|
||||
| Burning Crusade | The Black Morass | Keep the full portal-defense event; splitting it would remove the core mechanic. | C |
|
||||
| Burning Crusade | The Escape from Durnholde | Keep the full escort-and-escape story; it is already a self-contained event run. | C |
|
||||
| Wrath | Utgarde Keep | Keep the full short three-boss route. | C |
|
||||
| Wrath | Utgarde Pinnacle | Eagle’s Eye wave gauntlet, harpoons, and Skadi. | A |
|
||||
| Wrath | Utgarde Pinnacle | Eagle’s Eye guard clear and Skadi; the full wave and harpoon event is deferred. | A |
|
||||
| Wrath | The Nexus | A single mage/arcane wing could work, but the boss route crosses a hub; map a route before choosing a cut. | B |
|
||||
| Wrath | The Oculus | No first choice: drake riding and multi-level flight are the dungeon’s identity and make a small walking route misleading. | C |
|
||||
| Wrath | The Culling of Stratholme | Keep the whole compact timed story run, including its city push and final encounter. | C |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
|
||||
const out = path.resolve('playtest-artifacts/dungeon-race-exit');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
async function raceState(page) {
|
||||
return page.evaluate(async () => {
|
||||
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
|
||||
const race = useDungeonRaceStore.getState();
|
||||
return { mode: race.mode, status: race.status, inGame: race.inGame,
|
||||
matchId: race.matchId, opponentType: race.opponent?.type, error: race.error };
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveGame(page) {
|
||||
await page.evaluate(async () => {
|
||||
const { useGameStore } = await import('/src/game/store.ts');
|
||||
useGameStore.getState().openOverlay('pause');
|
||||
});
|
||||
await page.getByRole('dialog', { name: 'Paused' }).getByRole('button', { name: 'Main menu' }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.stack || error.message));
|
||||
try {
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
||||
await page.getByLabel('Username').fill(`RaceExit${Date.now().toString(36)}`.slice(0, 20));
|
||||
await page.getByLabel('Password').fill('raceTestPassword123');
|
||||
await page.getByRole('button', { name: /Create account/ }).click();
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Customize & name/ }).click();
|
||||
await page.getByLabel('Character name').fill('ExitTester');
|
||||
await page.getByRole('button', { name: /Create character/ }).click();
|
||||
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
|
||||
await page.getByRole('button', { name: 'Continue', exact: true }).click({ force: true });
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
|
||||
await page.getByRole('button', { name: /Dungeon Race/ }).click();
|
||||
await page.locator('.dungeon-race-screen__setup select').first()
|
||||
.selectOption('bite-sized-stormwind-stockade');
|
||||
await page.getByRole('button', { name: 'Enter race queue' }).click();
|
||||
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
await page.locator('.dungeon-race-wait').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
await page.locator('.dungeon-race-countdown').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
const first = await raceState(page);
|
||||
assert.equal(first.opponentType, 'bot');
|
||||
await page.screenshot({ path: path.join(out, '01-ranked-race.png') });
|
||||
|
||||
await leaveGame(page);
|
||||
const afterExit = await raceState(page);
|
||||
assert.deepEqual(afterExit, { mode: null, status: 'idle', inGame: false,
|
||||
matchId: null, opponentType: undefined, error: '' });
|
||||
await page.screenshot({ path: path.join(out, '02-main-menu-after-exit.png') });
|
||||
|
||||
await page.locator('.main-menu-mode strong').getByText('Dungeons', { exact: true }).click();
|
||||
await page.locator('.dungeon-category-tab').filter({ hasText: 'Bite-Sized' }).click();
|
||||
await page.locator('.dungeon-select-row').filter({ hasText: 'Cellblock Rush' }).click();
|
||||
await page.locator('.dungeon-select-enter').click();
|
||||
await page.locator('.dungeon-role-option').first().click();
|
||||
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
|
||||
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120_000 });
|
||||
assert.equal(await page.locator('.dungeon-race-hud').count(), 0);
|
||||
await page.screenshot({ path: path.join(out, '03-ordinary-dungeon-no-race-hud.png') });
|
||||
|
||||
await leaveGame(page);
|
||||
await page.getByRole('button', { name: /Dungeon Race/ }).click();
|
||||
await page.locator('.dungeon-race-screen__setup select').first()
|
||||
.selectOption('bite-sized-stormwind-stockade');
|
||||
await page.getByRole('button', { name: 'Enter race queue' }).click();
|
||||
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
|
||||
const second = await raceState(page);
|
||||
assert.equal(second.mode, 'online');
|
||||
assert.equal(second.inGame, true);
|
||||
assert.ok(second.matchId);
|
||||
assert.notEqual(second.matchId, first.matchId);
|
||||
assert.equal(second.error, '');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ ok: true, first, afterExit, second, screenshots: out }, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(out, 'failure.png') });
|
||||
console.error((await page.locator('body').innerText()).slice(0, 5000));
|
||||
throw error;
|
||||
} finally { await browser.close(); }
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -79,8 +79,21 @@ const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
|
||||
assert.equal(card.after, card.before + 1);
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: path.join(out, '05-card-after-loss-fixture.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByRole('dialog', { name: 'Paused' }).getByRole('button', { name: 'Main menu' }).click();
|
||||
await page.locator('.main-menu-screen').waitFor();
|
||||
const afterExit = await page.evaluate(async () => {
|
||||
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
|
||||
const race = useDungeonRaceStore.getState();
|
||||
return { mode: race.mode, inGame: race.inGame, matchId: race.matchId };
|
||||
});
|
||||
assert.deepEqual(afterExit, { mode: null, inGame: false, matchId: null });
|
||||
await page.getByRole('button', { name: /Dungeon Race/ }).click();
|
||||
await page.getByRole('button', { name: 'Start practice' }).click();
|
||||
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
|
||||
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(JSON.stringify({ ok: true, state, card, screenshots: out }, null, 2));
|
||||
console.log(JSON.stringify({ ok: true, state, card, afterExit, screenshots: out }, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: path.join(out, 'failure.png') });
|
||||
console.error((await page.locator('body').innerText()).slice(0, 5000));
|
||||
|
||||
+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