126 lines
8.6 KiB
JavaScript
126 lines
8.6 KiB
JavaScript
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);
|
|
`);
|
|
// Zero archives pre-slider runs so their old boss balance cannot win the new Easy board.
|
|
if (!database.prepare("PRAGMA table_info(dungeon_runs)").all().some((column) => column.name === "boss_difficulty")) {
|
|
database.exec("ALTER TABLE dungeon_runs ADD COLUMN boss_difficulty INTEGER NOT NULL DEFAULT 0 CHECK (boss_difficulty BETWEEN 0 AND 10)");
|
|
}
|
|
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_runs_boss_difficulty_idx
|
|
ON dungeon_runs(dungeon_id, difficulty_id, boss_difficulty, full_clear, duration_ms, finished_at, id)`);
|
|
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, boss_difficulty)
|
|
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.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) });
|
|
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);
|
|
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 };
|
|
}
|
|
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 = ? AND boss_difficulty = ?`)
|
|
.get(run.durationMs, run.durationMs, run.dungeonId, run.difficultyId, run.bossDifficulty ?? 0);
|
|
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, bossDifficulty } = selection(input);
|
|
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.");
|
|
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,
|
|
...(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 } : {}),
|
|
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(), bossDifficulty);
|
|
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, bossDifficulty } = 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 = ? AND boss_difficulty = ? ${condition}
|
|
ORDER BY duration_ms, finished_at, id LIMIT ? OFFSET ?`).all(dungeon.id, difficultyId, bossDifficulty, limit, offset);
|
|
const total = database.prepare(`SELECT COUNT(*) AS total FROM dungeon_runs
|
|
WHERE dungeon_id = ? AND difficulty_id = ? AND boss_difficulty = ? ${condition}`).get(dungeon.id, difficultyId, bossDifficulty).total;
|
|
return { entries: rows.map((row) => ({ ...record(row), rank: Number(row.place) })), total: Number(total) };
|
|
},
|
|
};
|
|
}
|