190 lines
12 KiB
JavaScript
190 lines
12 KiB
JavaScript
import catalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
|
import { hashRaceSeed, RACE_PERIOD_MS, racePoolForDungeon } from "./dungeon-race-rules.mjs";
|
|
|
|
const dungeons = new Map(catalog.dungeons.map((entry) => [entry.id, entry]));
|
|
|
|
function raceBossId(dungeonId, difficultyId, seed) {
|
|
const difficulty = dungeons.get(dungeonId)?.difficulties.find((entry) => entry.id === difficultyId);
|
|
const finalIds = difficulty?.finalBossIds;
|
|
const objectiveSeed = dungeonId.startsWith("randomized-bite-sized-") ? `${seed}:boss` : seed;
|
|
return finalIds?.length ? finalIds[hashRaceSeed(objectiveSeed) % finalIds.length] : null;
|
|
}
|
|
|
|
export function createDungeonRaceRepository(database, { ErrorType }) {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS dungeon_race_matches (
|
|
id TEXT PRIMARY KEY, dungeon_id TEXT NOT NULL, seed TEXT NOT NULL,
|
|
difficulty_id TEXT NOT NULL, boss_difficulty INTEGER NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'settled', 'void')),
|
|
created_at INTEGER NOT NULL, start_at INTEGER, settled_at INTEGER,
|
|
winner_side INTEGER CHECK (winner_side IN (0, 1)), bot_json TEXT
|
|
) STRICT;
|
|
CREATE TABLE IF NOT EXISTS dungeon_race_participants (
|
|
match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE,
|
|
side INTEGER NOT NULL CHECK (side IN (0, 1)),
|
|
account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
|
|
character_id TEXT, username TEXT NOT NULL, source TEXT NOT NULL,
|
|
role TEXT, ready_at INTEGER, clear_at INTEGER, duration_ms INTEGER,
|
|
outcome TEXT CHECK (outcome IN ('win', 'loss')),
|
|
PRIMARY KEY (match_id, side)
|
|
) STRICT;
|
|
CREATE INDEX IF NOT EXISTS dungeon_race_result_idx
|
|
ON dungeon_race_matches(status, settled_at, dungeon_id);
|
|
CREATE INDEX IF NOT EXISTS dungeon_race_account_idx
|
|
ON dungeon_race_participants(account_id, outcome, clear_at);
|
|
`);
|
|
if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "boss_id")) {
|
|
database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN boss_id TEXT");
|
|
}
|
|
const missingBosses = database.prepare("SELECT id, dungeon_id, difficulty_id, seed FROM dungeon_race_matches WHERE boss_id IS NULL").all();
|
|
const updateBoss = database.prepare("UPDATE dungeon_race_matches SET boss_id = ? WHERE id = ?");
|
|
for (const match of missingBosses) {
|
|
const bossId = raceBossId(match.dungeon_id, match.difficulty_id, match.seed);
|
|
if (bossId) updateBoss.run(bossId, match.id);
|
|
}
|
|
database.exec(`CREATE INDEX IF NOT EXISTS dungeon_race_boss_idx
|
|
ON dungeon_race_matches(dungeon_id, boss_id, status, settled_at)`);
|
|
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 = ?");
|
|
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)
|
|
VALUES (?, ?, ?, ?, 1, 'preparing', ?, ?, ?)`);
|
|
const insertParticipant = database.prepare(`INSERT INTO dungeon_race_participants
|
|
(match_id, side, account_id, character_id, username, source, role)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
const updateReady = database.prepare(`UPDATE dungeon_race_participants SET ready_at = COALESCE(ready_at, ?)
|
|
WHERE match_id = ? AND account_id = ?`);
|
|
const updateStart = database.prepare(`UPDATE dungeon_race_matches SET status = 'active', start_at = ?
|
|
WHERE id = ? AND status = 'preparing'`);
|
|
const updateBot = database.prepare(`UPDATE dungeon_race_matches SET bot_json = ?
|
|
WHERE id = ? AND status IN ('preparing', 'active')`);
|
|
const deleteParticipant = database.prepare("DELETE FROM dungeon_race_participants WHERE match_id = ? AND side = ?");
|
|
const updateClear = database.prepare(`UPDATE dungeon_race_participants
|
|
SET clear_at = COALESCE(clear_at, ?), duration_ms = COALESCE(duration_ms, ?)
|
|
WHERE match_id = ? AND account_id = ? AND clear_at IS NULL`);
|
|
const updateSettlement = database.prepare(`UPDATE dungeon_race_matches
|
|
SET status = 'settled', settled_at = ?, winner_side = ? WHERE id = ? AND status = 'active'`);
|
|
const updateVoid = database.prepare(`UPDATE dungeon_race_matches
|
|
SET status = 'void', settled_at = ? WHERE id = ? AND status IN ('preparing', 'active')`);
|
|
const updateOutcome = database.prepare(`UPDATE dungeon_race_participants SET outcome = ? WHERE match_id = ? AND side = ?`);
|
|
|
|
function transaction(action) {
|
|
database.exec("BEGIN IMMEDIATE");
|
|
try { const result = action(); database.exec("COMMIT"); return result; }
|
|
catch (error) { database.exec("ROLLBACK"); throw error; }
|
|
}
|
|
function getMatch(id) {
|
|
const match = matchById.get(id);
|
|
if (!match) return null;
|
|
return { id: match.id, dungeonId: match.dungeon_id, seed: match.seed,
|
|
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,
|
|
bot: match.bot_json ? JSON.parse(match.bot_json) : null,
|
|
participants: participantsById.all(id).map((row) => ({ side: row.side, accountId: row.account_id,
|
|
characterId: row.character_id, username: row.username, source: row.source, role: row.role,
|
|
readyAt: row.ready_at, clearAt: row.clear_at, durationMs: row.duration_ms, outcome: row.outcome })) };
|
|
}
|
|
function createMatch(match) {
|
|
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));
|
|
for (const participant of match.participants) insertParticipant.run(match.id, participant.side,
|
|
participant.accountId, participant.characterId, participant.username, participant.source, participant.role);
|
|
});
|
|
return getMatch(match.id);
|
|
}
|
|
function settleMatch(id, winnerSide, timestamp) {
|
|
return transaction(() => {
|
|
const changed = updateSettlement.run(timestamp, winnerSide, id).changes;
|
|
if (changed) for (const participant of participantsById.all(id)) {
|
|
if (participant.account_id) updateOutcome.run(participant.side === winnerSide ? "win" : "loss", id, participant.side);
|
|
}
|
|
return getMatch(id);
|
|
});
|
|
}
|
|
function completeRaceRun(id, accountId, bossIds, timestamp) {
|
|
const match = getMatch(id);
|
|
if (!match || !match.participants.some((entry) => entry.accountId === accountId))
|
|
throw new ErrorType("That race is not yours.", 404, "race_not_found");
|
|
if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 2 * 60 * 60_000
|
|
|| 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);
|
|
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)))
|
|
throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear");
|
|
return transaction(() => {
|
|
updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId);
|
|
return getMatch(id);
|
|
});
|
|
}
|
|
function replaceWithBot(id, side, bot) {
|
|
transaction(() => {
|
|
deleteParticipant.run(id, side);
|
|
insertParticipant.run(id, side, null, null, bot.name, bot.source, null);
|
|
updateBot.run(JSON.stringify(bot), id);
|
|
});
|
|
return getMatch(id);
|
|
}
|
|
function recordForAccount(accountId, timestamp) {
|
|
const row = database.prepare(`SELECT
|
|
SUM(CASE WHEN p.outcome = 'win' THEN 1 ELSE 0 END) AS wins,
|
|
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
|
|
FROM dungeon_race_participants p JOIN dungeon_race_matches m ON m.id = p.match_id
|
|
WHERE p.account_id = ? AND m.status = 'settled' AND m.settled_at >= ?`)
|
|
.get(accountId, timestamp - RACE_PERIOD_MS);
|
|
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()) {
|
|
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)
|
|
|| (dungeonId !== null && (!dungeons.has(dungeonId) || !racePoolForDungeon(dungeonId)))
|
|
|| (bossId !== null && (!bossIds || !bossIds.includes(bossId)))
|
|
|| !Number.isSafeInteger(limit) || limit < 1 || limit > 100
|
|
|| !Number.isSafeInteger(offset) || offset < 0 || offset > 1_000_000)
|
|
throw new ErrorType("Choose a valid race leaderboard.", 400, "invalid_race_leaderboard");
|
|
if (view === "times" && !dungeonId) throw new ErrorType("Choose a dungeon for race times.", 400, "race_dungeon_required");
|
|
const since = period === "30d" ? timestamp - RACE_PERIOD_MS : 0;
|
|
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 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,
|
|
SUM(CASE WHEN p.outcome = 'loss' THEN 1 ELSE 0 END) AS losses
|
|
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 m.status = 'settled' AND m.settled_at >= ? ${byDungeon} ${byBoss}
|
|
GROUP BY p.account_id, a.username ORDER BY wins DESC, losses ASC, a.username COLLATE NOCASE ASC`
|
|
: `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
|
|
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;
|
|
let rank = 0;
|
|
const ranked = all.map((row, index) => {
|
|
const value = view === "wins" ? Number(row.wins) : Number(row.durationMs);
|
|
if (value !== lastValue) rank = index + 1;
|
|
lastValue = value;
|
|
return { ...row, rank, ...(view === "wins"
|
|
? { wins: Number(row.wins), losses: Number(row.losses) }
|
|
: { durationMs: Number(row.durationMs) }) };
|
|
});
|
|
return { view, period, dungeonId, bossId: selectedBossId,
|
|
total: ranked.length, entries: ranked.slice(offset, offset + limit) };
|
|
}
|
|
return { getMatch, createMatch, ready: (id, accountId, at) => (updateReady.run(at, id, accountId), getMatch(id)),
|
|
start: (id, at) => (updateStart.run(at, id), getMatch(id)), settleMatch, completeRaceRun,
|
|
replaceWithBot, void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard };
|
|
}
|