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();
},
};
+512 -3
View File
@@ -7,7 +7,8 @@ import { after, before, test } from "node:test";
import { createGameApiHandler } from "./game-api.mjs";
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
const api = createGameApiHandler({ dataDirectory });
let roguelikePvpNowMs = 1_000_000;
const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs });
const server = createServer((request, response) => {
void api.handle(request, response, () => {
response.statusCode = 404;
@@ -36,7 +37,7 @@ async function json(path, init = {}) {
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: 6,
schemaVersion: 7,
slotId,
hunterName,
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
@@ -191,7 +192,7 @@ test("accounts, server saves, and top-five plus current rankings work end to end
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.schemaVersion, 7);
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);
@@ -296,6 +297,514 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
assert.equal(freshExchange.response.status, 200);
});
test("Roguelike PVP isolates matchmaking, validates progress, hides drafts, and handles rematch lifecycle", 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}` }),
});
assert.equal(registration.response.status, 201);
return registration.body.token;
};
const snapshot = (sequence, overrides = {}) => ({
sequence,
round: 1,
phase: "combat",
partyHp: [1, 0.9, 0.8, 0.7, 0.6],
bossHp: 350,
bossMaxHp: 500,
defeatedBosses: 0,
...overrides,
});
const alphaToken = await registerPlayer("rogue_pvp_alpha");
const betaToken = await registerPlayer("rogue_pvp_beta");
const invalidMode = await json("/api/roguelike-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ mode: "hockey-healing-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "priest" }),
});
assert.equal(invalidMode.response.status, 400);
const alphaQueue = await json("/api/roguelike-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Alpha", healerClassId: "druid" }),
});
assert.equal(alphaQueue.body.status, "waiting");
const betaQueue = await json("/api/roguelike-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 2, hunterName: "Beta", healerClassId: "shaman" }),
});
assert.equal(betaQueue.body.status, "matched");
assert.equal(betaQueue.body.match.mode, "roguelike-pvp");
assert.equal(betaQueue.body.match.role, "guest");
assert.equal(betaQueue.body.match.opponentName, "Alpha");
assert.equal(betaQueue.body.match.opponentHealerClassId, "druid");
assert.equal(betaQueue.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000);
const alphaMatched = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
assert.equal(alphaMatched.body.match.role, "host");
assert.equal(alphaMatched.body.match.opponentHealerClassId, "shaman");
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
const matchId = alphaMatched.body.match.id;
const hostState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
});
assert.equal(hostState.response.status, 200);
assert.equal(hostState.body.status, "active");
assert.equal(hostState.body.opponentSnapshot, null);
const guestState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: snapshot(1, { bossHp: 280 }) }),
});
assert.deepEqual(guestState.body.opponentSnapshot, snapshot(1));
assert.deepEqual(guestState.body.hostSnapshot, snapshot(1));
const staleState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
});
assert.equal(staleState.response.status, 409);
const invalidState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: snapshot(2, { partyHp: [1, 1] }) }),
});
assert.equal(invalidState.response.status, 400);
const roundOneDraftSnapshot = (sequence) => snapshot(sequence, {
phase: "draft",
bossHp: 0,
defeatedBosses: 2,
});
const hostRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }),
});
assert.equal(hostRoundOneDraftState.response.status, 200);
const guestRoundOneDraftState = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: roundOneDraftSnapshot(2) }),
});
assert.equal(guestRoundOneDraftState.response.status, 200);
const openedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(openedDraft.body.status, "waiting");
assert.equal(openedDraft.body.deadlineAtMs, roguelikePvpNowMs + 15_000);
assert.equal(openedDraft.body.buffChoices.length, 3);
assert.equal(openedDraft.body.curseChoices.length, 3);
const futureDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(futureDraft.response.status, 409);
const nonexistentSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: { buffId: "not-a-real-buff", curseId: openedDraft.body.curseChoices[0] },
}),
});
assert.equal(nonexistentSelection.response.status, 400);
const nonOfferedBuffId = [
"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",
].find((buffId) => !openedDraft.body.buffChoices.includes(buffId));
const nonOfferedSelection = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: { buffId: nonOfferedBuffId, curseId: openedDraft.body.curseChoices[0] },
}),
});
assert.equal(nonOfferedSelection.response.status, 400);
const alphaRoundOneSelection = {
buffId: openedDraft.body.buffChoices[0],
curseId: openedDraft.body.curseChoices[0],
};
const alphaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: alphaRoundOneSelection,
}),
});
assert.equal(alphaDraft.body.status, "waiting");
assert.equal(alphaDraft.body.submitted, true);
assert.equal("selection" in alphaDraft.body, false);
assert.equal("opponentSelection" in alphaDraft.body, false);
const betaDraftPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
headers: { Authorization: `Bearer ${betaToken}` },
});
assert.equal(betaDraftPoll.body.opponentSubmitted, true);
assert.equal("opponentSelection" in betaDraftPoll.body, false);
const betaRoundOneSelection = {
buffId: betaDraftPoll.body.buffChoices[0],
curseId: betaDraftPoll.body.curseChoices[0],
};
const betaDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: betaRoundOneSelection,
}),
});
assert.equal(betaDraft.body.status, "revealed");
assert.deepEqual(betaDraft.body.selection, {
...betaRoundOneSelection,
autoPicked: false,
});
assert.deepEqual(betaDraft.body.opponentSelection, {
...alphaRoundOneSelection,
autoPicked: false,
});
const changedBuffId = alphaRoundOneSelection.buffId === "mend-echo" ? "mend-efficiency" : "mend-echo";
const changedDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: { buffId: changedBuffId, curseId: alphaRoundOneSelection.curseId },
}),
});
assert.equal(changedDraft.response.status, 409);
const roundTwoDraftSnapshot = (sequence) => snapshot(sequence, {
round: 2,
phase: "draft",
bossHp: 0,
defeatedBosses: 2,
});
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }),
});
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: roundTwoDraftSnapshot(3) }),
});
const openedRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(openedRoundTwoDraft.response.status, 200);
const betaRoundTwoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
headers: { Authorization: `Bearer ${betaToken}` },
});
roguelikePvpNowMs += 15_000;
const lateManualDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: {
buffId: openedRoundTwoDraft.body.buffChoices[0],
curseId: openedRoundTwoDraft.body.curseChoices[0],
},
}),
});
assert.equal(lateManualDraft.response.status, 409);
const alphaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: {
buffId: openedRoundTwoDraft.body.buffChoices[0],
curseId: openedRoundTwoDraft.body.curseChoices[0],
autoPicked: true,
},
}),
});
assert.equal(alphaAutoDraft.body.deadlineExpired, true);
const betaAutoDraft = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: {
buffId: betaRoundTwoDraft.body.buffChoices[0],
curseId: betaRoundTwoDraft.body.curseChoices[0],
autoPicked: true,
},
}),
});
assert.equal(betaAutoDraft.body.status, "revealed");
assert.equal(betaAutoDraft.body.opponentSelection.autoPicked, true);
const clientAuthoredWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
snapshot: snapshot(4, { round: 3, phase: "won" }),
}),
});
assert.equal(clientAuthoredWin.response.status, 400);
const incompletePartyLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0.01] }),
}),
});
assert.equal(incompletePartyLoss.response.status, 400);
const hostPartyWipe = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }),
}),
});
assert.equal(hostPartyWipe.body.status, "lost-by-forfeit");
assert.equal(hostPartyWipe.body.outcomeReason, "party-wipe");
// First accepted valid wipe is the stable simultaneous-wipe tie-break.
// A later opposing wipe cannot oscillate or reverse the frozen result.
const guestPartyWipeAfterOutcome = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
snapshot: snapshot(4, { round: 3, phase: "lost", partyHp: [0, 0, 0, 0, 0] }),
}),
});
assert.equal(guestPartyWipeAfterOutcome.body.status, "won-by-forfeit");
assert.equal(guestPartyWipeAfterOutcome.body.outcomeReason, "party-wipe");
const alphaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(alphaRematch.body.status, "waiting");
const betaRematch = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
method: "POST",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(betaRematch.body.status, "matched");
assert.equal(betaRematch.body.match.generation, 2);
assert.equal(betaRematch.body.match.countdownEndsAtMs, roguelikePvpNowMs + 5_000);
const alphaRematchReady = await json(`/api/roguelike-pvp/matches/${matchId}/rematch`, {
method: "POST",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
assert.equal(alphaRematchReady.body.match.seed, betaRematch.body.match.seed);
const staleGeneration = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot: snapshot(1) }),
});
assert.equal(staleGeneration.response.status, 409);
await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }),
});
roguelikePvpNowMs += 15_001;
const hostForfeitWin = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 2, snapshot: snapshot(2) }),
});
assert.equal(hostForfeitWin.body.status, "won-by-forfeit");
assert.equal(hostForfeitWin.body.opponentConnection, "forfeited");
const guestForfeitLoss = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 2, snapshot: snapshot(1) }),
});
assert.equal(guestForfeitLoss.body.status, "lost-by-forfeit");
roguelikePvpNowMs += 10 * 60_000 + 1;
const expiredMatch = await json(`/api/roguelike-pvp/queue/${alphaQueue.body.ticketId}`, {
headers: { Authorization: `Bearer ${alphaToken}` },
});
assert.equal(expiredMatch.response.status, 404);
});
test("Roguelike PVP draft deadline deterministically resolves missing submissions", 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}` }),
});
assert.equal(registration.response.status, 201);
return registration.body.token;
};
const draftSnapshot = (sequence, round) => ({
sequence,
round,
phase: "draft",
partyHp: [1, 0.9, 0.8, 0.7, 0.6],
bossHp: 0,
bossMaxHp: 500,
defeatedBosses: 2,
});
const hostToken = await registerPlayer("rogue_deadline_host");
const guestToken = await registerPlayer("rogue_deadline_guest");
const hostQueue = await json("/api/roguelike-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Host", healerClassId: "priest" }),
});
const guestQueue = await json("/api/roguelike-pvp/queue", {
method: "POST",
headers: { Authorization: `Bearer ${guestToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ mode: "roguelike-pvp", slotId: 1, hunterName: "Guest", healerClassId: "druid" }),
});
assert.equal(hostQueue.body.status, "waiting");
assert.equal(guestQueue.body.status, "matched");
const matchId = guestQueue.body.match.id;
for (const [token, snapshot] of [
[hostToken, draftSnapshot(1, 1)],
[guestToken, draftSnapshot(1, 1)],
]) {
const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot }),
});
assert.equal(state.response.status, 200);
}
const hostRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1/open`, {
method: "POST",
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
const guestRoundOne = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
headers: { Authorization: `Bearer ${guestToken}` },
});
roguelikePvpNowMs = hostRoundOne.body.deadlineAtMs;
const expiredHostPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
headers: { Authorization: `Bearer ${hostToken}` },
});
assert.equal(expiredHostPoll.body.status, "revealed");
assert.equal(expiredHostPoll.body.deadlineExpired, true);
assert.deepEqual(expiredHostPoll.body.selection, {
buffId: hostRoundOne.body.buffChoices[0],
curseId: hostRoundOne.body.curseChoices[0],
autoPicked: true,
});
assert.deepEqual(expiredHostPoll.body.opponentSelection, {
buffId: guestRoundOne.body.buffChoices[0],
curseId: guestRoundOne.body.curseChoices[0],
autoPicked: true,
});
const mutateServerPick = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1`, {
method: "PUT",
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
body: JSON.stringify({
generation: 1,
selection: {
buffId: hostRoundOne.body.buffChoices[1],
curseId: hostRoundOne.body.curseChoices[1],
autoPicked: true,
},
}),
});
assert.equal(mutateServerPick.response.status, 409);
const expiredGuestPoll = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/1?generation=1`, {
headers: { Authorization: `Bearer ${guestToken}` },
});
assert.equal(expiredGuestPoll.body.status, "revealed");
for (const [token, snapshot] of [
[hostToken, draftSnapshot(2, 2)],
[guestToken, draftSnapshot(2, 2)],
]) {
const state = await json(`/api/roguelike-pvp/matches/${matchId}/state`, {
method: "PUT",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, snapshot }),
});
assert.equal(state.response.status, 200);
}
const hostRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2/open`, {
method: "POST",
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1 }),
});
const hostManualSelection = {
buffId: hostRoundTwo.body.buffChoices[1],
curseId: hostRoundTwo.body.curseChoices[1],
};
const hostSubmission = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2`, {
method: "PUT",
headers: { Authorization: `Bearer ${hostToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ generation: 1, selection: hostManualSelection }),
});
assert.equal(hostSubmission.body.status, "waiting");
const guestRoundTwo = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
headers: { Authorization: `Bearer ${guestToken}` },
});
roguelikePvpNowMs = hostRoundTwo.body.deadlineAtMs;
const guestAutoResolved = await json(`/api/roguelike-pvp/matches/${matchId}/drafts/2?generation=1`, {
headers: { Authorization: `Bearer ${guestToken}` },
});
assert.equal(guestAutoResolved.body.status, "revealed");
assert.deepEqual(guestAutoResolved.body.selection, {
buffId: guestRoundTwo.body.buffChoices[0],
curseId: guestRoundTwo.body.curseChoices[0],
autoPicked: true,
});
assert.deepEqual(guestAutoResolved.body.opponentSelection, {
...hostManualSelection,
autoPicked: false,
});
});
test("invalid credentials cannot access server saves", async () => {
const login = await json("/api/auth/login", {
method: "POST",