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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+154
-5
@@ -34,12 +34,12 @@ async function json(path, init = {}) {
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills) {
|
||||
function save(slotId, hunterName, bulldromeKills, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins = 0, hockeyHealingPvpLosses = 0, hockeyHealingPvpBossKills = 0, highestBlockbreakerBricks = 0, longestBlockbreakerSeconds = 0, highestBlockbreakerScore = 0, highestAetherAssaultScore = 0, highestAetherAssaultWaveAtBest = 0, longestAetherAssaultSecondsAtBest = 0) {
|
||||
return {
|
||||
schemaVersion: 5,
|
||||
schemaVersion: 6,
|
||||
slotId,
|
||||
hunterName,
|
||||
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills },
|
||||
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,13 +63,24 @@ test("accounts, server saves, and top-five plus current rankings work end to end
|
||||
const kills = 60 - index * 10;
|
||||
const highestRound = 30 - index * 4;
|
||||
const highestEndlessKills = 24 - index * 3;
|
||||
const highestHockeyReturns = 30 - index * 4;
|
||||
const hockeyDuration = 180 - index * 10;
|
||||
const pvpWins = 30 - index * 4;
|
||||
const pvpLosses = index + 1;
|
||||
const pvpBossKills = 120 - index * 12;
|
||||
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
|
||||
const blockbreakerSeconds = 360 - index * 30;
|
||||
const blockbreakerScore = 20_000 - index * 2_000;
|
||||
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
|
||||
const aetherWave = index < 2 ? 12 : 10 - index;
|
||||
const aetherDuration = 300 - index * 20;
|
||||
const upload = await json("/api/saves/1", {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills) }),
|
||||
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
|
||||
});
|
||||
assert.equal(upload.response.status, 200);
|
||||
players.push({ token, kills, highestRound, highestEndlessKills });
|
||||
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
|
||||
}
|
||||
|
||||
const current = players[5];
|
||||
@@ -97,12 +108,150 @@ test("accounts, server saves, and top-five plus current rankings work end to end
|
||||
assert.equal(endlessBoard.body.current.rank, 6);
|
||||
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
|
||||
|
||||
const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(hockeyBoard.body.kind, "hockey-healing");
|
||||
assert.equal(hockeyBoard.body.top.length, 5);
|
||||
assert.equal(hockeyBoard.body.top[0].value, 30);
|
||||
assert.equal(hockeyBoard.body.current.rank, 6);
|
||||
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
|
||||
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
|
||||
|
||||
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
|
||||
assert.equal(pvpWinsBoard.body.top[0].value, 30);
|
||||
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
|
||||
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
|
||||
|
||||
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
|
||||
assert.equal(pvpKillsBoard.body.top[0].value, 120);
|
||||
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
|
||||
|
||||
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
|
||||
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
|
||||
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
|
||||
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
|
||||
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
|
||||
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
|
||||
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
|
||||
|
||||
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
|
||||
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
|
||||
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
|
||||
|
||||
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
|
||||
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
|
||||
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
|
||||
|
||||
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(aetherBoard.body.kind, "aether-assault");
|
||||
assert.equal(aetherBoard.body.top.length, 5);
|
||||
assert.equal(aetherBoard.body.top[0].value, 50_000);
|
||||
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
|
||||
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
|
||||
assert.equal(aetherBoard.body.top[0].rank, 1);
|
||||
assert.equal(aetherBoard.body.top[1].value, 50_000);
|
||||
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
|
||||
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
|
||||
assert.equal(aetherBoard.body.top[1].rank, 2);
|
||||
assert.equal(aetherBoard.body.current.rank, 6);
|
||||
assert.equal(aetherBoard.body.current.value, current.aetherScore);
|
||||
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
|
||||
|
||||
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
|
||||
delete legacySnapshot.stats.highestBlockbreakerBricks;
|
||||
delete legacySnapshot.stats.longestBlockbreakerSeconds;
|
||||
delete legacySnapshot.stats.highestBlockbreakerScore;
|
||||
delete legacySnapshot.stats.highestAetherAssaultScore;
|
||||
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
|
||||
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
|
||||
legacySnapshot.schemaVersion = 5;
|
||||
const legacyUpload = await json("/api/saves/1", {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ save: legacySnapshot }),
|
||||
});
|
||||
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerBricks, current.blockbreakerBricks);
|
||||
assert.equal(legacyUpload.body.save.stats.longestBlockbreakerSeconds, current.blockbreakerSeconds);
|
||||
assert.equal(legacyUpload.body.save.stats.highestBlockbreakerScore, current.blockbreakerScore);
|
||||
assert.equal(legacyUpload.body.save.schemaVersion, 6);
|
||||
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultScore, current.aetherScore);
|
||||
assert.equal(legacyUpload.body.save.stats.highestAetherAssaultWaveAtBest, current.aetherWave);
|
||||
assert.equal(legacyUpload.body.save.stats.longestAetherAssaultSecondsAtBest, current.aetherDuration);
|
||||
|
||||
const download = await json("/api/saves/1", {
|
||||
headers: { Authorization: `Bearer ${current.token}` },
|
||||
});
|
||||
assert.equal(download.body.save.hunterName, "Hero 5");
|
||||
});
|
||||
|
||||
test("Healing Hockey PVP queue pairs players and relays match snapshots", async () => {
|
||||
const registerPlayer = async (username) => {
|
||||
const registration = await json("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password: `long-password-${username}` }),
|
||||
});
|
||||
return registration.body.token;
|
||||
};
|
||||
const alphaToken = await registerPlayer("pvp_alpha");
|
||||
const betaToken = await registerPlayer("pvp_beta");
|
||||
const alphaQueue = await json("/api/hockey-pvp/queue", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
|
||||
});
|
||||
assert.equal(alphaQueue.body.status, "waiting");
|
||||
|
||||
const betaQueue = await json("/api/hockey-pvp/queue", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
|
||||
});
|
||||
assert.equal(betaQueue.body.status, "matched");
|
||||
assert.equal(betaQueue.body.match.role, "guest");
|
||||
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
||||
|
||||
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
||||
headers: { Authorization: `Bearer ${alphaToken}` },
|
||||
});
|
||||
assert.equal(alphaMatched.body.status, "matched");
|
||||
assert.equal(alphaMatched.body.match.role, "host");
|
||||
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
||||
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
||||
|
||||
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
||||
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: hostSnapshot }),
|
||||
});
|
||||
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot: { sequence: 1, party: [] } }),
|
||||
});
|
||||
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
||||
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
||||
});
|
||||
|
||||
test("invalid credentials cannot access server saves", async () => {
|
||||
const login = await json("/api/auth/login", {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user