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

937 lines
40 KiB
JavaScript
Raw 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 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 validateSave(value, slotId) {
const schemaVersion = Number(value?.schemaVersion);
if (!value || typeof value !== "object" || schemaVersion !== 5 && schemaVersion !== 6) {
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: 6 };
}
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: 6,
stats: {
...stats,
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();
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/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();
database.close();
},
};
}