Files
i-want-to-heal-mmo/server/game-api.mjs
T
2026-07-19 18:48:28 -04:00

1635 lines
68 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 320 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 10128 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();
},
};
}