import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; import { mkdirSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; const MAX_JSON_BYTES = 1024 * 1024; const AUTH_WINDOW_MS = 15 * 60 * 1000; const AUTH_ATTEMPTS_PER_WINDOW = 20; const HOCKEY_PVP_COUNTDOWN_MS = 5_000; const ROGUELIKE_PVP_MODE = "roguelike-pvp"; const ROGUELIKE_PVP_COUNTDOWN_MS = 5_000; const ROGUELIKE_PVP_DRAFT_MS = 15_000; const ROGUELIKE_PVP_DISCONNECT_GRACE_MS = 15_000; const ROGUELIKE_PVP_CONNECTED_WINDOW_MS = 3_000; const ROGUELIKE_PVP_QUEUE_TTL_MS = 30_000; const ROGUELIKE_PVP_MATCH_TTL_MS = 10 * 60_000; const ROGUELIKE_PVP_MAX_SNAPSHOT_BYTES = 2_048; const HEALER_CLASS_IDS = new Set(["priest", "druid", "shaman", "paladin", "chronomancer"]); const ROGUELIKE_PVP_PHASES = new Set(["countdown", "combat", "draft", "won", "lost"]); const ROGUELIKE_PVP_BUFF_IDS = [ "mend-echo", "mend-efficiency", "mend-cast-speed", "renew-spread", "renew-duration", "renew-potency", "shield-echo", "shield-potency", "shield-guard", "purify-renew", "purify-shield", "purify-chain", "radiance-cooldown", "radiance-renew", "radiance-shield", "barrier-cooldown", "barrier-duration", "barrier-regen", ]; const ROGUELIKE_PVP_BUFF_ID_SET = new Set(ROGUELIKE_PVP_BUFF_IDS); const ROGUELIKE_PVP_SINGLE_RANK_BUFF_IDS = new Set([ "purify-renew", "purify-shield", "purify-chain", "radiance-renew", ]); const ROGUELIKE_PVP_CURSE_IDS = ["ability1", "ability2", "ability3", "ability4", "ability5", "ability6"] .flatMap((abilityId) => [`${abilityId}-mana-cost`, `${abilityId}-cooldown`]); const ROGUELIKE_PVP_CURSE_ID_SET = new Set(ROGUELIKE_PVP_CURSE_IDS); const ROGUELIKE_PVP_SUPPORTED_BUFF_IDS = { priest: new Set(ROGUELIKE_PVP_BUFF_IDS), druid: new Set(ROGUELIKE_PVP_BUFF_IDS), shaman: new Set(ROGUELIKE_PVP_BUFF_IDS), paladin: new Set([ "mend-echo", "mend-efficiency", "mend-cast-speed", "purify-renew", "purify-shield", "purify-chain", "barrier-cooldown", "barrier-duration", ]), chronomancer: new Set([ "mend-echo", "mend-efficiency", "mend-cast-speed", "purify-renew", "purify-shield", "purify-chain", "radiance-cooldown", "barrier-cooldown", ]), }; const authAttempts = new Map(); function apiError(message, status = 400) { const error = new Error(message); error.status = status; return error; } function sendJson(response, status, body) { response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.setHeader("X-Content-Type-Options", "nosniff"); response.end(JSON.stringify(body)); } function configuredCorsOrigins() { return String(process.env.CORS_ORIGINS ?? "") .split(",") .map((origin) => origin.trim()) .filter(Boolean); } function setCorsHeaders(request, response) { const origin = request.headers.origin; if (!origin) return; const configured = configuredCorsOrigins(); if (!configured.includes("*") && !configured.includes(origin)) return; response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); response.setHeader("Access-Control-Max-Age", "86400"); response.setHeader("Vary", "Origin"); } async function readJson(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > MAX_JSON_BYTES) throw apiError("Request body is too large.", 413); chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw apiError("Request body must be valid JSON."); } } function canonicalUsername(value) { return String(value ?? "").trim().toLocaleLowerCase(); } function validateUsername(value) { const username = String(value ?? "").trim(); if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) { throw apiError("Username must be 3–20 letters, numbers, or underscores."); } return username; } function validatePassword(value) { const password = String(value ?? ""); if (password.length < 10 || password.length > 128) { throw apiError("Password must be 10–128 characters."); } return password; } function passwordDigest(password, salt) { return scryptSync(password, salt, 64).toString("hex"); } function verifyPassword(password, account) { const actual = Buffer.from(passwordDigest(password, account.passwordSalt), "hex"); const expected = Buffer.from(account.passwordHash, "hex"); return actual.length === expected.length && timingSafeEqual(actual, expected); } function tokenHash(token) { return createHash("sha256").update(token).digest("hex"); } function bearerToken(request) { const authorization = String(request.headers.authorization ?? ""); return authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; } function createSession(database, accountId) { const token = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + SESSION_LIFETIME_MS).toISOString(); database.prepare(` INSERT INTO sessions (account_id, token_hash, expires_at) VALUES (?, ?, ?) `).run(accountId, tokenHash(token), expiresAt); return token; } function currentSession(database, request) { const token = bearerToken(request); if (!token) return null; return database.prepare(` SELECT accounts.id AS accountId, accounts.username FROM sessions JOIN accounts ON accounts.id = sessions.account_id WHERE sessions.token_hash = ? AND sessions.expires_at > CURRENT_TIMESTAMP `).get(tokenHash(token)) ?? null; } function requireSession(database, request) { const session = currentSession(database, request); if (!session) throw apiError("Sign in required.", 401); return session; } function clientAddress(request) { return request.socket?.remoteAddress ?? "unknown"; } function enforceAuthRateLimit(request) { const now = Date.now(); const key = clientAddress(request); const existing = authAttempts.get(key); const bucket = existing && now - existing.startedAt < AUTH_WINDOW_MS ? existing : { startedAt: now, count: 0 }; bucket.count += 1; authAttempts.set(key, bucket); if (bucket.count > AUTH_ATTEMPTS_PER_WINDOW) { throw apiError("Too many authentication attempts. Try again later.", 429); } } function register(database, payload) { const username = validateUsername(payload?.username); const password = validatePassword(payload?.password); const canonical = canonicalUsername(username); if (database.prepare("SELECT id FROM accounts WHERE canonical_username = ?").get(canonical)) { throw apiError("Account already exists.", 409); } const salt = randomBytes(16).toString("hex"); const result = database.prepare(` INSERT INTO accounts (username, canonical_username, password_hash, password_salt) VALUES (?, ?, ?, ?) `).run(username, canonical, passwordDigest(password, salt), salt); const accountId = Number(result.lastInsertRowid); return { account: { id: accountId, username }, token: createSession(database, accountId) }; } function login(database, payload) { const canonical = canonicalUsername(payload?.username); const password = String(payload?.password ?? ""); const account = database.prepare(` SELECT id, username, password_hash AS passwordHash, password_salt AS passwordSalt FROM accounts WHERE canonical_username = ? `).get(canonical); if (!account || !verifyPassword(password, account)) { throw apiError("Username or password is incorrect.", 401); } return { account: { id: account.id, username: account.username }, token: createSession(database, account.id), }; } function validateSlotId(value) { const slotId = Number(value); if (!Number.isInteger(slotId) || slotId < 1 || slotId > 3) throw apiError("Invalid save slot."); return slotId; } function validateHealerClassId(value) { const healerClassId = String(value ?? ""); if (!HEALER_CLASS_IDS.has(healerClassId)) throw apiError("Healer class is invalid."); return healerClassId; } function validateRoguelikePvpMode(value) { if (value !== ROGUELIKE_PVP_MODE) throw apiError("PVP queue mode is invalid."); return ROGUELIKE_PVP_MODE; } function validateRoguelikePvpGeneration(value) { const generation = Number(value); if (!Number.isSafeInteger(generation) || generation < 1) { throw apiError("Roguelike PVP match generation is invalid."); } return generation; } function validateRoguelikePvpRound(value) { const round = Number(value); if (!Number.isSafeInteger(round) || round < 1 || round > 100_000) { throw apiError("Roguelike PVP round is invalid."); } return round; } function validateRoguelikePvpSnapshot(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw apiError("Roguelike PVP snapshot is invalid."); } if (Buffer.byteLength(JSON.stringify(value), "utf8") > ROGUELIKE_PVP_MAX_SNAPSHOT_BYTES) { throw apiError("Roguelike PVP snapshot is too large.", 413); } const allowedKeys = new Set([ "sequence", "round", "phase", "partyHp", "bossHp", "bossMaxHp", "defeatedBosses", ]); if (Object.keys(value).some((key) => !allowedKeys.has(key))) { throw apiError("Roguelike PVP snapshot contains unsupported data."); } const sequence = value.sequence; if (typeof value.round !== "number") throw apiError("Roguelike PVP round is invalid."); const round = validateRoguelikePvpRound(value.round); const phase = String(value.phase ?? ""); const partyHp = value.partyHp; const bossHp = value.bossHp; const bossMaxHp = value.bossMaxHp; const defeatedBosses = value.defeatedBosses; if (!Number.isSafeInteger(sequence) || sequence < 1) { throw apiError("Roguelike PVP snapshot sequence is invalid."); } if (!ROGUELIKE_PVP_PHASES.has(phase)) throw apiError("Roguelike PVP snapshot phase is invalid."); if (!Array.isArray(partyHp) || partyHp.length !== 5 || partyHp.some((hp) => typeof hp !== "number" || !Number.isFinite(hp) || hp < 0 || hp > 1)) { throw apiError("Roguelike PVP party health is invalid."); } if (!Number.isFinite(bossHp) || !Number.isFinite(bossMaxHp) || bossHp < 0 || bossMaxHp < 0 || bossHp > bossMaxHp) { throw apiError("Roguelike PVP boss health is invalid."); } if (!Number.isSafeInteger(defeatedBosses) || defeatedBosses < 0) { throw apiError("Roguelike PVP defeated boss count is invalid."); } if (phase === "won") { throw apiError("Roguelike PVP wins are adjudicated by the match server."); } if (phase === "lost" && partyHp.some((hp) => hp !== 0)) { throw apiError("Roguelike PVP loss requires all five party members at zero health."); } return { sequence, round, phase, partyHp: [...partyHp], bossHp, bossMaxHp, defeatedBosses, }; } function validateRoguelikePvpDraftSelection(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { throw apiError("Roguelike PVP draft selection is invalid."); } const buffId = value.buffId === null ? null : String(value.buffId ?? ""); const curseId = value.curseId === null ? null : String(value.curseId ?? ""); if (buffId !== null && !ROGUELIKE_PVP_BUFF_ID_SET.has(buffId) || curseId !== null && !ROGUELIKE_PVP_CURSE_ID_SET.has(curseId)) { throw apiError("Roguelike PVP draft selection is invalid."); } if (value.autoPicked !== undefined && typeof value.autoPicked !== "boolean") { throw apiError("Roguelike PVP auto-pick marker is invalid."); } return { buffId, curseId, autoPicked: value.autoPicked === true }; } function createRoguelikePvpSeededRandom(seed) { let state = seed >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let value = state; value = Math.imul(value ^ (value >>> 15), value | 1); value ^= value + Math.imul(value ^ (value >>> 7), value | 61); return ((value ^ (value >>> 14)) >>> 0) / 0x100000000; }; } function selectRoguelikePvpPool(pool, random, count) { const available = [...pool]; const selected = []; while (selected.length < count && available.length > 0) { const index = Math.floor(random() * available.length); selected.push(available[index]); available.splice(index, 1); } return selected; } function roguelikePvpDraftOffers(match, side, round) { const progress = match.draftProgress; const buffRanks = progress.buffRanks[side]; const curseRanks = progress.curseRanks[side]; const random = createRoguelikePvpSeededRandom( (match.seed ^ Math.imul(round, 0x7f4a7c15)) >>> 0, ); const availableBuffs = ROGUELIKE_PVP_BUFF_IDS.filter((buffId) => { const maxRank = ROGUELIKE_PVP_SINGLE_RANK_BUFF_IDS.has(buffId) ? 1 : 3; return Math.max(0, Math.floor(buffRanks[buffId] ?? 0)) < maxRank; }); // Client draft generation shuffles the complete uncapped catalog before // filtering class-specific no-op buffs. Mirror that order exactly. const shuffledBuffs = selectRoguelikePvpPool(availableBuffs, random, availableBuffs.length); const supportedBuffs = ROGUELIKE_PVP_SUPPORTED_BUFF_IDS[match.players[side].healerClassId]; const buffChoices = shuffledBuffs.filter((buffId) => supportedBuffs.has(buffId)).slice(0, 3); const availableCurses = ROGUELIKE_PVP_CURSE_IDS.filter( (curseId) => Math.max(0, Math.floor(curseRanks[curseId] ?? 0)) < 3, ); const curseChoices = selectRoguelikePvpPool(availableCurses, random, 3); return { buffChoices, curseChoices }; } function roguelikePvpBossCountForRound(round) { return round % 5 === 0 ? 3 : 2; } function validateSave(value, slotId) { const schemaVersion = Number(value?.schemaVersion); if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6 && schemaVersion !== 7) { throw apiError("Save snapshot is invalid."); } if (Number(value.slotId) !== slotId) throw apiError("Save slot does not match request."); if (typeof value.hunterName !== "string" || !value.hunterName.trim()) { throw apiError("Save snapshot has no hunter name."); } return { ...value, schemaVersion: 7 }; } function normalizeNonNegativeInteger(value) { const number = Math.floor(Number(value)); return Number.isFinite(number) ? Math.max(0, number) : 0; } function normalizeNonNegativeNumber(value) { const number = Number(value); return Number.isFinite(number) ? Math.max(0, number) : 0; } function mergeLeaderboardHighWater(database, accountId, slotId, save) { const blockbreaker = database.prepare(` SELECT highest_bricks AS highestBricks, longest_seconds AS longestSeconds, highest_score AS highestScore FROM blockbreaker_records WHERE account_id = ? AND slot_id = ? `).get(accountId, slotId); const aether = database.prepare(` SELECT highest_score AS highestScore, wave_at_best AS waveAtBest, duration_at_best AS durationAtBest FROM aether_assault_records WHERE account_id = ? AND slot_id = ? `).get(accountId, slotId); const stats = save.stats && typeof save.stats === "object" ? save.stats : {}; const candidateAether = { score: normalizeNonNegativeInteger(stats.highestAetherAssaultScore), wave: normalizeNonNegativeInteger(stats.highestAetherAssaultWaveAtBest), duration: normalizeNonNegativeNumber(stats.longestAetherAssaultSecondsAtBest), }; const storedAether = { score: normalizeNonNegativeInteger(aether?.highestScore), wave: normalizeNonNegativeInteger(aether?.waveAtBest), duration: normalizeNonNegativeNumber(aether?.durationAtBest), }; const aetherRecord = candidateAether.score > storedAether.score || candidateAether.score === storedAether.score && candidateAether.wave > storedAether.wave || candidateAether.score === storedAether.score && candidateAether.wave === storedAether.wave && candidateAether.duration > storedAether.duration ? candidateAether : storedAether; return { ...save, schemaVersion: 7, stats: { ...stats, roguelikePvpWins: normalizeNonNegativeInteger(stats.roguelikePvpWins), roguelikePvpLosses: normalizeNonNegativeInteger(stats.roguelikePvpLosses), highestRoguelikePvpRound: normalizeNonNegativeInteger(stats.highestRoguelikePvpRound), highestBlockbreakerBricks: Math.max( normalizeNonNegativeInteger(stats.highestBlockbreakerBricks), normalizeNonNegativeInteger(blockbreaker?.highestBricks), ), longestBlockbreakerSeconds: Math.max( normalizeNonNegativeNumber(stats.longestBlockbreakerSeconds), normalizeNonNegativeNumber(blockbreaker?.longestSeconds), ), highestBlockbreakerScore: Math.max( normalizeNonNegativeInteger(stats.highestBlockbreakerScore), normalizeNonNegativeInteger(blockbreaker?.highestScore), ), highestAetherAssaultScore: aetherRecord.score, highestAetherAssaultWaveAtBest: aetherRecord.wave, longestAetherAssaultSecondsAtBest: aetherRecord.duration, }, }; } function syncLeaderboardStats(database, accountId, slotId, save) { database.prepare("DELETE FROM boss_kill_records WHERE account_id = ? AND slot_id = ?").run(accountId, slotId); const insertBoss = database.prepare(` INSERT INTO boss_kill_records (account_id, slot_id, boss_id, kills, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) `); const bossKills = save.stats?.bossKills && typeof save.stats.bossKills === "object" ? save.stats.bossKills : {}; for (const [bossId, rawKills] of Object.entries(bossKills)) { if (!/^[a-z0-9-]{1,64}$/.test(bossId)) continue; const kills = normalizeNonNegativeInteger(rawKills); if (kills > 0) insertBoss.run(accountId, slotId, bossId, kills); } const highestRound = normalizeNonNegativeInteger(save.stats?.highestRoguelikeRound); database.prepare(` INSERT INTO roguelike_records (account_id, slot_id, highest_round, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_round = excluded.highest_round, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestRound); const highestEndlessKills = normalizeNonNegativeInteger(save.stats?.highestRogueTrialsEndlessKills); database.prepare(` INSERT INTO rogue_trials_endless_records (account_id, slot_id, highest_boss_kills, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_boss_kills = excluded.highest_boss_kills, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestEndlessKills); const highestHockeyReturns = normalizeNonNegativeInteger(save.stats?.highestHockeyHealingReturns); const hockeyDurationSeconds = normalizeNonNegativeNumber(save.stats?.longestHockeyHealingSecondsAtBest); database.prepare(` INSERT INTO hockey_healing_records (account_id, slot_id, highest_returns, duration_seconds, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_returns = excluded.highest_returns, duration_seconds = excluded.duration_seconds, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestHockeyReturns, hockeyDurationSeconds); const hockeyPvpWins = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpWins); const hockeyPvpLosses = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpLosses); const hockeyPvpBossKills = normalizeNonNegativeInteger(save.stats?.hockeyHealingPvpBossKills); database.prepare(` INSERT INTO hockey_pvp_records (account_id, slot_id, wins, losses, boss_kills, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET wins = excluded.wins, losses = excluded.losses, boss_kills = excluded.boss_kills, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, hockeyPvpWins, hockeyPvpLosses, hockeyPvpBossKills); const highestBlockbreakerBricks = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerBricks); const longestBlockbreakerSeconds = normalizeNonNegativeNumber(save.stats?.longestBlockbreakerSeconds); const highestBlockbreakerScore = normalizeNonNegativeInteger(save.stats?.highestBlockbreakerScore); database.prepare(` INSERT INTO blockbreaker_records ( account_id, slot_id, highest_bricks, bricks_achieved_at, longest_seconds, time_achieved_at, highest_score, score_achieved_at, updated_at ) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET bricks_achieved_at = CASE WHEN excluded.highest_bricks > blockbreaker_records.highest_bricks THEN CURRENT_TIMESTAMP ELSE blockbreaker_records.bricks_achieved_at END, highest_bricks = MAX(blockbreaker_records.highest_bricks, excluded.highest_bricks), time_achieved_at = CASE WHEN excluded.longest_seconds > blockbreaker_records.longest_seconds THEN CURRENT_TIMESTAMP ELSE blockbreaker_records.time_achieved_at END, longest_seconds = MAX(blockbreaker_records.longest_seconds, excluded.longest_seconds), score_achieved_at = CASE WHEN excluded.highest_score > blockbreaker_records.highest_score THEN CURRENT_TIMESTAMP ELSE blockbreaker_records.score_achieved_at END, highest_score = MAX(blockbreaker_records.highest_score, excluded.highest_score), updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore); const highestAetherScore = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultScore); const aetherWaveAtBest = normalizeNonNegativeInteger(save.stats?.highestAetherAssaultWaveAtBest); const aetherDurationAtBest = normalizeNonNegativeNumber(save.stats?.longestAetherAssaultSecondsAtBest); database.prepare(` INSERT INTO aether_assault_records ( account_id, slot_id, highest_score, wave_at_best, duration_at_best, score_achieved_at, updated_at ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET highest_score = excluded.highest_score, wave_at_best = excluded.wave_at_best, duration_at_best = excluded.duration_at_best, score_achieved_at = CASE WHEN excluded.highest_score > aether_assault_records.highest_score OR excluded.highest_score = aether_assault_records.highest_score AND excluded.wave_at_best > aether_assault_records.wave_at_best THEN CURRENT_TIMESTAMP ELSE aether_assault_records.score_achieved_at END, updated_at = CURRENT_TIMESTAMP WHERE excluded.highest_score > aether_assault_records.highest_score OR excluded.highest_score = aether_assault_records.highest_score AND excluded.wave_at_best > aether_assault_records.wave_at_best OR excluded.highest_score = aether_assault_records.highest_score AND excluded.wave_at_best = aether_assault_records.wave_at_best AND excluded.duration_at_best > aether_assault_records.duration_at_best `).run(accountId, slotId, highestAetherScore, aetherWaveAtBest, aetherDurationAtBest); } function writeSave(database, accountId, slotId, rawSave) { const validatedSave = validateSave(rawSave, slotId); database.exec("BEGIN IMMEDIATE"); try { const save = mergeLeaderboardHighWater(database, accountId, slotId, validatedSave); const serialized = JSON.stringify(save); if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413); database.prepare(` INSERT INTO hunter_saves (account_id, slot_id, hunter_name, save_json, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(account_id, slot_id) DO UPDATE SET hunter_name = excluded.hunter_name, save_json = excluded.save_json, updated_at = CURRENT_TIMESTAMP `).run(accountId, slotId, save.hunterName.trim().slice(0, 20), serialized); syncLeaderboardStats(database, accountId, slotId, save); database.exec("COMMIT"); return save; } catch (error) { database.exec("ROLLBACK"); throw error; } } function readSave(database, accountId, slotId) { const row = database.prepare(` SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ? `).get(accountId, slotId); if (!row) return null; try { const save = JSON.parse(row.saveJson); return mergeLeaderboardHighWater(database, accountId, slotId, save); } catch { return null; } } function listSaves(database, accountId) { return database.prepare(` SELECT slot_id AS slotId, save_json AS saveJson, updated_at AS updatedAt FROM hunter_saves WHERE account_id = ? ORDER BY slot_id `).all(accountId).flatMap((row) => { try { const save = mergeLeaderboardHighWater(database, accountId, row.slotId, JSON.parse(row.saveJson)); return [{ slotId: row.slotId, save, updatedAt: row.updatedAt }]; } catch { return []; } }); } function leaderboardEntry(row, valueKey, secondaryValueKey) { return { rank: row.rank, username: row.username, hunterName: row.hunterName, slotId: row.slotId, value: row[valueKey], ...(secondaryValueKey ? { secondaryValue: row[secondaryValueKey] } : {}), }; } function bossLeaderboard(database, accountId, slotId, bossId) { if (!/^[a-z0-9-]{1,64}$/.test(bossId)) throw apiError("Invalid boss."); const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.kills DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.kills, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM boss_kill_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.boss_id = ? ) SELECT * FROM ranked ORDER BY kills DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(bossId); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "boss", bossId, top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "kills")), current: current ? leaderboardEntry(current, "kills") : null, }; } function roguelikeLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.highest_round DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_round AS highestRound, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM roguelike_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_round > 0 ) SELECT * FROM ranked ORDER BY highestRound DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "roguelike", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestRound")), current: current ? leaderboardEntry(current, "highestRound") : null, }; } function rogueTrialsEndlessLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.highest_boss_kills DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_boss_kills AS highestBossKills, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM rogue_trials_endless_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_boss_kills > 0 ) SELECT * FROM ranked ORDER BY highestBossKills DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "rogue-trials-endless", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestBossKills")), current: current ? leaderboardEntry(current, "highestBossKills") : null, }; } function hockeyHealingLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.highest_returns DESC, records.duration_seconds DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_returns AS highestReturns, records.duration_seconds AS durationSeconds, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM hockey_healing_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_returns > 0 OR records.duration_seconds > 0 ) SELECT * FROM ranked ORDER BY highestReturns DESC, durationSeconds DESC, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "hockey-healing", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestReturns", "durationSeconds")), current: current ? leaderboardEntry(current, "highestReturns", "durationSeconds") : null, }; } function hockeyPvpLeaderboard(database, accountId, slotId, kind) { const winsBoard = kind === "hockey-pvp-wins"; const order = winsBoard ? "records.wins DESC, records.losses ASC" : "records.boss_kills DESC, records.wins DESC"; const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY ${order}) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.wins, records.losses, records.boss_kills AS bossKills, accounts.username, saves.hunter_name AS hunterName, records.updated_at AS updatedAt FROM hockey_pvp_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE ${winsBoard ? "records.wins > 0 OR records.losses > 0" : "records.boss_kills > 0"} ) SELECT * FROM ranked ORDER BY ${winsBoard ? "wins DESC, losses ASC" : "bossKills DESC, wins DESC"}, updatedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; const valueKey = winsBoard ? "wins" : "bossKills"; const secondaryKey = winsBoard ? "losses" : undefined; return { kind, top: rows.slice(0, 5).map((row) => leaderboardEntry(row, valueKey, secondaryKey)), current: current ? leaderboardEntry(current, valueKey, secondaryKey) : null, }; } function blockbreakerLeaderboard(database, accountId, slotId, kind) { const boards = { "blockbreaker-bricks": { column: "highest_bricks", valueKey: "highestBricks", achieved: "bricks_achieved_at" }, "blockbreaker-time": { column: "longest_seconds", valueKey: "longestSeconds", achieved: "time_achieved_at" }, "blockbreaker-score": { column: "highest_score", valueKey: "highestScore", achieved: "score_achieved_at" }, }; const board = boards[kind]; if (!board) throw apiError("Invalid Blockbreaker leaderboard."); const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER (ORDER BY records.${board.column} DESC) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.${board.column} AS ${board.valueKey}, records.${board.achieved} AS achievedAt, accounts.username, saves.hunter_name AS hunterName FROM blockbreaker_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.${board.column} > 0 ) SELECT * FROM ranked ORDER BY ${board.valueKey} DESC, achievedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind, top: rows.slice(0, 5).map((row) => leaderboardEntry(row, board.valueKey)), current: current ? leaderboardEntry(current, board.valueKey) : null, }; } function aetherAssaultLeaderboard(database, accountId, slotId) { const rows = database.prepare(` WITH ranked AS ( SELECT RANK() OVER ( ORDER BY records.highest_score DESC, records.wave_at_best DESC, records.score_achieved_at ASC, records.account_id ASC, records.slot_id ASC ) AS rank, records.account_id AS accountId, records.slot_id AS slotId, records.highest_score AS highestScore, records.wave_at_best AS waveAtBest, records.score_achieved_at AS achievedAt, accounts.username, saves.hunter_name AS hunterName FROM aether_assault_records records JOIN accounts ON accounts.id = records.account_id JOIN hunter_saves saves ON saves.account_id = records.account_id AND saves.slot_id = records.slot_id WHERE records.highest_score > 0 ) SELECT * FROM ranked ORDER BY highestScore DESC, waveAtBest DESC, achievedAt ASC, accountId ASC, slotId ASC `).all(); const current = rows.find((row) => row.accountId === accountId && row.slotId === slotId) ?? null; return { kind: "aether-assault", top: rows.slice(0, 5).map((row) => leaderboardEntry(row, "highestScore", "waveAtBest")), current: current ? leaderboardEntry(current, "highestScore", "waveAtBest") : null, }; } export function createGameApiHandler(options = {}) { const dataDirectory = resolve(options.dataDirectory ?? process.env.DATA_DIR ?? "data"); mkdirSync(dataDirectory, { recursive: true }); const database = new DatabaseSync(resolve(dataDirectory, "game.db")); database.exec(readFileSync(new URL("../db/schema.sql", import.meta.url), "utf8")); const hockeyPvpTickets = new Map(); const hockeyPvpMatches = new Map(); const roguelikePvpTickets = new Map(); const roguelikePvpMatches = new Map(); const roguelikePvpNow = typeof options.roguelikePvpNow === "function" ? options.roguelikePvpNow : Date.now; function cleanupRoguelikePvp(now = roguelikePvpNow()) { for (const [matchId, match] of roguelikePvpMatches) { if (now - match.lastActivityAtMs <= ROGUELIKE_PVP_MATCH_TTL_MS) continue; roguelikePvpMatches.delete(matchId); roguelikePvpTickets.delete(match.players.host.id); roguelikePvpTickets.delete(match.players.guest.id); } for (const [ticketId, ticket] of roguelikePvpTickets) { const expiredWaitingTicket = !ticket.matchId && now - ticket.createdAtMs > ROGUELIKE_PVP_QUEUE_TTL_MS; const missingMatch = ticket.matchId && !roguelikePvpMatches.has(ticket.matchId); if (ticket.cancelled || expiredWaitingTicket || missingMatch) roguelikePvpTickets.delete(ticketId); } } function roguelikePvpQueueResult(ticket) { const match = ticket.matchId ? roguelikePvpMatches.get(ticket.matchId) : null; if (!match) return { ticketId: ticket.id, status: "waiting" }; const opponentSide = ticket.side === "host" ? "guest" : "host"; const opponent = match.players[opponentSide]; return { ticketId: ticket.id, status: "matched", match: { id: match.id, mode: match.mode, seed: match.seed, generation: match.generation, countdownEndsAtMs: match.countdownEndsAtMs, opponentName: opponent.hunterName, opponentHealerClassId: opponent.healerClassId, role: ticket.side, }, }; } function joinRoguelikePvpQueue(session, payload) { const mode = validateRoguelikePvpMode(payload?.mode); const slotId = validateSlotId(payload?.slotId); const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20); const healerClassId = validateHealerClassId(payload?.healerClassId); if (!hunterName) throw apiError("Hunter name is required."); const now = roguelikePvpNow(); cleanupRoguelikePvp(now); const existing = [...roguelikePvpTickets.values()].find((ticket) => ticket.accountId === session.accountId && ticket.mode === mode && !ticket.cancelled); if (existing) return roguelikePvpQueueResult(existing); const opponent = [...roguelikePvpTickets.values()].find((ticket) => ticket.mode === mode && !ticket.matchId && !ticket.cancelled && ticket.accountId !== session.accountId); const ticket = { id: randomBytes(18).toString("base64url"), mode, accountId: session.accountId, username: session.username, slotId, hunterName, healerClassId, createdAtMs: now, matchId: null, side: null, cancelled: false, }; roguelikePvpTickets.set(ticket.id, ticket); if (!opponent) return roguelikePvpQueueResult(ticket); const matchId = randomBytes(18).toString("base64url"); const match = { id: matchId, mode, seed: randomBytes(4).readUInt32BE(0) || 1, generation: 1, countdownEndsAtMs: now + ROGUELIKE_PVP_COUNTDOWN_MS, createdAtMs: now, lastActivityAtMs: now, players: { host: opponent, guest: ticket }, snapshots: { host: null, guest: null }, lastSeenAtMs: { host: now, guest: now }, drafts: new Map(), draftProgress: { completedRound: 0, buffRanks: { host: {}, guest: {} }, curseRanks: { host: {}, guest: {} }, }, outcome: null, rematch: null, }; opponent.matchId = matchId; opponent.side = "host"; ticket.matchId = matchId; ticket.side = "guest"; roguelikePvpMatches.set(matchId, match); return roguelikePvpQueueResult(ticket); } function requireRoguelikePvpTicket(session, ticketId) { cleanupRoguelikePvp(); const ticket = roguelikePvpTickets.get(ticketId); if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) { throw apiError("Roguelike PVP queue ticket not found.", 404); } return ticket; } function requireRoguelikePvpMatch(session, matchId) { cleanupRoguelikePvp(); const match = roguelikePvpMatches.get(matchId); if (!match) throw apiError("Roguelike PVP match not found.", 404); const side = match.players.host.accountId === session.accountId ? "host" : match.players.guest.accountId === session.accountId ? "guest" : null; if (!side) throw apiError("Roguelike PVP match access denied.", 403); return { match, side }; } function touchRoguelikePvpMatch(match, side, now) { match.lastActivityAtMs = now; match.lastSeenAtMs[side] = now; } function freezeRoguelikePvpOutcome(match, winner, loser, reason, now) { if (!match.outcome) match.outcome = { winner, loser, reason, atMs: now }; return match.outcome; } function roguelikePvpMatchStatus(match, side, now) { const opponentSide = side === "host" ? "guest" : "host"; const opponentLastSeenAtMs = match.lastSeenAtMs[opponentSide]; const disconnectDeadlineAtMs = opponentLastSeenAtMs + ROGUELIKE_PVP_DISCONNECT_GRACE_MS; if (!match.outcome && now >= disconnectDeadlineAtMs) { freezeRoguelikePvpOutcome(match, side, opponentSide, "disconnect", now); } // Keep the original response union so existing clients resolve any // authoritative terminal result without a protocol migration. const status = !match.outcome ? "active" : match.outcome.winner === side ? "won-by-forfeit" : "lost-by-forfeit"; const opponentConnection = match.outcome?.reason === "disconnect" && match.outcome.loser === opponentSide ? "forfeited" : now - opponentLastSeenAtMs > ROGUELIKE_PVP_CONNECTED_WINDOW_MS ? "grace" : "connected"; return { status, opponentConnection, opponentLastSeenAtMs, disconnectDeadlineAtMs, outcomeReason: match.outcome?.reason ?? null, }; } function validateRoguelikePvpSnapshotProgress(match, previous, snapshot) { const completedRound = match.draftProgress.completedRound; const lowestRound = Math.max(1, completedRound); const highestRound = completedRound + 1; if (snapshot.round < lowestRound || snapshot.round > highestRound || previous && snapshot.round < previous.round) { throw apiError("Roguelike PVP snapshot round is ahead of match progress.", 409); } } function exchangeRoguelikePvpState(session, matchId, payload) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(payload?.generation); if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); const snapshot = validateRoguelikePvpSnapshot(payload?.snapshot); const previous = match.snapshots[side]; if (previous && snapshot.sequence <= previous.sequence) { throw apiError("Roguelike PVP snapshot sequence is stale.", 409); } validateRoguelikePvpSnapshotProgress(match, previous, snapshot); const now = roguelikePvpNow(); // A grace deadline is an earlier terminal event than a snapshot arriving // after it, preserving disconnect-forfeit behavior at the boundary. roguelikePvpMatchStatus(match, side, now); match.snapshots[side] = snapshot; if (!match.outcome && snapshot.phase === "lost") { const opponentSide = side === "host" ? "guest" : "host"; // First accepted valid terminal report is final. If both parties wipe // between exchanges, request acceptance order is the stable tie-break. freezeRoguelikePvpOutcome(match, opponentSide, side, "party-wipe", now); } const matchStatus = roguelikePvpMatchStatus(match, side, now); touchRoguelikePvpMatch(match, side, now); const opponentSide = side === "host" ? "guest" : "host"; return { ...matchStatus, serverTimeMs: now, opponentSnapshot: match.snapshots[opponentSide], hostSnapshot: match.snapshots.host, }; } function createRoguelikePvpDraft(match, round, now) { const existing = match.drafts.get(round); if (existing) return existing; if (match.outcome) throw apiError("Roguelike PVP match is already complete.", 409); if (round !== match.draftProgress.completedRound + 1) { throw apiError("Roguelike PVP draft round is ahead of match progress.", 409); } const draft = { round, deadlineAtMs: now + ROGUELIKE_PVP_DRAFT_MS, submissions: { host: null, guest: null }, offers: { host: roguelikePvpDraftOffers(match, "host", round), guest: roguelikePvpDraftOffers(match, "guest", round), }, }; match.drafts.set(round, draft); return draft; } function resolveExpiredRoguelikePvpDraft(match, draft, now) { if (now < draft.deadlineAtMs) return; for (const side of ["host", "guest"]) { if (draft.submissions[side]) continue; const offers = draft.offers[side]; const selection = { buffId: offers.buffChoices[0] ?? null, curseId: offers.curseChoices[0] ?? null, autoPicked: true, }; validateRoguelikePvpDraftOffer(draft, side, selection); draft.submissions[side] = selection; applyRoguelikePvpDraftRanks(match, side, selection); } if (draft.submissions.host && draft.submissions.guest) { match.draftProgress.completedRound = Math.max(match.draftProgress.completedRound, draft.round); } } function roguelikePvpDraftResult(match, draft, side, now) { resolveExpiredRoguelikePvpDraft(match, draft, now); const opponentSide = side === "host" ? "guest" : "host"; const localSelection = draft.submissions[side]; const opponentSelection = draft.submissions[opponentSide]; const revealed = Boolean(localSelection && opponentSelection); return { status: revealed ? "revealed" : "waiting", round: draft.round, deadlineAtMs: draft.deadlineAtMs, deadlineExpired: now >= draft.deadlineAtMs, submitted: Boolean(localSelection), opponentSubmitted: Boolean(opponentSelection), buffChoices: [...draft.offers[side].buffChoices], curseChoices: [...draft.offers[side].curseChoices], ...(revealed ? { selection: localSelection, opponentSelection } : {}), }; } function requireRoguelikePvpDraftIntermission(match, side, round) { if (match.outcome) throw apiError("Roguelike PVP match is already complete.", 409); const snapshot = match.snapshots[side]; if (!snapshot || snapshot.round !== round || snapshot.phase !== "draft" || snapshot.bossHp !== 0 || snapshot.defeatedBosses < roguelikePvpBossCountForRound(round)) { throw apiError("Roguelike PVP draft requires the current cleared-round intermission.", 409); } } function validateRoguelikePvpDraftOffer(draft, side, selection) { const offers = draft.offers[side]; const validBuff = offers.buffChoices.length === 0 ? selection.buffId === null : selection.buffId !== null && offers.buffChoices.includes(selection.buffId); const validCurse = offers.curseChoices.length === 0 ? selection.curseId === null : selection.curseId !== null && offers.curseChoices.includes(selection.curseId); if (!validBuff || !validCurse) { throw apiError("Roguelike PVP draft selection was not offered."); } } function applyRoguelikePvpDraftRanks(match, side, selection) { if (selection.buffId) { const ranks = match.draftProgress.buffRanks[side]; ranks[selection.buffId] = (ranks[selection.buffId] ?? 0) + 1; } if (selection.curseId) { const ranks = match.draftProgress.curseRanks[side]; ranks[selection.curseId] = (ranks[selection.curseId] ?? 0) + 1; } } function openRoguelikePvpDraft(session, matchId, roundValue, payload) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(payload?.generation); if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); const round = validateRoguelikePvpRound(roundValue); const now = roguelikePvpNow(); requireRoguelikePvpDraftIntermission(match, side, round); const draft = createRoguelikePvpDraft(match, round, now); touchRoguelikePvpMatch(match, side, now); return roguelikePvpDraftResult(match, draft, side, now); } function pollRoguelikePvpDraft(session, matchId, roundValue, generationValue) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(generationValue); if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); const round = validateRoguelikePvpRound(roundValue); requireRoguelikePvpDraftIntermission(match, side, round); const draft = match.drafts.get(round); if (!draft) throw apiError("Roguelike PVP draft is not open.", 404); const now = roguelikePvpNow(); touchRoguelikePvpMatch(match, side, now); return roguelikePvpDraftResult(match, draft, side, now); } function submitRoguelikePvpDraft(session, matchId, roundValue, payload) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(payload?.generation); if (generation !== match.generation) throw apiError("Roguelike PVP match generation is stale.", 409); const round = validateRoguelikePvpRound(roundValue); const selection = validateRoguelikePvpDraftSelection(payload?.selection); const now = roguelikePvpNow(); requireRoguelikePvpDraftIntermission(match, side, round); const draft = createRoguelikePvpDraft(match, round, now); resolveExpiredRoguelikePvpDraft(match, draft, now); const existing = draft.submissions[side]; if (existing) { if (existing.buffId !== selection.buffId || existing.curseId !== selection.curseId || existing.autoPicked !== selection.autoPicked) { throw apiError("Roguelike PVP draft selection is already locked.", 409); } } else { if (now >= draft.deadlineAtMs && !selection.autoPicked) { throw apiError("Roguelike PVP draft deadline has passed; an auto-pick is required.", 409); } validateRoguelikePvpDraftOffer(draft, side, selection); draft.submissions[side] = selection; applyRoguelikePvpDraftRanks(match, side, selection); if (draft.submissions.host && draft.submissions.guest) { match.draftProgress.completedRound = Math.max(match.draftProgress.completedRound, round); } } touchRoguelikePvpMatch(match, side, now); return roguelikePvpDraftResult(match, draft, side, now); } function roguelikePvpRematchResult(match, side, requestedGeneration) { const rematch = match.rematch; if (!rematch || rematch.fromGeneration !== requestedGeneration || !rematch.ready) { return { status: "waiting" }; } const opponentSide = side === "host" ? "guest" : "host"; return { status: "matched", match: { id: match.id, mode: match.mode, seed: rematch.seed, generation: rematch.toGeneration, countdownEndsAtMs: rematch.countdownEndsAtMs, opponentName: match.players[opponentSide].hunterName, opponentHealerClassId: match.players[opponentSide].healerClassId, role: side, }, }; } function requestRoguelikePvpRematch(session, matchId, payload) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(payload?.generation); const now = roguelikePvpNow(); touchRoguelikePvpMatch(match, side, now); if (generation < match.generation) { if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) { throw apiError("Roguelike PVP match generation is stale.", 409); } return roguelikePvpRematchResult(match, side, generation); } if (generation > match.generation) throw apiError("Roguelike PVP match generation is invalid.", 409); if (!match.rematch || match.rematch.fromGeneration !== generation) { match.rematch = { fromGeneration: generation, toGeneration: generation + 1, requested: { host: false, guest: false }, ready: false, seed: 0, countdownEndsAtMs: 0, }; } match.rematch.requested[side] = true; if (!match.rematch.ready && match.rematch.requested.host && match.rematch.requested.guest) { match.rematch.ready = true; match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1; match.rematch.countdownEndsAtMs = now + ROGUELIKE_PVP_COUNTDOWN_MS; match.seed = match.rematch.seed; match.generation = match.rematch.toGeneration; match.countdownEndsAtMs = match.rematch.countdownEndsAtMs; match.snapshots = { host: null, guest: null }; match.lastSeenAtMs = { host: now, guest: now }; match.drafts = new Map(); match.draftProgress = { completedRound: 0, buffRanks: { host: {}, guest: {} }, curseRanks: { host: {}, guest: {} }, }; match.outcome = null; } return roguelikePvpRematchResult(match, side, generation); } function cancelRoguelikePvpRematch(session, matchId, payload) { const { match, side } = requireRoguelikePvpMatch(session, matchId); const generation = validateRoguelikePvpGeneration(payload?.generation); const now = roguelikePvpNow(); touchRoguelikePvpMatch(match, side, now); if (match.rematch?.fromGeneration === generation && !match.rematch.ready) { match.rematch.requested[side] = false; } return { ok: true }; } function queueResult(ticket) { const match = ticket.matchId ? hockeyPvpMatches.get(ticket.matchId) : null; if (!match) return { ticketId: ticket.id, status: "waiting" }; const opponentSide = ticket.side === "host" ? "guest" : "host"; const opponent = match.players[opponentSide]; return { ticketId: ticket.id, status: "matched", match: { id: match.id, seed: match.seed, generation: match.generation, countdownEndsAtMs: match.countdownEndsAtMs, opponentName: opponent.hunterName, role: ticket.side, }, }; } function joinHockeyPvpQueue(session, payload) { const slotId = validateSlotId(payload?.slotId); const hunterName = String(payload?.hunterName ?? "").trim().slice(0, 20); if (!hunterName) throw apiError("Hunter name is required."); const existing = [...hockeyPvpTickets.values()].find((ticket) => ticket.accountId === session.accountId && !ticket.matchId && !ticket.cancelled && !ticket.completed); if (existing) return queueResult(existing); const now = Date.now(); for (const ticket of hockeyPvpTickets.values()) { if (!ticket.matchId && now - ticket.createdAt > 30_000) { ticket.cancelled = true; ticket.completed = true; } } const opponent = [...hockeyPvpTickets.values()].find((ticket) => !ticket.matchId && !ticket.cancelled && !ticket.completed && ticket.accountId !== session.accountId); const ticket = { id: randomBytes(18).toString("base64url"), accountId: session.accountId, username: session.username, slotId, hunterName, createdAt: now, matchId: null, side: null, cancelled: false, completed: false, }; hockeyPvpTickets.set(ticket.id, ticket); if (!opponent) return queueResult(ticket); const matchId = randomBytes(18).toString("base64url"); const match = { id: matchId, seed: randomBytes(4).readUInt32BE(0) || 1, generation: 1, createdAt: now, countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS, players: { host: opponent, guest: ticket }, snapshots: { host: null, guest: null }, rematch: null, }; opponent.matchId = matchId; opponent.side = "host"; ticket.matchId = matchId; ticket.side = "guest"; hockeyPvpMatches.set(matchId, match); return queueResult(ticket); } function requireHockeyPvpTicket(session, ticketId) { const ticket = hockeyPvpTickets.get(ticketId); if (!ticket || ticket.accountId !== session.accountId || ticket.cancelled) throw apiError("Queue ticket not found.", 404); return ticket; } function requireHockeyPvpMatch(session, matchId) { const match = hockeyPvpMatches.get(matchId); if (!match) throw apiError("PVP match not found.", 404); const side = match.players.host.accountId === session.accountId ? "host" : match.players.guest.accountId === session.accountId ? "guest" : null; if (!side) throw apiError("PVP match access denied.", 403); return { match, side }; } function hockeyPvpRematchResult(match, side, requestedGeneration) { const rematch = match.rematch; if (!rematch || rematch.fromGeneration !== requestedGeneration || !rematch.ready) { return { status: "waiting" }; } const opponentSide = side === "host" ? "guest" : "host"; return { status: "matched", match: { id: match.id, seed: rematch.seed, generation: rematch.toGeneration, countdownEndsAtMs: rematch.countdownEndsAtMs, opponentName: match.players[opponentSide].hunterName, role: side, }, }; } function requestHockeyPvpRematch(session, matchId, payload) { const { match, side } = requireHockeyPvpMatch(session, matchId); const generation = Number(payload?.generation); if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid."); if (generation < match.generation) { if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) { throw apiError("PVP match generation is stale.", 409); } return hockeyPvpRematchResult(match, side, generation); } if (generation > match.generation) throw apiError("PVP match generation is invalid.", 409); if (!match.rematch || match.rematch.fromGeneration !== generation) { match.rematch = { fromGeneration: generation, toGeneration: generation + 1, requested: { host: false, guest: false }, ready: false, seed: 0, countdownEndsAtMs: 0, }; } match.rematch.requested[side] = true; if (!match.rematch.ready && match.rematch.requested.host && match.rematch.requested.guest) { const now = Date.now(); match.rematch.ready = true; match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1; match.rematch.countdownEndsAtMs = now + HOCKEY_PVP_COUNTDOWN_MS; match.seed = match.rematch.seed; match.generation = match.rematch.toGeneration; match.countdownEndsAtMs = match.rematch.countdownEndsAtMs; match.snapshots = { host: null, guest: null }; } return hockeyPvpRematchResult(match, side, generation); } function cancelHockeyPvpRematch(session, matchId, payload) { const { match, side } = requireHockeyPvpMatch(session, matchId); const generation = Number(payload?.generation); if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid."); if (match.rematch?.fromGeneration === generation && !match.rematch.ready) { match.rematch.requested[side] = false; } return { ok: true }; } async function handle(request, response, next) { if (!request.url?.startsWith("/api/")) return next(); setCorsHeaders(request, response); if (request.method === "OPTIONS") { response.statusCode = 204; return response.end(); } try { database.prepare("DELETE FROM sessions WHERE expires_at <= CURRENT_TIMESTAMP").run(); const url = new URL(request.url, "http://localhost"); const path = url.pathname; if (path === "/api/health" && request.method === "GET") { return sendJson(response, 200, { ok: true, database: "ready" }); } if (path === "/api/auth/register" && request.method === "POST") { enforceAuthRateLimit(request); return sendJson(response, 201, register(database, await readJson(request))); } if (path === "/api/auth/login" && request.method === "POST") { enforceAuthRateLimit(request); return sendJson(response, 200, login(database, await readJson(request))); } if (path === "/api/auth/session" && request.method === "GET") { const session = currentSession(database, request); return sendJson(response, session ? 200 : 401, session ? { account: { id: session.accountId, username: session.username } } : { error: "Sign in required." }); } if (path === "/api/auth/logout" && request.method === "POST") { const token = bearerToken(request); if (token) database.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash(token)); return sendJson(response, 200, { ok: true }); } const session = requireSession(database, request); if (path === "/api/roguelike-pvp/queue" && request.method === "POST") { return sendJson(response, 200, joinRoguelikePvpQueue(session, await readJson(request))); } const roguelikeQueueMatch = path.match(/^\/api\/roguelike-pvp\/queue\/([A-Za-z0-9_-]+)$/); if (roguelikeQueueMatch && request.method === "GET") { return sendJson(response, 200, roguelikePvpQueueResult(requireRoguelikePvpTicket(session, roguelikeQueueMatch[1]))); } if (roguelikeQueueMatch && request.method === "DELETE") { const ticket = requireRoguelikePvpTicket(session, roguelikeQueueMatch[1]); if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409); ticket.cancelled = true; roguelikePvpTickets.delete(ticket.id); return sendJson(response, 200, { ok: true }); } const roguelikeStateMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/); if (roguelikeStateMatch && request.method === "PUT") { return sendJson(response, 200, exchangeRoguelikePvpState( session, roguelikeStateMatch[1], await readJson(request), )); } const roguelikeDraftOpenMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/drafts\/([1-9][0-9]*)\/open$/); if (roguelikeDraftOpenMatch && request.method === "POST") { return sendJson(response, 200, openRoguelikePvpDraft( session, roguelikeDraftOpenMatch[1], roguelikeDraftOpenMatch[2], await readJson(request), )); } const roguelikeDraftMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/drafts\/([1-9][0-9]*)$/); if (roguelikeDraftMatch && request.method === "GET") { return sendJson(response, 200, pollRoguelikePvpDraft( session, roguelikeDraftMatch[1], roguelikeDraftMatch[2], url.searchParams.get("generation"), )); } if (roguelikeDraftMatch && request.method === "PUT") { return sendJson(response, 200, submitRoguelikePvpDraft( session, roguelikeDraftMatch[1], roguelikeDraftMatch[2], await readJson(request), )); } const roguelikeRematchMatch = path.match(/^\/api\/roguelike-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/); if (roguelikeRematchMatch && request.method === "POST") { return sendJson(response, 200, requestRoguelikePvpRematch( session, roguelikeRematchMatch[1], await readJson(request), )); } if (roguelikeRematchMatch && request.method === "DELETE") { return sendJson(response, 200, cancelRoguelikePvpRematch( session, roguelikeRematchMatch[1], await readJson(request), )); } if (path === "/api/hockey-pvp/queue" && request.method === "POST") { return sendJson(response, 200, joinHockeyPvpQueue(session, await readJson(request))); } const queueMatch = path.match(/^\/api\/hockey-pvp\/queue\/([A-Za-z0-9_-]+)$/); if (queueMatch && request.method === "GET") { return sendJson(response, 200, queueResult(requireHockeyPvpTicket(session, queueMatch[1]))); } if (queueMatch && request.method === "DELETE") { const ticket = requireHockeyPvpTicket(session, queueMatch[1]); if (ticket.matchId) throw apiError("Matched queue cannot be cancelled.", 409); ticket.cancelled = true; ticket.completed = true; return sendJson(response, 200, { ok: true }); } const pvpStateMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/state$/); if (pvpStateMatch && request.method === "PUT") { const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]); const payload = await readJson(request); if (payload?.generation !== match.generation) throw apiError("PVP match generation is stale.", 409); if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid."); match.snapshots[side] = payload.snapshot; return sendJson(response, 200, { opponentSnapshot: match.snapshots[side === "host" ? "guest" : "host"], hostSnapshot: match.snapshots.host, }); } const pvpRematchMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/); if (pvpRematchMatch && request.method === "POST") { return sendJson(response, 200, requestHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request))); } if (pvpRematchMatch && request.method === "DELETE") { return sendJson(response, 200, cancelHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request))); } if (path === "/api/saves" && request.method === "GET") { return sendJson(response, 200, { slots: listSaves(database, session.accountId) }); } const saveMatch = path.match(/^\/api\/saves\/([1-3])$/); if (saveMatch && request.method === "GET") { return sendJson(response, 200, { save: readSave(database, session.accountId, validateSlotId(saveMatch[1])) }); } if (saveMatch && request.method === "PUT") { const slotId = validateSlotId(saveMatch[1]); const payload = await readJson(request); return sendJson(response, 200, { save: writeSave(database, session.accountId, slotId, payload?.save) }); } const bossMatch = path.match(/^\/api\/leaderboards\/boss\/([a-z0-9-]+)$/); if (bossMatch && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, bossLeaderboard(database, session.accountId, slotId, bossMatch[1])); } if (path === "/api/leaderboards/roguelike" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, roguelikeLeaderboard(database, session.accountId, slotId)); } if (path === "/api/leaderboards/rogue-trials-endless" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, rogueTrialsEndlessLeaderboard(database, session.accountId, slotId)); } if (path === "/api/leaderboards/hockey-healing" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, hockeyHealingLeaderboard(database, session.accountId, slotId)); } if (path === "/api/leaderboards/hockey-pvp-wins" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-wins")); } if (path === "/api/leaderboards/hockey-pvp-boss-kills" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, hockeyPvpLeaderboard(database, session.accountId, slotId, "hockey-pvp-boss-kills")); } if (path === "/api/leaderboards/blockbreaker-bricks" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-bricks")); } if (path === "/api/leaderboards/blockbreaker-time" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-time")); } if (path === "/api/leaderboards/blockbreaker-score" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, blockbreakerLeaderboard(database, session.accountId, slotId, "blockbreaker-score")); } if (path === "/api/leaderboards/aether-assault" && request.method === "GET") { const slotId = validateSlotId(url.searchParams.get("slot")); return sendJson(response, 200, aetherAssaultLeaderboard(database, session.accountId, slotId)); } return sendJson(response, 404, { error: "API route not found." }); } catch (error) { const status = Number(error?.status) || 500; const message = status >= 500 ? "Server error." : error.message; if (status >= 500) console.error(error); return sendJson(response, status, { error: message }); } } return { handle, close: () => { hockeyPvpTickets.clear(); hockeyPvpMatches.clear(); roguelikePvpTickets.clear(); roguelikePvpMatches.clear(); database.close(); }, }; }