new rpg mode
This commit is contained in:
+428
-10
@@ -180,14 +180,15 @@ function validateSlotId(value) {
|
||||
}
|
||||
|
||||
function validateSave(value, slotId) {
|
||||
if (!value || typeof value !== "object" || Number(value.schemaVersion) !== 5) {
|
||||
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;
|
||||
return { ...value, schemaVersion: 6 };
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value) {
|
||||
@@ -195,6 +196,60 @@ function normalizeNonNegativeInteger(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(`
|
||||
@@ -225,14 +280,95 @@ function syncLeaderboardStats(database, accountId, slotId, save) {
|
||||
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 save = validateSave(rawSave, slotId);
|
||||
const serialized = JSON.stringify(save);
|
||||
if (Buffer.byteLength(serialized) > MAX_JSON_BYTES) throw apiError("Save snapshot is too large.", 413);
|
||||
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)
|
||||
@@ -243,11 +379,11 @@ function writeSave(database, accountId, slotId, rawSave) {
|
||||
`).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;
|
||||
}
|
||||
return save;
|
||||
}
|
||||
|
||||
function readSave(database, accountId, slotId) {
|
||||
@@ -255,7 +391,12 @@ function readSave(database, accountId, slotId) {
|
||||
SELECT save_json AS saveJson FROM hunter_saves WHERE account_id = ? AND slot_id = ?
|
||||
`).get(accountId, slotId);
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.saveJson); } catch { return null; }
|
||||
try {
|
||||
const save = JSON.parse(row.saveJson);
|
||||
return mergeLeaderboardHighWater(database, accountId, slotId, save);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listSaves(database, accountId) {
|
||||
@@ -263,18 +404,22 @@ function listSaves(database, accountId) {
|
||||
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 { return [{ slotId: row.slotId, save: JSON.parse(row.saveJson), updatedAt: row.updatedAt }]; }
|
||||
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) {
|
||||
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] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -358,11 +503,224 @@ function rogueTrialsEndlessLeaderboard(database, accountId, slotId) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
createdAt: now,
|
||||
players: { host: opponent, guest: ticket },
|
||||
snapshots: { host: null, guest: 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 };
|
||||
}
|
||||
|
||||
async function handle(request, response, next) {
|
||||
if (!request.url?.startsWith("/api/")) return next();
|
||||
@@ -400,6 +758,31 @@ export function createGameApiHandler(options = {}) {
|
||||
}
|
||||
|
||||
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?.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,
|
||||
});
|
||||
}
|
||||
if (path === "/api/saves" && request.method === "GET") {
|
||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||
}
|
||||
@@ -425,6 +808,34 @@ export function createGameApiHandler(options = {}) {
|
||||
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;
|
||||
@@ -434,5 +845,12 @@ export function createGameApiHandler(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return { handle, close: () => database.close() };
|
||||
return {
|
||||
handle,
|
||||
close: () => {
|
||||
hockeyPvpTickets.clear();
|
||||
hockeyPvpMatches.clear();
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user