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();
|
||||
},
|
||||
};
|
||||
|
||||
+512
-3
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user