Release v0.1.21 2026-07-19
This commit is contained in:
+701
-3
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user