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])); 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, 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, 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); CREATE TABLE IF NOT EXISTS dungeon_race_combat_logs ( match_id TEXT NOT NULL REFERENCES dungeon_race_matches(id) ON DELETE CASCADE, account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, received_at INTEGER NOT NULL, event_count INTEGER NOT NULL, kill_count INTEGER NOT NULL, dropped_event_count INTEGER NOT NULL, completed INTEGER NOT NULL CHECK (completed IN (0, 1)), payload_json TEXT NOT NULL, PRIMARY KEY (match_id, account_id) ) STRICT; CREATE INDEX IF NOT EXISTS dungeon_race_combat_log_recent_idx ON dungeon_race_combat_logs(received_at DESC); `); 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"); } if (!database.prepare("PRAGMA table_info(dungeon_race_matches)").all().some((column) => column.name === "affix_ids_json")) { database.exec("ALTER TABLE dungeon_race_matches ADD COLUMN affix_ids_json TEXT NOT NULL DEFAULT '[]'"); } 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 = ?"); 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) 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 = ?`); const insertCombatLog = database.prepare(`INSERT INTO dungeon_race_combat_logs (match_id, account_id, received_at, event_count, kill_count, dropped_event_count, completed, payload_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(match_id, account_id) DO UPDATE SET received_at = excluded.received_at, event_count = excluded.event_count, kill_count = excluded.kill_count, dropped_event_count = excluded.dropped_event_count, completed = excluded.completed, payload_json = excluded.payload_json WHERE dungeon_race_combat_logs.completed = 0`); 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, ...(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, bot: match.bot_json ? JSON.parse(match.bot_json) : null, affixIds: JSON.parse(match.affix_ids_json ?? "[]"), 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) { 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); } 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, combatLog = null) { 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 = 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))) throw new ErrorType("Defeat the race's final boss before reporting a clear.", 400, "invalid_race_clear"); const normalizedLog = combatLog === null ? null : normalizeRaceCombatLog(combatLog, { runId: match.seed, dungeonId: match.dungeonId }, ErrorType); if (normalizedLog && !normalizedLog.completed) throw new ErrorType("A race clear needs a completed combat log.", 400, "invalid_race_combat_log"); return transaction(() => { updateClear.run(timestamp, Math.max(1, timestamp - match.startAt), id, accountId); if (normalizedLog) insertCombatLog.run(id, accountId, timestamp, normalizedLog.events.length, normalizedLog.kills.length, normalizedLog.droppedEventCount, 1, JSON.stringify(normalizedLog)); return getMatch(id); }); } function savePartialCombatLog(id, accountId, combatLog, timestamp) { const match = getMatch(id); const participant = match?.participants.find((entry) => entry.accountId === accountId); if (!participant) throw new ErrorType("That race is not yours.", 404, "race_not_found"); if (!match.startAt || timestamp < match.startAt || timestamp - match.startAt > 24 * 60 * 60_000) throw new ErrorType("This race is not accepting combat logs.", 409, "race_combat_log_unavailable"); const normalized = normalizeRaceCombatLog(combatLog, { runId: match.seed, dungeonId: match.dungeonId }, ErrorType); if (normalized.completed) throw new ErrorType("Report a clear to submit a complete combat log.", 400, "invalid_race_combat_log"); insertCombatLog.run(id, accountId, timestamp, normalized.events.length, normalized.kills.length, normalized.droppedEventCount, 0, JSON.stringify(normalized)); return { ok: true, matchId: id, completed: false }; } function getCombatLog(account, id, requestedAccountId = null) { const ownerId = isGameMaster(account) && requestedAccountId ? requestedAccountId : account.id; const row = database.prepare(`SELECT log.*, match.dungeon_id, match.boss_id, account.username FROM dungeon_race_combat_logs log JOIN dungeon_race_matches match ON match.id = log.match_id JOIN accounts account ON account.id = log.account_id WHERE log.match_id = ? AND log.account_id = ?`).get(id, ownerId); if (!row) throw new ErrorType("That race combat log was not found.", 404, "race_combat_log_not_found"); return { matchId: row.match_id, accountId: row.account_id, username: row.username, dungeonId: row.dungeon_id, bossId: row.boss_id, receivedAt: row.received_at, completed: Boolean(row.completed), log: JSON.parse(row.payload_json) }; } function listCombatLogs(account, limit = 20) { if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required"); if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new ErrorType("Choose a valid combat log limit.", 400, "invalid_race_combat_log_limit"); return database.prepare(`SELECT log.match_id AS matchId, log.account_id AS accountId, account.username, match.dungeon_id AS dungeonId, match.boss_id AS bossId, log.received_at AS receivedAt, log.event_count AS eventCount, log.kill_count AS killCount, log.dropped_event_count AS droppedEventCount, log.completed AS completed FROM dungeon_race_combat_logs log JOIN dungeon_race_matches match ON match.id = log.match_id JOIN accounts account ON account.id = log.account_id ORDER BY log.received_at DESC LIMIT ?`).all(limit) .map((row) => ({ ...row, completed: Boolean(row.completed) })); } 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, 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) || (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 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, 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} ${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; let rank = 0; 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 >= ? ${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); 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), affixIds: JSON.parse(bestAffixes.get( row.accountId, dungeonId, selectedBossId, row.durationMs, since, ...spotArgs)?.affixIdsJson ?? "[]") }) }; }); 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, savePartialCombatLog, getCombatLog, listCombatLogs, replaceWithBot, void: (id, at) => (updateVoid.run(at, id), getMatch(id)), recordForAccount, leaderboard }; }