Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -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,
|
evidence required before calling a visual bug fixed. Update it whenever scene,
|
||||||
camera, input, physics, mob-rendering, or asset-loading code changes.
|
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
|
## 2026-09-27 Shadowfiend summon visibility
|
||||||
|
|
||||||
- The dedicated `scripts/combat-effects/shadowfiend-preview.html` page cast the
|
- The dedicated `scripts/combat-effects/shadowfiend-preview.html` page cast the
|
||||||
|
|||||||
@@ -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/`.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"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",
|
"dev:shelved": "vite --host 0.0.0.0 --mode shelved",
|
||||||
"start": "node server/server.mjs",
|
"start": "node server/server.mjs",
|
||||||
"db:init": "node scripts/init-game-database.mjs",
|
"db:init": "node scripts/init-game-database.mjs",
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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." } });
|
||||||
@@ -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; });
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -22,6 +22,8 @@ import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena
|
|||||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.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 { createDungeonRaceRepository } from "./dungeon-races.mjs";
|
||||||
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
||||||
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
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");
|
return accountRoles(account?.username).includes("gm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const biteSizedLayouts = createBiteSizedLayoutRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
|
||||||
|
|
||||||
function getManastormConfig() {
|
function getManastormConfig() {
|
||||||
const row = statements.manastormConfig.get();
|
const row = statements.manastormConfig.get();
|
||||||
try {
|
try {
|
||||||
@@ -904,6 +908,8 @@ export function openGameDatabase(options = {}) {
|
|||||||
: null,
|
: null,
|
||||||
startedAt: Date.now(),
|
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(`
|
database.prepare(`
|
||||||
UPDATE online_groups
|
UPDATE online_groups
|
||||||
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
||||||
@@ -1180,9 +1186,10 @@ export function openGameDatabase(options = {}) {
|
|||||||
return {
|
return {
|
||||||
databasePath,
|
databasePath,
|
||||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
...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 {
|
return {
|
||||||
getDungeonRaceMatch: races.getMatch,
|
getDungeonRaceMatch: races.getMatch,
|
||||||
createDungeonRaceMatch: races.createMatch,
|
createDungeonRaceMatch: races.createMatch,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
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 dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
||||||
const classes = new Set(catalog.classes.map((entry) => entry.id));
|
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;
|
return defeated.length === 1 ? defeated[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
export function createDungeonLeaderboardRepository(database, { ErrorType, biteSizedLayouts }) {
|
||||||
database.exec(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
||||||
id TEXT PRIMARY KEY,
|
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
|
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)`);
|
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 = ?");
|
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
|
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)
|
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
|
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,
|
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) } : {}),
|
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
|
||||||
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
|
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) });
|
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;
|
const bossDifficulty = input.bossDifficulty ?? 0;
|
||||||
if (!Number.isInteger(bossDifficulty) || bossDifficulty < 0 || bossDifficulty > 10)
|
if (!Number.isInteger(bossDifficulty) || bossDifficulty < 0 || bossDifficulty > 10)
|
||||||
fail("Choose a boss difficulty from 1 to 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) {
|
function boardBossId(difficulty, requestedBossId) {
|
||||||
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
|
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
|
||||||
@@ -76,15 +94,17 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
|||||||
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
|
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
|
||||||
}
|
}
|
||||||
function placement(run, selectedBossId = storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) {
|
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 variant = dungeons.get(run.dungeonId)?.difficulties.find((entry) => entry.id === run.difficultyId)?.finalBossIds?.length;
|
||||||
const bossCondition = variant ? "AND final_boss_id = ?" : "";
|
const bossCondition = variant ? "AND final_boss_id = ?" : "";
|
||||||
const counts = database.prepare(`SELECT COUNT(*) AS total,
|
const counts = database.prepare(`SELECT COUNT(*) AS total,
|
||||||
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
|
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
|
||||||
COALESCE(SUM(full_clear), 0) AS full_total,
|
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
|
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,
|
.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),
|
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
|
||||||
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_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) {
|
submitDungeonRun(account, input) {
|
||||||
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
|
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
|
||||||
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
|
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 (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 (!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.");
|
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.finalBossIds.some((id) => killedBossIds.includes(id))
|
||||||
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||||
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
||||||
|
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||||
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
||||||
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
|
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
|
||||||
const existing = byId.get(run.id);
|
const existing = byId.get(run.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
|
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,
|
durationMs: run.durationMs, finishedAt: run.finishedAt,
|
||||||
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
|
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
|
||||||
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
|
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,
|
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)) };
|
return { run, placement: placement(run, existing?.final_boss_id ?? storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) };
|
||||||
},
|
},
|
||||||
getDungeonRun(id) {
|
getDungeonRun(id) {
|
||||||
@@ -142,7 +172,7 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
|||||||
return { run, placement: placement(run, row.final_boss_id) };
|
return { run, placement: placement(run, row.final_boss_id) };
|
||||||
},
|
},
|
||||||
listDungeonLeaderboard(input) {
|
listDungeonLeaderboard(input) {
|
||||||
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
|
const { dungeon, difficultyId, difficulty, bossDifficulty, spotId, spotRevision } = selection(input);
|
||||||
const filter = input.clear || "final";
|
const filter = input.clear || "final";
|
||||||
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
|
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
|
||||||
const offset = Number(input.offset ?? 0);
|
const offset = Number(input.offset ?? 0);
|
||||||
@@ -152,12 +182,13 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
|||||||
const condition = filter === "full" ? "AND full_clear = 1" : "";
|
const condition = filter === "full" ? "AND full_clear = 1" : "";
|
||||||
const bossId = boardBossId(difficulty, input.bossId);
|
const bossId = boardBossId(difficulty, input.bossId);
|
||||||
const bossCondition = bossId ? "AND final_boss_id = ?" : "";
|
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
|
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);
|
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset);
|
||||||
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
|
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) };
|
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { GameDatabaseError } from "./database.mjs";
|
import { GameDatabaseError } from "./database.mjs";
|
||||||
|
import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs";
|
||||||
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes,
|
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";
|
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,
|
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
|
||||||
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
|
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
|
||||||
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
|
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
|
||||||
|
...(match.biteSizedSelection ? { biteSizedSelection: match.biteSizedSelection } : {}),
|
||||||
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
|
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
|
||||||
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
|
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),
|
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 dungeon = choices[hashRaceSeed(id) % choices.length];
|
||||||
const affixIds = rollRaceAffixes(id);
|
const affixIds = rollRaceAffixes(id);
|
||||||
const bot = right ? null : makeBot(id, left.accountId, affixIds);
|
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,
|
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
|
||||||
characterId: null, username: bot.name, source: bot.source, role: null }])]
|
characterId: null, username: bot.name, source: bot.source, role: null }])]
|
||||||
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
|
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
|
||||||
username: entry.username, source: entry.source, role: entry.role }));
|
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 });
|
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
|
||||||
matches.set(id, match);
|
matches.set(id, match);
|
||||||
for (const participant of participants) if (participant.accountId) {
|
for (const participant of participants) if (participant.accountId) {
|
||||||
|
|||||||
@@ -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.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(service.finish(left, match.matchId, { runId: match.seed, bossIds }).result, "win");
|
||||||
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId,
|
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);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||||
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
|
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
|
||||||
import { normalizeRaceCombatLog } from "./dungeon-race-combat-log.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]));
|
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;
|
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(`
|
database.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
|
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
|
||||||
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
|
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')")
|
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
|
||||||
.run(Date.now());
|
.run(Date.now());
|
||||||
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
|
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 participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
|
||||||
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
|
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)
|
(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);
|
const match = matchById.get(id);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
|
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,
|
difficultyId: match.difficulty_id, bossDifficulty: match.boss_difficulty,
|
||||||
status: match.status, createdAt: match.created_at, startAt: match.start_at,
|
status: match.status, createdAt: match.created_at, startAt: match.start_at,
|
||||||
settledAt: match.settled_at, winnerSide: match.winner_side,
|
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 })) };
|
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
|
||||||
}
|
}
|
||||||
function createMatch(match) {
|
function createMatch(match) {
|
||||||
|
const selection = match.biteSizedSelection ?? biteSizedLayouts?.resolveBiteSizedRun(match.dungeonId, match.seed);
|
||||||
transaction(() => {
|
transaction(() => {
|
||||||
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
|
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),
|
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed),
|
||||||
JSON.stringify(match.affixIds ?? []));
|
JSON.stringify(match.affixIds ?? []));
|
||||||
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
|
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
|
||||||
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
|
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);
|
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");
|
|| 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 difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
|
||||||
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
|
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))
|
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|
||||||
|| !bossIds.some((value) => finalIds.includes(value))
|
|| !bossIds.some((value) => finalIds.includes(value))
|
||||||
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
|
|| (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) };
|
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
|
||||||
}
|
}
|
||||||
function leaderboard({ view = "wins", period = "30d", dungeonId = null, bossId = null,
|
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 dungeon = dungeons.get(dungeonId);
|
||||||
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
|
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
|
||||||
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|
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 byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
|
||||||
const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
|
const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
|
||||||
const byBoss = selectedBossId ? "AND m.boss_id = ?" : "";
|
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" ? `
|
const query = view === "wins" ? `
|
||||||
SELECT p.account_id AS accountId, a.username AS username,
|
SELECT p.account_id AS accountId, a.username AS username,
|
||||||
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
|
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
|
: `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
|
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
|
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`;
|
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
|
||||||
const all = database.prepare(query).all(...args);
|
const all = database.prepare(query).all(...args);
|
||||||
let lastValue = null;
|
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
|
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
|
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 = ?
|
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;
|
ORDER BY p.clear_at ASC LIMIT 1`) : null;
|
||||||
const ranked = all.map((row, index) => {
|
const ranked = all.map((row, index) => {
|
||||||
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
|
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"
|
return { ...row, rank, ...(view === "wins"
|
||||||
? { wins: Number(row.wins), losses: Number(row.losses) }
|
? { wins: Number(row.wins), losses: Number(row.losses) }
|
||||||
: { durationMs: Number(row.durationMs), affixIds: JSON.parse(bestAffixes.get(
|
: { 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,
|
return { view, period, dungeonId, bossId: selectedBossId,
|
||||||
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
|
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
};
|
||||||
@@ -311,6 +311,8 @@ export function createGameServer(options = {}) {
|
|||||||
period: url.searchParams.get("period") ?? "30d",
|
period: url.searchParams.get("period") ?? "30d",
|
||||||
dungeonId: url.searchParams.get("dungeon") || null,
|
dungeonId: url.searchParams.get("dungeon") || null,
|
||||||
bossId: url.searchParams.get("boss") || 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),
|
limit: Number(url.searchParams.get("limit") ?? 20),
|
||||||
offset: Number(url.searchParams.get("offset") ?? 0),
|
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"),
|
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
||||||
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
||||||
bossId: url.searchParams.get("boss"),
|
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,
|
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
|
||||||
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
||||||
return;
|
return;
|
||||||
@@ -537,6 +540,25 @@ export function createGameServer(options = {}) {
|
|||||||
json(response, 201, arenaService.socketTicket(account.id));
|
json(response, 201, arenaService.socketTicket(account.id));
|
||||||
return;
|
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") {
|
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||||
const current = gameDatabase.getManastormConfig();
|
const current = gameDatabase.getManastormConfig();
|
||||||
json(response, 200, {
|
json(response, 200, {
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
@@ -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 } : {}) };
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
|
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
|
||||||
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
|
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
|
||||||
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
|
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
|
||||||
|
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
|
||||||
|
|
||||||
const GameRuntime = lazy(loadGameRuntime);
|
const GameRuntime = lazy(loadGameRuntime);
|
||||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
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 DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||||
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
|
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
|
||||||
|
const BiteSizedEditor = lazy(() => import("./ui/BiteSizedEditor").then((module) => ({ default: module.BiteSizedEditor })));
|
||||||
function ShellTransition() {
|
function ShellTransition() {
|
||||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
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 refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
|
||||||
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
|
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
|
||||||
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
|
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
|
||||||
|
useEffect(() => { void useBiteSizedLayoutsStore.getState().refresh(); }, [session?.ownerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
|
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 === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></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 === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
|
||||||
|
else if (phase === "bite-sized-editor") screen = <Suspense fallback={<ShellTransition />}><BiteSizedEditor /></Suspense>;
|
||||||
else if (isShelvedShellPhase(phase)) {
|
else if (isShelvedShellPhase(phase)) {
|
||||||
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
|
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -41,6 +41,8 @@ import { useDungeonRouteStore } from "./game/dungeonRouteStore";
|
|||||||
import { installCombatClock } from "./game/combatClock";
|
import { installCombatClock } from "./game/combatClock";
|
||||||
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
|
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
|
||||||
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
|
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
|
||||||
|
import { BiteSizedEditor } from "./ui/BiteSizedEditor";
|
||||||
|
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
|
||||||
|
|
||||||
function InputBridge() {
|
function InputBridge() {
|
||||||
const overlay = useGameStore((state) => state.overlay);
|
const overlay = useGameStore((state) => state.overlay);
|
||||||
@@ -49,6 +51,7 @@ function InputBridge() {
|
|||||||
useEffect(() => installInput({
|
useEffect(() => installInput({
|
||||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||||
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|
||||||
|
|| useBiteSizedLayoutsStore.getState().panelFocused
|
||||||
|| useGameStore.getState().dungeonIntroActive
|
|| useGameStore.getState().dungeonIntroActive
|
||||||
|| useGameStore.getState().raceStartBlocked
|
|| useGameStore.getState().raceStartBlocked
|
||||||
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
|
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
|
||||||
@@ -93,7 +96,7 @@ function InputBridge() {
|
|||||||
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||||
gameplayActionsBlocked: () => {
|
gameplayActionsBlocked: () => {
|
||||||
const game = useGameStore.getState();
|
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),
|
setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active),
|
||||||
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
|
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
|
||||||
@@ -126,6 +129,7 @@ export default function GameRuntime() {
|
|||||||
useShellStore.setState({ activeDungeonId: sourceDungeonId });
|
useShellStore.setState({ activeDungeonId: sourceDungeonId });
|
||||||
}
|
}
|
||||||
clearBossTestingDungeons();
|
clearBossTestingDungeons();
|
||||||
|
if (useShellStore.getState().phase !== "game") useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<DualDisplayFrame
|
<DualDisplayFrame
|
||||||
@@ -154,6 +158,7 @@ export default function GameRuntime() {
|
|||||||
{!dungeonSimulation && <DungeonRunResults />}
|
{!dungeonSimulation && <DungeonRunResults />}
|
||||||
{!dungeonSimulation && <UiLayoutEditor />}
|
{!dungeonSimulation && <UiLayoutEditor />}
|
||||||
{!dungeonSimulation && <KeybindsPanel />}
|
{!dungeonSimulation && <KeybindsPanel />}
|
||||||
|
<BiteSizedEditor inGame />
|
||||||
</main>
|
</main>
|
||||||
}
|
}
|
||||||
bottom={<LocalCompanionPanel />}
|
bottom={<LocalCompanionPanel />}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
|||||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||||
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||||
|
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||||
|
|
||||||
const GROUP_POLL_MS = 2_500;
|
const GROUP_POLL_MS = 2_500;
|
||||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||||
@@ -60,7 +61,8 @@ export async function enterOnlineGroupActivity(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
let entered: boolean;
|
let entered: boolean;
|
||||||
if (activity.type === "dungeon") {
|
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)) {
|
if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
|
||||||
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
|
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export async function enterOnlineGroupActivity(
|
|||||||
bossDifficulty: activity.bossDifficulty,
|
bossDifficulty: activity.bossDifficulty,
|
||||||
freshRun: true,
|
freshRun: true,
|
||||||
runId: activity.id,
|
runId: activity.id,
|
||||||
|
...(activity.biteSizedSelection ? { biteSizedSelection: activity.biteSizedSelection } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!SHELVED_MODES_ENABLED) return false;
|
if (!SHELVED_MODES_ENABLED) return false;
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ describe("bite-sized replay", () => {
|
|||||||
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
||||||
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
||||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||||
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
|
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", expect.objectContaining({
|
||||||
difficultyId: "normal",
|
difficultyId: "normal",
|
||||||
bossDifficulty: 1,
|
bossDifficulty: 1,
|
||||||
freshRun: true,
|
freshRun: true,
|
||||||
runId: expect.any(String),
|
runId: expect.any(String),
|
||||||
});
|
}));
|
||||||
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
|
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||||
expect.anything(), expect.anything());
|
expect.anything(), expect.anything());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useOnlineSessionStore } from "./onlineSessionStore";
|
|||||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||||
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
|
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
|
||||||
import { useShellStore } from "./shellStore";
|
import { useShellStore } from "./shellStore";
|
||||||
|
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||||
|
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||||
|
|
||||||
export function canStartAnotherBiteSizedDungeon(): boolean {
|
export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||||
const shell = useShellStore.getState();
|
const shell = useShellStore.getState();
|
||||||
@@ -19,6 +21,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BiteSizedReplayDependencies {
|
interface BiteSizedReplayDependencies {
|
||||||
|
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||||
readonly random: () => number;
|
readonly random: () => number;
|
||||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||||
readonly createRunId: () => string;
|
readonly createRunId: () => string;
|
||||||
@@ -27,6 +30,7 @@ interface BiteSizedReplayDependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
||||||
|
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||||
random: Math.random,
|
random: Math.random,
|
||||||
loadPolicy: loadDungeonQueuePolicy,
|
loadPolicy: loadDungeonQueuePolicy,
|
||||||
createRunId: () => globalThis.crypto.randomUUID(),
|
createRunId: () => globalThis.crypto.randomUUID(),
|
||||||
@@ -93,6 +97,9 @@ export async function startAnotherBiteSizedDungeon(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const runId = dependencies.createRunId();
|
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) {
|
if (randomized) {
|
||||||
try {
|
try {
|
||||||
await dependencies.prepareRandomizedContent(runId, next.id);
|
await dependencies.prepareRandomizedContent(runId, next.id);
|
||||||
@@ -107,6 +114,7 @@ export async function startAnotherBiteSizedDungeon(
|
|||||||
bossDifficulty: progress.bossDifficulty,
|
bossDifficulty: progress.bossDifficulty,
|
||||||
freshRun: true,
|
freshRun: true,
|
||||||
runId,
|
runId,
|
||||||
|
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||||
});
|
});
|
||||||
return entered;
|
return entered;
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboard
|
|||||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||||
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
|
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 STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
@@ -44,7 +52,7 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
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
|
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
|
||||||
.find((entry) => entry.id === difficultyId);
|
.find((entry) => entry.id === difficultyId);
|
||||||
const selectedBossId = difficulty?.finalBossIds?.length
|
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
|
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
|
||||||
&& (entry.bossDifficulty ?? 0) === bossDifficulty
|
&& (entry.bossDifficulty ?? 0) === bossDifficulty
|
||||||
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
|
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
|
||||||
|
&& matchesSpot(entry, spotId, spotRevision)
|
||||||
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|
||||||
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
||||||
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
|
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);
|
.find((entry) => entry.id === record.difficultyId);
|
||||||
const bossId = difficulty?.finalBossIds?.length
|
const bossId = difficulty?.finalBossIds?.length
|
||||||
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
|
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
|
||||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId);
|
const spotId = record.biteSizedSelection?.spot.id;
|
||||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId);
|
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
|
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
|
||||||
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
|
&& (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,
|
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
|
||||||
finalTotal: final.total, fullTotal: full.total,
|
finalTotal: final.total, fullTotal: full.total,
|
||||||
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
|
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,
|
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||||
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
|
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,
|
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
|
||||||
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
|
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
|
||||||
if (bossId) query.set("boss", bossId);
|
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 });
|
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
|
|||||||
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
|
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||||
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||||
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
|
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 RacePool = "authored" | "randomized";
|
||||||
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
|
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 }
|
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
|
||||||
export type RaceOpponent = RaceHumanOpponent | RaceBot;
|
export type RaceOpponent = RaceHumanOpponent | RaceBot;
|
||||||
export interface RaceServerState {
|
export interface RaceServerState {
|
||||||
|
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||||
readonly status: RaceStatus;
|
readonly status: RaceStatus;
|
||||||
readonly serverTime: number;
|
readonly serverTime: number;
|
||||||
readonly joinedAt?: number;
|
readonly joinedAt?: number;
|
||||||
@@ -41,6 +44,7 @@ export interface RaceServerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RaceState {
|
interface RaceState {
|
||||||
|
biteSizedSelection: BiteSizedRunSelection | null;
|
||||||
mode: "online" | "practice" | null;
|
mode: "online" | "practice" | null;
|
||||||
status: RaceStatus;
|
status: RaceStatus;
|
||||||
matchId: string | null;
|
matchId: string | null;
|
||||||
@@ -91,10 +95,12 @@ function choosePracticeDungeon(pool: RacePool): string | null {
|
|||||||
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
|
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
|
||||||
return entries.filter((entry) => entry.available)[0]?.id ?? null;
|
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);
|
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
|
||||||
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(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) => {
|
export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||||
@@ -104,7 +110,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
|||||||
preparingSeed = seed;
|
preparingSeed = seed;
|
||||||
set({ preparingContent: true, error: "" });
|
set({ preparingContent: true, error: "" });
|
||||||
try {
|
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 (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
|
||||||
if (!validDungeonRaceAffixes(get().affixIds)) {
|
if (!validDungeonRaceAffixes(get().affixIds)) {
|
||||||
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
|
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();
|
installDungeonShellRuntime();
|
||||||
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
|
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
|
||||||
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
|
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.");
|
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
|
||||||
startRaceCombatLog(seed, dungeonId);
|
startRaceCombatLog(seed, dungeonId);
|
||||||
set({ inGame: true, preparingContent: false });
|
set({ inGame: true, preparingContent: false, biteSizedSelection: biteSizedSelection ?? null });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stopRaceCombatLog();
|
stopRaceCombatLog();
|
||||||
if (raceRevision === revision) {
|
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,
|
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
|
||||||
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
|
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
|
||||||
|
biteSizedSelection: response.biteSizedSelection ?? previous.biteSizedSelection,
|
||||||
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
|
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
|
||||||
startAt: response.startAt ?? null,
|
startAt: response.startAt ?? null,
|
||||||
serverOffset: offset, opponent: response.opponent ?? 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();
|
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
|
||||||
}
|
}
|
||||||
return {
|
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,
|
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
|
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
|
||||||
queueAt: null, botAt: null,
|
queueAt: null, botAt: null,
|
||||||
@@ -306,7 +314,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
|||||||
cleanupDungeonRaceAffixes();
|
cleanupDungeonRaceAffixes();
|
||||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||||
stopRaceCombatLog(); pendingCombatLog = 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,
|
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
|
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { registerDungeonShellRuntime, type DungeonEntrySettings } from "./dungeo
|
|||||||
import { touchCharacter } from "./accountRepository";
|
import { touchCharacter } from "./accountRepository";
|
||||||
import { specializationById } from "../game/specializationCatalog";
|
import { specializationById } from "../game/specializationCatalog";
|
||||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||||
|
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
|
||||||
|
|
||||||
export function installDungeonShellRuntime(): void {
|
export function installDungeonShellRuntime(): void {
|
||||||
registerDungeonShellRuntime({ enterDungeon });
|
registerDungeonShellRuntime({ enterDungeon });
|
||||||
@@ -23,10 +24,6 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
|||||||
useShellStore.setState({ notice: "That dungeon is not available yet." });
|
useShellStore.setState({ notice: "That dungeon is not available yet." });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!dungeonContentIsUsable(dungeonId)) {
|
|
||||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const { session, selectedCharacterId, characters } = useShellStore.getState();
|
const { session, selectedCharacterId, characters } = useShellStore.getState();
|
||||||
if (!session || !selectedCharacterId) {
|
if (!session || !selectedCharacterId) {
|
||||||
useShellStore.setState({ notice: "Select or create a character first." });
|
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)
|
&& (settings?.bossDifficulty === undefined || checkpoint.run?.bossDifficulty === settings.bossDifficulty)
|
||||||
? activeCharacter.dungeonRunCheckpoint
|
? activeCharacter.dungeonRunCheckpoint
|
||||||
: null;
|
: 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, {
|
if (!activateDungeonSession(dungeonId, settings?.difficultyId ?? resume?.difficultyId, {
|
||||||
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
|
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
|
||||||
spawn: resume?.spawn,
|
spawn: resume?.spawn,
|
||||||
@@ -69,6 +72,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
|||||||
runProgress: resume?.run,
|
runProgress: resume?.run,
|
||||||
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
|
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
|
||||||
runId: settings?.runId,
|
runId: settings?.runId,
|
||||||
|
biteSizedSelection,
|
||||||
})) {
|
})) {
|
||||||
useShellStore.setState({
|
useShellStore.setState({
|
||||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface DungeonShellRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DungeonEntrySettings {
|
export interface DungeonEntrySettings {
|
||||||
|
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||||
readonly difficultyId?: string;
|
readonly difficultyId?: string;
|
||||||
readonly bossDifficulty?: number;
|
readonly bossDifficulty?: number;
|
||||||
readonly freshRun?: boolean;
|
readonly freshRun?: boolean;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface OnlineGroupMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OnlineGroupActivity {
|
export interface OnlineGroupActivity {
|
||||||
|
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||||
readonly difficultyId?: string;
|
readonly difficultyId?: string;
|
||||||
readonly bossDifficulty?: number;
|
readonly bossDifficulty?: number;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
|||||||
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
|
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
|
||||||
import { specializationById } from "../game/specializationCatalog";
|
import { specializationById } from "../game/specializationCatalog";
|
||||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
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 RandomDungeonQueueMode = "regular" | "bite-sized";
|
||||||
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
|
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
|
||||||
@@ -29,6 +31,7 @@ export interface RandomDungeonQueueOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RandomDungeonQueueDependencies {
|
interface RandomDungeonQueueDependencies {
|
||||||
|
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||||
readonly random: () => number;
|
readonly random: () => number;
|
||||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||||
@@ -37,6 +40,7 @@ interface RandomDungeonQueueDependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
|
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
|
||||||
|
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||||
random: Math.random,
|
random: Math.random,
|
||||||
loadPolicy: loadDungeonQueuePolicy,
|
loadPolicy: loadDungeonQueuePolicy,
|
||||||
contentIsUsable: dungeonContentIsUsable,
|
contentIsUsable: dungeonContentIsUsable,
|
||||||
@@ -107,10 +111,13 @@ export async function queueRandomDungeon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await dependencies.installDungeonRuntime();
|
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, {
|
const entered = options.enterDungeon(dungeon.id, role, {
|
||||||
difficultyId,
|
difficultyId,
|
||||||
bossDifficulty: 1,
|
bossDifficulty: 1,
|
||||||
freshRun: true,
|
freshRun: true,
|
||||||
|
...(biteSizedSelection ? { runId, biteSizedSelection } : {}),
|
||||||
});
|
});
|
||||||
return entered
|
return entered
|
||||||
? { status: "entered", dungeonTitle: dungeon.title }
|
? { status: "entered", dungeonTitle: dungeon.title }
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import {
|
|||||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||||
dungeonContentPackId,
|
dungeonContentPackId,
|
||||||
} from "../game/dungeonContentSource";
|
} 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[] {
|
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) => (
|
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
|
||||||
entity.visual.model?.url ? [entity.visual.model.url] : []
|
entity.visual.model?.url ? [entity.visual.model.url] : []
|
||||||
)))].sort();
|
)))].sort();
|
||||||
@@ -39,6 +43,7 @@ export async function ensureRandomizedBiteSizedRunContent(
|
|||||||
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
|
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
|
||||||
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (dependencies === DEFAULT_DEPENDENCIES) { await prepareBiteSizedLayoutContent(dungeonId, seed); return; }
|
||||||
if (dependencies.development) return;
|
if (dependencies.development) return;
|
||||||
const basePackId = dungeonContentPackId(dungeonId);
|
const basePackId = dungeonContentPackId(dungeonId);
|
||||||
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
|
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import type { ManastormProgress } from "../game/manastormProgress";
|
|||||||
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
|
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
|
||||||
import type { BossCardCollection } from "../game/bossCards";
|
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 SessionKind = "account" | "offline";
|
||||||
export type AccountRole = "gm";
|
export type AccountRole = "gm";
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||||
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
|
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
|
||||||
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
|
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
|
||||||
|
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||||
import {
|
import {
|
||||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||||
useDungeonSimulationStore,
|
useDungeonSimulationStore,
|
||||||
@@ -192,7 +193,7 @@ export function CombatBridge() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
if (game.dungeonIntroActive || game.raceStartBlocked) return;
|
if (game.dungeonIntroActive || game.raceStartBlocked || useBiteSizedLayoutsStore.getState().editorMode === "edit") return;
|
||||||
const sharedOnline = onlineSessionIsActive();
|
const sharedOnline = onlineSessionIsActive();
|
||||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||||
advanceOneTick(delta, Date.now(), false);
|
advanceOneTick(delta, Date.now(), false);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useGameStore } from "./store";
|
|||||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||||
import type { DungeonRunMember } from "./dungeonRunTypes";
|
import type { DungeonRunMember } from "./dungeonRunTypes";
|
||||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||||
|
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||||
|
|
||||||
function currentRunParty(): readonly DungeonRunMember[] {
|
function currentRunParty(): readonly DungeonRunMember[] {
|
||||||
const shell = useShellStore.getState();
|
const shell = useShellStore.getState();
|
||||||
@@ -36,7 +37,8 @@ export function DungeonRunBridge() {
|
|||||||
const advance = () => {
|
const advance = () => {
|
||||||
const game = useGameStore.getState();
|
const game = useGameStore.getState();
|
||||||
const shell = useShellStore.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
|
|| game.raceStartBlocked
|
||||||
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
||||||
const run = useDungeonRunStore.getState();
|
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),
|
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
|
||||||
Date.now(), party);
|
Date.now(), party);
|
||||||
const progress = useDungeonRunStore.getState().progress;
|
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;
|
announcedId = progress.id;
|
||||||
useGameStore.getState().openOverlay("dungeon-results");
|
useGameStore.getState().openOverlay("dungeon-results");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ describe("bite-sized Stormwind Stockades", () => {
|
|||||||
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
|
.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>>();
|
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) {
|
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}`);
|
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
|
||||||
@@ -95,9 +95,17 @@ describe("bite-sized Stormwind Stockades", () => {
|
|||||||
const boss = definition.bosses[0];
|
const boss = definition.bosses[0];
|
||||||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||||
const entity = definition.entities[spawn.entityId];
|
const entity = definition.entities[spawn.entityId];
|
||||||
expect(dungeonEnemyScaling(
|
const health = dungeonEnemyScaling(
|
||||||
definition, entity, boss.id, 17, "normal", 1, characterSource,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
|
|||||||
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
||||||
const wow = wowDungeonMapFor(dungeon);
|
const wow = wowDungeonMapFor(dungeon);
|
||||||
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
|
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
|
if (source?.mapId !== dungeon.mapId
|
||||||
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
||||||
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface ResolvedDungeonAssets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
|
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
|
||||||
const sourceId = dungeonContentSourceId(definition.id);
|
const sourceId = definition.contentSourceDungeonId ?? dungeonContentSourceId(definition.id);
|
||||||
return {
|
return {
|
||||||
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||||
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { DungeonDefinition } from "./dungeonTypes";
|
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 { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
|
||||||
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
|
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
|
||||||
import availability from "./generated/dungeonAvailability.json";
|
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. */
|
/** 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;
|
let definition: DungeonDefinition;
|
||||||
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
||||||
else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
|
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_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
|
||||||
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
|
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
|
||||||
else return dungeonDefinitionById(id);
|
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;
|
return definition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
|
|||||||
const definition = dungeonDefinitionById(progress.dungeonId);
|
const definition = dungeonDefinitionById(progress.dungeonId);
|
||||||
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
|
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
|
||||||
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
|
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
|
||||||
|
...(progress.biteSizedSelection ? { biteSizedSelection: progress.biteSizedSelection } : {}),
|
||||||
bossDifficulty: progress.bossDifficulty,
|
bossDifficulty: progress.bossDifficulty,
|
||||||
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
|
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
|
||||||
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
|
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { validBossDifficulty } from "./dungeonDifficulty";
|
import { validBossDifficulty } from "./dungeonDifficulty";
|
||||||
|
import { normalizeBiteSizedSelection, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||||
|
|
||||||
export interface DungeonRunMember {
|
export interface DungeonRunMember {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
@@ -10,6 +11,7 @@ export interface DungeonRunMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DungeonRunProgress {
|
export interface DungeonRunProgress {
|
||||||
|
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly dungeonId: string;
|
readonly dungeonId: string;
|
||||||
readonly difficultyId: string;
|
readonly difficultyId: string;
|
||||||
@@ -23,6 +25,7 @@ export interface DungeonRunProgress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DungeonRunRecord {
|
export interface DungeonRunRecord {
|
||||||
|
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly dungeonId: string;
|
readonly dungeonId: string;
|
||||||
readonly difficultyId: string;
|
readonly difficultyId: string;
|
||||||
@@ -74,7 +77,12 @@ export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress
|
|||||||
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|
||||||
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|
||||||
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
|
|| 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,
|
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
|
||||||
|
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||||
...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}),
|
...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}),
|
||||||
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
|
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
|
||||||
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
|
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCombatStore } from "./combatStore";
|
import { useCombatStore } from "./combatStore";
|
||||||
|
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||||
|
import { preparedBiteSizedRunSelection } from "../app/biteSizedLayoutContent";
|
||||||
import { releaseDungeonGltfCache } from "./dungeonAssetCache";
|
import { releaseDungeonGltfCache } from "./dungeonAssetCache";
|
||||||
import { clearDungeonRuntimeCaches } from "./dungeonRuntimeReset";
|
import { clearDungeonRuntimeCaches } from "./dungeonRuntimeReset";
|
||||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||||
@@ -11,6 +13,7 @@ import {
|
|||||||
dungeonCanEnter,
|
dungeonCanEnter,
|
||||||
dungeonDefinitionById,
|
dungeonDefinitionById,
|
||||||
prepareDungeonSessionDefinition,
|
prepareDungeonSessionDefinition,
|
||||||
|
legacyBiteSizedRunSelection,
|
||||||
type DungeonId,
|
type DungeonId,
|
||||||
} from "./dungeonRegistry";
|
} from "./dungeonRegistry";
|
||||||
import type { WorldSpawn } from "./dungeonTypes";
|
import type { WorldSpawn } from "./dungeonTypes";
|
||||||
@@ -32,6 +35,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
|||||||
import { dungeonRouteDefinition } from "./dungeonRoutes";
|
import { dungeonRouteDefinition } from "./dungeonRoutes";
|
||||||
|
|
||||||
export interface DungeonSessionOptions {
|
export interface DungeonSessionOptions {
|
||||||
|
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||||
readonly bossDifficulty?: number;
|
readonly bossDifficulty?: number;
|
||||||
readonly gameMode?: GameMode;
|
readonly gameMode?: GameMode;
|
||||||
readonly spawn?: WorldSpawn;
|
readonly spawn?: WorldSpawn;
|
||||||
@@ -63,8 +67,11 @@ export function activateDungeonSession(
|
|||||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||||
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
|
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))
|
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|
||||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
|| !dungeonContentIsUsable(testing ? testingSource! : suppliedSelection?.spot.sourceDungeonId ?? dungeonId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
||||||
@@ -78,7 +85,15 @@ export function activateDungeonSession(
|
|||||||
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
|
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
|
||||||
}
|
}
|
||||||
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
|
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(
|
activateDungeonEncounterDefinitions(
|
||||||
dungeonId,
|
dungeonId,
|
||||||
definition.encounters ?? [],
|
definition.encounters ?? [],
|
||||||
@@ -159,5 +174,6 @@ export function restartActiveDungeonSession(): boolean {
|
|||||||
preserveCharacter: true,
|
preserveCharacter: true,
|
||||||
bossDifficulty: game.activeBossDifficulty,
|
bossDifficulty: game.activeBossDifficulty,
|
||||||
encounterSnapshots,
|
encounterSnapshots,
|
||||||
|
biteSizedSelection: useDungeonRunStore.getState().progress?.biteSizedSelection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -242,6 +242,8 @@ export interface DungeonValidationStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DungeonDefinition {
|
export interface DungeonDefinition {
|
||||||
|
readonly contentSourceDungeonId?: string;
|
||||||
|
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||||
readonly schemaVersion: typeof DUNGEON_DEFINITION_SCHEMA_VERSION;
|
readonly schemaVersion: typeof DUNGEON_DEFINITION_SCHEMA_VERSION;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface PartyNavigationGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PartyNavigationBuildOptions {
|
export interface PartyNavigationBuildOptions {
|
||||||
|
/** Validation may retain floor triangles while gameplay routes off-mesh links through nodes. */
|
||||||
|
readonly retainSurfaceTriangles?: boolean;
|
||||||
readonly weldTolerance?: number;
|
readonly weldTolerance?: number;
|
||||||
/** Defensive runtime ceiling; checked before graph allocations begin. */
|
/** Defensive runtime ceiling; checked before graph allocations begin. */
|
||||||
readonly maxSourceTriangles?: number;
|
readonly maxSourceTriangles?: number;
|
||||||
@@ -167,7 +169,7 @@ function connectedComponents(nodes: readonly MutableWeldedNode[]): number[][] {
|
|||||||
return components;
|
return components;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPartyNavigationTriangleNodes(
|
export function buildPartyNavigationTriangleNodes(
|
||||||
triangles: readonly PartyNavigationTriangle[],
|
triangles: readonly PartyNavigationTriangle[],
|
||||||
positions: readonly PartyNavigationPosition[],
|
positions: readonly PartyNavigationPosition[],
|
||||||
): readonly PartyNavigationTriangleNode[] {
|
): readonly PartyNavigationTriangleNode[] {
|
||||||
@@ -432,7 +434,7 @@ export function buildPartyNavigationGraph(
|
|||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
nodes: Object.freeze(nodes),
|
nodes: Object.freeze(nodes),
|
||||||
...(usesOffMeshLinks ? {} : {
|
...(usesOffMeshLinks && !options.retainSurfaceTriangles ? {} : {
|
||||||
triangles: Object.freeze(triangles),
|
triangles: Object.freeze(triangles),
|
||||||
triangleNodes,
|
triangleNodes,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
|||||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
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(
|
function graphCacheKey(
|
||||||
url: string,
|
url: string,
|
||||||
offMeshLinks: readonly DungeonNavigationLink[],
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
@@ -60,8 +64,10 @@ export function preparePartyNavigationGraph(
|
|||||||
scene: Group,
|
scene: Group,
|
||||||
offMeshLinks: readonly DungeonNavigationLink[],
|
offMeshLinks: readonly DungeonNavigationLink[],
|
||||||
ramps: readonly DungeonTraversalRamp[] = [],
|
ramps: readonly DungeonTraversalRamp[] = [],
|
||||||
|
maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||||
|
retainSurfaceTriangles = false,
|
||||||
): PartyNavigationGraph {
|
): PartyNavigationGraph {
|
||||||
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
const cacheKey = graphCacheKey(url, offMeshLinks, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "");
|
||||||
const cached = graphCache.get(cacheKey);
|
const cached = graphCache.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
graphCache.delete(cacheKey);
|
graphCache.delete(cacheKey);
|
||||||
@@ -79,7 +85,8 @@ export function preparePartyNavigationGraph(
|
|||||||
}
|
}
|
||||||
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
||||||
graph = buildPartyNavigationGraph(geometry, {
|
graph = buildPartyNavigationGraph(geometry, {
|
||||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
maxSourceTriangles,
|
||||||
|
retainSurfaceTriangles,
|
||||||
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ describe("Randomized Bite-Sized Stockades", () => {
|
|||||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||||
|
|
||||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
|
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", () => {
|
it("uses the selected cross-dungeon boss for combat and completion", () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface WowDungeonMap {
|
|||||||
const maps = catalog.dungeons as unknown as Readonly<Record<string, WowDungeonMap>>;
|
const maps = catalog.dungeons as unknown as Readonly<Record<string, WowDungeonMap>>;
|
||||||
|
|
||||||
export function wowDungeonMapFor(dungeon: DungeonDefinition): WowDungeonMap | null {
|
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.
|
// A replacement environment must be reimported, never silently misaligned.
|
||||||
return map?.mapId === dungeon.mapId
|
return map?.mapId === dungeon.mapId
|
||||||
&& map.coordinateTransform === dungeon.provenance.coordinateTransform
|
&& map.coordinateTransform === dungeon.provenance.coordinateTransform
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Canvas, useThree } from "@react-three/fiber";
|
import { Canvas, useThree } from "@react-three/fiber";
|
||||||
|
import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers";
|
||||||
|
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||||
import { Physics } from "@react-three/rapier";
|
import { Physics } from "@react-three/rapier";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import { spawnMatchesDifficulty } from "../game/mobPopulation";
|
import { spawnMatchesDifficulty } from "../game/mobPopulation";
|
||||||
@@ -173,6 +175,7 @@ export function GameScene() {
|
|||||||
];
|
];
|
||||||
const roamingPacks = sessionPopulation.roamingPacks;
|
const roamingPacks = sessionPopulation.roamingPacks;
|
||||||
const paused = useGameStore((state) => state.paused);
|
const paused = useGameStore((state) => state.paused);
|
||||||
|
const editingBiteSized = useBiteSizedLayoutsStore((state) => state.editorMode === "edit");
|
||||||
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
|
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
|
||||||
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
|
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
|
||||||
const mapOpen = useGameStore((state) => state.mapOpen);
|
const mapOpen = useGameStore((state) => state.mapOpen);
|
||||||
@@ -407,7 +410,7 @@ export function GameScene() {
|
|||||||
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
|
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
|
||||||
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
|
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
|
||||||
<PartyPopulation
|
<PartyPopulation
|
||||||
active={!simulationBlocked}
|
active={!simulationBlocked && !editingBiteSized}
|
||||||
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
|
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
|
||||||
/>
|
/>
|
||||||
<DungeonSimulationSceneBridge />
|
<DungeonSimulationSceneBridge />
|
||||||
@@ -416,13 +419,14 @@ export function GameScene() {
|
|||||||
{gameMode === "dungeon" && (
|
{gameMode === "dungeon" && (
|
||||||
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
|
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
|
||||||
)}
|
)}
|
||||||
<MobPopulation
|
{!editingBiteSized && <MobPopulation
|
||||||
active={!simulationBlocked}
|
active={!simulationBlocked}
|
||||||
entities={stagePopulation.entities}
|
entities={stagePopulation.entities}
|
||||||
roamingPacks={roamingPacks}
|
roamingPacks={roamingPacks}
|
||||||
staticSpawns={staticSpawns}
|
staticSpawns={staticSpawns}
|
||||||
allowedRuntimeIds={allowedRuntimeIds}
|
allowedRuntimeIds={allowedRuntimeIds}
|
||||||
/>
|
/>}
|
||||||
|
<BiteSizedEditorMarkers />
|
||||||
<CombatSummonPopulation active={!simulationBlocked} />
|
<CombatSummonPopulation active={!simulationBlocked} />
|
||||||
<WailingNaralexEvent />
|
<WailingNaralexEvent />
|
||||||
<ShelvedGameSceneFeatures />
|
<ShelvedGameSceneFeatures />
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
|
|||||||
export function combatEffectVisualTuning(
|
export function combatEffectVisualTuning(
|
||||||
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
|
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
|
||||||
): { readonly scale: number; readonly opacity: number } {
|
): { 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"
|
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
|
||||||
? { scale: 0.45, opacity: 0.4 }
|
? { scale: 0.45, opacity: 0.4 }
|
||||||
: { scale: 1, opacity: 1 };
|
: { scale: 1, opacity: 1 };
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/p
|
|||||||
import { specializationById } from "../game/specializationCatalog";
|
import { specializationById } from "../game/specializationCatalog";
|
||||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||||
import "./dungeonRace.css";
|
import "./dungeonRace.css";
|
||||||
|
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
|
||||||
|
|
||||||
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
|
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
|
||||||
readonly wins?: number; readonly losses?: number; readonly durationMs?: 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)
|
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
|
||||||
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
|
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
|
||||||
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
|
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
|
||||||
|
const spotBoard = useBiteSizedBoardSelection(boardDungeonId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
|
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
|
||||||
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
|
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; }
|
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
|
||||||
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
|
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
|
||||||
.then((value) => { setBoard(value); setBoardError(""); })
|
.then((value) => { setBoard(value); setBoardError(""); })
|
||||||
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
|
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
|
||||||
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
|
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId]);
|
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId, spotBoard.spotId, spotBoard.spotRevision]);
|
||||||
const specialization = specializationById(character?.specializationId);
|
const specialization = specializationById(character?.specializationId);
|
||||||
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
|
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
|
||||||
: specialization ? [specialization.role] : [];
|
: specialization ? [specialization.role] : [];
|
||||||
@@ -121,6 +124,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
|
|||||||
</section>
|
</section>
|
||||||
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
|
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
|
||||||
<h2>Leaderboards</h2>
|
<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>
|
<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>
|
<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 & losses</button>
|
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins & losses</button>
|
||||||
|
|||||||
@@ -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>
|
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
|
||||||
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
|
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
|
||||||
<p>The final boss is defeated. Your expedition is complete.</p>
|
<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>
|
</header>
|
||||||
<div className="dungeon-results__score">
|
<div className="dungeon-results__score">
|
||||||
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
|
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
|
||||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||||
import { classById, raceById } from "../app/characterCatalog";
|
import { classById, raceById } from "../app/characterCatalog";
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
type DungeonCategoryId,
|
type DungeonCategoryId,
|
||||||
type DungeonId,
|
type DungeonId,
|
||||||
} from "../game/dungeonCatalog";
|
} from "../game/dungeonCatalog";
|
||||||
import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
import { dungeonDefinitionById, legacyBiteSizedRunSelection } from "../game/dungeonRegistry";
|
||||||
import {
|
import {
|
||||||
partyRolesForClass,
|
partyRolesForClass,
|
||||||
PARTY_ROLE_LABELS,
|
PARTY_ROLE_LABELS,
|
||||||
@@ -38,6 +39,7 @@ import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"
|
|||||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||||
|
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
|
||||||
|
|
||||||
installDungeonShellRuntime();
|
installDungeonShellRuntime();
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ export function DungeonSelectScreen() {
|
|||||||
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
|
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
|
||||||
const [downloadError, setDownloadError] = useState("");
|
const [downloadError, setDownloadError] = useState("");
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [preparingSpot, setPreparingSpot] = useState(false);
|
||||||
const categoryDungeons = dungeonsByCategory(selectedCategory);
|
const categoryDungeons = dungeonsByCategory(selectedCategory);
|
||||||
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
|
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
|
||||||
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
|
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
|
||||||
@@ -105,8 +108,9 @@ export function DungeonSelectScreen() {
|
|||||||
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
|
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
|
||||||
if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].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",
|
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 toggleRun = (id: string) => setExpandedRuns((current) => {
|
||||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
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) => {
|
const continueAsRole = async (role: PartyRole) => {
|
||||||
if (!selectedDungeon?.available || !character) return;
|
if (!selectedDungeon?.available || !character || preparingSpot) return;
|
||||||
if (!onlineGroup || !session || session.kind !== "account") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!await updateOnlineMember(session, character, role)) return;
|
if (!await updateOnlineMember(session, character, role)) return;
|
||||||
@@ -240,6 +255,7 @@ export function DungeonSelectScreen() {
|
|||||||
? availableRoles.map((role) => ({
|
? availableRoles.map((role) => ({
|
||||||
id: `dungeon-role-${role}`,
|
id: `dungeon-role-${role}`,
|
||||||
run: () => enterAsRole(role),
|
run: () => enterAsRole(role),
|
||||||
|
enabled: !preparingSpot,
|
||||||
}))
|
}))
|
||||||
: [{
|
: [{
|
||||||
id: "choose-dungeon-role",
|
id: "choose-dungeon-role",
|
||||||
@@ -251,7 +267,7 @@ export function DungeonSelectScreen() {
|
|||||||
run: () => setRolePickerOpen(false),
|
run: () => setRolePickerOpen(false),
|
||||||
}] : []),
|
}] : []),
|
||||||
{ id: "dungeon-back", run: returnToMainMenu },
|
{ 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, {
|
const controller = useMenuController(actions, {
|
||||||
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
||||||
onBack: pendingDownload
|
onBack: pendingDownload
|
||||||
@@ -389,6 +405,7 @@ export function DungeonSelectScreen() {
|
|||||||
select={controller.select}
|
select={controller.select}
|
||||||
className={`dungeon-role-option dungeon-role-option--${role}`}
|
className={`dungeon-role-option dungeon-role-option--${role}`}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={preparingSpot}
|
||||||
onClick={() => enterAsRole(role)}
|
onClick={() => enterAsRole(role)}
|
||||||
>
|
>
|
||||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||||
@@ -433,6 +450,7 @@ export function DungeonSelectScreen() {
|
|||||||
</section>
|
</section>
|
||||||
{leaderboardOpen && (
|
{leaderboardOpen && (
|
||||||
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
|
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
|
||||||
|
<BiteSizedBoardFilters selection={spotBoard} />
|
||||||
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
||||||
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
||||||
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ export function DungeonSimulationAdminScreen() {
|
|||||||
<BrandMark />
|
<BrandMark />
|
||||||
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
|
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
|
||||||
<div className="dungeon-sim-admin__header-actions">
|
<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={() => setQueuePolicyOpen(true)}>Random queues</button>
|
||||||
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</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>
|
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function GmAdminScreen() {
|
|||||||
<BrandMark compact />
|
<BrandMark compact />
|
||||||
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
|
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
|
||||||
<div className="gm-admin-header__actions">
|
<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" 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); }}>
|
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
|
||||||
{saving ? "Publishing…" : "Save & publish"}
|
{saving ? "Publishing…" : "Save & publish"}
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
|||||||
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
|
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
|
||||||
] : gmMenuOpen ? [
|
] : gmMenuOpen ? [
|
||||||
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
|
...(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-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
|
||||||
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
|
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
|
||||||
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
|
...(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}>
|
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>
|
<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>
|
</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}
|
<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}>
|
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>
|
<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>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
|||||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||||
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
|
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
|
||||||
|
import { BITE_SIZED_LAYOUT_CATALOG } from "../../shared/bite-sized-layouts.mjs";
|
||||||
|
|
||||||
installDungeonShellRuntime();
|
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 dungeonId = pool === "all" ? RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID : RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID;
|
||||||
const DUNGEON = dungeonById(dungeonId);
|
const DUNGEON = dungeonById(dungeonId);
|
||||||
const CANDIDATES = pool === "all" ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG : BITE_SIZED_RANDOMIZED_CREATURE_CATALOG;
|
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 mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
|
||||||
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
|
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
|
||||||
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
|
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
|
||||||
@@ -124,7 +125,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
|||||||
<section className="randomized-bite-sized-screen__hero">
|
<section className="randomized-bite-sized-screen__hero">
|
||||||
<p className="front-kicker">A different lineup every run</p>
|
<p className="front-kicker">A different lineup every run</p>
|
||||||
<h1>Randomized Bite-Sized</h1>
|
<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 className="randomized-bite-sized-screen__odds">
|
||||||
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
||||||
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -4,13 +4,13 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
|
|||||||
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
|
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
|
||||||
|
|
||||||
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter,
|
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 [offset, setOffset] = useState(0);
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
|
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
|
||||||
data: { entries: [], total: 0 }, scope: "world", loading: true,
|
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(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refresh = () => setRevision((value) => value + 1);
|
const refresh = () => setRevision((value) => value + 1);
|
||||||
@@ -22,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
|
|||||||
let live = true;
|
let live = true;
|
||||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
|
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 }); })
|
.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 }); });
|
scope: "device", loading: false }); });
|
||||||
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
|
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
|
||||||
return () => { live = false; controller.abort(); clearTimeout(timeout); };
|
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) };
|
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user