new rpg mode

This commit is contained in:
Warren H
2026-07-17 12:47:42 -04:00
parent de7b59ce77
commit 10ff124466
180 changed files with 20863 additions and 1014 deletions
+428 -10
View File
@@ -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();
},
};
}