Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 21:48:00 -04:00
1 parent 15a5254380
commit 9d7e0e2f9e
71 files changed
+3490 -81

No files matched your search

+27
View File
@@ -4,6 +4,33 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-09-27 Bite-Sized spot editor
- `scripts/playtest-bite-sized-editor.cjs` used the real app in headless Edge
with an isolated temporary API database and GM account. It created a named
Stockades spot, added an imported Forsaken Abbey Carrion Spider, duplicated
and removed an individual placement, captured anchors, and published through
the GM API. The scene showed textured imported mobs and selectable placement
rings; the collapsible scrolling panel kept its publish controls accessible.
- WoW Stockades and RoM Forsaken Abbey editing loaded grounded players, normal
dungeon geometry and textures, mob previews, and five party-seat markers.
Evidence: `playtest-artifacts/bite-sized-editor/wow-editor.png`,
`cross-dungeon-preview.png`, and `rom-editor.png`.
- In draft test runs, the after-mobs boss stayed inactive until the trash died;
the present-from-start boss was active immediately. Injected lethal combat
damage exercised encounter completion, and the editor displayed Test complete
with an untimed run. This verifies activation/completion, not encounter balance
or a manually played full clear. Evidence: `boss-after-mobs.png` and
`boss-present.png` in the same directory.
- Focusing the spot-name field prevented W movement. After collapsing the panel,
right drag changed camera yaw, pointer lock stayed null, and releasing the
button stopped camera changes. No mouse-capture prompt or page error occurred.
- Randomized runs entered both the Stockades and Forsaken Abbey parent maps with
ready assets and pinned creature assignments. Evidence:
`randomized-stormwind-stockade.png`, `randomized-forsaken-abbey.png`, and
`observations.json`. These are local isolated runs; no production layouts were
published by this check.
## 2026-09-27 Shadowfiend summon visibility
- The dedicated `scripts/combat-effects/shadowfiend-preview.html` page cast the
+90
View File
@@ -0,0 +1,90 @@
# Bite-Sized spot authoring
Open **GM Menu → Bite-Sized Editor**, select a dungeon and spot, and use **Go to spot**.
GM administration also provides the same entry. A selected character and an online
GM account are required. Leave an online activity or Dungeon Race before editing.
The scene uses the normal renderer, movement, and hold-right-drag camera. The right
panel scrolls and collapses to leave the world visible. Focusing a panel control
suspends movement and camera input. Click a world marker or collapse the panel to
return to placement.
- Select Party start, Random boss spawn, or an individual mob point. Walk to a
grounded position and press **Set here**, edit coordinates/facing, or use
**Go to point**. Cyan markers show all five party seats, yellow markers show
mobs, and the red marker shows the random boss anchor.
- The searchable mob picker starts with the full parent dungeon. **Other dungeons**
includes imported mobs and their source names. Each point is independent;
repeated creature types are supported. Assets are prepared before previews.
- New spots start empty with **After all mobs die**. A zero-mob layout activates
its boss immediately. **Present from start** activates it alongside the mobs.
Ordinary bosses always come from the parent dungeon and are rolled per run.
- New spots use the parent's full geometry without the original artificial
branch closures. Duplicates retain the copied spot's legacy closures.
- **Test run** exercises the draft with combat, completion, and no saved rewards,
progression, checkpoints, or rankings. **Return to editing** starts another
placement session. A test completion appears in the panel.
- **Save & publish** validates the draft's references, IDs, transforms, grounded
anchors and reachable navigation. Keep at least one spot in every dungeon.
Imported layouts also validate their retained permanent branch closures.
A revision conflict or download/validation failure leaves the draft intact.
**Refresh published** preserves a dirty draft; **Discard** restores published
data. Drafts are held in this client session until published or discarded.
## Runtime and persistence
`shared/bite-sized-layouts.mjs` owns normalization, gameplay identity, and independent
deterministic location, spot, mob and boss rolls. Ordinary runs choose a spot
uniformly within their dungeon and retain its creature types. Randomized activities
keep the two existing source pools, choose an eligible parent dungeon and spot,
then deal rolled creatures into those placements without replacement until a pool
is exhausted. Both activity IDs remain unchanged for saves and network protocols.
`BiteSizedRunSelection` includes the complete immutable layout snapshot, its stable
ID/gameplay revision, parent source, boss and resolved mob references. Checkpoints,
online activities and race matches pin it. Publishing affects new runs; retries,
offline cached resumes, party members and race opponents retain their snapshot.
Legacy checkpoints keep the original layout, and legacy randomized runs preserve
the original Stockades creature deal.
`src/game/biteSizedLayoutRuntime.ts` materializes placements over the selected parent
definition. `contentSourceDungeonId` controls environment, navigation and artwork
resolution. `src/app/biteSizedLayoutContent.ts` verifies that parent's content and
selected creature assets before entry and retains a bounded preparation cache.
Published configuration is cached in local storage; checkpoint snapshots do not
depend on the latest configuration being available online.
`server/bite-sized-layouts.mjs` owns SQLite `bite_sized_layout_config` and immutable
`bite_sized_spot_revisions`. Public reads use `/api/bite-sized-layouts`; authenticated
GM reads/writes use `/api/gm/bite-sized-layouts`. PUT accepts `{ expectedRevision,
config }`. `?spotId=<id>&revision=<number>` reads archived data, including removed
spots; `?history=1` lists historical revisions. Gameplay edits create timing
revisions, while renames retain the timing revision and immutable historical data.
Dungeon and race clear-time rankings include spot ID/revision alongside existing
boss/difficulty dimensions. Existing results migrate to the original default spot,
revision zero. Results carry spot names; timing screens expose spot and revision
filters. Overall race wins keep their aggregate dimensions.
## Generated owners and verification
Run `npm run bitesized:generate` after changing parent definitions, supported source
creatures, or navigation assets. `scripts/generate-bite-sized-layouts.ts` emits
`shared/bite-sized-defaults.json` and `shared/bite-sized-catalog.json` from the
authoritative registry/templates. `scripts/generate-bite-sized-navigation.ts`
emits a compressed API-side navigation surface/reachability index and bundles the
same TypeScript floor validator into `server/generated/`. These artifacts must ship
with the API server; the server does not need rendering downloads for validation.
Focused coverage lives in `src/game/biteSizedLayouts.test.ts`,
`src/game/biteSizedLayoutsStore.test.ts`, `src/app/biteSizedLayoutContent.test.ts`
and `server/bite-sized-layouts.test.mjs`, alongside the existing dungeon, content,
checkpoint, online, race and input tests. Run `npm run build` and
`npm run release:check` before delivery.
`node scripts/playtest-bite-sized-editor.cjs` runs Edge against an isolated temporary
API database and the dev app at port 5173. Override `BITE_SIZED_EDITOR_URL` when
using another dev port. It records WoW/RoM authoring, imported previews, both boss
settings, completion and unranked safeguards, panel input gating, right-drag release
without pointer lock, and randomized entry into distinct parent maps under
`playtest-artifacts/bite-sized-editor/`.
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"bitesized:generate": "vite-node scripts/generate-bite-sized-layouts.ts && vite-node scripts/generate-bite-sized-navigation.ts",
"dev:shelved": "vite --host 0.0.0.0 --mode shelved",
"start": "node server/server.mjs",
"db:init": "node scripts/init-game-database.mjs",
+35
View File
@@ -0,0 +1,35 @@
import { writeFileSync } from "node:fs";
import { DUNGEON_DEFINITIONS, AVAILABLE_RANDOMIZED_CREATURE_CATALOG, BITE_SIZED_RANDOMIZED_CREATURE_CATALOG } from "../src/game/dungeonRegistry";
import { dungeonContentSourceId, isBiteSizedDungeonId, isRandomizedBiteSizedDungeonId } from "../src/game/dungeonContentSource";
import { sourceBossCandidates } from "../src/game/dungeons/sourceBossCandidates";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "../src/game/dungeons/biteSizedStockades";
const layouts = DUNGEON_DEFINITIONS.filter((d) => isBiteSizedDungeonId(d.id) && !isRandomizedBiteSizedDungeonId(d.id));
const sources = DUNGEON_DEFINITIONS.filter((d) => !isBiteSizedDungeonId(d.id));
const defaults = layouts.map((d) => {
const sourceDungeonId = dungeonContentSourceId(d.id);
const bossIds = new Set(d.bosses.map((b) => b.id));
const boss = [...d.staticSpawns, ...(d.encounterSpawnGroups ?? []).flatMap((g) => g.spawns)].find((s) => bossIds.has(s.id))!;
return { id: `${d.id}:default`, dungeonId: d.id, sourceDungeonId, name: d.title.split(": ").slice(1).join(": ") || d.title,
revision: 0, legacyLayout: true, partySpawn: { footPosition: d.entrance.footPosition, yaw: d.entrance.yaw },
mobs: d.staticSpawns.filter((s) => !bossIds.has(s.id)).map((s) => ({ ...s, sourceDungeonId })),
bossSpawn: { footPosition: boss.position, yaw: boss.yaw ?? 0 }, bossActivation: d.bosses[0].eventOnly ? "after-mobs" : "immediate" };
});
const metadata = layouts.map((d) => {
const source = sources.find((s) => s.id === dungeonContentSourceId(d.id))!;
const candidates = sourceBossCandidates(source);
return { id: d.id, sourceDungeonId: source.id, title: source.title, expansion: source.expansion,
bounds: source.bounds, difficultyIds: source.difficultyVariants.map((v) => v.id),
legacyBlockers: d.serverGameObjects.filter((object) => !object.interaction && object.states.find((state) => state.id === object.initialState)?.blocksMovement)
.map((object) => ({ position: object.position, yaw: object.yaw ?? 0, halfExtents: object.halfExtents })),
bossCandidates: (source.id === "stormwind-stockade" ? BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((b) => candidates.find((c) => c.objective.id === b.sourceSpawnId)!) : candidates)
.map((b) => ({ sourceDungeonId: source.id, entityId: b.spawn.entityId, objectiveId: b.objective.id, name: b.objective.name })) };
});
const creatures = sources.flatMap((d) => Object.values(d.entities).map((e) => ({ sourceDungeonId: d.id, entityId: e.id, kind: e.kind,
name: e.name, dungeonTitle: d.title, modelUrl: e.visual.model?.url ?? null })));
const pool = (entries: typeof AVAILABLE_RANDOMIZED_CREATURE_CATALOG) => entries.map((e) => ({ sourceDungeonId: e.sourceDungeonId, entityId: e.entityId,
objectiveId: `randomized:${e.key}`, key: e.key, name: e.name, kind: e.kind }));
writeFileSync(new URL("../shared/bite-sized-defaults.json", import.meta.url), `${JSON.stringify({ schemaVersion: 1, spots: defaults }, null, 2)}\n`);
writeFileSync(new URL("../shared/bite-sized-catalog.json", import.meta.url), `${JSON.stringify({ dungeons: metadata, creatures,
pools: { "randomized-bite-sized-stormwind-stockade": pool(AVAILABLE_RANDOMIZED_CREATURE_CATALOG),
"randomized-bite-sized-stormwind-stockade-available": pool(BITE_SIZED_RANDOMIZED_CREATURE_CATALOG) } })}\n`);
+63
View File
@@ -0,0 +1,63 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { gzipSync } from "node:zlib";
import { build } from "esbuild";
import { Group, Mesh } from "three";
import { DUNGEON_REGISTRY } from "../src/game/dungeonRegistry";
import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs";
import { preparePartyNavigationGraph } from "../src/game/partyNavigationAssetCache";
import { buildPartyNavigationTriangleNodes } from "../src/game/partyNavigation";
import { biteSizedRouteSegmentClear } from "../src/game/biteSizedSpotNavigation";
import { readDungeonGeometry } from "./dungeon-pipeline/wow-stair-geometry";
// Ship the exact gameplay navigation validator and graphs with the API server.
// The server does not need the downloadable rendering assets to validate a draft.
mkdirSync("server/generated", { recursive: true });
const graphs: Record<string, unknown> = {};
for (const entry of BITE_SIZED_LAYOUT_CATALOG.dungeons) {
const source = DUNGEON_REGISTRY[entry.sourceDungeonId];
const asset = source.assets.navigation!;
const file = asset.url?.startsWith("/") ? `public${asset.url}` : `src/assets/game/dungeons/${source.id}/${asset.fileName}`;
const geometries = await readDungeonGeometry(file);
const scene = new Group();
geometries.forEach((g) => scene.add(new Mesh(g)));
const graph = preparePartyNavigationGraph(source.id, scene, source.navigationLinks, source.traversalRamps, 1_000_000, true);
const triangleNodes = graph.triangleNodes ?? buildPartyNavigationTriangleNodes(graph.triangles!, graph.nodes.map((n) => n.position));
const regions = new Int32Array(triangleNodes.length).fill(graph.triangleNodes ? -1 : 0);
let region = 0;
for (let i = 0; i < triangleNodes.length; i++) {
if (regions[i] !== -1) continue;
const stack = [i]; regions[i] = region;
while (stack.length) {
const current = stack.pop()!;
for (const edge of triangleNodes[current].neighbors) if (regions[edge.to] === -1) { regions[edge.to] = region; stack.push(edge.to); }
}
region++;
}
let legacyRegions: number[] | undefined;
if (entry.legacyBlockers.length) {
const filtered = new Int32Array(triangleNodes.length).fill(-1);
let component = 0;
for (let i = 0; i < triangleNodes.length; i++) {
if (filtered[i] !== -1) continue;
const stack = [i]; filtered[i] = component;
while (stack.length) {
const current = stack.pop()!;
for (const edge of triangleNodes[current].neighbors) {
if (filtered[edge.to] !== -1 || !biteSizedRouteSegmentClear(entry.legacyBlockers, triangleNodes[current].centroid, triangleNodes[edge.to].centroid)) continue;
filtered[edge.to] = component; stack.push(edge.to);
}
}
component++;
}
legacyRegions = Array.from(filtered);
}
// Publication needs a surface and reachability components, not A* costs and
// portals for every triangle. Keep four-decimal coordinate precision.
graphs[source.id] = { nodes: graph.nodes.map((n) => ({ id: n.id, position: n.position.map((v) => Math.round(v * 10_000) / 10_000), neighbors: [] })),
triangles: graph.triangles, regions: Array.from(regions), legacyRegions };
geometries.forEach((g) => g.dispose());
console.log(`Navigation: ${source.id}`);
}
writeFileSync("server/generated/bite-sized-navigation.json.gz", gzipSync(JSON.stringify(graphs), { level: 9 }));
await build({ entryPoints: ["src/game/biteSizedSpotNavigation.ts"], bundle: true, platform: "node", format: "esm",
outfile: "server/generated/bite-sized-navigation.mjs", banner: { js: "// Generated by scripts/generate-bite-sized-navigation.ts; edit the shared TypeScript source." } });
+188
View File
@@ -0,0 +1,188 @@
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 os = require('node:os');
const out = path.resolve('playtest-artifacts/bite-sized-editor');
fs.mkdirSync(out, { recursive: true });
const observations = [];
async function state(page) {
return page.evaluate(async () => {
const { useGameStore } = await window.playtestImport('/src/game/store.ts');
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
const { useDungeonRunStore } = await window.playtestImport('/src/game/dungeonRunStore.ts');
const game = useGameStore.getState(), layouts = useBiteSizedLayoutsStore.getState();
return { yaw: game.cameraYaw, position: game.playerPosition, grounded: game.playerGrounded, assetStatus: game.assetStatus,
mode: layouts.editorMode, point: layouts.editingPointId, focused: layouts.panelFocused, dirty: layouts.dirty,
run: useDungeonRunStore.getState().progress, pointerLock: document.pointerLockElement !== null };
});
}
async function ready(page) {
await page.locator('.game-shell').waitFor({ timeout: 120000 });
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 120000 });
await page.waitForFunction(async () => {
const { useGameStore } = await window.playtestImport('/src/game/store.ts');
return useGameStore.getState().assetStatus === 'ready';
}, null, { timeout: 120000 });
await page.waitForTimeout(1200);
console.log(`Ready: ${(await state(page)).mode || 'run'}`);
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-editor-playtest-'));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
const auth = await (await fetch(`${origin}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'codex_dev_gm', password: 'local-playtest-password' }) })).json();
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
await page.addInitScript(() => {
// Vite app imports may carry an HMR timestamp even in a fresh browser.
// Reuse that exact URL so inspection never creates a second Zustand store.
window.playtestImport = (modulePath) => {
const url = performance.getEntriesByType('resource').find((entry) => new URL(entry.name).pathname === modulePath)?.name;
return import(url || modulePath);
};
});
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/api/**', async (route) => {
const request = route.request();
const response = await route.fetch({ url: `${origin}${new URL(request.url()).pathname}${new URL(request.url()).search}` });
await route.fulfill({ response });
});
try {
await page.goto(process.env.BITE_SIZED_EDITOR_URL || 'http://127.0.0.1:5173', { waitUntil: 'domcontentloaded', timeout: 120000 });
await page.getByLabel('Username', { exact: true }).fill('codex_dev_gm');
await page.getByLabel('Password', { exact: true }).fill('local-playtest-password');
await page.getByRole('button', { name: /Enter world/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('SpotTester');
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: /GM Menu/ }).click();
await page.getByRole('button', { name: /Bite-Sized Editor/ }).click();
const panel = page.locator('.bite-sized-editor');
await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-stormwind-stockade');
await panel.getByRole('button', { name: 'Go to spot', exact: true }).click();
await ready(page);
assert.equal((await state(page)).mode, 'edit');
assert.equal((await state(page)).grounded, true);
await page.screenshot({ path: path.join(out, 'wow-editor.png') });
await panel.getByRole('button', { name: 'New spot', exact: true }).click();
await ready(page);
await panel.getByLabel('Spot name', { exact: true }).fill('Playtest new spot');
await panel.getByLabel('Other dungeons', { exact: true }).check();
await panel.getByLabel('Search mobs', { exact: true }).fill('Forsaken');
await panel.getByRole('button', { name: 'Add mob here', exact: true }).click();
await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).waitFor();
await panel.getByRole('button', { name: 'Duplicate mob point', exact: true }).click();
await panel.getByRole('button', { name: 'Remove mob point', exact: true }).click();
await panel.getByRole('button', { name: 'Random boss spawn', exact: true }).click();
await panel.getByRole('button', { name: 'Set here', exact: true }).click();
await page.screenshot({ path: path.join(out, 'cross-dungeon-preview.png') });
await panel.getByRole('button', { name: 'Save & publish', exact: true }).click();
await page.waitForFunction(async () => {
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
return !useBiteSizedLayoutsStore.getState().saving && !useBiteSizedLayoutsStore.getState().dirty;
}, null, { timeout: 120000 });
observations.push({ name: 'publish new spot with imported mob', state: await state(page) });
await panel.getByRole('button', { name: 'Test run', exact: true }).click();
await ready(page);
assert.equal((await state(page)).run.timed, false);
assert.equal((await state(page)).mode, 'test');
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), false, 'The after-mobs boss must be absent before the trash dies');
await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
const combat = useCombatStore.getState();
Object.values(combat.mobs).filter((m) => !m.boss).forEach((m) => combat.damageMob(m.id, 1e9));
});
await page.waitForTimeout(1200);
await page.screenshot({ path: path.join(out, 'boss-after-mobs.png') });
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), true, 'The boss must activate after mobs die');
await panel.getByRole('button', { name: 'Return to editing', exact: true }).click();
await ready(page);
await panel.getByLabel('Boss arrival', { exact: true }).selectOption('immediate');
await panel.getByRole('button', { name: 'Test run', exact: true }).click();
await ready(page);
await page.screenshot({ path: path.join(out, 'boss-present.png') });
assert.equal(await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
return Object.values(useCombatStore.getState().mobs).some((m) => m.boss && !m.dead);
}), true, 'The immediate boss must be present');
await page.evaluate(async () => {
const { useCombatStore } = await window.playtestImport('/src/game/combatStore.ts');
const combat = useCombatStore.getState(); Object.values(combat.mobs).forEach((m) => combat.damageMob(m.id, 1e9));
});
await panel.getByText(/Test complete/).waitFor({ timeout: 15000 });
observations.push({ name: 'both boss modes unranked test', state: await state(page) });
await panel.getByRole('button', { name: 'Return to editing', exact: true }).click();
await ready(page);
await panel.getByLabel('Spot name', { exact: true }).focus();
const before = await state(page);
await page.keyboard.down('w'); await page.waitForTimeout(350); await page.keyboard.up('w');
assert.ok(Math.hypot(...(await state(page)).position.map((v, i) => v - before.position[i])) < .1, 'Panel focus must gate movement');
await panel.getByRole('button', { name: 'Collapse editor' }).click();
await page.mouse.move(550, 380); await page.mouse.down({ button: 'right' });
const yaw = (await state(page)).yaw;
await page.mouse.move(650, 395, { steps: 8 });
await page.waitForTimeout(300);
assert.notEqual((await state(page)).yaw, yaw);
assert.equal((await state(page)).pointerLock, false);
await page.mouse.up({ button: 'right' }); await page.waitForTimeout(100);
const released = (await state(page)).yaw;
await page.mouse.move(700, 420, { steps: 4 }); await page.waitForTimeout(300);
assert.equal((await state(page)).yaw, released);
observations.push({ name: 'panel input gating and right drag, no pointer lock', state: await state(page) });
await panel.getByRole('button', { name: 'Spot browser', exact: true }).click();
await panel.getByLabel('Dungeon', { exact: true }).selectOption('bite-sized-forsaken-abbey-ancalon');
await panel.getByRole('button', { name: 'Go to spot', exact: true }).click();
await ready(page);
await page.screenshot({ path: path.join(out, 'rom-editor.png') });
observations.push({ name: 'RoM editing', state: await state(page) });
await panel.getByRole('button', { name: 'Spot browser', exact: true }).click();
await panel.getByRole('button', { name: 'Main menu', exact: true }).click();
for (const source of ['stormwind-stockade', 'forsaken-abbey']) {
await page.evaluate(async (source) => {
const { selectBiteSizedRun } = await window.playtestImport('/shared/bite-sized-layouts.mjs');
const { useBiteSizedLayoutsStore } = await window.playtestImport('/src/game/biteSizedLayoutsStore.ts');
const { prepareBiteSizedLayoutContent } = await window.playtestImport('/src/app/biteSizedLayoutContent.ts');
const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts');
const { installDungeonShellRuntime } = await window.playtestImport('/src/app/dungeonShellRuntime.ts');
const id = 'randomized-bite-sized-stormwind-stockade-available', seed = `playtest-${source}`;
const parent = useBiteSizedLayoutsStore.getState().config.spots.find((s) => s.sourceDungeonId === source).dungeonId;
const selection = selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, { eligibleDungeonIds: [parent] });
await prepareBiteSizedLayoutContent(id, seed, selection); installDungeonShellRuntime();
useShellStore.getState().enterDungeon(id, 'healer', { freshRun: true, runId: seed, biteSizedSelection: selection });
}, source);
await ready(page);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 30000 });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(out, `randomized-${source}.png`) });
observations.push({ name: `randomized ${source}`, state: await state(page) });
await page.evaluate(async () => { const { useShellStore } = await window.playtestImport('/src/app/shellStore.ts'); useShellStore.getState().returnToMainMenu(); });
await page.locator('.main-menu-screen').waitFor();
}
assert.deepEqual(errors, []);
fs.writeFileSync(path.join(out, 'observations.json'), `${JSON.stringify({ observations, errors }, null, 2)}\n`);
console.log(`Passed ${observations.length} live checks. Screenshots: ${out}`);
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(-2400));
console.error(errors.slice(-8)); throw error;
} finally {
await browser.close(); await new Promise((resolve) => runtime.server.close(resolve));
fs.rmSync(dataDir, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+77
View File
@@ -0,0 +1,77 @@
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, normalizeBiteSizedConfig, normalizeBiteSizedSelection, biteSizedGameplayKey, selectBiteSizedRun } from "../shared/bite-sized-layouts.mjs";
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
import { validateBiteSizedSpotNavigation } from "./generated/bite-sized-navigation.mjs";
const navigationGraphs = JSON.parse(gunzipSync(readFileSync(new URL("./generated/bite-sized-navigation.json.gz", import.meta.url))).toString("utf8"));
export function createBiteSizedLayoutRepository(database, { ErrorType, isGameMaster }) {
database.exec(`CREATE TABLE IF NOT EXISTS bite_sized_layout_config (
id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL, updated_at INTEGER, config_json TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS bite_sized_spot_revisions (
spot_id TEXT NOT NULL, revision INTEGER NOT NULL, spot_json TEXT NOT NULL, PRIMARY KEY(spot_id, revision)
) STRICT;`);
database.prepare("INSERT OR IGNORE INTO bite_sized_layout_config VALUES (1, 0, NULL, ?)").run(JSON.stringify(normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG)));
const archive = database.prepare("INSERT OR IGNORE INTO bite_sized_spot_revisions VALUES (?, ?, ?)");
for (const spot of normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG).spots) archive.run(spot.id, spot.revision, JSON.stringify(spot));
function getBiteSizedLayouts() {
const row = database.prepare("SELECT * FROM bite_sized_layout_config WHERE id = 1").get();
return { revision: row.revision, updatedAt: row.updated_at, config: normalizeBiteSizedConfig(JSON.parse(row.config_json)) };
}
function getBiteSizedSpotRevision(id, revision) {
const row = database.prepare("SELECT spot_json FROM bite_sized_spot_revisions WHERE spot_id = ? AND revision = ?").get(id, revision);
if (!row) throw new ErrorType("That spot revision is unavailable.", 404, "spot_not_found");
return JSON.parse(row.spot_json);
}
function listBiteSizedSpotRevisions() {
return database.prepare("SELECT spot_json FROM bite_sized_spot_revisions ORDER BY spot_id, revision DESC").all().map((row) => JSON.parse(row.spot_json));
}
function putBiteSizedLayouts(account, expectedRevision, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
let config;
try { config = normalizeBiteSizedConfig(input); }
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_layout"); }
database.exec("BEGIN IMMEDIATE");
try {
const current = getBiteSizedLayouts();
if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== current.revision) throw new ErrorType("Layouts changed on the server. Refresh before publishing; your draft is still available.", 409, "revision_conflict");
for (const spot of config.spots) {
const previous = current.config.spots.find((s) => s.id === spot.id);
if (previous && biteSizedGameplayKey(previous) === biteSizedGameplayKey(spot)) continue;
try {
const graph = navigationGraphs[spot.sourceDungeonId];
const blockers = spot.legacyLayout ? BITE_SIZED_LAYOUT_CATALOG.dungeons.find((d) => d.id === spot.dungeonId).legacyBlockers : [];
validateBiteSizedSpotNavigation(spot, graph, spot.legacyLayout ? graph.legacyRegions ?? graph.regions : graph.regions, blockers);
}
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_navigation"); }
}
const spots = config.spots.map((spot) => {
const previous = current.config.spots.find((s) => s.id === spot.id);
const latest = database.prepare("SELECT MAX(revision) AS revision FROM bite_sized_spot_revisions WHERE spot_id = ?").get(spot.id).revision;
const revision = previous && biteSizedGameplayKey(previous) === biteSizedGameplayKey(spot) ? previous.revision : (latest ?? -1) + 1;
const next = { ...spot, revision };
archive.run(next.id, revision, JSON.stringify(next));
return next;
});
const next = { schemaVersion: 1, spots };
database.prepare("UPDATE bite_sized_layout_config SET revision = revision + 1, updated_at = ?, config_json = ? WHERE id = 1").run(Date.now(), JSON.stringify(next));
database.exec("COMMIT");
return getBiteSizedLayouts();
} catch (error) { database.exec("ROLLBACK"); throw error; }
}
function resolveBiteSizedRun(dungeonId, seed, eligibleDungeonIds) {
return selectBiteSizedRun(getBiteSizedLayouts().config, dungeonId, seed, { eligibleDungeonIds });
}
function validateBiteSizedRunSelection(input, dungeonId) {
let selection;
try { selection = normalizeBiteSizedSelection(input); }
catch (error) { throw new ErrorType(error.message, 400, "invalid_bite_sized_selection"); }
const archived = getBiteSizedSpotRevision(selection.spot.id, selection.spot.revision);
if (selection.dungeonId !== dungeonId || biteSizedGameplayKey(archived) !== biteSizedGameplayKey(selection.spot)) {
throw new ErrorType("The run does not match its published spot revision.", 400, "invalid_bite_sized_selection");
}
return selection;
}
return { getBiteSizedLayouts, getBiteSizedSpotRevision, listBiteSizedSpotRevisions, putBiteSizedLayouts, resolveBiteSizedRun, validateBiteSizedRunSelection };
}
+142
View File
@@ -0,0 +1,142 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DatabaseSync } from "node:sqlite";
import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
import { BITE_SIZED_DEFAULT_CONFIG, selectBiteSizedRun } from "../shared/bite-sized-layouts.mjs";
import { mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import { createGameServer } from "./server.mjs";
import { readFileSync } from "node:fs";
import { gunzipSync } from "node:zlib";
class ApiError extends Error { constructor(message, status) { super(message); this.status = status; } }
function fixture(t) {
const db = new DatabaseSync(":memory:"); t.after(() => db.close());
return createBiteSizedLayoutRepository(db, { ErrorType: ApiError, isGameMaster: (a) => a?.gm === true });
}
const gm = { gm: true };
test("publishes, duplicates, removes, archives gameplay changes and preserves rename timing", (t) => {
const repository = fixture(t);
const original = repository.getBiteSizedLayouts();
const spot = original.config.spots.find((s) => s.sourceDungeonId === "stormwind-stockade");
const renamed = structuredClone(original.config);
renamed.spots.find((s) => s.id === spot.id).name = "A new name";
const first = repository.putBiteSizedLayouts(gm, 0, renamed);
assert.equal(first.config.spots.find((s) => s.id === spot.id).revision, 0);
const edited = structuredClone(first.config);
edited.spots.find((s) => s.id === spot.id).bossActivation = "after-mobs";
edited.spots.push({ ...spot, id: "second-spot" });
const second = repository.putBiteSizedLayouts(gm, 1, edited);
assert.equal(second.config.spots.find((s) => s.id === spot.id).revision, 1);
const pinned = selectBiteSizedRun(original.config, spot.dungeonId, "active-run");
assert.deepEqual(repository.validateBiteSizedRunSelection(pinned, spot.dungeonId), pinned);
repository.putBiteSizedLayouts(gm, 2, { ...second.config, spots: second.config.spots.filter((s) => s.id !== spot.id) });
assert.equal(repository.getBiteSizedSpotRevision(spot.id, 0).name, spot.name);
assert.equal(repository.getBiteSizedSpotRevision(spot.id, 1).name, "A new name");
assert.throws(() => repository.putBiteSizedLayouts(gm, 1, edited), { status: 409 });
assert.equal(repository.getBiteSizedLayouts().revision, 3);
});
test("rejects unauthorized publication, missing spots, malformed references, and off-floor transforms", (t) => {
const repository = fixture(t);
assert.throws(() => repository.putBiteSizedLayouts(null, 0, BITE_SIZED_DEFAULT_CONFIG), { status: 403 });
assert.throws(() => repository.putBiteSizedLayouts(gm, 0, { schemaVersion: 1, spots: [] }), { status: 400 });
const edited = structuredClone(BITE_SIZED_DEFAULT_CONFIG);
const spot = edited.spots.find((s) => s.sourceDungeonId === "stormwind-stockade");
spot.partySpawn.footPosition[1] += 10;
assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /walkable floor/);
spot.partySpawn.footPosition[1] -= 10;
spot.mobs[0].entityId = "missing-creature";
assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /known non-boss/);
assert.equal(repository.getBiteSizedLayouts().revision, 0);
assert.throws(() => repository.getBiteSizedSpotRevision("missing", 0), { status: 404 });
});
test("imported layouts retain branch closures while new spots can use the full parent floor", (t) => {
const repository = fixture(t);
const edited = structuredClone(BITE_SIZED_DEFAULT_CONFIG);
const spot = edited.spots.find((s) => s.sourceDungeonId === "stormwind-stockade");
const graph = JSON.parse(gunzipSync(readFileSync(new URL("./generated/bite-sized-navigation.json.gz", import.meta.url))).toString("utf8"))[spot.sourceDungeonId];
const triangle = graph.triangles.find((indices) => {
const points = indices.map((i) => graph.nodes[i].position);
return points.every((p) => Math.abs(p[0] + 129.17) < 4 && p[2] > 27 && p[2] < 48);
});
assert.ok(triangle, "a floor triangle exists beyond the original sealed branch");
const position = [0, 1, 2].map((axis) => triangle.reduce((total, i) => total + graph.nodes[i].position[axis], 0) / 3);
spot.mobs = [{ ...spot.mobs[0], position }];
assert.throws(() => repository.putBiteSizedLayouts(gm, 0, edited), /cannot be reached/);
const newSpot = { ...spot, id: "open-parent-branch", legacyLayout: false };
const published = repository.putBiteSizedLayouts(gm, 0, { ...BITE_SIZED_DEFAULT_CONFIG, spots: [...BITE_SIZED_DEFAULT_CONFIG.spots, newSpot] });
assert.deepEqual(published.config.spots.find((s) => s.id === newSpot.id).mobs[0].position, position);
});
test("public reads, GM-only writes, conflict recovery, and archived HTTP revision reads", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-layout-api-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${runtime.server.address().port}`;
try {
const read = async (query = "") => (await (await fetch(`${origin}/api/bite-sized-layouts${query}`)).json());
const initial = await read();
const register = async (username) => (await (await fetch(`${origin}/api/auth/register`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: "test-password" }),
})).json()).token;
const playerToken = await register("layout_reader"), gmToken = await register("codex_dev_gm");
const draft = structuredClone(initial.config);
const spot = draft.spots.find((s) => s.sourceDungeonId === "stormwind-stockade");
spot.bossActivation = "after-mobs";
const put = (token, revision) => fetch(`${origin}/api/gm/bite-sized-layouts`, {
method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ expectedRevision: revision, config: draft }),
});
assert.equal((await fetch(`${origin}/api/gm/bite-sized-layouts`)).status, 401);
assert.equal((await put(playerToken, 0)).status, 403);
assert.equal((await put(gmToken, 0)).status, 200);
assert.equal((await put(gmToken, 0)).status, 409);
assert.equal((await read()).revision, 1);
assert.equal((await read(`?spotId=${encodeURIComponent(spot.id)}&revision=0`)).bossActivation, "immediate");
assert.equal((await read(`?spotId=${encodeURIComponent(spot.id)}&revision=1`)).bossActivation, "after-mobs");
} finally { await new Promise((resolve) => runtime.server.close(resolve)); rmSync(dataDir, { recursive: true, force: true }); }
});
test("timing rankings isolate spot and gameplay revision and keep active race snapshots", async () => {
const dataDir = mkdtempSync(path.join(os.tmpdir(), "healer-man-layout-rank-"));
const runtime = createGameServer({ dataDir, staticDir: dataDir, contentDir: dataDir });
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
const database = runtime.database;
try {
const account = (await database.register("codex_dev_gm", "test-password")).account;
const initial = database.getBiteSizedLayouts();
const spot = initial.config.spots.find((s) => s.sourceDungeonId === "stormwind-stockade");
const selection = database.resolveBiteSizedRun(spot.dungeonId, "same-boss");
const run = (id, pinned, duration) => ({ id, dungeonId: pinned.dungeonId, difficultyId: "normal", bossDifficulty: 1,
durationMs: duration, finishedAt: Date.now(), killedBossIds: [pinned.boss.objectiveId], biteSizedSelection: pinned,
party: [{ name: "Tester", classId: "priest", role: "healer", level: 20, npc: false }] });
const record = run("old-timing-run", selection, 1000);
assert.deepEqual(database.submitDungeonRun(account, record), database.submitDungeonRun(account, record));
const match = database.createDungeonRaceMatch({ id: "pinned-race", seed: "same-boss", dungeonId: spot.dungeonId,
difficultyId: "normal", createdAt: Date.now(), participants: [] });
const member = (await database.register("layout_party_member", "test-password")).account;
database.inviteOnlineGroupMember(account, member.username);
const invite = database.onlineGroupState(member.id).invitations[0];
database.respondToOnlineGroupInvite(member.id, invite.id, true);
database.updateOnlineGroupMember(account.id, { id: "leader-character", name: "Leader", classId: "warrior", specializationId: "warrior:protection", level: 20 }, "tank");
database.updateOnlineGroupMember(member.id, { id: "member-character", name: "Member", classId: "priest", specializationId: "priest:discipline", level: 20 }, "healer");
const activity = database.startOnlineGroupActivity(account.id, { type: "dungeon", contentId: spot.dungeonId, fillWithAi: true, partySize: 5 }).group.activity;
assert.deepEqual(database.onlineGroupState(member.id).group.activity.biteSizedSelection, activity.biteSizedSelection);
const draft = structuredClone(initial.config);
draft.spots.find((s) => s.id === spot.id).bossActivation = "after-mobs";
database.putBiteSizedLayouts(account, 0, draft);
const changed = database.resolveBiteSizedRun(spot.dungeonId, "same-boss");
database.submitDungeonRun(account, run("new-timing-run", changed, 900));
const board = (revision) => database.listDungeonLeaderboard({ dungeonId: spot.dungeonId, difficultyId: "normal", bossDifficulty: 1,
bossId: selection.boss.objectiveId, spotId: spot.id, spotRevision: revision });
assert.equal(board(0).total, 1); assert.equal(board(1).total, 1);
assert.equal(board(0).entries[0].id, "old-timing-run");
assert.deepEqual(database.getDungeonRaceMatch(match.id).biteSizedSelection, selection);
assert.deepEqual(database.onlineGroupState(account.id).group.activity.biteSizedSelection, activity.biteSizedSelection);
assert.deepEqual(database.onlineGroupState(member.id).group.activity.biteSizedSelection, activity.biteSizedSelection);
database.submitDungeonRun(account, run("old-after-publication", selection, 1100));
assert.equal(board(0).total, 2);
} finally { await new Promise((resolve) => runtime.server.close(resolve)); rmSync(dataDir, { recursive: true, force: true }); }
});
+9 -2
View File
@@ -22,6 +22,8 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
@@ -414,6 +416,8 @@ export function openGameDatabase(options = {}) {
return accountRoles(account?.username).includes("gm");
}
const biteSizedLayouts = createBiteSizedLayoutRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
function getManastormConfig() {
const row = statements.manastormConfig.get();
try {
@@ -904,6 +908,8 @@ export function openGameDatabase(options = {}) {
: null,
startedAt: Date.now(),
};
if (type === "dungeon" && isLayoutBiteSizedDungeon(contentId)) activity.biteSizedSelection = biteSizedLayouts.resolveBiteSizedRun(contentId, activity.id,
BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !getDungeonQueuePolicy().config.disabled.biteSized.includes(d.id)).map((d) => d.id));
database.prepare(`
UPDATE online_groups
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
@@ -1180,9 +1186,10 @@ export function openGameDatabase(options = {}) {
return {
databasePath,
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
...biteSizedLayouts,
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError, biteSizedLayouts }),
...(() => {
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster, biteSizedLayouts });
return {
getDungeonRaceMatch: races.getMatch,
createDungeonRaceMatch: races.createMatch,
+44 -13
View File
@@ -1,4 +1,5 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { defaultBiteSizedBoardScope, normalizeBiteSizedSelection } from "../shared/bite-sized-layouts.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
const classes = new Set(catalog.classes.map((entry) => entry.id));
@@ -15,7 +16,7 @@ function storedFinalBossId(dungeonId, difficultyId, killedBossIds) {
return defeated.length === 1 ? defeated[0] : null;
}
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
export function createDungeonLeaderboardRepository(database, { ErrorType, biteSizedLayouts }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_runs (
id TEXT PRIMARY KEY,
@@ -51,11 +52,20 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_final_boss_idx
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, final_boss_id, full_clear, duration_ms, finished_at, id)`);
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
for (const [name, type] of [["spot_id", "TEXT"], ["spot_revision", "INTEGER"], ["selection_json", "TEXT"]]) {
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((c) => c.name === name)) database.exec(`ALTER TABLE dungeon_runs ADD COLUMN ${name} ${type}`);
}
for (const row of database.prepare("SELECT id, dungeon_id FROM dungeon_runs WHERE spot_id IS NULL").all()) {
const scope = defaultBiteSizedBoardScope(row.dungeon_id);
if (scope.spotId) database.prepare("UPDATE dungeon_runs SET spot_id = ?, spot_revision = ? WHERE id = ?").run(scope.spotId, scope.spotRevision, row.id);
}
database.exec("CREATE INDEX IF NOT EXISTS dungeon_runs_spot_times_idx ON dungeon_runs(dungeon_id, spot_id, spot_revision, difficulty_id, boss_difficulty, final_boss_id, duration_ms)");
const insert = database.prepare(`INSERT INTO dungeon_runs
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id, spot_id, spot_revision, selection_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
...(row.selection_json ? { biteSizedSelection: JSON.parse(row.selection_json) } : {}),
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
party: JSON.parse(row.party_json), killedBossIds: JSON.parse(row.bosses_json) });
@@ -68,7 +78,15 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
const bossDifficulty = input.bossDifficulty ?? 0;
if (!Number.isInteger(bossDifficulty) || bossDifficulty < 0 || bossDifficulty > 10)
fail("Choose a boss difficulty from 1 to 10.");
return { dungeon, difficultyId, difficulty, bossDifficulty };
const defaults = defaultBiteSizedBoardScope(dungeon.id);
const spotId = input.spotId || defaults.spotId;
const spotRevision = input.spotRevision == null ? defaults.spotRevision : Number(input.spotRevision);
if (spotId && (!Number.isSafeInteger(spotRevision) || spotRevision < 0)) fail("Choose a valid spot revision.");
if (spotId && biteSizedLayouts) {
const spot = biteSizedLayouts.getBiteSizedSpotRevision(spotId, spotRevision);
if (!dungeon.id.startsWith("randomized-bite-sized-") && spot.dungeonId !== dungeon.id) fail("Choose a spot in this dungeon.");
}
return { dungeon, difficultyId, difficulty, bossDifficulty, spotId, spotRevision };
}
function boardBossId(difficulty, requestedBossId) {
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
@@ -76,15 +94,17 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
}
function placement(run, selectedBossId = storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) {
const scope = run.biteSizedSelection ? { spotId: run.biteSizedSelection.spot.id, spotRevision: run.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(run.dungeonId);
const spotCondition = scope.spotId ? "AND spot_id = ? AND spot_revision = ?" : "";
const variant = dungeons.get(run.dungeonId)?.difficulties.find((entry) => entry.id === run.difficultyId)?.finalBossIds?.length;
const bossCondition = variant ? "AND final_boss_id = ?" : "";
const counts = database.prepare(`SELECT COUNT(*) AS total,
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
COALESCE(SUM(full_clear), 0) AS full_total,
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition}`)
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition} ${spotCondition}`)
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0,
...(variant ? [selectedBossId] : []));
...(variant ? [selectedBossId] : []), ...(scope.spotId ? [scope.spotId, scope.spotRevision] : []));
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
}
@@ -92,6 +112,12 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
submitDungeonRun(account, input) {
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
let biteSizedSelection;
if (input.biteSizedSelection !== undefined) {
try { biteSizedSelection = biteSizedLayouts ? biteSizedLayouts.validateBiteSizedRunSelection(input.biteSizedSelection, dungeon.id) : normalizeBiteSizedSelection(input.biteSizedSelection); }
catch (error) { fail(error.message); }
if (biteSizedSelection.dungeonId !== dungeon.id || input.killedBossIds?.length !== 1 || input.killedBossIds[0] !== biteSizedSelection.boss.objectiveId) fail("Defeat the selected spot's boss.");
}
if (input.bossDifficulty !== undefined && bossDifficulty === 0) fail("Choose a boss difficulty from 1 to 10.");
if (!difficulty.finalBossId) fail("This dungeon does not have a configured final boss yet.");
if (typeof input.id !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(input.id)) fail("Provide a valid run id.");
@@ -121,18 +147,22 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
? difficulty.finalBossIds.some((id) => killedBossIds.includes(id))
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
...(biteSizedSelection ? { biteSizedSelection } : {}),
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
const existing = byId.get(run.id);
if (existing) {
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
difficultyId: run.difficultyId, ...(bossDifficulty > 0 ? { bossDifficulty } : {}),
difficultyId: run.difficultyId, ...(biteSizedSelection ? { biteSizedSelection } : {}), ...(bossDifficulty > 0 ? { bossDifficulty } : {}),
durationMs: run.durationMs, finishedAt: run.finishedAt,
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
} else {
const scope = biteSizedSelection ? { spotId: biteSizedSelection.spot.id, spotRevision: biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(run.dungeonId);
insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty,
storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds));
storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds), scope.spotId, scope.spotRevision, biteSizedSelection ? JSON.stringify(biteSizedSelection) : null);
}
return { run, placement: placement(run, existing?.final_boss_id ?? storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) };
},
getDungeonRun(id) {
@@ -142,7 +172,7 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
return { run, placement: placement(run, row.final_boss_id) };
},
listDungeonLeaderboard(input) {
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
const { dungeon, difficultyId, difficulty, bossDifficulty, spotId, spotRevision } = selection(input);
const filter = input.clear || "final";
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
const offset = Number(input.offset ?? 0);
@@ -152,12 +182,13 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
const condition = filter === "full" ? "AND full_clear = 1" : "";
const bossId = boardBossId(difficulty, input.bossId);
const bossCondition = bossId ? "AND final_boss_id = ?" : "";
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : [])];
const spotCondition = spotId ? "AND spot_id = ? AND spot_revision = ?" : "";
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : []), ...(spotId ? [spotId, spotRevision] : [])];
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition} ${spotCondition}
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset);
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}`).get(...args).total;
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition} ${spotCondition}`).get(...args).total;
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
},
};
+5 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { GameDatabaseError } from "./database.mjs";
import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs";
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes,
RACE_COUNTDOWN_MS, RACE_DISCONNECT_MS, RACE_MAX_MS, RACE_PREPARE_MS, RACE_QUEUE_WAIT_MS } from "./dungeon-race-rules.mjs";
@@ -31,6 +32,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
...(match.biteSizedSelection ? { biteSizedSelection: match.biteSizedSelection } : {}),
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
source: rival.source, ready: rival.readyAt != null, finished: rival.clearAt != null } : null),
@@ -56,11 +58,13 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
const dungeon = choices[hashRaceSeed(id) % choices.length];
const affixIds = rollRaceAffixes(id);
const bot = right ? null : makeBot(id, left.accountId, affixIds);
const biteSizedSelection = database.resolveBiteSizedRun?.(dungeon.id, id,
BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !database.getDungeonQueuePolicy().config.disabled.biteSized.includes(d.id)).map((d) => d.id));
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
characterId: null, username: bot.name, source: bot.source, role: null }])]
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
username: entry.username, source: entry.source, role: entry.role }));
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds,
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds, biteSizedSelection,
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
matches.set(id, match);
for (const participant of participants) if (participant.accountId) {
+1 -1
View File
@@ -186,7 +186,7 @@ test("both randomized creature pools validate and rank their cross-dungeon final
assert.throws(() => service.finish(left, match.matchId, { runId: match.seed, bossIds: [wrongBossId] }), { status: 400 });
assert.equal(service.finish(left, match.matchId, { runId: match.seed, bossIds }).result, "win");
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId,
bossId: bossIds[0] }, time()).total, 1);
bossId: bossIds[0], spotId: match.biteSizedSelection.spot.id, spotRevision: match.biteSizedSelection.spot.revision }, time()).total, 1);
}
}));
+31 -7
View File
@@ -1,6 +1,7 @@
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
import { normalizeRaceCombatLog } from "./dungeon-race-combat-log.mjs";
import { defaultBiteSizedBoardScope } from "../shared/bite-sized-layouts.mjs";
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
@@ -11,7 +12,7 @@ function raceBossId(dungeonId, difficultyId, seed) {
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
}
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false }) {
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false, biteSizedLayouts }) {
database.exec(`
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
@@ -64,6 +65,14 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
.run(Date.now());
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
for (const [name, type] of [["spot_id", "TEXT"], ["spot_revision", "INTEGER"], ["selection_json", "TEXT"]]) {
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((c) => c.name === name)) database.exec(`ALTER TABLE dungeon_race_matches ADD COLUMN ${name} ${type}`);
}
for (const row of database.prepare("SELECT id, dungeon_id FROM dungeon_race_matches WHERE spot_id IS NULL").all()) {
const scope = defaultBiteSizedBoardScope(row.dungeon_id);
if (scope.spotId) database.prepare("UPDATE dungeon_race_matches SET spot_id = ?, spot_revision = ? WHERE id = ?").run(scope.spotId, scope.spotRevision, row.id);
}
database.exec("CREATE INDEX IF NOT EXISTS dungeon_race_spot_times_idx ON dungeon_race_matches(dungeon_id, spot_id, spot_revision, boss_id, status)");
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id, affix_ids_json)
@@ -104,6 +113,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
const match = matchById.get(id);
if (!match) return null;
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
...(match.selection_json ? { biteSizedSelection: JSON.parse(match.selection_json) } : {}),
difficultyId: match.difficulty_id, bossDifficulty: match.boss_difficulty,
status: match.status, createdAt: match.created_at, startAt: match.start_at,
settledAt: match.settled_at, winnerSide: match.winner_side,
@@ -114,12 +124,16 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
}
function createMatch(match) {
const selection = match.biteSizedSelection ?? biteSizedLayouts?.resolveBiteSizedRun(match.dungeonId, match.seed);
transaction(() => {
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed),
JSON.stringify(match.affixIds ?? []));
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
const scope = selection ? { spotId: selection.spot.id, spotRevision: selection.spot.revision } : defaultBiteSizedBoardScope(match.dungeonId);
database.prepare("UPDATE dungeon_race_matches SET spot_id = ?, spot_revision = ?, selection_json = ?, boss_id = COALESCE(?, boss_id) WHERE id = ?")
.run(scope.spotId, scope.spotRevision, selection ? JSON.stringify(selection) : null, selection?.boss.objectiveId ?? null, match.id);
});
return getMatch(match.id);
}
@@ -140,7 +154,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|| match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active");
const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
const selectedBossId = raceBossId(match.dungeonId, match.difficultyId, match.seed);
const selectedBossId = match.biteSizedSelection?.boss.objectiveId ?? raceBossId(match.dungeonId, match.difficultyId, match.seed);
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|| !bossIds.some((value) => finalIds.includes(value))
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
@@ -216,7 +230,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
}
function leaderboard({ view = "wins", period = "30d", dungeonId = null, bossId = null,
limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
spotId = null, spotRevision = null, limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
const dungeon = dungeons.get(dungeonId);
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
@@ -230,7 +244,17 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
const byBoss = selectedBossId ? "AND m.boss_id = ?" : "";
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : [])];
const defaults = defaultBiteSizedBoardScope(dungeonId);
const selectedSpotId = view === "times" ? spotId ?? defaults.spotId : null;
const selectedSpotRevision = view === "times" ? spotRevision == null ? defaults.spotRevision : Number(spotRevision) : null;
if (selectedSpotId && (!Number.isSafeInteger(selectedSpotRevision) || selectedSpotRevision < 0)) throw new ErrorType("Choose a valid spot revision.", 400, "invalid_race_leaderboard");
if (selectedSpotId && biteSizedLayouts) {
const spot = biteSizedLayouts.getBiteSizedSpotRevision(selectedSpotId, selectedSpotRevision);
if (!dungeonId.startsWith("randomized-bite-sized-") && spot.dungeonId !== dungeonId) throw new ErrorType("Choose a spot in this dungeon.", 400, "invalid_race_leaderboard");
}
const bySpot = selectedSpotId ? "AND m.spot_id = ? AND m.spot_revision = ?" : "";
const spotArgs = selectedSpotId ? [selectedSpotId, selectedSpotRevision] : [];
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : []), ...spotArgs];
const query = view === "wins" ? `
SELECT p.account_id AS accountId, a.username AS username,
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
@@ -242,7 +266,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
: `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
JOIN accounts a ON a.id = p.account_id
WHERE p.clear_at >= ? ${byDungeon} ${byBoss} AND p.duration_ms IS NOT NULL
WHERE p.clear_at >= ? ${byDungeon} ${byBoss} ${bySpot} AND p.duration_ms IS NOT NULL
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
const all = database.prepare(query).all(...args);
let lastValue = null;
@@ -250,7 +274,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
const bestAffixes = view === "times" ? database.prepare(`SELECT m.affix_ids_json AS affixIdsJson
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
WHERE p.account_id = ? AND m.dungeon_id = ? AND m.boss_id = ?
AND p.duration_ms = ? AND p.clear_at >= ?
AND p.duration_ms = ? AND p.clear_at >= ? ${bySpot}
ORDER BY p.clear_at ASC LIMIT 1`) : null;
const ranked = all.map((row, index) => {
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
@@ -259,7 +283,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
return { ...row, rank, ...(view === "wins"
? { wins: Number(row.wins), losses: Number(row.losses) }
: { durationMs: Number(row.durationMs), affixIds: JSON.parse(bestAffixes.get(
row.accountId, dungeonId, selectedBossId, row.durationMs, since)?.affixIdsJson ?? "[]") }) };
row.accountId, dungeonId, selectedBossId, row.durationMs, since, ...spotArgs)?.affixIdsJson ?? "[]") }) };
});
return { view, period, dungeonId, bossId: selectedBossId,
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
Binary file not shown.
+476
View File
@@ -0,0 +1,476 @@
// Generated by scripts/generate-bite-sized-navigation.ts; edit the shared TypeScript source.
// src/game/partyNavigation.ts
var PARTY_NAVIGATION_WELD_TOLERANCE = 1e-3;
var DISTANCE_EPSILON = 1e-9;
function pointComponents(point) {
const values = "x" in point ? [point.x, point.y, point.z] : [point[0], point[1], point[2]];
if (!values.every(Number.isFinite)) {
throw new Error("Party navigation points must contain finite coordinates.");
}
return values;
}
function distanceSquared(left, right) {
const dx = right[0] - left[0];
const dy = right[1] - left[1];
const dz = right[2] - left[2];
return dx * dx + dy * dy + dz * dz;
}
function distance(left, right) {
return Math.sqrt(distanceSquared(left, right));
}
function buildPartyNavigationTriangleNodes(triangles, positions) {
const centroids = triangles.map(([a, b, c]) => Object.freeze([
(positions[a][0] + positions[b][0] + positions[c][0]) / 3,
(positions[a][1] + positions[b][1] + positions[c][1]) / 3,
(positions[a][2] + positions[b][2] + positions[c][2]) / 3
]));
const edgeOwners = /* @__PURE__ */ new Map();
triangles.forEach(([a, b, c], triangleId) => {
for (const [left, right] of [[a, b], [b, c], [c, a]]) {
const portal = Object.freeze([
Math.min(left, right),
Math.max(left, right)
]);
const key = `${portal[0]},${portal[1]}`;
const owners = edgeOwners.get(key);
if (owners) owners.push({ triangleId, portal });
else edgeOwners.set(key, [{ triangleId, portal }]);
}
});
const neighbors = triangles.map(() => []);
for (const owners of edgeOwners.values()) {
for (let leftIndex = 0; leftIndex < owners.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < owners.length; rightIndex += 1) {
const left = owners[leftIndex];
const right = owners[rightIndex];
const cost = distance(centroids[left.triangleId], centroids[right.triangleId]);
neighbors[left.triangleId].push(Object.freeze({
to: right.triangleId,
portal: left.portal,
cost
}));
neighbors[right.triangleId].push(Object.freeze({
to: left.triangleId,
portal: right.portal,
cost
}));
}
}
}
return Object.freeze(centroids.map((centroid, id) => Object.freeze({
id,
centroid,
neighbors: Object.freeze(neighbors[id].sort((left, right) => left.to - right.to || left.portal[0] - right.portal[0] || left.portal[1] - right.portal[1]))
})));
}
function findNearestPartyNavigationNode(graph, point, maxDistance = Number.POSITIVE_INFINITY) {
const target = pointComponents(point);
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
let nearest = null;
let nearestDistanceSquared = limit * limit;
for (const node of graph.nodes) {
const candidateDistanceSquared = distanceSquared(target, node.position);
if (candidateDistanceSquared < nearestDistanceSquared - DISTANCE_EPSILON || Math.abs(candidateDistanceSquared - nearestDistanceSquared) <= DISTANCE_EPSILON && (nearest === null || node.id < nearest.id)) {
nearest = node;
nearestDistanceSquared = candidateDistanceSquared;
}
}
return nearest ? Object.freeze({ node: nearest, distance: Math.sqrt(nearestDistanceSquared) }) : null;
}
function closestPointOnSegment(point, start, end) {
const segmentX = end[0] - start[0];
const segmentY = end[1] - start[1];
const segmentZ = end[2] - start[2];
const lengthSquared = segmentX * segmentX + segmentY * segmentY + segmentZ * segmentZ;
if (lengthSquared <= DISTANCE_EPSILON) return [start[0], start[1], start[2]];
const projection = Math.max(0, Math.min(1, ((point[0] - start[0]) * segmentX + (point[1] - start[1]) * segmentY + (point[2] - start[2]) * segmentZ) / lengthSquared));
return [
start[0] + segmentX * projection,
start[1] + segmentY * projection,
start[2] + segmentZ * projection
];
}
function closestPointOnTriangle(point, a, b, c) {
const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
const crossX = ab[1] * ac[2] - ab[2] * ac[1];
const crossY = ab[2] * ac[0] - ab[0] * ac[2];
const crossZ = ab[0] * ac[1] - ab[1] * ac[0];
if (crossX * crossX + crossY * crossY + crossZ * crossZ <= DISTANCE_EPSILON) {
const candidates = [
closestPointOnSegment(point, a, b),
closestPointOnSegment(point, b, c),
closestPointOnSegment(point, c, a)
];
return candidates.reduce((best, candidate) => distanceSquared(point, candidate) < distanceSquared(point, best) - DISTANCE_EPSILON ? candidate : best);
}
const ap = [point[0] - a[0], point[1] - a[1], point[2] - a[2]];
const d1 = ab[0] * ap[0] + ab[1] * ap[1] + ab[2] * ap[2];
const d2 = ac[0] * ap[0] + ac[1] * ap[1] + ac[2] * ap[2];
if (d1 <= 0 && d2 <= 0) return [a[0], a[1], a[2]];
const bp = [point[0] - b[0], point[1] - b[1], point[2] - b[2]];
const d3 = ab[0] * bp[0] + ab[1] * bp[1] + ab[2] * bp[2];
const d4 = ac[0] * bp[0] + ac[1] * bp[1] + ac[2] * bp[2];
if (d3 >= 0 && d4 <= d3) return [b[0], b[1], b[2]];
const vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) {
const along = d1 / (d1 - d3);
return [a[0] + ab[0] * along, a[1] + ab[1] * along, a[2] + ab[2] * along];
}
const cp = [point[0] - c[0], point[1] - c[1], point[2] - c[2]];
const d5 = ab[0] * cp[0] + ab[1] * cp[1] + ab[2] * cp[2];
const d6 = ac[0] * cp[0] + ac[1] * cp[1] + ac[2] * cp[2];
if (d6 >= 0 && d5 <= d6) return [c[0], c[1], c[2]];
const vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) {
const along = d2 / (d2 - d6);
return [a[0] + ac[0] * along, a[1] + ac[1] * along, a[2] + ac[2] * along];
}
const va = d3 * d6 - d5 * d4;
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) {
const along = (d4 - d3) / (d4 - d3 + (d5 - d6));
return [
b[0] + (c[0] - b[0]) * along,
b[1] + (c[1] - b[1]) * along,
b[2] + (c[2] - b[2]) * along
];
}
const denominator = 1 / (va + vb + vc);
const alongB = vb * denominator;
const alongC = vc * denominator;
return [
a[0] + ab[0] * alongB + ac[0] * alongC,
a[1] + ab[1] * alongB + ac[1] * alongC,
a[2] + ab[2] * alongB + ac[2] * alongC
];
}
function findNearestPartyNavigationSurface(graph, point, maxDistance = Number.POSITIVE_INFINITY) {
const triangles = graph.triangles;
if (!triangles?.length) {
const nearest = findNearestPartyNavigationNode(graph, point, maxDistance);
return nearest ? Object.freeze({
position: nearest.node.position,
triangleId: -1,
distance: nearest.distance
}) : null;
}
const target = pointComponents(point);
const limit = Number.isFinite(maxDistance) ? Math.max(0, maxDistance) : Number.POSITIVE_INFINITY;
let nearestPosition = null;
let nearestTriangleId = -1;
let nearestDistanceSquared = limit * limit;
for (let triangleId = 0; triangleId < triangles.length; triangleId += 1) {
const [a, b, c] = triangles[triangleId];
const candidate = closestPointOnTriangle(
target,
graph.nodes[a].position,
graph.nodes[b].position,
graph.nodes[c].position
);
const candidateDistanceSquared = distanceSquared(target, candidate);
if (candidateDistanceSquared < nearestDistanceSquared - DISTANCE_EPSILON || Math.abs(candidateDistanceSquared - nearestDistanceSquared) <= DISTANCE_EPSILON && (nearestTriangleId < 0 || triangleId < nearestTriangleId)) {
nearestPosition = candidate;
nearestTriangleId = triangleId;
nearestDistanceSquared = candidateDistanceSquared;
}
}
return nearestPosition ? Object.freeze({
position: Object.freeze([...nearestPosition]),
triangleId: nearestTriangleId,
distance: Math.sqrt(nearestDistanceSquared)
}) : null;
}
function pointToSegmentDistance(point, start, end) {
return distance(point, closestPointOnSegment(point, start, end));
}
function simplifyPartyNavigationPath(points, tolerance = PARTY_NAVIGATION_WELD_TOLERANCE) {
const safeTolerance = Number.isFinite(tolerance) ? Math.max(0, tolerance) : 0;
const simplified = [];
for (const rawPoint of points) {
const point = Object.freeze([...pointComponents(rawPoint)]);
if (simplified.length && distance(simplified[simplified.length - 1], point) <= safeTolerance) continue;
simplified.push(point);
while (simplified.length >= 3) {
const end = simplified[simplified.length - 1];
const middle = simplified[simplified.length - 2];
const start = simplified[simplified.length - 3];
if (pointToSegmentDistance(middle, start, end) > safeTolerance) break;
simplified.splice(simplified.length - 2, 1);
}
}
return Object.freeze(simplified);
}
function compareOpenEntries(left, right) {
return left.f - right.f || left.h - right.h || left.id - right.id;
}
var OpenHeap = class {
entries = [];
get length() {
return this.entries.length;
}
push(entry) {
const entries = this.entries;
entries.push(entry);
let index = entries.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (compareOpenEntries(entries[parent], entry) <= 0) break;
entries[index] = entries[parent];
index = parent;
}
entries[index] = entry;
}
pop() {
const entries = this.entries;
if (!entries.length) return null;
const first = entries[0];
const last = entries.pop();
if (entries.length) {
let index = 0;
while (true) {
const left = index * 2 + 1;
if (left >= entries.length) break;
const right = left + 1;
const bestChild = right < entries.length && compareOpenEntries(entries[right], entries[left]) < 0 ? right : left;
if (compareOpenEntries(entries[bestChild], last) >= 0) break;
entries[index] = entries[bestChild];
index = bestChild;
}
entries[index] = last;
}
return first;
}
};
function findPartyNavigationNodePath(graph, startId, endId) {
if (startId === endId) return Object.freeze([startId]);
const nodeCount = graph.nodes.length;
const cameFrom = new Int32Array(nodeCount);
cameFrom.fill(-1);
const scores = new Float64Array(nodeCount);
scores.fill(Number.POSITIVE_INFINITY);
scores[startId] = 0;
const closed = new Uint8Array(nodeCount);
const open = new OpenHeap();
const initialHeuristic = distance(graph.nodes[startId].position, graph.nodes[endId].position);
open.push({ id: startId, g: 0, h: initialHeuristic, f: initialHeuristic });
while (open.length) {
const current = open.pop();
if (closed[current.id] || current.g > scores[current.id] + DISTANCE_EPSILON) continue;
if (current.id === endId) break;
closed[current.id] = 1;
for (const edge of graph.nodes[current.id].neighbors) {
if (closed[edge.to]) continue;
const tentative = scores[current.id] + edge.cost;
if (tentative >= scores[edge.to] - DISTANCE_EPSILON) continue;
cameFrom[edge.to] = current.id;
scores[edge.to] = tentative;
const heuristic = distance(graph.nodes[edge.to].position, graph.nodes[endId].position);
open.push({ id: edge.to, g: tentative, h: heuristic, f: tentative + heuristic });
}
}
if (cameFrom[endId] < 0) return null;
const nodeIds = [endId];
let cursor = endId;
while (cursor !== startId && nodeIds.length <= nodeCount) {
cursor = cameFrom[cursor];
if (cursor < 0) return null;
nodeIds.push(cursor);
}
if (cursor !== startId) return null;
nodeIds.reverse();
return Object.freeze(nodeIds);
}
function findPartyNavigationTrianglePath(triangleNodes, startId, endId, blocked) {
if (blocked?.[startId] || blocked?.[endId]) return null;
if (startId === endId) return Object.freeze([startId]);
const nodeCount = triangleNodes.length;
const cameFrom = new Int32Array(nodeCount);
cameFrom.fill(-1);
const scores = new Float64Array(nodeCount);
scores.fill(Number.POSITIVE_INFINITY);
scores[startId] = 0;
const closed = new Uint8Array(nodeCount);
const open = new OpenHeap();
const initialHeuristic = distance(triangleNodes[startId].centroid, triangleNodes[endId].centroid);
open.push({ id: startId, g: 0, h: initialHeuristic, f: initialHeuristic });
while (open.length) {
const current = open.pop();
if (closed[current.id] || current.g > scores[current.id] + DISTANCE_EPSILON) continue;
if (current.id === endId) break;
closed[current.id] = 1;
for (const edge of triangleNodes[current.id].neighbors) {
if (blocked?.[edge.to] || closed[edge.to]) continue;
const tentative = scores[current.id] + edge.cost;
if (tentative >= scores[edge.to] - DISTANCE_EPSILON) continue;
cameFrom[edge.to] = current.id;
scores[edge.to] = tentative;
const heuristic = distance(triangleNodes[edge.to].centroid, triangleNodes[endId].centroid);
open.push({ id: edge.to, g: tentative, h: heuristic, f: tentative + heuristic });
}
}
if (cameFrom[endId] < 0) return null;
const triangleIds = [endId];
let cursor = endId;
while (cursor !== startId && triangleIds.length <= nodeCount) {
cursor = cameFrom[cursor];
if (cursor < 0) return null;
triangleIds.push(cursor);
}
if (cursor !== startId) return null;
triangleIds.reverse();
return Object.freeze(triangleIds);
}
function findPartyNavigationNodeSnappedPath(graph, start, end, options) {
const maxSnapDistance = options.maxSnapDistance ?? Number.POSITIVE_INFINITY;
const startMatch = findNearestPartyNavigationNode(
graph,
start,
options.maxStartSnapDistance ?? maxSnapDistance
);
const endMatch = findNearestPartyNavigationNode(
graph,
end,
options.maxEndSnapDistance ?? maxSnapDistance
);
if (!startMatch || !endMatch) return null;
const nodeIds = findPartyNavigationNodePath(graph, startMatch.node.id, endMatch.node.id);
return nodeIds ? simplifyPartyNavigationPath(
nodeIds.map((id) => graph.nodes[id].position),
options.simplifyTolerance ?? graph.weldTolerance
) : null;
}
function findPartyNavigationPath(graph, start, end, options = {}) {
if (!graph.triangles?.length) {
return findPartyNavigationNodeSnappedPath(graph, start, end, options);
}
const maxSnapDistance = options.maxSnapDistance ?? Number.POSITIVE_INFINITY;
const startMatch = findNearestPartyNavigationSurface(
graph,
start,
options.maxStartSnapDistance ?? maxSnapDistance
);
const endMatch = findNearestPartyNavigationSurface(
graph,
end,
options.maxEndSnapDistance ?? maxSnapDistance
);
if (!startMatch || !endMatch) return null;
const triangleNodes = graph.triangleNodes?.length === graph.triangles.length ? graph.triangleNodes : buildPartyNavigationTriangleNodes(
graph.triangles,
graph.nodes.map((node) => node.position)
);
const blocked = new Uint8Array(triangleNodes.length);
const maximumReroutes = Math.min(64, triangleNodes.length);
for (let reroute = 0; reroute <= maximumReroutes; reroute += 1) {
const triangleIds = findPartyNavigationTrianglePath(
triangleNodes,
startMatch.triangleId,
endMatch.triangleId,
blocked
);
if (!triangleIds) return null;
const path = [startMatch.position];
for (let index = 0; index < triangleIds.length - 1; index += 1) {
const from = triangleIds[index];
const to = triangleIds[index + 1];
const transition = triangleNodes[from].neighbors.find((edge) => edge.to === to);
if (!transition) return null;
const [a, b] = transition.portal;
const left = graph.nodes[a].position;
const right = graph.nodes[b].position;
path.push(Object.freeze([
(left[0] + right[0]) / 2,
(left[1] + right[1]) / 2,
(left[2] + right[2]) / 2
]));
}
path.push(endMatch.position);
let rejectedSegment = -1;
if (options.routeSegmentAllowed) {
for (let index = 0; index < path.length - 1; index += 1) {
if (!options.routeSegmentAllowed(path[index], path[index + 1])) {
rejectedSegment = index;
break;
}
}
}
if (rejectedSegment < 0) return Object.freeze(path);
if (triangleIds.length <= 1 || reroute >= maximumReroutes) return null;
const blockedTriangleId = rejectedSegment === 0 ? triangleIds[1] : rejectedSegment === triangleIds.length - 1 ? triangleIds[triangleIds.length - 2] : triangleIds[rejectedSegment];
if (blocked[blockedTriangleId]) return null;
blocked[blockedTriangleId] = 1;
}
return null;
}
// src/game/partySpawnSlots.ts
function derivePartySpawnSlots(spawn, partySize = 5) {
const size = Math.max(1, Math.min(5, Math.trunc(partySize)));
const offsets = [
[0, 0],
[-1.65, -1.8],
[1.65, -1.8],
[-3.05, -3.5],
[3.05, -3.5]
];
const sin = Math.sin(spawn.yaw);
const cos = Math.cos(spawn.yaw);
return Object.freeze(offsets.slice(0, size).map(([lateral, forward]) => ({
footPosition: [
spawn.footPosition[0] + lateral * cos + forward * sin,
spawn.footPosition[1],
spawn.footPosition[2] - lateral * sin + forward * cos
],
yaw: spawn.yaw
})));
}
// src/game/biteSizedSpotNavigation.ts
function biteSizedRouteSegmentClear(blockers, from, to) {
return blockers.every((blocker) => {
const sin = Math.sin(blocker.yaw), cos = Math.cos(blocker.yaw);
const local = (point) => {
const x = point[0] - blocker.position[0], z = point[2] - blocker.position[2];
return [x * cos - z * sin, point[1] - blocker.position[1], x * sin + z * cos];
};
const start = local(from), end = local(to);
let enter = 0, leave = 1;
for (let axis = 0; axis < 3; axis++) {
const extent = blocker.halfExtents[axis] + (axis === 1 ? 1 : 0.34);
const delta = end[axis] - start[axis];
if (Math.abs(delta) < 1e-9) {
if (Math.abs(start[axis]) > extent) return true;
continue;
}
const first = (-extent - start[axis]) / delta, second = (extent - start[axis]) / delta;
enter = Math.max(enter, Math.min(first, second));
leave = Math.min(leave, Math.max(first, second));
if (enter > leave) return true;
}
return false;
});
}
function validateBiteSizedSpotNavigation(spot, graph, regions, blockers = []) {
const start = findNearestPartyNavigationSurface(graph, spot.partySpawn.footPosition, 0.75);
if (!start) throw new Error(`${spot.name}: Party anchor is off the walkable floor. Move its spawn point.`);
const points = [
...derivePartySpawnSlots(spot.partySpawn).map((s, i) => ({ label: `Party member ${i + 1}`, position: s.footPosition, snapDistance: i === 0 ? 0.75 : 6 })),
...spot.mobs.map((m, i) => ({ label: `Mob ${i + 1}`, position: m.position })),
{ label: "Boss", position: spot.bossSpawn.footPosition }
];
for (const point of points) {
const match = findNearestPartyNavigationSurface(graph, point.position, point.snapDistance ?? 0.75);
if (!match) throw new Error(`${spot.name}: ${point.label} is off the walkable floor. Move its spawn point.`);
if (!biteSizedRouteSegmentClear(blockers, match.position, match.position)) throw new Error(`${spot.name}: ${point.label} is inside a route blocker.`);
const reachable = regions ? regions[start.triangleId] === regions[match.triangleId] : findPartyNavigationPath(graph, start.position, match.position, {
maxSnapDistance: 0.75,
routeSegmentAllowed: (from, to) => biteSizedRouteSegmentClear(blockers, from, to)
});
if (!reachable) throw new Error(`${spot.name}: ${point.label} cannot be reached from the party start.`);
}
}
export {
biteSizedRouteSegmentClear,
validateBiteSizedSpotNavigation
};
+22
View File
@@ -311,6 +311,8 @@ export function createGameServer(options = {}) {
period: url.searchParams.get("period") ?? "30d",
dungeonId: url.searchParams.get("dungeon") || null,
bossId: url.searchParams.get("boss") || null,
spotId: url.searchParams.get("spot") || null,
spotRevision: url.searchParams.get("spotRevision"),
limit: Number(url.searchParams.get("limit") ?? 20),
offset: Number(url.searchParams.get("offset") ?? 0),
}));
@@ -365,6 +367,7 @@ export function createGameServer(options = {}) {
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
bossId: url.searchParams.get("boss"),
spotId: url.searchParams.get("spot"), spotRevision: url.searchParams.get("spotRevision"),
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
return;
@@ -537,6 +540,25 @@ export function createGameServer(options = {}) {
json(response, 201, arenaService.socketTicket(account.id));
return;
}
if (["/api/bite-sized-layouts", "/api/gm/bite-sized-layouts"].includes(url.pathname)) {
const privileged = url.pathname.startsWith("/api/gm/");
let account = null;
if (privileged) {
account = gameDatabase.authenticate(bearerToken(request));
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
if (request.method === "GET") {
const spotId = url.searchParams.get("spotId");
json(response, 200, spotId ? gameDatabase.getBiteSizedSpotRevision(spotId, Number(url.searchParams.get("revision")))
: url.searchParams.get("history") === "1" ? gameDatabase.listBiteSizedSpotRevisions() : gameDatabase.getBiteSizedLayouts());
return;
}
if (privileged && request.method === "PUT") {
const body = await readJson(request, bodyLimit);
json(response, 200, gameDatabase.putBiteSizedLayouts(account, body.expectedRevision, body.config));
return;
}
}
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
const current = gameDatabase.getManastormConfig();
json(response, 200, {
+1
View File
@@ -0,0 +1 @@
{"dungeons":[{"id":"bite-sized-stormwind-stockade","sourceDungeonId":"stormwind-stockade","title":"Stormwind Stockade","expansion":"classic","bounds":{"min":[-237.771,-55.1896,-181.922],"max":[-9.23,1.66,192.931]},"difficultyIds":["normal"],"legacyBlockers":[{"position":[-129.17,-22.1,16.4],"yaw":0,"halfExtents":[6,5.2,0.65]},{"position":[-129.17,-22.1,-16.4],"yaw":0,"halfExtents":[6,5.2,0.65]}],"bossCandidates":[{"sourceDungeonId":"stormwind-stockade","entityId":"entry-1696","objectiveId":"creature-84027","name":"Targorr the Dread"},{"sourceDungeonId":"stormwind-stockade","entityId":"entry-1666","objectiveId":"creature-79088","name":"Kam Deepfury"},{"sourceDungeonId":"stormwind-stockade","entityId":"entry-1717","objectiveId":"creature-79098","name":"Hamhock"},{"sourceDungeonId":"stormwind-stockade","entityId":"entry-1716","objectiveId":"creature-79124","name":"Bazil Thredd"},{"sourceDungeonId":"stormwind-stockade","entityId":"entry-1663","objectiveId":"creature-79116","name":"Dextren Ward"}]},{"id":"bite-sized-wailing-caverns","sourceDungeonId":"wailing-caverns","title":"Wailing Caverns","expansion":"classic","bounds":{"min":[-191.6667,-119.1605,-370.8333],"max":[483.4794,56.469,554.3612]},"difficultyIds":["normal"],"legacyBlockers":[],"bossCandidates":[{"sourceDungeonId":"wailing-caverns","entityId":"lady-anacondra","objectiveId":"lady-anacondra-spawn","name":"Lady Anacondra"},{"sourceDungeonId":"wailing-caverns","entityId":"kresh","objectiveId":"kresh-patrol:kresh-roaming","name":"Kresh"},{"sourceDungeonId":"wailing-caverns","entityId":"lord-cobrahn","objectiveId":"lord-cobrahn-spawn","name":"Lord Cobrahn"},{"sourceDungeonId":"wailing-caverns","entityId":"lord-pythas","objectiveId":"lord-pythas-spawn","name":"Lord Pythas"},{"sourceDungeonId":"wailing-caverns","entityId":"skum","objectiveId":"skum-spawn","name":"Skum"},{"sourceDungeonId":"wailing-caverns","entityId":"lord-serpentis","objectiveId":"lord-serpentis-spawn","name":"Lord Serpentis"},{"sourceDungeonId":"wailing-caverns","entityId":"verdan-the-everliving","objectiveId":"verdan-spawn","name":"Verdan the Everliving"},{"sourceDungeonId":"wailing-caverns","entityId":"mutanus-the-devourer","objectiveId":"mutanus-spawn","name":"Mutanus the Devourer"}]},{"id":"bite-sized-deadmines-ship","sourceDungeonId":"deadmines","title":"The Deadmines","expansion":"classic","bounds":{"min":[-964.0937,-17.4997,-358.1248],"max":[-304.6317,81.9028,45.0756]},"difficultyIds":["normal"],"legacyBlockers":[],"bossCandidates":[{"sourceDungeonId":"deadmines","entityId":"entry-644","objectiveId":"creature-79168","name":"Rhahk'zor"},{"sourceDungeonId":"deadmines","entityId":"entry-642","objectiveId":"creature-79223","name":"Sneed"},{"sourceDungeonId":"deadmines","entityId":"entry-1763","objectiveId":"creature-79206","name":"Gilnid"},{"sourceDungeonId":"deadmines","entityId":"entry-646","objectiveId":"creature-79337","name":"Mr. Smite"},{"sourceDungeonId":"deadmines","entityId":"entry-645","objectiveId":"creature-79344","name":"Cookie"},{"sourceDungeonId":"deadmines","entityId":"entry-647","objectiveId":"creature-79333","name":"Captain Greenskin"},{"sourceDungeonId":"deadmines","entityId":"entry-639","objectiveId":"creature-79336","name":"Edwin VanCleef"}]},{"id":"bite-sized-utgarde-pinnacle-skadi","sourceDungeonId":"utgarde-pinnacle","title":"Utgarde Pinnacle","expansion":"wrath","bounds":{"min":[-552.325,55.0607,179.479],"max":[-190.62,139.777,629.117]},"difficultyIds":["normal","heroic"],"legacyBlockers":[],"bossCandidates":[{"sourceDungeonId":"utgarde-pinnacle","entityId":"entry-26668","objectiveId":"encounter-577","name":"Svala Sorrowgrave"},{"sourceDungeonId":"utgarde-pinnacle","entityId":"entry-26687","objectiveId":"creature-126102","name":"Gortok Palehoof"},{"sourceDungeonId":"utgarde-pinnacle","entityId":"entry-26693","objectiveId":"creature-126103","name":"Skadi the Ruthless"},{"sourceDungeonId":"utgarde-pinnacle","entityId":"entry-26861","objectiveId":"creature-126255","name":"King Ymiron"}]},{"id":"bite-sized-forsaken-abbey-ancalon","sourceDungeonId":"forsaken-abbey","title":"Forsaken Abbey","expansion":"runewaker","bounds":{"min":[-29.38274,-21.74707,-110.44788],"max":[416.5376,34.07087,85.36886]},"difficultyIds":["normal"],"legacyBlockers":[],"bossCandidates":[{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100070","objectiveId":"rw-fa-spawn-7384","name":"Necromage Keeper 1"},{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100070","objectiveId":"rw-fa-spawn-7426","name":"Necromage Keeper 2"},{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100070","objectiveId":"rw-fa-spawn-7379","name":"Necromage Keeper 3"},{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100070","objectiveId":"rw-fa-spawn-7425","name":"Necromage Keeper 4"},{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100561","objectiveId":"rw-fa-spawn-7472","name":"Flowing Chaos"},{"sourceDungeonId":"forsaken-abbey","entityId":"rw-fa-100562","objectiveId":"rw-fa-spawn-7755","name":Line truncated
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
export type LayoutVector = readonly [number, number, number];
export interface LayoutSpawn { readonly footPosition: LayoutVector; readonly yaw: number }
export interface BiteSizedLayoutBlocker { readonly position: LayoutVector; readonly yaw: number; readonly halfExtents: LayoutVector }
export interface BiteSizedMobPlacement { readonly id: string; readonly sourceDungeonId: string; readonly entityId: string; readonly position: LayoutVector; readonly yaw: number; readonly spawnMask?: number; readonly spawnChance?: number }
export interface BiteSizedSpot {
readonly id: string; readonly dungeonId: string; readonly sourceDungeonId: string; readonly name: string; readonly revision: number;
readonly legacyLayout: boolean; readonly partySpawn: LayoutSpawn; readonly mobs: readonly BiteSizedMobPlacement[];
readonly bossSpawn: LayoutSpawn; readonly bossActivation: "immediate" | "after-mobs";
}
export interface BiteSizedConfig { readonly schemaVersion: 1; readonly spots: readonly BiteSizedSpot[] }
export interface BiteSizedCreatureRef { readonly sourceDungeonId: string; readonly entityId: string }
export interface BiteSizedBossRef extends BiteSizedCreatureRef { readonly objectiveId: string; readonly name: string }
export interface BiteSizedRunSelection {
readonly legacyRandomized?: true;
readonly schemaVersion: 1; readonly dungeonId: string; readonly spot: BiteSizedSpot; readonly boss: BiteSizedBossRef;
readonly mobs: readonly (BiteSizedCreatureRef & { readonly placementId: string })[];
}
export interface PublishedBiteSizedConfig { readonly revision: number; readonly updatedAt: number | null; readonly config: BiteSizedConfig }
export const BITE_SIZED_DEFAULT_CONFIG: BiteSizedConfig;
export const BITE_SIZED_LAYOUT_CATALOG: {
readonly dungeons: readonly { readonly id: string; readonly sourceDungeonId: string; readonly title: string; readonly expansion: string;
readonly bounds: { readonly min: LayoutVector; readonly max: LayoutVector }; readonly difficultyIds: readonly string[]; readonly bossCandidates: readonly BiteSizedBossRef[]; readonly legacyBlockers: readonly BiteSizedLayoutBlocker[] }[];
readonly creatures: readonly (BiteSizedCreatureRef & { readonly kind: "mob" | "boss"; readonly name: string; readonly dungeonTitle: string; readonly modelUrl: string | null })[];
readonly pools: Readonly<Record<string, readonly (BiteSizedBossRef & { readonly kind: "mob" | "boss"; readonly key: string })[]>>;
};
export function normalizeBiteSizedSpot(value: unknown): BiteSizedSpot;
export function normalizeBiteSizedConfig(value: unknown): BiteSizedConfig;
export function normalizeBiteSizedSelection(value: unknown): BiteSizedRunSelection;
export function biteSizedGameplayKey(value: BiteSizedSpot): string;
export function biteSizedSeedHash(seed: string): number;
export function isLayoutBiteSizedDungeon(id: string): boolean;
export function defaultBiteSizedBoardScope(id: string): { readonly spotId: string | null; readonly spotRevision: number | null };
export function selectBiteSizedRun(config: BiteSizedConfig, dungeonId: string, seed: string, options?: { readonly eligibleDungeonIds?: readonly string[]; readonly spotId?: string }): BiteSizedRunSelection;
+124
View File
@@ -0,0 +1,124 @@
import bundled from "./bite-sized-defaults.json" with { type: "json" };
import catalog from "./bite-sized-catalog.json" with { type: "json" };
export const BITE_SIZED_DEFAULT_CONFIG = bundled;
export const BITE_SIZED_LAYOUT_CATALOG = catalog;
const dungeonBySource = new Map(catalog.dungeons.map((d) => [d.sourceDungeonId, d]));
const creatureByRef = new Map(catalog.creatures.map((c) => [`${c.sourceDungeonId}:${c.entityId}`, c]));
const fail = (message) => { throw new Error(message); };
const identifier = (value) => typeof value === "string" && /^[A-Za-z0-9_:.-]{1,100}$/.test(value);
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
function vector(value, label) {
if (!Array.isArray(value) || value.length !== 3 || !value.every((n) => typeof n === "number" && Number.isFinite(n))) fail(`${label} needs three finite coordinates.`);
return [...value];
}
function spawn(value, label) {
if (!object(value) || !Number.isFinite(value.yaw)) fail(`${label} needs a finite facing.`);
return { footPosition: vector(value.footPosition, label), yaw: value.yaw };
}
export function normalizeBiteSizedSpot(value) {
if (!object(value) || !identifier(value.id)) fail("A spot needs a valid stable ID.");
const dungeon = dungeonBySource.get(value.sourceDungeonId);
if (!dungeon || value.dungeonId !== dungeon.id) fail("Choose a supported Bite-Sized parent dungeon.");
if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 100) fail("Name the spot using 1–100 characters.");
if (!Number.isSafeInteger(value.revision) || value.revision < 0) fail("Invalid spot revision.");
if (!["immediate", "after-mobs"].includes(value.bossActivation)) fail("Choose when the boss appears.");
if (!Array.isArray(value.mobs) || value.mobs.length > 500) fail("A spot supports up to 500 mob placements.");
const ids = new Set();
const mobs = value.mobs.map((m) => {
if (!object(m) || !identifier(m.id) || ids.has(m.id)) fail("Mob placement IDs must be unique.");
ids.add(m.id);
if (creatureByRef.get(`${m.sourceDungeonId}:${m.entityId}`)?.kind !== "mob") fail("Choose a known non-boss creature for each mob placement.");
if (m.yaw !== undefined && !Number.isFinite(m.yaw)) fail("Mob facing must be finite.");
if (m.spawnMask !== undefined && (!Number.isInteger(m.spawnMask) || m.spawnMask < 1 || m.spawnMask > 255)) fail("Invalid mob difficulty mask.");
if (m.spawnChance !== undefined && (!Number.isFinite(m.spawnChance) || m.spawnChance < 0 || m.spawnChance > 1)) fail("Invalid mob spawn chance.");
return { id: m.id, sourceDungeonId: m.sourceDungeonId, entityId: m.entityId, position: vector(m.position, "Mob spawn"), yaw: m.yaw ?? 0,
...(m.spawnMask !== undefined ? { spawnMask: m.spawnMask } : {}), ...(m.spawnChance !== undefined ? { spawnChance: m.spawnChance } : {}) };
});
const partySpawn = spawn(value.partySpawn, "Party spawn");
const bossSpawn = spawn(value.bossSpawn, "Boss spawn");
for (const point of [partySpawn.footPosition, bossSpawn.footPosition, ...mobs.map((m) => m.position)]) {
if (point.some((n, axis) => n < dungeon.bounds.min[axis] - 25 || n > dungeon.bounds.max[axis] + 25)) fail("Spawn points must stay inside the parent dungeon.");
}
return { id: value.id, dungeonId: dungeon.id, sourceDungeonId: dungeon.sourceDungeonId, name: value.name.trim(), revision: value.revision,
legacyLayout: value.legacyLayout === true, partySpawn, mobs, bossSpawn, bossActivation: value.bossActivation };
}
export function normalizeBiteSizedConfig(value) {
if (!object(value) || value.schemaVersion !== 1 || !Array.isArray(value.spots) || value.spots.length > 960) fail("Invalid Bite-Sized layout configuration.");
const spots = value.spots.map(normalizeBiteSizedSpot);
if (new Set(spots.map((s) => s.id)).size !== spots.length) fail("Spot IDs must be unique.");
for (const dungeon of catalog.dungeons) {
const count = spots.filter((s) => s.sourceDungeonId === dungeon.sourceDungeonId).length;
if (count < 1 || count > 80) fail(`${dungeon.title} needs between 1 and 80 spots.`);
}
return { schemaVersion: 1, spots };
}
export function biteSizedGameplayKey(value) {
const { name: _name, revision: _revision, ...gameplay } = normalizeBiteSizedSpot(value);
return JSON.stringify(gameplay);
}
export function biteSizedSeedHash(seed) {
let hash = 2166136261;
for (let i = 0; i < seed.length; i++) { hash ^= seed.charCodeAt(i); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function isLayoutBiteSizedDungeon(id) {
return catalog.dungeons.some((d) => d.id === id) || Object.hasOwn(catalog.pools, id);
}
export function defaultBiteSizedBoardScope(dungeonId) {
const id = Object.hasOwn(catalog.pools, dungeonId) ? "bite-sized-stormwind-stockade" : dungeonId;
const spot = bundled.spots.find((s) => s.dungeonId === id);
return spot ? { spotId: spot.id, spotRevision: 0 } : { spotId: null, spotRevision: null };
}
export function selectBiteSizedRun(config, dungeonId, seed, options = {}) {
const randomized = Object.hasOwn(catalog.pools, dungeonId);
const allowed = options.eligibleDungeonIds ?? catalog.dungeons.map((d) => d.id);
const parents = catalog.dungeons.filter((d) => allowed.includes(d.id) && (randomized || d.id === dungeonId)).sort((a, b) => a.id.localeCompare(b.id));
if (!parents.length) fail("No eligible Bite-Sized dungeon has a published spot.");
const parent = parents[biteSizedSeedHash(`${seed}:location`) % parents.length];
const spots = config.spots.filter((s) => s.dungeonId === parent.id).sort((a, b) => a.id.localeCompare(b.id));
const spot = options.spotId ? spots.find((s) => s.id === options.spotId) : spots[biteSizedSeedHash(`${seed}:spot`) % spots.length];
if (!spot) fail("That Bite-Sized spot is unavailable.");
const bossPool = randomized ? catalog.pools[dungeonId].filter((c) => c.kind === "boss") : parent.bossCandidates;
const boss = bossPool[biteSizedSeedHash(randomized ? `${seed}:boss` : seed) % bossPool.length];
const mobPool = randomized ? catalog.pools[dungeonId].filter((c) => c.kind === "mob") : [];
let remaining = [...mobPool];
const mobs = spot.mobs.map((placement, index) => {
let ref = placement;
if (randomized) {
if (!remaining.length) remaining = [...mobPool];
const entry = /(?:entry-|^rw-.*-)(\d+)$/.exec(placement.entityId)?.[1];
const originalKey = entry ? `${placement.entityId.startsWith("rw-") ? "rom" : "wow"}:mob:${entry}` : null;
const choices = remaining.filter((c) => c.key !== originalKey);
// Preserve the existing deal without replacement. Large authored spots
// start another deterministic deal only after exhausting the pool.
const candidates = choices.length ? choices : remaining;
ref = candidates[biteSizedSeedHash(`${seed}:mob:${placement.id}:${index}`) % candidates.length];
remaining.splice(remaining.indexOf(ref), 1);
}
return { placementId: placement.id, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
});
return normalizeBiteSizedSelection({ schemaVersion: 1, dungeonId, spot, boss, mobs });
}
export function normalizeBiteSizedSelection(value) {
if (!object(value) || value.schemaVersion !== 1 || !isLayoutBiteSizedDungeon(value.dungeonId)) fail("Invalid Bite-Sized run selection.");
const spot = normalizeBiteSizedSpot(value.spot);
const randomized = Object.hasOwn(catalog.pools, value.dungeonId);
if (!randomized && spot.dungeonId !== value.dungeonId) fail("The spot belongs to a different dungeon.");
const parent = dungeonBySource.get(spot.sourceDungeonId);
const pool = randomized ? catalog.pools[value.dungeonId].filter((c) => c.kind === "boss") : parent.bossCandidates;
const boss = pool.find((c) => c.sourceDungeonId === value.boss?.sourceDungeonId && c.entityId === value.boss?.entityId && c.objectiveId === value.boss?.objectiveId);
if (!boss || spot.mobs.some((m) => m.id === boss.objectiveId)) fail("Invalid selected boss or conflicting placement ID.");
if (!Array.isArray(value.mobs) || value.mobs.length !== spot.mobs.length) fail("The run is missing mob assignments.");
const mobs = spot.mobs.map((placement) => {
const entries = value.mobs.filter((m) => m.placementId === placement.id);
const ref = entries[0];
if (entries.length !== 1 || creatureByRef.get(`${ref.sourceDungeonId}:${ref.entityId}`)?.kind !== "mob") fail("Invalid selected mob.");
if (randomized ? !catalog.pools[value.dungeonId].some((c) => c.kind === "mob" && c.sourceDungeonId === ref.sourceDungeonId && c.entityId === ref.entityId)
: ref.sourceDungeonId !== placement.sourceDungeonId || ref.entityId !== placement.entityId) fail("The selected mob is outside this run's creature pool.");
return { placementId: placement.id, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
});
if (value.legacyRandomized && (!randomized || spot.id !== "bite-sized-stormwind-stockade:default" || spot.revision !== 0)) fail("Invalid legacy randomized layout.");
return { schemaVersion: 1, dungeonId: value.dungeonId, spot, boss: { sourceDungeonId: boss.sourceDungeonId, entityId: boss.entityId, objectiveId: boss.objectiveId, name: boss.name }, mobs,
...(value.legacyRandomized ? { legacyRandomized: true } : {}) };
}
+4
View File
@@ -16,6 +16,7 @@ import {
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
const GameRuntime = lazy(loadGameRuntime);
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
@@ -24,6 +25,7 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
const BiteSizedEditor = lazy(() => import("./ui/BiteSizedEditor").then((module) => ({ default: module.BiteSizedEditor })));
function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
}
@@ -84,6 +86,7 @@ export default function App() {
useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
useEffect(() => { void useBiteSizedLayoutsStore.getState().refresh(); }, [session?.ownerId]);
useEffect(() => {
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
@@ -100,6 +103,7 @@ export default function App() {
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
else if (phase === "bite-sized-editor") screen = <Suspense fallback={<ShellTransition />}><BiteSizedEditor /></Suspense>;
else if (isShelvedShellPhase(phase)) {
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
}
+6 -1
View File
@@ -41,6 +41,8 @@ import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
import { BiteSizedEditor } from "./ui/BiteSizedEditor";
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -49,6 +51,7 @@ function InputBridge() {
useEffect(() => installInput({
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| useBiteSizedLayoutsStore.getState().panelFocused
|| useGameStore.getState().dungeonIntroActive
|| useGameStore.getState().raceStartBlocked
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
@@ -93,7 +96,7 @@ function InputBridge() {
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
gameplayActionsBlocked: () => {
const game = useGameStore.getState();
return game.overlay !== null || game.companionOpen;
return game.overlay !== null || game.companionOpen || useBiteSizedLayoutsStore.getState().editorMode === "edit";
},
setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active),
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
@@ -126,6 +129,7 @@ export default function GameRuntime() {
useShellStore.setState({ activeDungeonId: sourceDungeonId });
}
clearBossTestingDungeons();
if (useShellStore.getState().phase !== "game") useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
}, []);
return (
<DualDisplayFrame
@@ -154,6 +158,7 @@ export default function GameRuntime() {
{!dungeonSimulation && <DungeonRunResults />}
{!dungeonSimulation && <UiLayoutEditor />}
{!dungeonSimulation && <KeybindsPanel />}
<BiteSizedEditor inGame />
</main>
}
bottom={<LocalCompanionPanel />}
+4 -1
View File
@@ -14,6 +14,7 @@ import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
@@ -60,7 +61,8 @@ export async function enterOnlineGroupActivity(
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.prepareDungeonContent(activity.contentId);
if (activity.biteSizedSelection) await prepareBiteSizedLayoutContent(activity.contentId, activity.id, activity.biteSizedSelection);
await dependencies.prepareDungeonContent(activity.biteSizedSelection?.spot.sourceDungeonId ?? activity.contentId);
if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
}
@@ -70,6 +72,7 @@ export async function enterOnlineGroupActivity(
bossDifficulty: activity.bossDifficulty,
freshRun: true,
runId: activity.id,
...(activity.biteSizedSelection ? { biteSizedSelection: activity.biteSizedSelection } : {}),
});
} else {
if (!SHELVED_MODES_ENABLED) return false;
+2 -2
View File
@@ -75,12 +75,12 @@ describe("bite-sized replay", () => {
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", expect.objectContaining({
difficultyId: "normal",
bossDifficulty: 1,
freshRun: true,
runId: expect.any(String),
});
}));
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
expect.anything(), expect.anything());
});
+8
View File
@@ -9,6 +9,8 @@ import { useOnlineSessionStore } from "./onlineSessionStore";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { useShellStore } from "./shellStore";
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
export function canStartAnotherBiteSizedDungeon(): boolean {
const shell = useShellStore.getState();
@@ -19,6 +21,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
}
interface BiteSizedReplayDependencies {
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string;
@@ -27,6 +30,7 @@ interface BiteSizedReplayDependencies {
}
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
prepareBiteSized: prepareBiteSizedLayoutContent,
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(),
@@ -93,6 +97,9 @@ export async function startAnotherBiteSizedDungeon(
return false;
}
const runId = dependencies.createRunId();
let biteSizedSelection: BiteSizedRunSelection | undefined;
try { biteSizedSelection = await dependencies.prepareBiteSized?.(next.id, runId); }
catch (caught) { shell.setNotice(caught instanceof Error ? caught.message : "The spot content could not be prepared."); return false; }
if (randomized) {
try {
await dependencies.prepareRandomizedContent(runId, next.id);
@@ -107,6 +114,7 @@ export async function startAnotherBiteSizedDungeon(
bossDifficulty: progress.bossDifficulty,
freshRun: true,
runId,
...(biteSizedSelection ? { biteSizedSelection } : {}),
});
return entered;
}
+41
View File
@@ -0,0 +1,41 @@
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { useShellStore } from "./shellStore";
import { touchCharacter } from "./accountRepository";
import { sessionCanUseGmTools } from "./releaseFeatures";
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
import { activateDungeonSession } from "../game/dungeonSession";
import { useGameStore } from "../game/store";
import { defaultPartyRoleForClass } from "../game/partyRoles";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useDungeonRaceStore } from "./dungeonRaceStore";
export async function enterBiteSizedEditorSpot(spotId: string, mode: "edit" | "test" = "edit"): Promise<boolean> {
const shell = useShellStore.getState();
const layouts = useBiteSizedLayoutsStore.getState();
const spot = layouts.draft.spots.find((s) => s.id === spotId);
try {
if (!sessionCanUseGmTools(shell.session)) throw new Error("Game Master access is required.");
if (useOnlineGroupStore.getState().activeActivity) throw new Error("Leave the shared activity before opening an isolated editor session.");
if (["queued", "preparing", "countdown", "active"].includes(useDungeonRaceStore.getState().status)) throw new Error("Leave the Dungeon Race before opening an editor session.");
const character = shell.selectedCharacterId ? touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
?? shell.characters.find((c) => c.id === shell.selectedCharacterId) : null;
if (!spot || !character) throw new Error("Choose a spot and a character first.");
const seed = `gm-spot-${globalThis.crypto.randomUUID()}`;
const selection = selectBiteSizedRun(layouts.draft, spot.dungeonId, seed, { spotId });
await prepareBiteSizedLayoutContent(spot.dungeonId, seed, selection);
if (!activateDungeonSession(spot.dungeonId, undefined, { gmTestMode: true, runId: seed, biteSizedSelection: selection })) throw new Error("The selected dungeon could not be opened.");
useBiteSizedLayoutsStore.setState({ editingSpotId: spotId, editingPointId: "party", editorMode: mode, panelFocused: false, error: null });
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: spot.dungeonId,
activeDungeonRole: defaultPartyRoleForClass(character.classId), notice: "" });
return true;
} catch (error) {
useBiteSizedLayoutsStore.setState({ error: error instanceof Error ? error.message : "Could not open the spot." });
return false;
}
}
export function leaveBiteSizedEditor() {
useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
useGameStore.getState().closeOverlay();
useShellStore.setState({ phase: "bite-sized-editor", activeCharacter: null });
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
import { prepareBiteSizedLayoutContent, preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
const id = "randomized-bite-sized-stormwind-stockade";
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG), id, "content-selected-map", { eligibleDungeonIds: ["bite-sized-forsaken-abbey-ancalon"] });
describe("pinned spot content preparation", () => {
it("installs the actual parent map and every rolled creature before entry", async () => {
let usable = false;
const installPack = vi.fn(async () => { usable = true; });
const installUrls = vi.fn(async (_urls: readonly string[]) => undefined), refresh = vi.fn(async () => undefined);
const result = await prepareBiteSizedLayoutContent(id, "install-map", selection, { development: false, installPack, installUrls,
refresh, packIsUsable: () => usable, packHasUpdate: () => false });
expect(installPack).toHaveBeenCalledExactlyOnceWith("dungeon:forsaken-abbey");
expect(refresh).not.toHaveBeenCalled();
expect(installUrls.mock.calls[0]![0].length).toBeGreaterThan(0);
expect(result).toEqual(selection);
});
it("keeps an installed pinned snapshot usable offline without a layout refresh or download", async () => {
const installPack = vi.fn(async () => { throw new Error("Offline"); }), refresh = vi.fn(async () => { throw new Error("Offline"); });
const result = await prepareBiteSizedLayoutContent(id, "offline-resume", selection, { development: false, installPack,
refresh, packIsUsable: () => true, packHasUpdate: () => false, installUrls: vi.fn(async () => undefined) });
expect(result).toEqual(selection); expect(refresh).not.toHaveBeenCalled(); expect(installPack).not.toHaveBeenCalled();
});
it("rejects unavailable maps and creatures without caching a partially prepared run", async () => {
const deps = { development: false, refresh: vi.fn(async () => undefined), packIsUsable: () => false,
packHasUpdate: () => false, installPack: vi.fn(async () => undefined), installUrls: vi.fn(async () => undefined) };
await expect(prepareBiteSizedLayoutContent(id, "missing-map", selection, deps)).rejects.toThrow(/verified/);
expect(preparedBiteSizedRunSelection(id, "missing-map")).toBeUndefined();
await expect(prepareBiteSizedLayoutContent(id, "missing-creature", selection, { ...deps, packIsUsable: () => true,
installUrls: vi.fn(async () => { throw new Error("Creature unavailable"); }) })).rejects.toThrow(/Creature unavailable/);
expect(preparedBiteSizedRunSelection(id, "missing-creature")).toBeUndefined();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
import { contentPackIsUsable, contentPackHasUpdate, installContentPack, installContentForLogicalUrls } from "../content/contentManager";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { useDungeonQueuePolicyStore } from "./dungeonQueuePolicyStore";
const prepared = new Map<string, BiteSizedRunSelection>();
interface LayoutContentDependencies {
readonly development: boolean;
readonly refresh: () => Promise<void>;
readonly packIsUsable: (id: string) => boolean;
readonly packHasUpdate: (id: string) => boolean;
readonly installPack: (id: string) => Promise<void>;
readonly installUrls: (urls: readonly string[]) => Promise<void>;
}
const DEFAULT_DEPENDENCIES: LayoutContentDependencies = { development: import.meta.env.DEV,
refresh: () => useBiteSizedLayoutsStore.getState().refresh(), packIsUsable: contentPackIsUsable,
packHasUpdate: contentPackHasUpdate, installPack: installContentPack, installUrls: installContentForLogicalUrls };
export function preparedBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined { return prepared.get(`${id}:${seed}`); }
export async function prepareBiteSizedLayoutContent(id: string, seed: string, pinned?: BiteSizedRunSelection, dependencies = DEFAULT_DEPENDENCIES): Promise<BiteSizedRunSelection | undefined> {
if (!isLayoutBiteSizedDungeon(id)) return undefined;
if (!pinned && !preparedBiteSizedRunSelection(id, seed)) await dependencies.refresh();
const selection = pinned ? normalizeBiteSizedSelection(pinned) : preparedBiteSizedRunSelection(id, seed)
?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, {
eligibleDungeonIds: BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !useDungeonQueuePolicyStore.getState().config.disabled.biteSized.includes(d.id)).map((d) => d.id),
});
if (selection.dungeonId !== id) throw new Error("The run selection belongs to another activity.");
if (!dependencies.development) {
const pack = `dungeon:${selection.spot.sourceDungeonId}`;
if (!dependencies.packIsUsable(pack) || dependencies.packHasUpdate(pack)) await dependencies.installPack(pack);
if (!dependencies.packIsUsable(pack)) throw new Error("The selected spot's dungeon download could not be verified.");
const refs = [...selection.mobs, selection.boss];
const urls = [...new Set(refs.flatMap((ref) => {
const creature = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === ref.sourceDungeonId && c.entityId === ref.entityId);
return creature?.modelUrl ? [creature.modelUrl] : [];
}))];
await dependencies.installUrls(urls);
}
prepared.set(`${id}:${seed}`, selection);
if (prepared.size > 24) prepared.delete(prepared.keys().next().value!);
return selection;
}
+17 -5
View File
@@ -4,6 +4,14 @@ import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboard
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
import { defaultBiteSizedBoardScope } from "../../shared/bite-sized-layouts.mjs";
export interface BiteSizedBoardFilter { readonly spotId: string; readonly spotRevision: number }
function matchesSpot(entry: DungeonRunRecord, spotId?: string, spotRevision?: number): boolean {
const scope = entry.biteSizedSelection ? { spotId: entry.biteSizedSelection.spot.id, spotRevision: entry.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(entry.dungeonId);
const defaults = defaultBiteSizedBoardScope(entry.dungeonId);
return !defaults.spotId || (scope.spotId === (spotId ?? defaults.spotId) && scope.spotRevision === (spotRevision ?? defaults.spotRevision));
}
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
const listeners = new Set<() => void>();
@@ -44,7 +52,7 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
}
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string): DungeonLeaderboard {
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string, spotId?: string, spotRevision?: number): DungeonLeaderboard {
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
.find((entry) => entry.id === difficultyId);
const selectedBossId = difficulty?.finalBossIds?.length
@@ -52,6 +60,7 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
&& (entry.bossDifficulty ?? 0) === bossDifficulty
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
&& matchesSpot(entry, spotId, spotRevision)
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
@@ -63,11 +72,13 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
.find((entry) => entry.id === record.difficultyId);
const bossId = difficulty?.finalBossIds?.length
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId);
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId);
const spotId = record.biteSizedSelection?.spot.id;
const spotRevision = record.biteSizedSelection?.spot.revision;
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
&& (!bossId || entry.killedBossIds.includes(bossId)));
&& (!bossId || entry.killedBossIds.includes(bossId)) && matchesSpot(entry, spotId, spotRevision));
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
finalTotal: final.total, fullTotal: full.total,
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
@@ -75,10 +86,11 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
bossId?: string): Promise<DungeonLeaderboard> {
bossId?: string, spotId?: string, spotRevision?: number): Promise<DungeonLeaderboard> {
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
if (bossId) query.set("boss", bossId);
if (spotId) { query.set("spot", spotId); query.set("spotRevision", String(spotRevision ?? 0)); }
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
}
+14 -6
View File
@@ -12,6 +12,8 @@ import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
@@ -22,6 +24,7 @@ export interface RaceHumanOpponent { readonly type: "human"; readonly name: stri
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
export type RaceOpponent = RaceHumanOpponent | RaceBot;
export interface RaceServerState {
readonly biteSizedSelection?: BiteSizedRunSelection;
readonly status: RaceStatus;
readonly serverTime: number;
readonly joinedAt?: number;
@@ -41,6 +44,7 @@ export interface RaceServerState {
}
interface RaceState {
biteSizedSelection: BiteSizedRunSelection | null;
mode: "online" | "practice" | null;
status: RaceStatus;
matchId: string | null;
@@ -91,10 +95,12 @@ function choosePracticeDungeon(pool: RacePool): string | null {
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
return entries.filter((entry) => entry.available)[0]?.id ?? null;
}
async function prepareContent(dungeonId: string, seed: string): Promise<void> {
async function prepareContent(dungeonId: string, seed: string, pinned?: BiteSizedRunSelection) {
const selection = await prepareBiteSizedLayoutContent(dungeonId, seed, pinned);
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
if (!dungeonContentIsUsable(selection?.spot.sourceDungeonId ?? dungeonId)) throw new Error("The dungeon content could not be verified.");
return selection;
}
export const useDungeonRaceStore = create<RaceState>((set, get) => {
@@ -104,7 +110,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
preparingSeed = seed;
set({ preparingContent: true, error: "" });
try {
await prepareContent(dungeonId, seed);
const biteSizedSelection = await prepareContent(dungeonId, seed, get().biteSizedSelection ?? undefined);
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
if (!validDungeonRaceAffixes(get().affixIds)) {
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
@@ -114,10 +120,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
installDungeonShellRuntime();
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
...(biteSizedSelection ? { biteSizedSelection } : {}),
});
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
startRaceCombatLog(seed, dungeonId);
set({ inGame: true, preparingContent: false });
set({ inGame: true, preparingContent: false, biteSizedSelection: biteSizedSelection ?? null });
} catch (error) {
stopRaceCombatLog();
if (raceRevision === revision) {
@@ -146,6 +153,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
}
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
biteSizedSelection: response.biteSizedSelection ?? previous.biteSizedSelection,
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
startAt: response.startAt ?? null,
serverOffset: offset, opponent: response.opponent ?? null,
@@ -158,7 +166,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
}
return {
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
queueAt: null, botAt: null,
@@ -306,7 +314,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
cleanupDungeonRaceAffixes();
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
stopRaceCombatLog(); pendingCombatLog = null;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
},
+8 -4
View File
@@ -12,6 +12,7 @@ import { registerDungeonShellRuntime, type DungeonEntrySettings } from "./dungeo
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
import { dungeonContentIsUsable } from "../content/contentManager";
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
@@ -23,10 +24,6 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
useShellStore.setState({ notice: "That dungeon is not available yet." });
return false;
}
if (!dungeonContentIsUsable(dungeonId)) {
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
return false;
}
const { session, selectedCharacterId, characters } = useShellStore.getState();
if (!session || !selectedCharacterId) {
useShellStore.setState({ notice: "Select or create a character first." });
@@ -62,6 +59,12 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
&& (settings?.bossDifficulty === undefined || checkpoint.run?.bossDifficulty === settings.bossDifficulty)
? activeCharacter.dungeonRunCheckpoint
: null;
const biteSizedSelection = settings?.biteSizedSelection ?? resume?.run?.biteSizedSelection
?? (settings?.runId ? preparedBiteSizedRunSelection(dungeonId, settings.runId) : undefined);
if (!dungeonContentIsUsable(biteSizedSelection?.spot.sourceDungeonId ?? dungeonId)) {
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
return false;
}
if (!activateDungeonSession(dungeonId, settings?.difficultyId ?? resume?.difficultyId, {
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
spawn: resume?.spawn,
@@ -69,6 +72,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
runProgress: resume?.run,
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
runId: settings?.runId,
biteSizedSelection,
})) {
useShellStore.setState({
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
+1
View File
@@ -5,6 +5,7 @@ export interface DungeonShellRuntime {
}
export interface DungeonEntrySettings {
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly freshRun?: boolean;
+1
View File
@@ -22,6 +22,7 @@ export interface OnlineGroupMember {
}
export interface OnlineGroupActivity {
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
readonly difficultyId?: string;
readonly bossDifficulty?: number;
readonly id: string;
+7
View File
@@ -9,6 +9,8 @@ import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
export type RandomDungeonQueueMode = "regular" | "bite-sized";
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
@@ -29,6 +31,7 @@ export interface RandomDungeonQueueOptions {
}
interface RandomDungeonQueueDependencies {
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly contentIsUsable: (dungeonId: string) => boolean;
@@ -37,6 +40,7 @@ interface RandomDungeonQueueDependencies {
}
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
prepareBiteSized: prepareBiteSizedLayoutContent,
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
contentIsUsable: dungeonContentIsUsable,
@@ -107,10 +111,13 @@ export async function queueRandomDungeon(
}
await dependencies.installDungeonRuntime();
const runId = globalThis.crypto.randomUUID();
const biteSizedSelection = options.mode === "bite-sized" ? await dependencies.prepareBiteSized?.(dungeon.id, runId) : undefined;
const entered = options.enterDungeon(dungeon.id, role, {
difficultyId,
bossDifficulty: 1,
freshRun: true,
...(biteSizedSelection ? { runId, biteSizedSelection } : {}),
});
return entered
? { status: "entered", dungeonTitle: dungeon.title }
+7 -2
View File
@@ -8,10 +8,14 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
dungeonContentPackId,
} from "../game/dungeonContentSource";
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
import { biteSizedDefinitionForSelection } from "../game/dungeonRegistry";
import { preparedBiteSizedRunSelection, prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] {
const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId);
const selection = preparedBiteSizedRunSelection(dungeonId, seed) ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, dungeonId, seed);
const definition = biteSizedDefinitionForSelection(selection, seed);
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : []
)))].sort();
@@ -39,6 +43,7 @@ export async function ensureRandomizedBiteSizedRunContent(
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
): Promise<void> {
if (dependencies === DEFAULT_DEPENDENCIES) { await prepareBiteSizedLayoutContent(dungeonId, seed); return; }
if (dependencies.development) return;
const basePackId = dungeonContentPackId(dungeonId);
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
+1 -1
View File
@@ -7,7 +7,7 @@ import type { ManastormProgress } from "../game/manastormProgress";
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import type { BossCardCollection } from "../game/bossCards";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "bite-sized-editor" | "game";
export type SessionKind = "account" | "offline";
export type AccountRole = "gm";
+2 -1
View File
@@ -25,6 +25,7 @@ import {
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
import {
DUNGEON_SIMULATION_FIXED_STEP_MS,
useDungeonSimulationStore,
@@ -192,7 +193,7 @@ export function CombatBridge() {
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive || game.raceStartBlocked) return;
if (game.dungeonIntroActive || game.raceStartBlocked || useBiteSizedLayoutsStore.getState().editorMode === "edit") return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
+4 -2
View File
@@ -9,6 +9,7 @@ import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunMember } from "./dungeonRunTypes";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
function currentRunParty(): readonly DungeonRunMember[] {
const shell = useShellStore.getState();
@@ -36,7 +37,8 @@ export function DungeonRunBridge() {
const advance = () => {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
const editorTest = useBiteSizedLayoutsStore.getState().editorMode === "test";
if (shell.phase !== "game" || game.gameMode !== "dungeon" || (game.gmTestMode && !editorTest) || game.dungeonIntroActive
|| game.raceStartBlocked
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
@@ -55,7 +57,7 @@ export function DungeonRunBridge() {
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
const progress = useDungeonRunStore.getState().progress;
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
if (progress?.finishedAt !== null && progress && announcedId !== progress.id && !editorTest) {
announcedId = progress.id;
useGameStore.getState().openOverlay("dungeon-results");
}
+63
View File
@@ -0,0 +1,63 @@
import { BITE_SIZED_DEFAULT_CONFIG, biteSizedGameplayKey, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
import type { DungeonDefinition } from "./dungeonTypes";
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "./mobPopulation";
export function materializeBiteSizedLayout(
source: DungeonDefinition, baseline: DungeonDefinition, selection: BiteSizedRunSelection,
sources: Readonly<Record<string, DungeonDefinition>>, difficultyId?: string,
): DungeonDefinition {
const { spot } = selection;
const randomized = selection.dungeonId.startsWith("randomized-bite-sized-");
const original = BITE_SIZED_DEFAULT_CONFIG.spots.find((s) => s.id === spot.id);
if (!randomized && spot.legacyLayout && spot.revision === 0 && original && biteSizedGameplayKey(original) === biteSizedGameplayKey(spot) && baseline.bosses[0]?.id === selection.boss.objectiveId) {
return { ...baseline, contentSourceDungeonId: spot.sourceDungeonId, biteSizedSelection: selection };
}
const entities: Record<string, PopulationEntityDefinition> = {};
const resolve = (sourceDungeonId: string, entityId: string) => {
const original = sources[sourceDungeonId]?.entities[entityId];
if (!original) throw new Error(`Creature unavailable: ${sourceDungeonId}/${entityId}`);
const id = `placed:${sourceDungeonId}:${entityId}`;
entities[id] = { ...original, ...(randomized && original.combat ? { combat: { ...original.combat, healthMultiplier: 1, damageMultiplier: 1 } } : {}) };
return id;
};
const difficulty = source.difficultyVariants.find((d) => d.id === difficultyId) ?? source.difficultyVariants.find((d) => d.id === source.defaultDifficultyId)!;
const allMasks = source.difficultyVariants.reduce((mask, d) => mask | d.spawnMask, 0);
const mobs: StaticMobSpawnDefinition[] = spot.mobs.flatMap((placement) => {
if (((placement.spawnMask ?? allMasks) & difficulty.spawnMask) === 0) return [];
const ref = selection.mobs.find((m) => m.placementId === placement.id)!;
return [{ id: placement.id, entityId: resolve(ref.sourceDungeonId, ref.entityId), position: placement.position,
yaw: placement.yaw, spawnMask: placement.spawnMask ?? allMasks, spawnChance: 1 }];
});
const bossSpawn: StaticMobSpawnDefinition = { id: selection.boss.objectiveId, entityId: resolve(selection.boss.sourceDungeonId, selection.boss.entityId),
position: spot.bossSpawn.footPosition, yaw: spot.bossSpawn.yaw, spawnMask: allMasks };
const held = spot.bossActivation === "after-mobs" && mobs.length > 0;
const groupId = `${spot.id}:boss`;
const encounter: DungeonEncounterDefinition = {
id: `${spot.id}:progression`, dungeonId: selection.dungeonId, initialPhase: held ? "mobs" : "boss",
initialState: { flags: { "dungeon-complete": false } }, resetPolicy: { bossWipePhase: held ? "mobs" : "boss", checkpointPhase: held ? "mobs" : "boss" },
rules: [
...(held ? [{ id: "mobs-cleared", trigger: { kind: "entities-dead" as const, entityIds: mobs.map((m) => m.id) },
guards: [{ kind: "phase" as const, phases: ["mobs"] }], actions: [
{ id: "spawn-boss", kind: "activate-spawn-group" as const, spawnGroupId: groupId },
{ id: "boss-phase", kind: "set-phase" as const, phase: "boss" },
] }] : []),
{ id: "boss-defeated", trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] }, guards: [{ kind: "phase", phases: ["boss"] }], actions: [
{ id: "objective", kind: "complete-objective", objectiveId: bossSpawn.id, persistence: "run" },
{ id: "checkpoint", kind: "set-checkpoint", checkpointId: bossSpawn.id, persistence: "checkpoint" },
{ id: "complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
] },
],
};
const objective = { id: bossSpawn.id, name: selection.boss.name, position: bossSpawn.position, eventOnly: held };
return { ...source, id: selection.dungeonId, contentSourceDungeonId: source.id, biteSizedSelection: selection,
title: `${source.title}: ${spot.name}`, lfgDungeonIds: [],
entrance: { ...source.entrance, name: spot.name, ...spot.partySpawn, forward: [Math.sin(spot.partySpawn.yaw), 0, Math.cos(spot.partySpawn.yaw)] },
presentation: { ...baseline.presentation, location: `${source.title} · ${spot.name}`, theme: "Bite-Sized Dungeon", loadingMessage: `Entering ${spot.name}...` },
areas: [{ id: "spot", name: spot.name, center: spot.partySpawn.footPosition, radius: 100 }],
entities, staticSpawns: held ? mobs : [...mobs, bossSpawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
finalBossDurability: "standard", encounters: [encounter], encounterSpawnGroups: held ? [{ id: groupId, spawns: [bossSpawn] }] : [],
serverGameObjects: spot.legacyLayout ? baseline.serverGameObjects : [],
};
}
+28
View File
@@ -0,0 +1,28 @@
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
import { resolveContentUrl } from "../content/contentManager";
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
import { resolveDungeonAssets } from "./dungeonAssets";
import { cachedPartyNavigationGraph, preparePartyNavigationGraph } from "./partyNavigationAssetCache";
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, type BiteSizedSpot } from "../../shared/bite-sized-layouts.mjs";
import { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
export { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
export async function validateChangedBiteSizedSpots(spots: readonly BiteSizedSpot[], previous: readonly BiteSizedSpot[]): Promise<void> {
for (const spot of spots) {
const old = previous.find((s) => s.id === spot.id);
if (old && biteSizedGameplayKey(old) === biteSizedGameplayKey(spot)) continue;
const source = DUNGEON_REGISTRY[spot.sourceDungeonId];
const url = resolveDungeonAssets(source).navigation;
if (!url) throw new Error(`${spot.name}: the parent dungeon has no navigation surface.`);
let graph = cachedPartyNavigationGraph(url, source.navigationLinks, source.traversalRamps, true);
if (!graph) {
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).loadAsync(resolveContentUrl(url));
graph = preparePartyNavigationGraph(url, gltf.scene, source.navigationLinks, source.traversalRamps, 1_000_000, true);
gltf.scene.traverse((node) => { if ("geometry" in node) (node as import("three").Mesh).geometry.dispose(); });
}
// Off-mesh routes are already included in the retained connected node
// component; triangle-only A* would incorrectly reject bridged floors.
const blockers = spot.legacyLayout ? BITE_SIZED_LAYOUT_CATALOG.dungeons.find((d) => d.id === spot.dungeonId)!.legacyBlockers : [];
validateBiteSizedSpotNavigation(spot, graph, graph.triangleNodes || blockers.length ? undefined : graph.triangles!.map(() => 0), blockers);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from "vitest";
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, normalizeBiteSizedSelection, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
import { biteSizedDefinitionForSelection, dungeonDefinitionById, legacyBiteSizedRunSelection, randomizedBiteSizedDefinitionForSeed } from "./dungeonRegistry";
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
import { useDungeonRunStore } from "./dungeonRunStore";
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "./dungeonEncounterRuntime";
import { normalizeDungeonRunCheckpoint, DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
const config = () => ({ schemaVersion: 1 as const, spots: [...normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG).spots] });
const parent = config().spots.find((s) => s.sourceDungeonId === "stormwind-stockade")!;
describe("published Bite-Sized spots", () => {
beforeEach(() => useBiteSizedLayoutsStore.setState({ config: config(), draft: config(), dirty: false, editorMode: null }));
it("permits repeated creatures, imported references, and different stable placement ids", () => {
const next = config();
const imported = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.kind === "mob" && c.sourceDungeonId !== parent.sourceDungeonId)!;
const duplicate = { ...parent, id: "another-spot", legacyLayout: false, mobs: [
{ ...parent.mobs[0], id: "one", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
{ ...parent.mobs[0], id: "two", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
] };
next.spots.push(duplicate);
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(next), parent.dungeonId, "duplicate", { spotId: duplicate.id });
const definition = biteSizedDefinitionForSelection(selection, "duplicate")!;
expect(definition.staticSpawns.filter((s) => s.id === "one" || s.id === "two")).toHaveLength(2);
expect(definition.serverGameObjects).toEqual([]);
expect(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection)))).toEqual(selection);
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.map((s) => s.id === duplicate.id ? { ...s, mobs: [s.mobs[0], s.mobs[0]] } : s) })).toThrow(/unique/);
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.filter((s) => s.sourceDungeonId !== parent.sourceDungeonId) })).toThrow(/between 1/);
expect(biteSizedGameplayKey(parent)).toBe(biteSizedGameplayKey({ ...parent, name: "Renamed", revision: 5 }));
});
it("pins the same spot, boss, and mobs through checkpoints, publication, and retries", () => {
const selection = selectBiteSizedRun(config(), parent.dungeonId, "pinned");
expect(activateDungeonSession(parent.dungeonId, undefined, { runId: "pinned", biteSizedSelection: selection })).toBe(true);
const run = useDungeonRunStore.getState().progress!;
const checkpoint = normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify({ version: DUNGEON_RUN_CHECKPOINT_VERSION,
dungeonId: parent.dungeonId, difficultyId: run.difficultyId, checkpointId: "entrance", spawn: parent.partySpawn,
encounters: [], savedAt: Date.now(), run })));
expect(checkpoint?.run?.biteSizedSelection).toEqual(selection);
const changed = config();
changed.spots = changed.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], name: "Published later" } : s);
useBiteSizedLayoutsStore.setState({ config: changed });
expect(restartActiveDungeonSession()).toBe(true);
expect(dungeonDefinitionById(parent.dungeonId)?.biteSizedSelection).toEqual(selection);
});
it("uses independent deterministic rolls and multiple parent maps for both randomized pools", () => {
for (const id of Object.keys(BITE_SIZED_LAYOUT_CATALOG.pools)) {
const maps = new Set<string>();
for (let i = 0; i < 60; i++) {
const seed = `random-${i}`;
const selection = selectBiteSizedRun(config(), id, seed);
maps.add(selection.spot.sourceDungeonId);
expect(selection).toEqual(selectBiteSizedRun(config(), id, seed));
expect(normalizeBiteSizedSelection(selection)).toEqual(selection);
expect(biteSizedDefinitionForSelection(selection, seed)?.contentSourceDungeonId).toBe(selection.spot.sourceDungeonId);
}
expect(maps.size).toBe(12);
}
});
it.each(["immediate", "after-mobs"] as const)("completes an edited %s encounter", (activation) => {
const next = config();
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, bossActivation: activation } : s);
const selection = selectBiteSizedRun(next, parent.dungeonId, "completion");
const definition = biteSizedDefinitionForSelection(selection, "completion")!;
const encounter = definition.encounters![0];
let state = createDungeonEncounterSnapshot(encounter);
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 1000, facts: {
registeredEntityIds: selection.spot.mobs.map((m) => m.id), deadEntityIds: selection.spot.mobs.map((m) => m.id),
} }).snapshot;
expect(state.phase).toBe("boss");
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 2000, facts: {
registeredEntityIds: [selection.boss.objectiveId], deadEntityIds: [selection.boss.objectiveId],
} }).snapshot;
expect(state.phase).toBe("complete");
expect(state.flags["dungeon-complete"]).toBe(true);
expect(definition.encounterSpawnGroups?.length ?? 0).toBe(activation === "after-mobs" ? 1 : 0);
});
it("activates a boss-only after-mobs layout immediately", () => {
const next = config();
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], bossActivation: "after-mobs" } : s);
const definition = biteSizedDefinitionForSelection(selectBiteSizedRun(next, parent.dungeonId, "solo"), "solo")!;
expect(definition.staticSpawns).toHaveLength(1);
expect(definition.encounterSpawnGroups).toEqual([]);
expect(definition.encounters![0].initialPhase).toBe("boss");
});
it("preserves the original Stockades map and creature deal for legacy randomized checkpoints", () => {
const id = "randomized-bite-sized-stormwind-stockade", seed = "legacy-checkpoint";
const legacy = randomizedBiteSizedDefinitionForSeed(seed, id);
const selection = legacyBiteSizedRunSelection(id, seed)!;
expect(selection.legacyRandomized).toBe(true);
expect(selection.spot.sourceDungeonId).toBe("stormwind-stockade");
expect(biteSizedDefinitionForSelection(selection, seed).staticSpawns).toEqual(legacy.staticSpawns);
expect(biteSizedDefinitionForSelection(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection))), seed).staticSpawns).toEqual(legacy.staticSpawns);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
const request = vi.hoisted(() => vi.fn());
vi.mock("../app/onlineAccountClient", () => ({ requestOnlineJson: request }));
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
describe("GM spot drafts", () => {
beforeEach(() => { request.mockReset(); const config = normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG);
useBiteSizedLayoutsStore.setState({ config, draft: config, revision: 0, dirty: false, saving: false, error: null }); });
it("preserves unsaved placements on a conflict and on refresh, then permits discard", async () => {
const store = useBiteSizedLayoutsStore.getState();
const draft = { ...store.draft.spots[0], name: "Unsaved name" };
store.updateSpot(draft);
request.mockRejectedValueOnce(new Error("Revision conflict"));
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(false);
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: true, saving: false, error: "Revision conflict" });
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
request.mockResolvedValueOnce({ revision: 1, updatedAt: 1, config: store.config });
await store.refresh();
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
store.discard(); expect(useBiteSizedLayoutsStore.getState().dirty).toBe(false);
expect(useBiteSizedLayoutsStore.getState().draft.spots[0].name).toBe(store.config.spots[0].name);
});
it("requires GM credentials and atomically replaces a successfully published draft", async () => {
const store = useBiteSizedLayoutsStore.getState(); store.updateSpot({ ...store.draft.spots[0], name: "Draft" });
expect(await store.publish(null)).toBe(false); expect(request).not.toHaveBeenCalled();
const config = useBiteSizedLayoutsStore.getState().draft;
request.mockResolvedValueOnce({ config, revision: 1, updatedAt: 1 });
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(true);
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: false, revision: 1, saving: false, config });
});
});
+58
View File
@@ -0,0 +1,58 @@
import { create } from "zustand";
import { requestOnlineJson } from "../app/onlineAccountClient";
import type { PlayerSession } from "../app/types";
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, normalizeBiteSizedSpot, type BiteSizedConfig, type BiteSizedSpot, type PublishedBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
const CACHE_KEY = "healer-man.bite-sized-layouts.v1";
function cached(): PublishedBiteSizedConfig {
try {
const payload = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null");
if (!Number.isSafeInteger(payload?.revision) || payload.revision < 0) throw new Error("Missing cache");
return { revision: payload.revision, updatedAt: payload.updatedAt ?? null, config: normalizeBiteSizedConfig(payload.config) };
} catch { return { revision: 0, updatedAt: null, config: normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG) }; }
}
function cache(payload: PublishedBiteSizedConfig) {
try { localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* Cached defaults remain usable without storage. */ }
}
interface LayoutState extends PublishedBiteSizedConfig {
readonly draft: BiteSizedConfig; readonly dirty: boolean; readonly saving: boolean; readonly loading: boolean; readonly error: string | null;
readonly editingSpotId: string | null; readonly editingPointId: string; readonly editorMode: "edit" | "test" | null;
readonly panelFocused: boolean; readonly historicalSpots: readonly BiteSizedSpot[];
refresh: () => Promise<void>; loadHistory: () => Promise<void>; updateSpot: (spot: BiteSizedSpot) => void;
replaceDraft: (draft: BiteSizedConfig) => void; discard: () => void; publish: (session: PlayerSession | null) => Promise<boolean>;
}
const initial = cached();
export const useBiteSizedLayoutsStore = create<LayoutState>((set, get) => ({
...initial, draft: initial.config, dirty: false, saving: false, loading: false, error: null,
editingSpotId: null, editingPointId: "party", editorMode: null, panelFocused: false, historicalSpots: [],
refresh: async () => {
set({ loading: true });
try {
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/bite-sized-layouts");
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
cache(normalized);
set((state) => ({ ...normalized, loading: false, error: null, ...(state.dirty ? {} : { draft: normalized.config }) }));
} catch (error) { set({ loading: false, error: error instanceof Error ? error.message : "Could not load layouts." }); }
},
loadHistory: async () => {
try { const entries = await requestOnlineJson<readonly BiteSizedSpot[]>("/api/bite-sized-layouts?history=1"); set({ historicalSpots: entries.map(normalizeBiteSizedSpot) }); }
catch { set({ historicalSpots: get().config.spots }); }
},
replaceDraft: (draft) => set({ draft, dirty: true, error: null }),
updateSpot: (spot) => set((state) => ({ draft: { schemaVersion: 1, spots: state.draft.spots.map((s) => s.id === spot.id ? spot : s) }, dirty: true, error: null })),
discard: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) { set({ error: "Game Master access is required." }); return false; }
set({ saving: true, error: null });
try {
const config = normalizeBiteSizedConfig(get().draft);
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/gm/bite-sized-layouts", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config }),
}, session.accessToken);
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
return true;
} catch (error) { set({ saving: false, error: error instanceof Error ? error.message : "Could not publish layouts." }); return false; }
},
}));
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { biteSizedRouteSegmentClear } from "./biteSizedSpotNavigation";
describe("Bite-Sized branch blocker clearance", () => {
const wall = { position: [0, 2, 0] as const, halfExtents: [4, 3, 0.5] as const, yaw: 0 };
it("blocks crossing and occupied points while allowing routes around the wall", () => {
expect(biteSizedRouteSegmentClear([wall], [0, 0, -4], [0, 0, 4])).toBe(false);
expect(biteSizedRouteSegmentClear([wall], [0, 0, 0], [0, 0, 0])).toBe(false);
expect(biteSizedRouteSegmentClear([wall], [5, 0, -4], [5, 0, 4])).toBe(true);
expect(biteSizedRouteSegmentClear([wall], [0, 8, -4], [0, 8, 4])).toBe(true);
expect(biteSizedRouteSegmentClear([], [0, 0, -4], [0, 0, 4])).toBe(true);
});
it("checks rotated boxes in their local frame", () => {
const rotated = { ...wall, yaw: Math.PI / 2 };
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 0], [4, 0, 0])).toBe(false);
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 5], [4, 0, 5])).toBe(true);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationGraph } from "./partyNavigation";
import { derivePartySpawnSlots } from "./partySpawnSlots";
import type { BiteSizedLayoutBlocker, BiteSizedSpot, LayoutVector } from "../../shared/bite-sized-layouts.mjs";
export function biteSizedRouteSegmentClear(blockers: readonly BiteSizedLayoutBlocker[], from: LayoutVector, to: LayoutVector): boolean {
return blockers.every((blocker) => {
const sin = Math.sin(blocker.yaw), cos = Math.cos(blocker.yaw);
const local = (point: LayoutVector) => {
const x = point[0] - blocker.position[0], z = point[2] - blocker.position[2];
return [x * cos - z * sin, point[1] - blocker.position[1], x * sin + z * cos];
};
const start = local(from), end = local(to);
let enter = 0, leave = 1;
for (let axis = 0; axis < 3; axis++) {
const extent = blocker.halfExtents[axis] + (axis === 1 ? 1 : 0.34);
const delta = end[axis] - start[axis];
if (Math.abs(delta) < 1e-9) { if (Math.abs(start[axis]) > extent) return true; continue; }
const first = (-extent - start[axis]) / delta, second = (extent - start[axis]) / delta;
enter = Math.max(enter, Math.min(first, second));
leave = Math.min(leave, Math.max(first, second));
if (enter > leave) return true;
}
return false;
});
}
export function validateBiteSizedSpotNavigation(spot: BiteSizedSpot, graph: PartyNavigationGraph, regions?: readonly number[], blockers: readonly BiteSizedLayoutBlocker[] = []): void {
const start = findNearestPartyNavigationSurface(graph, spot.partySpawn.footPosition, 0.75);
if (!start) throw new Error(`${spot.name}: Party anchor is off the walkable floor. Move its spawn point.`);
const points: { label: string; position: readonly [number, number, number]; snapDistance?: number }[] = [
...derivePartySpawnSlots(spot.partySpawn).map((s, i) => ({ label: `Party member ${i + 1}`, position: s.footPosition, snapDistance: i === 0 ? 0.75 : 6 })),
...spot.mobs.map((m, i) => ({ label: `Mob ${i + 1}`, position: m.position })),
{ label: "Boss", position: spot.bossSpawn.footPosition },
];
for (const point of points) {
// Companions project their formation seats onto the floor within six metres,
// exactly as PartyPopulation does. Authored mob/boss points never auto-snap.
const match = findNearestPartyNavigationSurface(graph, point.position, point.snapDistance ?? 0.75);
if (!match) throw new Error(`${spot.name}: ${point.label} is off the walkable floor. Move its spawn point.`);
if (!biteSizedRouteSegmentClear(blockers, match.position, match.position)) throw new Error(`${spot.name}: ${point.label} is inside a route blocker.`);
const reachable = regions ? regions[start.triangleId] === regions[match.triangleId]
: findPartyNavigationPath(graph, start.position, match.position, { maxSnapDistance: 0.75,
routeSegmentAllowed: (from, to) => biteSizedRouteSegmentClear(blockers, from, to) });
if (!reachable) throw new Error(`${spot.name}: ${point.label} cannot be reached from the party start.`);
}
}
+11 -3
View File
@@ -76,7 +76,7 @@ describe("bite-sized Stormwind Stockades", () => {
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
});
it("uses ordinary boss durability for every level-17 roll", () => {
it("retains the shorter Bite-Sized durability curve for every level-17 roll", () => {
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
@@ -95,9 +95,17 @@ describe("bite-sized Stormwind Stockades", () => {
const boss = definition.bosses[0];
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
const entity = definition.entities[spawn.entityId];
expect(dungeonEnemyScaling(
const health = dungeonEnemyScaling(
definition, entity, boss.id, 17, "normal", 1, characterSource,
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
).maxHealth;
expect(health, `${characterSource}:${boss.name}`).toBeGreaterThan(0);
expect(health, `${characterSource}:${boss.name}`).toBeLessThan(ordinaryHealth);
// The established leveling curve interpolates a 0.685–0.835 WoW
// floor/ceiling at level 17; editor work must not restore campaign HP.
if (characterSource === "wow") {
expect(health).toBeGreaterThanOrEqual(ordinaryHealth * 0.685 - 0.001);
expect(health).toBeLessThanOrEqual(ordinaryHealth * 0.835 + 0.001);
}
}
}
});
+1 -1
View File
@@ -39,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
const wow = wowDungeonMapFor(dungeon);
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
const source = runewakerMaps[dungeonContentSourceId(dungeon.id)];
const source = runewakerMaps[dungeon.contentSourceDungeonId ?? dungeonContentSourceId(dungeon.id)];
if (source?.mapId !== dungeon.mapId
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
+1 -1
View File
@@ -20,7 +20,7 @@ export interface ResolvedDungeonAssets {
}
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
const sourceId = dungeonContentSourceId(definition.id);
const sourceId = definition.contentSourceDungeonId ?? dungeonContentSourceId(definition.id);
return {
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
+38 -2
View File
@@ -1,4 +1,7 @@
import type { DungeonDefinition } from "./dungeonTypes";
import { materializeBiteSizedLayout } from "./biteSizedLayoutRuntime";
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
import availability from "./generated/dungeonAvailability.json";
@@ -353,7 +356,7 @@ export function clearBossTestingDungeons(): void {
}
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
function legacyBiteSizedDefinition(id: string, seed: string): DungeonDefinition | null {
let definition: DungeonDefinition;
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
@@ -369,7 +372,40 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
else return dungeonDefinitionById(id);
sessionDungeonDefinitions.set(id, definition);
return definition;
}
export function biteSizedDefinitionForSelection(selection: BiteSizedRunSelection, seed: string, difficultyId?: string): DungeonDefinition {
const validated = normalizeBiteSizedSelection(selection);
if (validated.legacyRandomized) return { ...legacyBiteSizedDefinition(validated.dungeonId, seed)!, contentSourceDungeonId: validated.spot.sourceDungeonId, biteSizedSelection: validated };
const source = adaptedSourceRegistry[validated.spot.sourceDungeonId];
const baseline = legacyBiteSizedDefinition(validated.spot.dungeonId, seed);
if (!source || !baseline) throw new Error("The selected Bite-Sized parent is unavailable.");
return materializeBiteSizedLayout(source, baseline, validated, adaptedSourceRegistry, difficultyId);
}
/** Checkpoints made before the editor retain the original map and creature deal. */
export function legacyBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined {
if (!isLayoutBiteSizedDungeon(id)) return undefined;
const randomized = id.startsWith("randomized-");
const selection = selectBiteSizedRun(BITE_SIZED_DEFAULT_CONFIG, id, seed, {
eligibleDungeonIds: [randomized ? "bite-sized-stormwind-stockade" : id],
});
if (!randomized) return selection;
const definition = legacyBiteSizedDefinition(id, seed)!;
const pool = BITE_SIZED_LAYOUT_CATALOG.pools[id];
return normalizeBiteSizedSelection({ ...selection, legacyRandomized: true, mobs: selection.mobs.map((m) => {
const spawn = definition.staticSpawns.find((s) => s.id === m.placementId)!;
const ref = pool.find((c) => c.objectiveId === spawn.entityId)!;
return { placementId: m.placementId, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
}) });
}
export function prepareDungeonSessionDefinition(id: string, seed: string, selection?: BiteSizedRunSelection, difficultyId?: string): DungeonDefinition | null {
const definition = isLayoutBiteSizedDungeon(id)
? biteSizedDefinitionForSelection(selection ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed), seed, difficultyId)
: dungeonDefinitionById(id);
if (definition) sessionDungeonDefinitions.set(id, definition);
return definition;
}
+1
View File
@@ -22,6 +22,7 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
const definition = dungeonDefinitionById(progress.dungeonId);
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
...(progress.biteSizedSelection ? { biteSizedSelection: progress.biteSizedSelection } : {}),
bossDifficulty: progress.bossDifficulty,
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
+8
View File
@@ -1,4 +1,5 @@
import { validBossDifficulty } from "./dungeonDifficulty";
import { normalizeBiteSizedSelection, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
export interface DungeonRunMember {
readonly name: string;
@@ -10,6 +11,7 @@ export interface DungeonRunMember {
}
export interface DungeonRunProgress {
readonly biteSizedSelection?: BiteSizedRunSelection;
readonly id: string;
readonly dungeonId: string;
readonly difficultyId: string;
@@ -23,6 +25,7 @@ export interface DungeonRunProgress {
}
export interface DungeonRunRecord {
readonly biteSizedSelection?: BiteSizedRunSelection;
readonly id: string;
readonly dungeonId: string;
readonly difficultyId: string;
@@ -74,7 +77,12 @@ export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
let biteSizedSelection: BiteSizedRunSelection | undefined;
try { if (input.biteSizedSelection !== undefined) biteSizedSelection = normalizeBiteSizedSelection(input.biteSizedSelection); }
catch { return null; }
if (biteSizedSelection && biteSizedSelection.dungeonId !== input.dungeonId) return null;
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
...(biteSizedSelection ? { biteSizedSelection } : {}),
...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}),
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
+18 -2
View File
@@ -1,4 +1,6 @@
import { useCombatStore } from "./combatStore";
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
import { preparedBiteSizedRunSelection } from "../app/biteSizedLayoutContent";
import { releaseDungeonGltfCache } from "./dungeonAssetCache";
import { clearDungeonRuntimeCaches } from "./dungeonRuntimeReset";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
@@ -11,6 +13,7 @@ import {
dungeonCanEnter,
dungeonDefinitionById,
prepareDungeonSessionDefinition,
legacyBiteSizedRunSelection,
type DungeonId,
} from "./dungeonRegistry";
import type { WorldSpawn } from "./dungeonTypes";
@@ -32,6 +35,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { dungeonRouteDefinition } from "./dungeonRoutes";
export interface DungeonSessionOptions {
readonly biteSizedSelection?: BiteSizedRunSelection;
readonly bossDifficulty?: number;
readonly gameMode?: GameMode;
readonly spawn?: WorldSpawn;
@@ -63,8 +67,11 @@ export function activateDungeonSession(
const testingSource = bossTestingSourceDungeonId(dungeonId);
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
const suppliedSelection = options.biteSizedSelection ?? options.runProgress?.biteSizedSelection
?? (options.preserveCharacter ? useDungeonRunStore.getState().progress?.biteSizedSelection : undefined)
?? (options.runId ? preparedBiteSizedRunSelection(dungeonId, options.runId) : undefined);
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|| !dungeonContentIsUsable(testing ? testingSource! : suppliedSelection?.spot.sourceDungeonId ?? dungeonId)) {
return false;
}
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
@@ -78,7 +85,15 @@ export function activateDungeonSession(
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
}
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
definition = prepareDungeonSessionDefinition(dungeonId, runSeed) ?? definition;
const legacySelection = options.runProgress && !options.runProgress.biteSizedSelection
? legacyBiteSizedRunSelection(dungeonId, runSeed) : undefined;
definition = prepareDungeonSessionDefinition(dungeonId, runSeed,
suppliedSelection ?? legacySelection ?? preparedBiteSizedRunSelection(dungeonId, runSeed),
difficultyId ?? definition.defaultDifficultyId) ?? definition;
const progress = useDungeonRunStore.getState().progress;
if (progress && definition.biteSizedSelection) useDungeonRunStore.setState({ progress: {
...progress, biteSizedSelection: definition.biteSizedSelection, ...(options.gmTestMode ? { timed: false } : {}),
} });
activateDungeonEncounterDefinitions(
dungeonId,
definition.encounters ?? [],
@@ -159,5 +174,6 @@ export function restartActiveDungeonSession(): boolean {
preserveCharacter: true,
bossDifficulty: game.activeBossDifficulty,
encounterSnapshots,
biteSizedSelection: useDungeonRunStore.getState().progress?.biteSizedSelection,
});
}
+2
View File
@@ -242,6 +242,8 @@ export interface DungeonValidationStatus {
}
export interface DungeonDefinition {
readonly contentSourceDungeonId?: string;
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
readonly schemaVersion: typeof DUNGEON_DEFINITION_SCHEMA_VERSION;
readonly id: string;
readonly title: string;
+4 -2
View File
@@ -47,6 +47,8 @@ export interface PartyNavigationGraph {
}
export interface PartyNavigationBuildOptions {
/** Validation may retain floor triangles while gameplay routes off-mesh links through nodes. */
readonly retainSurfaceTriangles?: boolean;
readonly weldTolerance?: number;
/** Defensive runtime ceiling; checked before graph allocations begin. */
readonly maxSourceTriangles?: number;
@@ -167,7 +169,7 @@ function connectedComponents(nodes: readonly MutableWeldedNode[]): number[][] {
return components;
}
function buildPartyNavigationTriangleNodes(
export function buildPartyNavigationTriangleNodes(
triangles: readonly PartyNavigationTriangle[],
positions: readonly PartyNavigationPosition[],
): readonly PartyNavigationTriangleNode[] {
@@ -432,7 +434,7 @@ export function buildPartyNavigationGraph(
return Object.freeze({
nodes: Object.freeze(nodes),
...(usesOffMeshLinks ? {} : {
...(usesOffMeshLinks && !options.retainSurfaceTriangles ? {} : {
triangles: Object.freeze(triangles),
triangleNodes,
}),
+9 -2
View File
@@ -10,6 +10,10 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
const graphCache = new Map<string, PartyNavigationGraph>();
export function cachedPartyNavigationGraph(url: string, links: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[] = [], retainSurfaceTriangles = false): PartyNavigationGraph | null {
return graphCache.get(graphCacheKey(url, links, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "")) ?? null;
}
function graphCacheKey(
url: string,
offMeshLinks: readonly DungeonNavigationLink[],
@@ -60,8 +64,10 @@ export function preparePartyNavigationGraph(
scene: Group,
offMeshLinks: readonly DungeonNavigationLink[],
ramps: readonly DungeonTraversalRamp[] = [],
maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
retainSurfaceTriangles = false,
): PartyNavigationGraph {
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
const cacheKey = graphCacheKey(url, offMeshLinks, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "");
const cached = graphCache.get(cacheKey);
if (cached) {
graphCache.delete(cacheKey);
@@ -79,7 +85,8 @@ export function preparePartyNavigationGraph(
}
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
graph = buildPartyNavigationGraph(geometry, {
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
maxSourceTriangles,
retainSurfaceTriangles,
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
});
} finally {
@@ -112,7 +112,10 @@ describe("Randomized Bite-Sized Stockades", () => {
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
const selected = dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)!;
expect(selected.biteSizedSelection?.dungeonId).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run", biteSizedSelection: selected.biteSizedSelection })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(selected.staticSpawns);
});
it("uses the selected cross-dungeon boss for combat and completion", () => {
+1 -1
View File
@@ -20,7 +20,7 @@ export interface WowDungeonMap {
const maps = catalog.dungeons as unknown as Readonly<Record<string, WowDungeonMap>>;
export function wowDungeonMapFor(dungeon: DungeonDefinition): WowDungeonMap | null {
const map = maps[dungeon.id];
const map = maps[dungeon.contentSourceDungeonId ?? dungeon.id];
// A replacement environment must be reimported, never silently misaligned.
return map?.mapId === dungeon.mapId
&& map.coordinateTransform === dungeon.provenance.coordinateTransform
+46
View File
@@ -0,0 +1,46 @@
import { useMemo } from "react";
import { Html } from "@react-three/drei";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { biteSizedDefinitionForSelection } from "../game/dungeonRegistry";
import { selectBiteSizedRun, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
import { MobPopulation } from "./MobPopulation";
import { useGameStore } from "../game/store";
import { resolveDungeonAssets } from "../game/dungeonAssets";
import { cachedPartyNavigationGraph } from "../game/partyNavigationAssetCache";
import { findNearestPartyNavigationSurface } from "../game/partyNavigation";
function Marker({ id, position, yaw, color, label, selected }: {
readonly id: string; readonly position: LayoutVector; readonly yaw: number; readonly color: string; readonly label: string; readonly selected: boolean;
}) {
const choose = () => useBiteSizedLayoutsStore.setState({ editingPointId: id, panelFocused: false });
return <group position={position} rotation={[0, yaw, 0]}>
<mesh position={[0, .08, 0]} rotation={[-Math.PI / 2, 0, 0]} onClick={(e) => { e.stopPropagation(); choose(); }}>
<ringGeometry args={[selected ? .7 : .55, selected ? 1 : .8, 24]} /><meshBasicMaterial color={color} depthTest={false} transparent opacity={.85} />
</mesh>
<mesh position={[0, .65, 0]} onClick={(e) => { e.stopPropagation(); choose(); }}><coneGeometry args={[.2, 1.1, 8]} /><meshBasicMaterial color={color} transparent opacity={.6} depthTest={false} /></mesh>
<mesh position={[0, .1, 1.15]}><boxGeometry args={[.13, .13, .9]} /><meshBasicMaterial color={color} depthTest={false} /></mesh>
{selected && <Html position={[0, 1.6, 0]} center style={{ pointerEvents: "none", color, fontSize: 12, whiteSpace: "nowrap", background: "#17171ddd", padding: "4px 8px", borderRadius: 5 }}>{label}</Html>}
</group>;
}
export function BiteSizedEditorMarkers() {
const layouts = useBiteSizedLayoutsStore();
const difficultyId = useGameStore((s) => s.activeDifficultyId);
const spot = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
const preview = useMemo(() => {
if (!spot || layouts.editorMode !== "edit") return null;
try { return biteSizedDefinitionForSelection(selectBiteSizedRun(layouts.draft, spot.dungeonId, "gm-preview", { spotId: spot.id }), "gm-preview", difficultyId); }
catch { return null; }
}, [layouts.draft, layouts.editorMode, spot, difficultyId]);
if (!spot || layouts.editorMode !== "edit") return null;
const navigationUrl = preview && resolveDungeonAssets(preview).navigation;
const graph = navigationUrl && preview ? cachedPartyNavigationGraph(navigationUrl, preview.navigationLinks, preview.traversalRamps, true)
?? cachedPartyNavigationGraph(navigationUrl, preview.navigationLinks, preview.traversalRamps) : null;
return <group name="bite-sized-editor-markers">
{preview && <MobPopulation active={false} showLabels={false} entities={preview.entities} roamingPacks={[]}
staticSpawns={[...preview.staticSpawns, ...(preview.encounterSpawnGroups ?? []).flatMap((g) => g.spawns)]} />}
{derivePartySpawnSlots(spot.partySpawn).map((p, i) => <Marker key={`party-${i}`} id="party" position={i > 0 && graph ? findNearestPartyNavigationSurface(graph, p.footPosition, 6)?.position ?? p.footPosition : p.footPosition} yaw={p.yaw} color="#55e8ff" label={`Party start · seat ${i + 1}`} selected={layouts.editingPointId === "party" && i === 0} />)}
<Marker id="boss" position={spot.bossSpawn.footPosition} yaw={spot.bossSpawn.yaw} color="#ff5570" label="Random boss spawn" selected={layouts.editingPointId === "boss"} />
{spot.mobs.map((m, i) => <Marker key={m.id} id={m.id} position={m.position} yaw={m.yaw} color="#ffd255" label={`Mob point ${i + 1}`} selected={layouts.editingPointId === m.id} />)}
</group>;
}
+7 -3
View File
@@ -9,6 +9,8 @@ import {
type PointerEvent as ReactPointerEvent,
} from "react";
import { Canvas, useThree } from "@react-three/fiber";
import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { Physics } from "@react-three/rapier";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { spawnMatchesDifficulty } from "../game/mobPopulation";
@@ -173,6 +175,7 @@ export function GameScene() {
];
const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused);
const editingBiteSized = useBiteSizedLayoutsStore((state) => state.editorMode === "edit");
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
const mapOpen = useGameStore((state) => state.mapOpen);
@@ -407,7 +410,7 @@ export function GameScene() {
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
<PartyPopulation
active={!simulationBlocked}
active={!simulationBlocked && !editingBiteSized}
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
/>
<DungeonSimulationSceneBridge />
@@ -416,13 +419,14 @@ export function GameScene() {
{gameMode === "dungeon" && (
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
)}
<MobPopulation
{!editingBiteSized && <MobPopulation
active={!simulationBlocked}
entities={stagePopulation.entities}
roamingPacks={roamingPacks}
staticSpawns={staticSpawns}
allowedRuntimeIds={allowedRuntimeIds}
/>
/>}
<BiteSizedEditorMarkers />
<CombatSummonPopulation active={!simulationBlocked} />
<WailingNaralexEvent />
<ShelvedGameSceneFeatures />
+4
View File
@@ -21,6 +21,10 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
export function combatEffectVisualTuning(
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
): { readonly scale: number; readonly opacity: number } {
if ((event.abilityId === "priest-flash-heal" || event.abilityId === "wow335-priest-flash-heal")
&& event.phase === "impact") {
return { scale: 0.6, opacity: 0.4 };
}
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
? { scale: 0.45, opacity: 0.4 }
: { scale: 1, opacity: 1 };
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { isLayoutBiteSizedDungeon } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
export function useBiteSizedBoardSelection(dungeonId: string) {
const config = useBiteSizedLayoutsStore((s) => s.config);
const history = useBiteSizedLayoutsStore((s) => s.historicalSpots);
const [spotId, setSpotId] = useState("");
const [revision, setRevision] = useState<number | null>(null);
const enabled = isLayoutBiteSizedDungeon(dungeonId);
useEffect(() => { if (enabled) void useBiteSizedLayoutsStore.getState().loadHistory(); }, [enabled, dungeonId]);
const spots = [...config.spots, ...history].filter((s) => dungeonId.startsWith("randomized-") || s.dungeonId === dungeonId)
.filter((s, i, all) => all.findIndex((other) => other.id === s.id) === i);
const spot = spots.find((s) => s.id === spotId) ?? spots[0];
const revisions = [...new Set([...history, ...config.spots].filter((s) => s.id === spot?.id).map((s) => s.revision))].sort((a, b) => b - a);
const spotRevision = revision !== null && revisions.includes(revision) ? revision : spot?.revision;
return { enabled, spots, spotId: enabled ? spot?.id : undefined, spotRevision: enabled ? spotRevision : undefined,
revisions, selectSpot: (id: string) => { setSpotId(id); setRevision(null); }, selectRevision: setRevision };
}
export function BiteSizedBoardFilters({ selection }: { readonly selection: ReturnType<typeof useBiteSizedBoardSelection> }) {
if (!selection.enabled) return null;
return <div className="bite-sized-board-filters" style={{ display: "flex", gap: 12, padding: "12px 0", flexWrap: "wrap" }}>
<label>Spot<select aria-label="Leaderboard spot" value={selection.spotId} onChange={(e) => selection.selectSpot(e.target.value)}>
{selection.spots.map((s) => <option key={s.id} value={s.id}>{s.name} · {s.sourceDungeonId}</option>)}
</select></label>
<label>Gameplay revision<select aria-label="Leaderboard revision" value={selection.spotRevision} onChange={(e) => selection.selectRevision(Number(e.target.value))}>
{selection.revisions.map((r) => <option key={r} value={r}>Revision {r}</option>)}
</select></label>
</div>;
}
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from "react";
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, selectBiteSizedRun, type BiteSizedSpot, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
import { useShellStore } from "../app/shellStore";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useGameStore } from "../game/store";
import { enterBiteSizedEditorSpot, leaveBiteSizedEditor } from "../app/biteSizedEditorRuntime";
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
import { FrontSurface } from "./FrontSurface";
import { releasePointerLock } from "../game/inputManager";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import "./biteSizedEditor.css";
export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean }) {
const layouts = useBiteSizedLayoutsStore();
const session = useShellStore((s) => s.session);
const grounded = useGameStore((s) => s.playerGrounded);
const testComplete = useDungeonRunStore((s) => s.progress?.finishedAt != null);
const [dungeonId, setDungeonId] = useState(BITE_SIZED_LAYOUT_CATALOG.dungeons[0].id);
const [collapsed, setCollapsed] = useState(false);
const [search, setSearch] = useState("");
const [crossDungeon, setCrossDungeon] = useState(false);
const [creatureKey, setCreatureKey] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const current = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
const activeDungeon = inGame && current ? current.dungeonId : dungeonId;
const spots = layouts.draft.spots.filter((s) => s.dungeonId === activeDungeon);
const spot = current?.dungeonId === activeDungeon ? current : spots[0];
const selectedPoint = layouts.editingPointId;
const mob = spot?.mobs.find((m) => m.id === selectedPoint);
const position = mob?.position ?? (selectedPoint === "boss" ? spot?.bossSpawn.footPosition : spot?.partySpawn.footPosition);
const yaw = mob?.yaw ?? (selectedPoint === "boss" ? spot?.bossSpawn.yaw : spot?.partySpawn.yaw) ?? 0;
const creatures = useMemo(() => BITE_SIZED_LAYOUT_CATALOG.creatures.filter((c) => c.kind === "mob"
&& (crossDungeon || c.sourceDungeonId === spot?.sourceDungeonId)
&& `${c.name} ${c.dungeonTitle}`.toLowerCase().includes(search.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name)), [crossDungeon, search, spot?.sourceDungeonId]);
const creature = creatures.find((c) => `${c.sourceDungeonId}:${c.entityId}` === creatureKey) ?? creatures[0];
useEffect(() => { if (!inGame) void useBiteSizedLayoutsStore.getState().refresh(); }, [inGame]);
useEffect(() => () => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); }, []);
if (!sessionCanUseGmTools(session) || (inGame && !layouts.editorMode)) return null;
const attempt = async (action: () => Promise<unknown>) => {
setBusy(true); setMessage("");
try { await action(); } catch (error) { setMessage(error instanceof Error ? error.message : "Action failed."); }
finally { setBusy(false); }
};
const select = (id: string) => useBiteSizedLayoutsStore.setState({ editingSpotId: id, editingPointId: "party" });
const updatePoint = (next: LayoutVector, facing = yaw) => {
if (!spot) return;
const changed = selectedPoint === "boss" ? { ...spot, bossSpawn: { footPosition: next, yaw: facing } }
: mob ? { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, position: next, yaw: facing } : m) }
: { ...spot, partySpawn: { footPosition: next, yaw: facing } };
layouts.updateSpot(changed);
};
const capture = () => { const game = useGameStore.getState(); if (game.playerGrounded) updatePoint([...game.playerPosition], game.cameraYaw); };
const preparePreview = async (next: BiteSizedSpot) => {
const currentDraft = useBiteSizedLayoutsStore.getState().draft;
const draft = { ...currentDraft, spots: currentDraft.spots.map((s) => s.id === next.id ? next : s) };
const selection = selectBiteSizedRun(draft, next.dungeonId, "gm-preview", { spotId: next.id });
await prepareBiteSizedLayoutContent(next.dungeonId, "gm-preview", selection);
};
const addMob = () => {
if (!spot || !creature) return;
const game = useGameStore.getState();
const id = `mob-${crypto.randomUUID()}`;
const next = { ...spot, mobs: [...spot.mobs, { id, sourceDungeonId: creature.sourceDungeonId, entityId: creature.entityId,
position: inGame ? [...game.playerPosition] as LayoutVector : spot.partySpawn.footPosition, yaw: inGame ? game.cameraYaw : 0 }] };
void attempt(async () => { await preparePreview(next); layouts.updateSpot(next); useBiteSizedLayoutsStore.setState({ editingPointId: id }); });
};
const createSpot = (duplicate: boolean) => {
if (!spot) return;
const id = `spot-${crypto.randomUUID()}`;
const game = useGameStore.getState();
const next: BiteSizedSpot = duplicate ? { ...spot, id, revision: 0, name: `${spot.name.slice(0, 90)} copy`, mobs: spot.mobs.map((m) => ({ ...m, id: `mob-${crypto.randomUUID()}` })) }
: { ...spot, id, revision: 0, name: "New spot", legacyLayout: false, mobs: [], bossActivation: "after-mobs",
partySpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn,
bossSpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn };
layouts.replaceDraft({ schemaVersion: 1, spots: [...layouts.draft.spots, next] }); select(id);
if (inGame) void attempt(() => enterBiteSizedEditorSpot(id));
};
const publish = () => void attempt(async () => {
const draft = normalizeBiteSizedConfig(useBiteSizedLayoutsStore.getState().draft);
for (const changed of draft.spots) {
const previous = layouts.config.spots.find((s) => s.id === changed.id);
if (!previous || biteSizedGameplayKey(previous) !== biteSizedGameplayKey(changed)) await preparePreview(changed);
}
await validateChangedBiteSizedSpots(draft.spots, layouts.config.spots);
if (await layouts.publish(session)) setMessage("Published. New runs use these layouts; active runs keep their saved spot.");
});
const content = <section className={`bite-sized-editor ${inGame ? "bite-sized-editor--world" : ""} ${collapsed ? "is-collapsed" : ""}`}
aria-label="Bite-Sized Editor" onPointerDown={() => releasePointerLock()}
onFocusCapture={() => { releasePointerLock(); useBiteSizedLayoutsStore.setState({ panelFocused: true }); }}
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) useBiteSizedLayoutsStore.setState({ panelFocused: false }); }}>
<header><div><p className="eyebrow">GM world authoring</p><h1>Bite-Sized Editor</h1></div>
{inGame && <button onClick={() => { setCollapsed(!collapsed); useBiteSizedLayoutsStore.setState({ panelFocused: false }); }} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button>}
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
{!collapsed && <div className="bite-sized-editor__body">
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
{spot && <>
<div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id))}>{layouts.editorMode === "test" ? "Return to editing" : "Go to spot"}</button>
<button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id, "test"))}>Test run</button></div>
{layouts.editorMode !== "test" && <>
<label>Spot name<input value={spot.name} maxLength={100} onChange={(e) => layouts.updateSpot({ ...spot, name: e.target.value })} /></label>
<div className="bite-sized-editor__actions"><button disabled={busy || spots.length >= 80} onClick={() => createSpot(false)}>New spot</button><button disabled={busy || spots.length >= 80} onClick={() => createSpot(true)}>Duplicate spot</button>
<button disabled={busy || spots.length <= 1} onClick={() => { layouts.replaceDraft({ schemaVersion: 1, spots: layouts.draft.spots.filter((s) => s.id !== spot.id) }); const next = spots.find((s) => s.id !== spot.id)!; select(next.id); if (inGame) void attempt(() => enterBiteSizedEditorSpot(next.id)); }}>Remove spot</button></div>
<label>Boss arrival<select aria-label="Boss arrival" value={spot.bossActivation} onChange={(e) => layouts.updateSpot({ ...spot, bossActivation: e.target.value as BiteSizedSpot["bossActivation"] })}><option value="after-mobs">After all mobs die</option><option value="immediate">Present from start</option></select></label>
<div className="bite-sized-editor__points"><button aria-pressed={selectedPoint === "party"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "party" })}>Party start</button><button aria-pressed={selectedPoint === "boss"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "boss" })}>Random boss spawn</button></div>
<label>Mob points ({spot.mobs.length})<select value={mob?.id ?? ""} onChange={(e) => useBiteSizedLayoutsStore.setState({ editingPointId: e.target.value || "party" })}><option value="">Select a mob point</option>{spot.mobs.map((m, i) => <option key={m.id} value={m.id}>{i + 1}. {BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === m.sourceDungeonId && c.entityId === m.entityId)?.name ?? m.entityId}</option>)}</select></label>
{position && <fieldset><legend>{mob ? "Mob spawn" : selectedPoint === "boss" ? "Boss spawn" : "Party anchor"}</legend>
<div className="bite-sized-editor__coordinates">{["X", "Y", "Z"].map((axis, i) => <label key={axis}>{axis}<input aria-label={`Spawn ${axis}`} type="number" step="0.1" value={position[i]} onChange={(e) => { const n = e.currentTarget.valueAsNumber; if (Number.isFinite(n)) { const next = [...position] as [number, number, number]; next[i] = n; updatePoint(next); } }} /></label>)}</div>
<label>Facing<input aria-label="Spawn facing" type="number" step="0.05" value={yaw} onChange={(e) => { if (Number.isFinite(e.currentTarget.valueAsNumber)) updatePoint(position, e.currentTarget.valueAsNumber); }} /></label>
<div className="bite-sized-editor__actions"><button disabled={!inGame || !grounded} onClick={capture}>Set here</button><button disabled={!inGame} onClick={() => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); useGameStore.getState().relocatePlayer({ footPosition: position, yaw }); }}>Go to point</button></div>
</fieldset>}
<h2>Dungeon creatures</h2><label>Search mobs<input aria-label="Search mobs" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Name or source dungeon" /></label>
<label className="bite-sized-editor__check"><input type="checkbox" checked={crossDungeon} onChange={(e) => setCrossDungeon(e.target.checked)} />Other dungeons</label>
<label>Mob type<select aria-label="Mob type" value={creature ? `${creature.sourceDungeonId}:${creature.entityId}` : ""} onChange={(e) => setCreatureKey(e.target.value)}>{creatures.map((c) => <option key={`${c.sourceDungeonId}:${c.entityId}`} value={`${c.sourceDungeonId}:${c.entityId}`}>{c.name}{crossDungeon ? ` · ${c.dungeonTitle}` : ""}</option>)}</select></label>
<div className="bite-sized-editor__actions"><button disabled={busy || !creature || (inGame && !grounded) || spot.mobs.length >= 500} onClick={addMob}>{inGame ? "Add mob here" : "Add mob point"}</button>
{mob && <button disabled={busy || !creature} onClick={() => void attempt(async () => { const next = { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, sourceDungeonId: creature!.sourceDungeonId, entityId: creature!.entityId } : m) }; await preparePreview(next); layouts.updateSpot(next); })}>Change mob type</button>}</div>
{mob && <div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => { const id = `mob-${crypto.randomUUID()}`; layouts.updateSpot({ ...spot, mobs: [...spot.mobs, { ...mob, id, position: [mob.position[0] + 1, mob.position[1], mob.position[2]] }] }); useBiteSizedLayoutsStore.setState({ editingPointId: id }); }}>Duplicate mob point</button><button onClick={() => { layouts.updateSpot({ ...spot, mobs: spot.mobs.filter((m) => m.id !== mob.id) }); useBiteSizedLayoutsStore.setState({ editingPointId: "party" }); }}>Remove mob point</button></div>}
<p className="bite-sized-editor__hint">Bosses are rolled per run. Randomized mode uses these points with creatures from its selected pool.</p>
</>}
</>}
{(message || layouts.error) && <p role="status">{message || layouts.error}</p>}
<footer><button disabled={!layouts.dirty || busy || layouts.saving} onClick={() => { layouts.discard(); setMessage("Draft discarded."); }}>Discard</button><button disabled={busy || layouts.loading} onClick={() => void layouts.refresh()}>Refresh published</button><button disabled={!layouts.dirty || busy || layouts.saving} onClick={publish}>{busy || layouts.saving ? "Working…" : "Save & publish"}</button></footer>
</div>}
</section>;
return inGame ? content : <FrontSurface className="bite-sized-editor-browser" ariaLabel="Bite-Sized spot browser">{content}</FrontSurface>;
}
+6 -2
View File
@@ -13,6 +13,7 @@ import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/p
import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
@@ -46,18 +47,20 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
const spotBoard = useBiteSizedBoardSelection(boardDungeonId);
useEffect(() => {
const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}) } : {}) });
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}),
...(boardView === "times" && spotBoard.spotId ? { spot: spotBoard.spotId, spotRevision: String(spotBoard.spotRevision) } : {}) } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId]);
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId, spotBoard.spotId, spotBoard.spotRevision]);
const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : [];
@@ -121,6 +124,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
</section>
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
<h2>Leaderboards</h2>
{boardScope === "dungeon" && boardView === "times" && <BiteSizedBoardFilters selection={spotBoard} />}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardScope === "overall"} onClick={() => setBoardScope("overall")}>Overall wins</button>
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
+1
View File
@@ -68,6 +68,7 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
<p>The final boss is defeated. Your expedition is complete.</p>
{progress.biteSizedSelection && <p>{progress.biteSizedSelection.spot.name} · layout revision {progress.biteSizedSelection.spot.revision}</p>}
</header>
<div className="dungeon-results__score">
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
+23 -5
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { useContentManagerSnapshot } from "../content/useContentManager";
import { classById, raceById } from "../app/characterCatalog";
@@ -12,7 +13,7 @@ import {
type DungeonCategoryId,
type DungeonId,
} from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { dungeonDefinitionById, legacyBiteSizedRunSelection } from "../game/dungeonRegistry";
import {
partyRolesForClass,
PARTY_ROLE_LABELS,
@@ -38,6 +39,7 @@ import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
installDungeonShellRuntime();
@@ -81,6 +83,7 @@ export function DungeonSelectScreen() {
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
const [downloadError, setDownloadError] = useState("");
const [downloading, setDownloading] = useState(false);
const [preparingSpot, setPreparingSpot] = useState(false);
const categoryDungeons = dungeonsByCategory(selectedCategory);
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
@@ -105,8 +108,9 @@ export function DungeonSelectScreen() {
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].id);
};
const spotBoard = useBiteSizedBoardSelection(selectedDungeon?.id ?? "");
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal",
clearFilter, bossDifficulty, selectedBoss?.id);
clearFilter, bossDifficulty, selectedBoss?.id, spotBoard.spotId, spotBoard.spotRevision);
const toggleRun = (id: string) => setExpandedRuns((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
@@ -140,9 +144,20 @@ export function DungeonSelectScreen() {
};
const continueAsRole = async (role: PartyRole) => {
if (!selectedDungeon?.available || !character) return;
if (!selectedDungeon?.available || !character || preparingSpot) return;
if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty });
setPreparingSpot(true);
useShellStore.getState().setNotice("Preparing dungeon and creature downloads...");
try {
const checkpoint = character.dungeonRunCheckpoint;
const resume = checkpoint?.dungeonId === selectedDungeon.id && checkpoint.run?.finishedAt == null
&& (leaderboardDifficulty?.id === undefined || checkpoint.difficultyId === leaderboardDifficulty.id)
&& checkpoint.run?.bossDifficulty === bossDifficulty ? checkpoint.run : undefined;
const runId = resume?.id ?? crypto.randomUUID();
const biteSizedSelection = await prepareBiteSizedLayoutContent(selectedDungeon.id, runId, resume ? resume.biteSizedSelection ?? legacyBiteSizedRunSelection(selectedDungeon.id, runId) : undefined);
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty, runId, ...(biteSizedSelection ? { biteSizedSelection } : {}) });
} catch (error) { useShellStore.getState().setNotice(error instanceof Error ? error.message : "Could not prepare the spot."); }
finally { setPreparingSpot(false); }
return;
}
if (!await updateOnlineMember(session, character, role)) return;
@@ -240,6 +255,7 @@ export function DungeonSelectScreen() {
? availableRoles.map((role) => ({
id: `dungeon-role-${role}`,
run: () => enterAsRole(role),
enabled: !preparingSpot,
}))
: [{
id: "choose-dungeon-role",
@@ -251,7 +267,7 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
], [availableRoles, categoryDungeons, downloading, preparingSpot, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload
@@ -389,6 +405,7 @@ export function DungeonSelectScreen() {
select={controller.select}
className={`dungeon-role-option dungeon-role-option--${role}`}
type="button"
disabled={preparingSpot}
onClick={() => enterAsRole(role)}
>
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
@@ -433,6 +450,7 @@ export function DungeonSelectScreen() {
</section>
{leaderboardOpen && (
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
<BiteSizedBoardFilters selection={spotBoard} />
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
+1
View File
@@ -225,6 +225,7 @@ export function DungeonSimulationAdminScreen() {
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
<button className="front-button" type="button" onClick={() => setQueuePolicyOpen(true)}>Random queues</button>
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</button>
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
+1
View File
@@ -130,6 +130,7 @@ export function GmAdminScreen() {
<BrandMark compact />
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
<div className="gm-admin-header__actions">
<button className="front-button" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
<button className="front-button" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
{saving ? "Publishing…" : "Save & publish"}
+5
View File
@@ -222,6 +222,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
] : gmMenuOpen ? [
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
...(isGm ? [{ id: "gm-menu-bite-sized", run: () => useShellStore.setState({ phase: "bite-sized-editor" }) }] : []),
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
@@ -445,6 +446,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={openGmAdmin}>
<b aria-hidden="true">GM</b><span><strong>GM Tools</strong><small>Run dungeon simulations, inspect findings, and review recordings.</small></span><em>{isGm ? "Authorized" : "GM account required"}</em>
</ControllerButton>
{isGm && <ControllerButton controlId="gm-menu-bite-sized" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>
<b aria-hidden="true">BS</b><span><strong>Bite-Sized Editor</strong><small>Visit spots and place party, mob, and boss spawns.</small></span><em>Author</em>
</ControllerButton>}
<ControllerButton controlId="gm-menu-boss-testing" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenBossTesting}>
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Fight a selected or random encounter in an empty testing room.</small></span><em>Open</em>
+3 -2
View File
@@ -21,6 +21,7 @@ import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
import { BITE_SIZED_LAYOUT_CATALOG } from "../../shared/bite-sized-layouts.mjs";
installDungeonShellRuntime();
@@ -50,7 +51,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const dungeonId = pool === "all" ? RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID : RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID;
const DUNGEON = dungeonById(dungeonId);
const CANDIDATES = pool === "all" ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG : BITE_SIZED_RANDOMIZED_CREATURE_CATALOG;
const installed = dungeonContentIsUsable(dungeonId);
const installed = BITE_SIZED_LAYOUT_CATALOG.dungeons.some((d) => dungeonContentIsUsable(d.sourceDungeonId));
const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
@@ -124,7 +125,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
<section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1>
<p>Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.</p>
<p>Visit a random saved spot in an eligible dungeon. Each mob point and the final boss draw creatures from the pool you choose.</p>
<div className="randomized-bite-sized-screen__odds">
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
+28
View File
@@ -0,0 +1,28 @@
.bite-sized-editor { --spot-accent: #e8c580; color: #f4ebd8; background: rgba(22, 21, 26, .96); border: 1px solid #635443; border-radius: 12px; padding: 14px; width: min(680px, 94vw); margin: 20px auto; box-shadow: 0 12px 40px #0008; }
.bite-sized-editor--world { position: absolute; z-index: 45; top: 12px; right: 12px; width: min(330px, 30vw); max-height: calc(100dvh - 76px); margin: 0; display: flex; flex-direction: column; }
.bite-sized-editor--world header { flex-shrink: 0; }
.bite-sized-editor--world .bite-sized-editor__body { min-height: 0; max-height: none; flex: 1 1 auto; }
.bite-sized-editor header { display: flex; gap: 8px; align-items: center; }
.bite-sized-editor header > div { flex: 1; }
.bite-sized-editor h1 { font-size: 19px; margin: 2px 0 8px; }
.bite-sized-editor h2 { font-size: 15px; margin: 14px 0 5px; color: var(--spot-accent); }
.bite-sized-editor__body { overflow-y: auto; max-height: calc(100dvh - 110px); display: grid; gap: 9px; padding-right: 3px; }
.bite-sized-editor label { display: grid; gap: 4px; font-size: 12px; }
.bite-sized-editor input, .bite-sized-editor select { min-width: 0; width: 100%; box-sizing: border-box; padding: 7px; color: #f4ebd8; background: #17171d; border: 1px solid #615747; border-radius: 5px; }
.bite-sized-editor button { color: #f4ebd8; background: #3b332a; border: 1px solid #74614c; border-radius: 5px; padding: 7px 9px; font: inherit; font-size: 12px; cursor: pointer; }
.bite-sized-editor button:disabled { opacity: .45; cursor: default; }
.bite-sized-editor button[aria-pressed="true"] { border-color: var(--spot-accent); background: #55412b; }
.bite-sized-editor fieldset { border: 1px solid #615747; border-radius: 8px; display: grid; gap: 8px; padding: 10px; }
.bite-sized-editor__coordinates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.bite-sized-editor__actions, .bite-sized-editor__points, .bite-sized-editor footer { display: flex; gap: 6px; flex-wrap: wrap; }
.bite-sized-editor__status, .bite-sized-editor__hint { font-size: 12px; color: #cfbda1; margin: 3px 0; }
.bite-sized-editor .bite-sized-editor__check { display: flex; gap: 8px; align-items: center; }
.bite-sized-editor__check input { width: auto; }
.bite-sized-editor footer { border-top: 1px solid #615747; padding-top: 10px; }
.bite-sized-editor footer button:last-child { background: #70562d; }
.bite-sized-editor--world.is-collapsed { width: 235px; }
@media (max-width: 760px) {
.bite-sized-editor--world { width: min(300px, 83vw); max-height: 72dvh; }
.bite-sized-editor--world .bite-sized-editor__body { max-height: 58dvh; }
.bite-sized-editor--world.is-collapsed { width: 210px; }
}
+5 -5
View File
@@ -4,13 +4,13 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter,
bossDifficulty = 1, bossId?: string) {
bossDifficulty = 1, bossId?: string, spotId?: string, spotRevision?: number) {
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
data: { entries: [], total: 0 }, scope: "world", loading: true,
});
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId]);
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId, spotId, spotRevision]);
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
useEffect(() => {
const refresh = () => setRevision((value) => value + 1);
@@ -22,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
let live = true;
const timeout = setTimeout(() => controller.abort(), 8000);
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId)
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId, spotId, spotRevision)
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId),
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId, spotId, spotRevision),
scope: "device", loading: false }); });
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
return () => { live = false; controller.abort(); clearTimeout(timeout); };
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId]);
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId, spotId, spotRevision]);
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
}