Release Healer Man 0.1.39
This commit is contained in:
1 parent
b417b7c3c3
commit
ed0d2fea3d
51 files changed
+17811
-49
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
import { arenaRatingBand, calculateArenaRating, clampArenaRating } from "./arena-rules.mjs";
|
||||
import { arenaRoleForSpecialization } from "./arena-specializations.mjs";
|
||||
import { createDungeonReportRepository } from "./dungeon-reports.mjs";
|
||||
import { createDungeonLeaderboardRepository } from "./dungeon-leaderboards.mjs";
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
|
||||
@@ -897,6 +898,7 @@ export function openGameDatabase(options = {}) {
|
||||
return {
|
||||
databasePath,
|
||||
...createDungeonReportRepository(database, { isGameMaster, ErrorType: GameDatabaseError }),
|
||||
...createDungeonLeaderboardRepository(database, { ErrorType: GameDatabaseError }),
|
||||
register,
|
||||
login,
|
||||
authenticate,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
||||
const classes = new Set(catalog.classes.map((entry) => entry.id));
|
||||
|
||||
export function createDungeonLeaderboardRepository(database, { ErrorType }) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS dungeon_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
dungeon_id TEXT NOT NULL,
|
||||
difficulty_id TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL CHECK (duration_ms > 0),
|
||||
full_clear INTEGER NOT NULL CHECK (full_clear IN (0, 1)),
|
||||
finished_at INTEGER NOT NULL,
|
||||
party_json TEXT NOT NULL,
|
||||
bosses_json TEXT NOT NULL,
|
||||
submitted_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
||||
received_at INTEGER NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX IF NOT EXISTS dungeon_runs_times_idx
|
||||
ON dungeon_runs(dungeon_id, difficulty_id, duration_ms, finished_at, id);
|
||||
CREATE INDEX IF NOT EXISTS dungeon_runs_full_times_idx
|
||||
ON dungeon_runs(dungeon_id, difficulty_id, full_clear, duration_ms);
|
||||
`);
|
||||
const byId = database.prepare("SELECT * FROM dungeon_runs WHERE id = ?");
|
||||
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)
|
||||
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,
|
||||
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) });
|
||||
function selection(input) {
|
||||
const dungeon = dungeons.get(input.dungeonId);
|
||||
if (!dungeon) fail("Choose a known dungeon.");
|
||||
const difficultyId = input.difficultyId || dungeon.defaultDifficultyId;
|
||||
if (!dungeon.difficulties.some((entry) => entry.id === difficultyId)) fail("Choose a supported dungeon difficulty.");
|
||||
const difficulty = dungeon.difficulties.find((entry) => entry.id === difficultyId);
|
||||
return { dungeon, difficultyId, difficulty };
|
||||
}
|
||||
function placement(run) {
|
||||
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 = ?`)
|
||||
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId);
|
||||
return { finalRank: Number(counts.faster) + 1, finalTotal: Number(counts.total),
|
||||
fullRank: run.fullClear ? Number(counts.full_faster) + 1 : null, fullTotal: Number(counts.full_total) };
|
||||
}
|
||||
return {
|
||||
submitDungeonRun(account, input) {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) fail("Provide a completed dungeon run.");
|
||||
const { dungeon, difficultyId, difficulty } = selection(input);
|
||||
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 (!Number.isSafeInteger(input.durationMs) || input.durationMs < 1 || input.durationMs > 7 * 24 * 60 * 60 * 1000)
|
||||
fail("Provide a valid completion time.");
|
||||
if (!Number.isSafeInteger(input.finishedAt) || input.finishedAt < 0 || input.finishedAt > Date.now() + 60_000)
|
||||
fail("Provide a valid finish timestamp.");
|
||||
if (!Array.isArray(input.killedBossIds) || input.killedBossIds.length > 100
|
||||
|| input.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) fail("Provide bosses from this dungeon and difficulty.");
|
||||
const killedBossIds = [...new Set(input.killedBossIds)].sort();
|
||||
if (!killedBossIds.includes(difficulty.finalBossId)) fail("The final boss must be defeated before recording a run.");
|
||||
if (!Array.isArray(input.party) || input.party.length < 1 || input.party.length > 5) fail("Provide the run's party.");
|
||||
const party = input.party.map((member) => {
|
||||
if (!member || typeof member !== "object" || typeof member.name !== "string" || !member.name.trim()
|
||||
|| member.name.length > 40 || !classes.has(member.classId)
|
||||
|| !Number.isInteger(member.level) || member.level < 1 || member.level > 80
|
||||
|| !["tank", "healer", "damage"].includes(member.role) || typeof member.npc !== "boolean"
|
||||
|| (member.secondaryClassId !== undefined && !classes.has(member.secondaryClassId))) fail("Provide valid party classes and levels.");
|
||||
return { name: member.name.trim(), classId: member.classId, level: member.level,
|
||||
role: member.role, npc: member.npc,
|
||||
...(member.secondaryClassId ? { secondaryClassId: member.secondaryClassId } : {}) };
|
||||
});
|
||||
const fullClear = difficulty.bossIds.every((id) => killedBossIds.includes(id));
|
||||
const run = { id: input.id, dungeonId: dungeon.id, difficultyId, durationMs: input.durationMs,
|
||||
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, 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,
|
||||
run.finishedAt, JSON.stringify(party), JSON.stringify(killedBossIds), account?.id ?? null, Date.now());
|
||||
return { run, placement: placement(run) };
|
||||
},
|
||||
getDungeonRun(id) {
|
||||
const row = byId.get(id);
|
||||
if (!row) throw new ErrorType("That dungeon run is not recorded yet.", 404, "run_not_found");
|
||||
const run = record(row);
|
||||
return { run, placement: placement(run) };
|
||||
},
|
||||
listDungeonLeaderboard(input) {
|
||||
const { dungeon, difficultyId } = 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);
|
||||
const limit = Number(input.limit ?? 20);
|
||||
if (!Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000
|
||||
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail("Choose a valid leaderboard page.");
|
||||
const condition = filter === "full" ? "AND full_clear = 1" : "";
|
||||
const rows = database.prepare(`SELECT *, RANK() OVER (ORDER BY duration_ms) AS place FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}
|
||||
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, limit, offset);
|
||||
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
|
||||
WHERE dungeon_id = ? AND difficulty_id = ? ${condition}`).get(dungeon.id, difficultyId).total;
|
||||
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { openGameDatabase } from "./database.mjs";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
function run(id, durationMs, fullClear = true, dungeonId = "deadmines", difficultyId) {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
|
||||
return { id, dungeonId, difficultyId: difficultyId ?? dungeon.defaultDifficultyId, durationMs,
|
||||
finishedAt: 1700000000000, killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId],
|
||||
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false },
|
||||
{ name: "Garrick", classId: "warrior", level: 20, role: "tank", npc: true }] };
|
||||
}
|
||||
async function withDatabase(callback) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-test-"));
|
||||
let database = openGameDatabase({ dataDir: directory });
|
||||
try { await callback(database, () => { database.close(); database = openGameDatabase({ dataDir: directory }); return database; }); }
|
||||
finally { database.close(); rmSync(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
test("persists ranked party/boss records, derives all-boss eligibility, handles ties, and retries without duplicates", () => withDatabase((database, reopen) => {
|
||||
database.submitDungeonRun(null, run("run-fastest", 1000, false));
|
||||
database.submitDungeonRun(null, run("run-full-one", 2000));
|
||||
database.submitDungeonRun(null, run("run-full-tie", 2000));
|
||||
const last = run("run-slowest", 3000);
|
||||
const result = database.submitDungeonRun(null, last);
|
||||
assert.deepEqual(result.placement, { finalRank: 4, finalTotal: 4, fullRank: 3, fullTotal: 3 });
|
||||
assert.deepEqual(database.submitDungeonRun(null, last), result);
|
||||
assert.throws(() => database.submitDungeonRun(null, { ...last, durationMs: 5 }), { status: 409 });
|
||||
const final = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final" });
|
||||
assert.deepEqual(final.entries.map((entry) => entry.rank), [1, 2, 2, 4]);
|
||||
const full = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "full" });
|
||||
assert.deepEqual(full.entries.map((entry) => entry.rank), [1, 1, 3]);
|
||||
assert.deepEqual(full.entries[0].party, last.party);
|
||||
assert.equal(full.entries[0].killedBossIds.length, last.killedBossIds.length);
|
||||
const page = database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "final", limit: 2, offset: 2 });
|
||||
assert.equal(page.total, 4); assert.deepEqual(page.entries.map((entry) => entry.rank), [2, 4]);
|
||||
database = reopen();
|
||||
assert.deepEqual(database.getDungeonRun(last.id), result);
|
||||
}));
|
||||
test("validates final boss, dungeon/difficulty, duration and party without trusting client full-clear flags", () => withDatabase((database) => {
|
||||
const valid = run("valid-run-id", 1000, false);
|
||||
for (const invalid of [{ ...valid, durationMs: 0 }, { ...valid, durationMs: Infinity },
|
||||
{ ...valid, killedBossIds: [] }, { ...valid, killedBossIds: ["fake-boss"] },
|
||||
{ ...valid, dungeonId: "unknown" }, { ...valid, difficultyId: "fake" },
|
||||
{ ...valid, party: [] }, { ...valid, party: [{ ...valid.party[0], classId: "fake" }] },
|
||||
{ ...valid, party: [{ ...valid.party[0], level: 81 }] }])
|
||||
assert.throws(() => database.submitDungeonRun(null, invalid), { code: "invalid_dungeon_run" });
|
||||
assert.equal(database.submitDungeonRun(null, { ...valid, fullClear: true }).run.fullClear, false);
|
||||
const caves = run("barren-caves-test", 2000, true, "barren-caves");
|
||||
caves.party[0].classId = "rom-priest";
|
||||
assert.equal(database.submitDungeonRun(null, caves).placement.fullRank, 1);
|
||||
assert.equal(database.listDungeonLeaderboard({ dungeonId: "deadmines" }).total, 1);
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", clear: "bad" }), { status: 400 });
|
||||
assert.throws(() => database.listDungeonLeaderboard({ dungeonId: "deadmines", offset: -1 }), { status: 400 });
|
||||
}));
|
||||
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
|
||||
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
|
||||
try {
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const payload = run("http-caves-clear", 15555, true, "barren-caves");
|
||||
const response = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
assert.equal(response.status, 201);
|
||||
const result = await response.json(); assert.equal(result.placement.finalRank, 1);
|
||||
const leaderboard = await fetch(`${base}/api/dungeon-leaderboards?dungeon=barren-caves&clear=full`);
|
||||
assert.equal(leaderboard.status, 200); assert.equal((await leaderboard.json()).entries[0].durationMs, 15555);
|
||||
assert.deepEqual(await (await fetch(`${base}/api/dungeon-runs/${payload.id}`)).json(), result);
|
||||
const bad = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, id: "unfinished-run", killedBossIds: [] }) });
|
||||
assert.equal(bad.status, 400);
|
||||
const unauthorized = await fetch(`${base}/api/dungeon-runs`, { method: "POST", headers: { Authorization: "Bearer invalid", "Content-Type": "application/json" }, body: JSON.stringify(payload) });
|
||||
assert.equal(unauthorized.status, 401);
|
||||
} finally { await new Promise((resolve) => server.close(resolve)); rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("separates normal and heroic times and counts only bosses that exist on each difficulty", () => withDatabase((database) => {
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.difficulties.length > 1 && entry.difficulties.every((difficulty) => difficulty.finalBossId));
|
||||
for (const difficulty of dungeon.difficulties) {
|
||||
const payload = run(`difficulty-${difficulty.id}-test`, 1000, true, dungeon.id, difficulty.id);
|
||||
payload.killedBossIds = difficulty.bossIds;
|
||||
const result = database.submitDungeonRun(null, payload);
|
||||
assert.equal(result.run.fullClear, true);
|
||||
assert.equal(result.placement.finalRank, 1); assert.equal(result.placement.finalTotal, 1);
|
||||
assert.equal(database.listDungeonLeaderboard({ dungeonId: dungeon.id, difficultyId: difficulty.id, clear: "full" }).total, 1);
|
||||
}
|
||||
}));
|
||||
@@ -182,6 +182,7 @@ export function createGameServer(options = {}) {
|
||||
const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes });
|
||||
const allowAuthRequest = createAuthLimiter();
|
||||
const allowReportRequest = createAuthLimiter();
|
||||
const allowDungeonRunRequest = createAuthLimiter();
|
||||
gameDatabase.pruneExpiredSessions();
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -240,6 +241,25 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"),
|
||||
difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"),
|
||||
offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-runs" && request.method === "POST") {
|
||||
const token = bearerToken(request);
|
||||
const account = token ? gameDatabase.authenticate(token) : null;
|
||||
if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress))
|
||||
throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited");
|
||||
json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024)));
|
||||
return;
|
||||
}
|
||||
const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname);
|
||||
if (dungeonRunMatch && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1]));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/dungeon-reports" && request.method === "GET") {
|
||||
json(response, 200, gameDatabase.listDungeonReports({
|
||||
status: url.searchParams.get("status"),
|
||||
|
||||
Reference in new issue
Block a user