Release v0.1.21 2026-07-19

This commit is contained in:
Warren H
2026-07-19 18:48:28 -04:00
parent 018e060cdd
commit 0e36ca1a41
30 changed files with 4701 additions and 120 deletions
+701 -3
View File
@@ -8,6 +8,61 @@ 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) {
@@ -180,16 +235,167 @@ function validateSlotId(value) {
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) {
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: 6 };
return { ...value, schemaVersion: 7 };
}
function normalizeNonNegativeInteger(value) {
@@ -229,9 +435,12 @@ function mergeLeaderboardHighWater(database, accountId, slotId, save) {
: storedAether;
return {
...save,
schemaVersion: 6,
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),
@@ -639,6 +848,430 @@ export function createGameApiHandler(options = {}) {
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;
@@ -830,6 +1463,69 @@ export function createGameApiHandler(options = {}) {
}
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)));
}
@@ -930,6 +1626,8 @@ export function createGameApiHandler(options = {}) {
close: () => {
hockeyPvpTickets.clear();
hockeyPvpMatches.clear();
roguelikePvpTickets.clear();
roguelikePvpMatches.clear();
database.close();
},
};