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