Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -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 { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||
import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
|
||||
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
|
||||
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
||||
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
@@ -414,6 +416,8 @@ export function openGameDatabase(options = {}) {
|
||||
return accountRoles(account?.username).includes("gm");
|
||||
}
|
||||
|
||||
const biteSizedLayouts = createBiteSizedLayoutRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
|
||||
|
||||
function getManastormConfig() {
|
||||
const row = statements.manastormConfig.get();
|
||||
try {
|
||||
@@ -904,6 +908,8 @@ export function openGameDatabase(options = {}) {
|
||||
: null,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
if (type === "dungeon" && isLayoutBiteSizedDungeon(contentId)) activity.biteSizedSelection = biteSizedLayouts.resolveBiteSizedRun(contentId, activity.id,
|
||||
BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !getDungeonQueuePolicy().config.disabled.biteSized.includes(d.id)).map((d) => d.id));
|
||||
database.prepare(`
|
||||
UPDATE online_groups
|
||||
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
|
||||
@@ -1180,9 +1186,10 @@ export function openGameDatabase(options = {}) {
|
||||
return {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||
...biteSizedLayouts,
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError, biteSizedLayouts }),
|
||||
...(() => {
|
||||
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster });
|
||||
const races = createDungeonRaceRepository(database, { ErrorType: GameDatabaseError, isGameMaster, biteSizedLayouts });
|
||||
return {
|
||||
getDungeonRaceMatch: races.getMatch,
|
||||
createDungeonRaceMatch: races.createMatch,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
import { defaultBiteSizedBoardScope, normalizeBiteSizedSelection } from "../shared/bite-sized-layouts.mjs";
|
||||
|
||||
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
||||
const classes = new Set(catalog.classes.map((entry) => entry.id));
|
||||
@@ -15,7 +16,7 @@ function storedFinalBossId(dungeonId, difficultyId, killedBossIds) {
|
||||
return defeated.length === 1 ? defeated[0] : null;
|
||||
}
|
||||
|
||||
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
export function createDungeonLeaderboardRepository(database, { ErrorType, biteSizedLayouts }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -51,11 +52,20 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_final_boss_idx
|
||||
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, final_boss_id, full_clear, duration_ms, finished_at, id)`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
|
||||
for (const [name, type] of [["spot_id", "TEXT"], ["spot_revision", "INTEGER"], ["selection_json", "TEXT"]]) {
|
||||
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((c) => c.name === name)) database.exec(`ALTER TABLE dungeon_runs ADD COLUMN ${name} ${type}`);
|
||||
}
|
||||
for (const row of database.prepare("SELECT id, dungeon_id FROM dungeon_runs WHERE spot_id IS NULL").all()) {
|
||||
const scope = defaultBiteSizedBoardScope(row.dungeon_id);
|
||||
if (scope.spotId) database.prepare("UPDATE dungeon_runs SET spot_id = ?, spot_revision = ? WHERE id = ?").run(scope.spotId, scope.spotRevision, row.id);
|
||||
}
|
||||
database.exec("CREATE INDEX IF NOT EXISTS dungeon_runs_spot_times_idx ON dungeon_runs(dungeon_id, spot_id, spot_revision, difficulty_id, boss_difficulty, final_boss_id, duration_ms)");
|
||||
const insert = database.prepare(`INSERT INTO dungeon_runs
|
||||
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
(id, dungeon_id, difficulty_id, duration_ms, full_clear, finished_at, party_json, bosses_json, submitted_by_account_id, received_at, boss_difficulty, final_boss_id, spot_id, spot_revision, selection_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const fail = (message) => { throw new ErrorType(message, 400, "invalid_dungeon_run"); };
|
||||
const record = (row) => ({ id: row.id, dungeonId: row.dungeon_id, difficultyId: row.difficulty_id,
|
||||
...(row.selection_json ? { biteSizedSelection: JSON.parse(row.selection_json) } : {}),
|
||||
...(row.boss_difficulty > 0 ? { bossDifficulty: Number(row.boss_difficulty) } : {}),
|
||||
durationMs: Number(row.duration_ms), finishedAt: Number(row.finished_at), fullClear: row.full_clear === 1,
|
||||
party: JSON.parse(row.party_json), killedBossIds: JSON.parse(row.bosses_json) });
|
||||
@@ -68,7 +78,15 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
const bossDifficulty = input.bossDifficulty ?? 0;
|
||||
if (!Number.isInteger(bossDifficulty) || bossDifficulty < 0 || bossDifficulty > 10)
|
||||
fail("Choose a boss difficulty from 1 to 10.");
|
||||
return { dungeon, difficultyId, difficulty, bossDifficulty };
|
||||
const defaults = defaultBiteSizedBoardScope(dungeon.id);
|
||||
const spotId = input.spotId || defaults.spotId;
|
||||
const spotRevision = input.spotRevision == null ? defaults.spotRevision : Number(input.spotRevision);
|
||||
if (spotId && (!Number.isSafeInteger(spotRevision) || spotRevision < 0)) fail("Choose a valid spot revision.");
|
||||
if (spotId && biteSizedLayouts) {
|
||||
const spot = biteSizedLayouts.getBiteSizedSpotRevision(spotId, spotRevision);
|
||||
if (!dungeon.id.startsWith("randomized-bite-sized-") && spot.dungeonId !== dungeon.id) fail("Choose a spot in this dungeon.");
|
||||
}
|
||||
return { dungeon, difficultyId, difficulty, bossDifficulty, spotId, spotRevision };
|
||||
}
|
||||
function boardBossId(difficulty, requestedBossId) {
|
||||
const finalBossIds = difficulty.finalBossIds ?? [difficulty.finalBossId];
|
||||
@@ -76,15 +94,17 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
return difficulty.finalBossIds?.length ? requestedBossId || finalBossIds[0] : null;
|
||||
}
|
||||
function placement(run, selectedBossId = storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) {
|
||||
const scope = run.biteSizedSelection ? { spotId: run.biteSizedSelection.spot.id, spotRevision: run.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(run.dungeonId);
|
||||
const spotCondition = scope.spotId ? "AND spot_id = ? AND spot_revision = ?" : "";
|
||||
const variant = dungeons.get(run.dungeonId)?.difficulties.find((entry) => entry.id === run.difficultyId)?.finalBossIds?.length;
|
||||
const bossCondition = variant ? "AND final_boss_id = ?" : "";
|
||||
const counts = database.prepare(`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN duration_ms < ? THEN 1 ELSE 0 END), 0) AS faster,
|
||||
COALESCE(SUM(full_clear), 0) AS full_total,
|
||||
COALESCE(SUM(CASE WHEN full_clear = 1 AND duration_ms < ? THEN 1 ELSE 0 END), 0) AS full_faster
|
||||
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition}`)
|
||||
FROM dungeon_runs WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${bossCondition} ${spotCondition}`)
|
||||
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0,
|
||||
...(variant ? [selectedBossId] : []));
|
||||
...(variant ? [selectedBossId] : []), ...(scope.spotId ? [scope.spotId, scope.spotRevision] : []));
|
||||
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
|
||||
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
|
||||
}
|
||||
@@ -92,6 +112,12 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
submitDungeonRun(account, input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
|
||||
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
|
||||
let biteSizedSelection;
|
||||
if (input.biteSizedSelection !== undefined) {
|
||||
try { biteSizedSelection = biteSizedLayouts ? biteSizedLayouts.validateBiteSizedRunSelection(input.biteSizedSelection, dungeon.id) : normalizeBiteSizedSelection(input.biteSizedSelection); }
|
||||
catch (error) { fail(error.message); }
|
||||
if (biteSizedSelection.dungeonId !== dungeon.id || input.killedBossIds?.length !== 1 || input.killedBossIds[0] !== biteSizedSelection.boss.objectiveId) fail("Defeat the selected spot's boss.");
|
||||
}
|
||||
if (input.bossDifficulty !== undefined && bossDifficulty === 0) fail("Choose a boss difficulty from 1 to 10.");
|
||||
if (!difficulty.finalBossId) fail("This dungeon does not have a configured final boss yet.");
|
||||
if (typeof input.id !== "string" || !/^[A-Za-z0-9_-]{8,100}$/.test(input.id)) fail("Provide a valid run id.");
|
||||
@@ -121,18 +147,22 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
? difficulty.finalBossIds.some((id) => killedBossIds.includes(id))
|
||||
: difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
||||
finishedAt: input.finishedAt, killedBossIds, party, fullClear };
|
||||
const existing = byId.get(run.id);
|
||||
if (existing) {
|
||||
if (JSON.stringify(record(existing)) !== JSON.stringify({ id: run.id, dungeonId: run.dungeonId,
|
||||
difficultyId: run.difficultyId, ...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
||||
difficultyId: run.difficultyId, ...(biteSizedSelection ? { biteSizedSelection } : {}), ...(bossDifficulty > 0 ? { bossDifficulty } : {}),
|
||||
durationMs: run.durationMs, finishedAt: run.finishedAt,
|
||||
fullClear: run.fullClear, party: run.party, killedBossIds: run.killedBossIds }))
|
||||
throw new ErrorType("That run id already belongs to a different result.", 409, "run_conflict");
|
||||
} else insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
|
||||
} else {
|
||||
const scope = biteSizedSelection ? { spotId: biteSizedSelection.spot.id, spotRevision: biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(run.dungeonId);
|
||||
insert.run(run.id, run.dungeonId, run.difficultyId, run.durationMs, fullClear ? 1 : 0,
|
||||
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now(), bossDifficulty,
|
||||
storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds));
|
||||
storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds), scope.spotId, scope.spotRevision, biteSizedSelection ? JSON.stringify(biteSizedSelection) : null);
|
||||
}
|
||||
return { run, placement: placement(run, existing?.final_boss_id ?? storedFinalBossId(run.dungeonId, run.difficultyId, run.killedBossIds)) };
|
||||
},
|
||||
getDungeonRun(id) {
|
||||
@@ -142,7 +172,7 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
return { run, placement: placement(run, row.final_boss_id) };
|
||||
},
|
||||
listDungeonLeaderboard(input) {
|
||||
const { dungeon, difficultyId, difficulty, bossDifficulty } = selection(input);
|
||||
const { dungeon, difficultyId, difficulty, bossDifficulty, spotId, spotRevision } = selection(input);
|
||||
const filter = input.clear || "final";
|
||||
if (!["full", "final"].includes(filter)) fail("Choose full dungeon clears or final boss clears.");
|
||||
const offset = Number(input.offset ?? 0);
|
||||
@@ -152,12 +182,13 @@ export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
const condition = filter === "full" ? "AND full_clear = 1" : "";
|
||||
const bossId = boardBossId(difficulty, input.bossId);
|
||||
const bossCondition = bossId ? "AND final_boss_id = ?" : "";
|
||||
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : [])];
|
||||
const spotCondition = spotId ? "AND spot_id = ? AND spot_revision = ?" : "";
|
||||
const args = [dungeon.id, difficultyId, bossDifficulty, ...(bossId ? [bossId] : []), ...(spotId ? [spotId, spotRevision] : [])];
|
||||
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition} ${spotCondition}
|
||||
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(...args, limit, offset);
|
||||
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition}`).get(...args).total;
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition} ${bossCondition} ${spotCondition}`).get(...args).total;
|
||||
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { GameDatabaseError } from "./database.mjs";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG } from "../shared/bite-sized-layouts.mjs";
|
||||
import { botOpponent, botProgress, botTierForRecord, compatibleDungeons, hashRaceSeed, raceDungeons, rollRaceAffixes,
|
||||
RACE_COUNTDOWN_MS, RACE_DISCONNECT_MS, RACE_MAX_MS, RACE_PREPARE_MS, RACE_QUEUE_WAIT_MS } from "./dungeon-race-rules.mjs";
|
||||
|
||||
@@ -31,6 +32,7 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
|
||||
return { status: match.status === "active" && timestamp < match.startAt ? "countdown" : match.status,
|
||||
serverTime: timestamp, matchId: match.id, seed: match.seed, dungeonId: match.dungeonId,
|
||||
difficultyId: match.difficultyId, bossDifficulty: match.bossDifficulty, affixIds: match.affixIds,
|
||||
...(match.biteSizedSelection ? { biteSizedSelection: match.biteSizedSelection } : {}),
|
||||
prepareDeadline: match.createdAt + RACE_PREPARE_MS, startAt: match.startAt,
|
||||
ready: self?.readyAt != null, opponent: match.bot ?? (rival ? { type: "human", name: rival.username,
|
||||
source: rival.source, ready: rival.readyAt != null, finished: rival.clearAt != null } : null),
|
||||
@@ -56,11 +58,13 @@ export function createDungeonRaceService({ database, now = () => Date.now(), aut
|
||||
const dungeon = choices[hashRaceSeed(id) % choices.length];
|
||||
const affixIds = rollRaceAffixes(id);
|
||||
const bot = right ? null : makeBot(id, left.accountId, affixIds);
|
||||
const biteSizedSelection = database.resolveBiteSizedRun?.(dungeon.id, id,
|
||||
BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !database.getDungeonQueuePolicy().config.disabled.biteSized.includes(d.id)).map((d) => d.id));
|
||||
const participants = [left, ...(right ? [right] : [{ side: 1, accountId: null,
|
||||
characterId: null, username: bot.name, source: bot.source, role: null }])]
|
||||
.map((entry, side) => ({ side, accountId: entry.accountId, characterId: entry.characterId,
|
||||
username: entry.username, source: entry.source, role: entry.role }));
|
||||
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds,
|
||||
const match = database.createDungeonRaceMatch({ id, dungeonId: dungeon.id, seed: id, affixIds, biteSizedSelection,
|
||||
difficultyId: dungeon.defaultDifficultyId, createdAt: timestamp, participants, bot });
|
||||
matches.set(id, match);
|
||||
for (const participant of participants) if (participant.accountId) {
|
||||
|
||||
@@ -186,7 +186,7 @@ test("both randomized creature pools validate and rank their cross-dungeon final
|
||||
assert.throws(() => service.finish(left, match.matchId, { runId: match.seed, bossIds: [wrongBossId] }), { status: 400 });
|
||||
assert.equal(service.finish(left, match.matchId, { runId: match.seed, bossIds }).result, "win");
|
||||
assert.equal(database.listDungeonRaceLeaderboard({ view: "times", period: "all", dungeonId,
|
||||
bossId: bossIds[0] }, time()).total, 1);
|
||||
bossId: bossIds[0], spotId: match.biteSizedSelection.spot.id, spotRevision: match.biteSizedSelection.spot.revision }, time()).total, 1);
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
|
||||
import { normalizeRaceCombatLog } from "./dungeon-race-combat-log.mjs";
|
||||
import { defaultBiteSizedBoardScope } from "../shared/bite-sized-layouts.mjs";
|
||||
|
||||
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
||||
|
||||
@@ -11,7 +12,7 @@ function raceBossId(dungeonId, difficultyId, seed) {
|
||||
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
|
||||
}
|
||||
|
||||
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false }) {
|
||||
export function createDungeonRaceRepository(database, { ErrorType, isGameMaster = () => false, biteSizedLayouts }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
|
||||
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
|
||||
@@ -64,6 +65,14 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
database.prepare("UPDATE dungeon_race_matches SET status = 'void', settled_at = ? WHERE status IN ('preparing', 'active')")
|
||||
.run(Date.now());
|
||||
const matchById = database.prepare("SELECT * FROM dungeon_race_matches WHERE id = ?");
|
||||
for (const [name, type] of [["spot_id", "TEXT"], ["spot_revision", "INTEGER"], ["selection_json", "TEXT"]]) {
|
||||
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((c) => c.name === name)) database.exec(`ALTER TABLE dungeon_race_matches ADD COLUMN ${name} ${type}`);
|
||||
}
|
||||
for (const row of database.prepare("SELECT id, dungeon_id FROM dungeon_race_matches WHERE spot_id IS NULL").all()) {
|
||||
const scope = defaultBiteSizedBoardScope(row.dungeon_id);
|
||||
if (scope.spotId) database.prepare("UPDATE dungeon_race_matches SET spot_id = ?, spot_revision = ? WHERE id = ?").run(scope.spotId, scope.spotRevision, row.id);
|
||||
}
|
||||
database.exec("CREATE INDEX IF NOT EXISTS dungeon_race_spot_times_idx ON dungeon_race_matches(dungeon_id, spot_id, spot_revision, boss_id, status)");
|
||||
const participantsById = database.prepare("SELECT * FROM dungeon_race_participants WHERE match_id = ? ORDER BY side");
|
||||
const insertMatch = database.prepare(`INSERT INTO dungeon_race_matches
|
||||
(id, dungeon_id, seed, difficulty_id, boss_difficulty, status, created_at, bot_json, boss_id, affix_ids_json)
|
||||
@@ -104,6 +113,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
const match = matchById.get(id);
|
||||
if (!match) return null;
|
||||
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
|
||||
...(match.selection_json ? { biteSizedSelection: JSON.parse(match.selection_json) } : {}),
|
||||
difficultyId: match.difficulty_id, bossDifficulty: match.boss_difficulty,
|
||||
status: match.status, createdAt: match.created_at, startAt: match.start_at,
|
||||
settledAt: match.settled_at, winnerSide: match.winner_side,
|
||||
@@ -114,12 +124,16 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
|
||||
}
|
||||
function createMatch(match) {
|
||||
const selection = match.biteSizedSelection ?? biteSizedLayouts?.resolveBiteSizedRun(match.dungeonId, match.seed);
|
||||
transaction(() => {
|
||||
insertMatch.run(match.id, match.dungeonId, match.seed, match.difficultyId, match.createdAt,
|
||||
match.bot ? JSON.stringify(match.bot) : null, raceBossId(match.dungeonId, match.difficultyId, match.seed),
|
||||
JSON.stringify(match.affixIds ?? []));
|
||||
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
|
||||
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
|
||||
const scope = selection ? { spotId: selection.spot.id, spotRevision: selection.spot.revision } : defaultBiteSizedBoardScope(match.dungeonId);
|
||||
database.prepare("UPDATE dungeon_race_matches SET spot_id = ?, spot_revision = ?, selection_json = ?, boss_id = COALESCE(?, boss_id) WHERE id = ?")
|
||||
.run(scope.spotId, scope.spotRevision, selection ? JSON.stringify(selection) : null, selection?.boss.objectiveId ?? null, match.id);
|
||||
});
|
||||
return getMatch(match.id);
|
||||
}
|
||||
@@ -140,7 +154,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
|| match.status === "void") throw new ErrorType("This race is not accepting clears.", 409, "race_not_active");
|
||||
const difficulty = dungeons.get(match.dungeonId)?.difficulties.find((entry) => entry.id === match.difficultyId);
|
||||
const finalIds = difficulty?.finalBossIds ?? (difficulty?.finalBossId ? [difficulty.finalBossId] : []);
|
||||
const selectedBossId = raceBossId(match.dungeonId, match.difficultyId, match.seed);
|
||||
const selectedBossId = match.biteSizedSelection?.boss.objectiveId ?? raceBossId(match.dungeonId, match.difficultyId, match.seed);
|
||||
if (!Array.isArray(bossIds) || bossIds.length > 100 || bossIds.some((value) => !difficulty?.bossIds.includes(value))
|
||||
|| !bossIds.some((value) => finalIds.includes(value))
|
||||
|| (selectedBossId && (bossIds.length !== 1 || bossIds[0] !== selectedBossId)))
|
||||
@@ -216,7 +230,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
return { wins: Number(row.wins ?? 0), losses: Number(row.losses ?? 0) };
|
||||
}
|
||||
function leaderboard({ view = "wins", period = "30d", dungeonId = null, bossId = null,
|
||||
limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
|
||||
spotId = null, spotRevision = null, limit = 20, offset = 0 } = {}, timestamp = Date.now()) {
|
||||
const dungeon = dungeons.get(dungeonId);
|
||||
const bossIds = dungeon?.difficulties.find((entry) => entry.id === dungeon.defaultDifficultyId)?.finalBossIds;
|
||||
if (!["wins", "times"].includes(view) || !["30d", "all"].includes(period)
|
||||
@@ -230,7 +244,17 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
const byDungeon = dungeonId ? "AND m.dungeon_id = ?" : "";
|
||||
const selectedBossId = dungeonId && bossIds?.length ? bossId ?? bossIds[0] : null;
|
||||
const byBoss = selectedBossId ? "AND m.boss_id = ?" : "";
|
||||
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : [])];
|
||||
const defaults = defaultBiteSizedBoardScope(dungeonId);
|
||||
const selectedSpotId = view === "times" ? spotId ?? defaults.spotId : null;
|
||||
const selectedSpotRevision = view === "times" ? spotRevision == null ? defaults.spotRevision : Number(spotRevision) : null;
|
||||
if (selectedSpotId && (!Number.isSafeInteger(selectedSpotRevision) || selectedSpotRevision < 0)) throw new ErrorType("Choose a valid spot revision.", 400, "invalid_race_leaderboard");
|
||||
if (selectedSpotId && biteSizedLayouts) {
|
||||
const spot = biteSizedLayouts.getBiteSizedSpotRevision(selectedSpotId, selectedSpotRevision);
|
||||
if (!dungeonId.startsWith("randomized-bite-sized-") && spot.dungeonId !== dungeonId) throw new ErrorType("Choose a spot in this dungeon.", 400, "invalid_race_leaderboard");
|
||||
}
|
||||
const bySpot = selectedSpotId ? "AND m.spot_id = ? AND m.spot_revision = ?" : "";
|
||||
const spotArgs = selectedSpotId ? [selectedSpotId, selectedSpotRevision] : [];
|
||||
const args = [since, ...(dungeonId ? [dungeonId] : []), ...(selectedBossId ? [selectedBossId] : []), ...spotArgs];
|
||||
const query = view === "wins" ? `
|
||||
SELECT p.account_id AS accountId, a.username AS username,
|
||||
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
|
||||
@@ -242,7 +266,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
: `SELECT p.account_id AS accountId, a.username AS username, MIN(p.duration_ms) AS durationMs
|
||||
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
|
||||
JOIN accounts a ON a.id = p.account_id
|
||||
WHERE p.clear_at >= ? ${byDungeon} ${byBoss} AND p.duration_ms IS NOT NULL
|
||||
WHERE p.clear_at >= ? ${byDungeon} ${byBoss} ${bySpot} AND p.duration_ms IS NOT NULL
|
||||
GROUP BY p.account_id, a.username ORDER BY durationMs ASC, a.username COLLATE NOCASE ASC`;
|
||||
const all = database.prepare(query).all(...args);
|
||||
let lastValue = null;
|
||||
@@ -250,7 +274,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
const bestAffixes = view === "times" ? database.prepare(`SELECT m.affix_ids_json AS affixIdsJson
|
||||
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
|
||||
WHERE p.account_id = ? AND m.dungeon_id = ? AND m.boss_id = ?
|
||||
AND p.duration_ms = ? AND p.clear_at >= ?
|
||||
AND p.duration_ms = ? AND p.clear_at >= ? ${bySpot}
|
||||
ORDER BY p.clear_at ASC LIMIT 1`) : null;
|
||||
const ranked = all.map((row, index) => {
|
||||
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
|
||||
@@ -259,7 +283,7 @@ export function createDungeonRaceRepository(database, { ErrorType, isGameMaster
|
||||
return { ...row, rank, ...(view === "wins"
|
||||
? { wins: Number(row.wins), losses: Number(row.losses) }
|
||||
: { durationMs: Number(row.durationMs), affixIds: JSON.parse(bestAffixes.get(
|
||||
row.accountId, dungeonId, selectedBossId, row.durationMs, since)?.affixIdsJson ?? "[]") }) };
|
||||
row.accountId, dungeonId, selectedBossId, row.durationMs, since, ...spotArgs)?.affixIdsJson ?? "[]") }) };
|
||||
});
|
||||
return { view, period, dungeonId, bossId: selectedBossId,
|
||||
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
|
||||
|
||||
Binary file not shown.
@@ -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",
|
||||
dungeonId: url.searchParams.get("dungeon") || null,
|
||||
bossId: url.searchParams.get("boss") || null,
|
||||
spotId: url.searchParams.get("spot") || null,
|
||||
spotRevision: url.searchParams.get("spotRevision"),
|
||||
limit: Number(url.searchParams.get("limit") ?? 20),
|
||||
offset: Number(url.searchParams.get("offset") ?? 0),
|
||||
}));
|
||||
@@ -365,6 +367,7 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
||||
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
||||
bossId: url.searchParams.get("boss"),
|
||||
spotId: url.searchParams.get("spot"), spotRevision: url.searchParams.get("spotRevision"),
|
||||
bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined,
|
||||
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
||||
return;
|
||||
@@ -537,6 +540,25 @@ export function createGameServer(options = {}) {
|
||||
json(response, 201, arenaService.socketTicket(account.id));
|
||||
return;
|
||||
}
|
||||
if (["/api/bite-sized-layouts", "/api/gm/bite-sized-layouts"].includes(url.pathname)) {
|
||||
const privileged = url.pathname.startsWith("/api/gm/");
|
||||
let account = null;
|
||||
if (privileged) {
|
||||
account = gameDatabase.authenticate(bearerToken(request));
|
||||
if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
|
||||
}
|
||||
if (request.method === "GET") {
|
||||
const spotId = url.searchParams.get("spotId");
|
||||
json(response, 200, spotId ? gameDatabase.getBiteSizedSpotRevision(spotId, Number(url.searchParams.get("revision")))
|
||||
: url.searchParams.get("history") === "1" ? gameDatabase.listBiteSizedSpotRevisions() : gameDatabase.getBiteSizedLayouts());
|
||||
return;
|
||||
}
|
||||
if (privileged && request.method === "PUT") {
|
||||
const body = await readJson(request, bodyLimit);
|
||||
json(response, 200, gameDatabase.putBiteSizedLayouts(account, body.expectedRevision, body.config));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (url.pathname === "/api/manastorm-config" && request.method === "GET") {
|
||||
const current = gameDatabase.getManastormConfig();
|
||||
json(response, 200, {
|
||||
|
||||
Reference in new issue
Block a user