818 lines
37 KiB
JavaScript
818 lines
37 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { after, before, test } from "node:test";
|
|
import { createGameApiHandler } from "./game-api.mjs";
|
|
|
|
const dataDirectory = mkdtempSync(join(tmpdir(), "iwt-heal-api-"));
|
|
let roguelikePvpNowMs = 1_000_000;
|
|
const api = createGameApiHandler({ dataDirectory, roguelikePvpNow: () => roguelikePvpNowMs });
|
|
const server = createServer((request, response) => {
|
|
void api.handle(request, response, () => {
|
|
response.statusCode = 404;
|
|
response.end();
|
|
});
|
|
});
|
|
let baseUrl;
|
|
|
|
before(async () => {
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
});
|
|
|
|
after(async () => {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
api.close();
|
|
rmSync(dataDirectory, { recursive: true, force: true });
|
|
});
|
|
|
|
async function json(path, init = {}) {
|
|
const response = await fetch(`${baseUrl}${path}`, init);
|
|
const body = await response.json();
|
|
return { response, body };
|
|
}
|
|
|
|
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: 7,
|
|
slotId,
|
|
hunterName,
|
|
stats: { bossKills: { bulldrome: bulldromeKills }, highestRoguelikeRound, highestRogueTrialsEndlessKills, highestHockeyHealingReturns, longestHockeyHealingSecondsAtBest, hockeyHealingPvpWins, hockeyHealingPvpLosses, hockeyHealingPvpBossKills, highestBlockbreakerBricks, longestBlockbreakerSeconds, highestBlockbreakerScore, highestAetherAssaultScore, highestAetherAssaultWaveAtBest, longestAetherAssaultSecondsAtBest },
|
|
};
|
|
}
|
|
|
|
test("health endpoint reports persistent database readiness", async () => {
|
|
const { response, body } = await json("/api/health");
|
|
assert.equal(response.status, 200);
|
|
assert.deepEqual(body, { ok: true, database: "ready" });
|
|
});
|
|
|
|
test("accounts, server saves, and top-five plus current rankings work end to end", async () => {
|
|
const players = [];
|
|
for (let index = 0; index < 6; index += 1) {
|
|
const username = `hunter_${index}`;
|
|
const registration = await json("/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password: `long-password-${index}` }),
|
|
});
|
|
assert.equal(registration.response.status, 201);
|
|
const token = registration.body.token;
|
|
const kills = 60 - index * 10;
|
|
const highestRound = 30 - index * 4;
|
|
const highestEndlessKills = 24 - index * 3;
|
|
const highestHockeyReturns = 30 - index * 4;
|
|
const hockeyDuration = 180 - index * 10;
|
|
const pvpWins = 30 - index * 4;
|
|
const pvpLosses = index + 1;
|
|
const pvpBossKills = 120 - index * 12;
|
|
const blockbreakerBricks = index < 2 ? 600 : 700 - index * 100;
|
|
const blockbreakerSeconds = 360 - index * 30;
|
|
const blockbreakerScore = 20_000 - index * 2_000;
|
|
const aetherScore = index < 2 ? 50_000 : 54_000 - index * 5_000;
|
|
const aetherWave = index < 2 ? 12 : 10 - index;
|
|
const aetherDuration = 300 - index * 20;
|
|
const upload = await json("/api/saves/1", {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ save: save(1, `Hero ${index}`, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration) }),
|
|
});
|
|
assert.equal(upload.response.status, 200);
|
|
players.push({ token, kills, highestRound, highestEndlessKills, highestHockeyReturns, hockeyDuration, pvpWins, pvpLosses, pvpBossKills, blockbreakerBricks, blockbreakerSeconds, blockbreakerScore, aetherScore, aetherWave, aetherDuration });
|
|
}
|
|
|
|
const current = players[5];
|
|
const bossBoard = await json("/api/leaderboards/boss/bulldrome?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(bossBoard.body.top.length, 5);
|
|
assert.equal(bossBoard.body.top[0].value, 60);
|
|
assert.equal(bossBoard.body.current.rank, 6);
|
|
assert.equal(bossBoard.body.current.value, current.kills);
|
|
|
|
const rogueBoard = await json("/api/leaderboards/roguelike?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(rogueBoard.body.top.length, 5);
|
|
assert.equal(rogueBoard.body.current.rank, 6);
|
|
assert.equal(rogueBoard.body.current.value, current.highestRound);
|
|
|
|
const endlessBoard = await json("/api/leaderboards/rogue-trials-endless?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(endlessBoard.body.kind, "rogue-trials-endless");
|
|
assert.equal(endlessBoard.body.top.length, 5);
|
|
assert.equal(endlessBoard.body.top[0].value, 24);
|
|
assert.equal(endlessBoard.body.current.rank, 6);
|
|
assert.equal(endlessBoard.body.current.value, current.highestEndlessKills);
|
|
|
|
const hockeyBoard = await json("/api/leaderboards/hockey-healing?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(hockeyBoard.body.kind, "hockey-healing");
|
|
assert.equal(hockeyBoard.body.top.length, 5);
|
|
assert.equal(hockeyBoard.body.top[0].value, 30);
|
|
assert.equal(hockeyBoard.body.current.rank, 6);
|
|
assert.equal(hockeyBoard.body.current.value, current.highestHockeyReturns);
|
|
assert.equal(hockeyBoard.body.current.secondaryValue, current.hockeyDuration);
|
|
|
|
const pvpWinsBoard = await json("/api/leaderboards/hockey-pvp-wins?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(pvpWinsBoard.body.kind, "hockey-pvp-wins");
|
|
assert.equal(pvpWinsBoard.body.top[0].value, 30);
|
|
assert.equal(pvpWinsBoard.body.current.value, current.pvpWins);
|
|
assert.equal(pvpWinsBoard.body.current.secondaryValue, current.pvpLosses);
|
|
|
|
const pvpKillsBoard = await json("/api/leaderboards/hockey-pvp-boss-kills?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(pvpKillsBoard.body.kind, "hockey-pvp-boss-kills");
|
|
assert.equal(pvpKillsBoard.body.top[0].value, 120);
|
|
assert.equal(pvpKillsBoard.body.current.value, current.pvpBossKills);
|
|
|
|
const blockbreakerBricksBoard = await json("/api/leaderboards/blockbreaker-bricks?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(blockbreakerBricksBoard.body.kind, "blockbreaker-bricks");
|
|
assert.equal(blockbreakerBricksBoard.body.top.length, 5);
|
|
assert.equal(blockbreakerBricksBoard.body.top[0].value, 600);
|
|
assert.equal(blockbreakerBricksBoard.body.top[0].rank, 1);
|
|
assert.equal(blockbreakerBricksBoard.body.top[1].rank, 1);
|
|
assert.equal(blockbreakerBricksBoard.body.current.rank, 6);
|
|
assert.equal(blockbreakerBricksBoard.body.current.value, current.blockbreakerBricks);
|
|
|
|
const blockbreakerTimeBoard = await json("/api/leaderboards/blockbreaker-time?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(blockbreakerTimeBoard.body.kind, "blockbreaker-time");
|
|
assert.equal(blockbreakerTimeBoard.body.top[0].value, 360);
|
|
assert.equal(blockbreakerTimeBoard.body.current.value, current.blockbreakerSeconds);
|
|
|
|
const blockbreakerScoreBoard = await json("/api/leaderboards/blockbreaker-score?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(blockbreakerScoreBoard.body.kind, "blockbreaker-score");
|
|
assert.equal(blockbreakerScoreBoard.body.top[0].value, 20_000);
|
|
assert.equal(blockbreakerScoreBoard.body.current.value, current.blockbreakerScore);
|
|
|
|
const aetherBoard = await json("/api/leaderboards/aether-assault?slot=1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(aetherBoard.body.kind, "aether-assault");
|
|
assert.equal(aetherBoard.body.top.length, 5);
|
|
assert.equal(aetherBoard.body.top[0].value, 50_000);
|
|
assert.equal(aetherBoard.body.top[0].secondaryValue, 12);
|
|
assert.equal(aetherBoard.body.top[0].username, "hunter_0");
|
|
assert.equal(aetherBoard.body.top[0].rank, 1);
|
|
assert.equal(aetherBoard.body.top[1].value, 50_000);
|
|
assert.equal(aetherBoard.body.top[1].secondaryValue, 12);
|
|
assert.equal(aetherBoard.body.top[1].username, "hunter_1");
|
|
assert.equal(aetherBoard.body.top[1].rank, 2);
|
|
assert.equal(aetherBoard.body.current.rank, 6);
|
|
assert.equal(aetherBoard.body.current.value, current.aetherScore);
|
|
assert.equal(aetherBoard.body.current.secondaryValue, current.aetherWave);
|
|
|
|
const legacySnapshot = save(1, "Hero 5", current.kills, current.highestRound, current.highestEndlessKills, current.highestHockeyReturns, current.hockeyDuration, current.pvpWins, current.pvpLosses, current.pvpBossKills);
|
|
delete legacySnapshot.stats.highestBlockbreakerBricks;
|
|
delete legacySnapshot.stats.longestBlockbreakerSeconds;
|
|
delete legacySnapshot.stats.highestBlockbreakerScore;
|
|
delete legacySnapshot.stats.highestAetherAssaultScore;
|
|
delete legacySnapshot.stats.highestAetherAssaultWaveAtBest;
|
|
delete legacySnapshot.stats.longestAetherAssaultSecondsAtBest;
|
|
legacySnapshot.schemaVersion = 5;
|
|
const legacyUpload = await json("/api/saves/1", {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${current.token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ save: legacySnapshot }),
|
|
});
|
|
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, 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);
|
|
|
|
const download = await json("/api/saves/1", {
|
|
headers: { Authorization: `Bearer ${current.token}` },
|
|
});
|
|
assert.equal(download.body.save.hunterName, "Hero 5");
|
|
});
|
|
|
|
test("Healing Hockey PVP queue pairs players and relays match snapshots", 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}` }),
|
|
});
|
|
return registration.body.token;
|
|
};
|
|
const alphaToken = await registerPlayer("pvp_alpha");
|
|
const betaToken = await registerPlayer("pvp_beta");
|
|
const alphaQueue = await json("/api/hockey-pvp/queue", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slotId: 1, hunterName: "Alpha" }),
|
|
});
|
|
assert.equal(alphaQueue.body.status, "waiting");
|
|
|
|
const betaQueue = await json("/api/hockey-pvp/queue", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ slotId: 1, hunterName: "Beta" }),
|
|
});
|
|
assert.equal(betaQueue.body.status, "matched");
|
|
assert.equal(betaQueue.body.match.role, "guest");
|
|
assert.equal(betaQueue.body.match.opponentName, "Alpha");
|
|
assert.equal(betaQueue.body.match.generation, 1);
|
|
assert.ok(betaQueue.body.match.countdownEndsAtMs > Date.now());
|
|
|
|
const alphaMatched = await json(`/api/hockey-pvp/queue/${alphaQueue.body.ticketId}`, {
|
|
headers: { Authorization: `Bearer ${alphaToken}` },
|
|
});
|
|
assert.equal(alphaMatched.body.status, "matched");
|
|
assert.equal(alphaMatched.body.match.role, "host");
|
|
assert.equal(alphaMatched.body.match.id, betaQueue.body.match.id);
|
|
assert.equal(alphaMatched.body.match.seed, betaQueue.body.match.seed);
|
|
assert.equal(alphaMatched.body.match.generation, betaQueue.body.match.generation);
|
|
assert.equal(alphaMatched.body.match.countdownEndsAtMs, betaQueue.body.match.countdownEndsAtMs);
|
|
|
|
const hostSnapshot = { sequence: 1, party: [], puck: { goalSequence: 0 } };
|
|
await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
|
});
|
|
const guestExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1, snapshot: { sequence: 1, party: [] } }),
|
|
});
|
|
assert.deepEqual(guestExchange.body.opponentSnapshot, hostSnapshot);
|
|
assert.deepEqual(guestExchange.body.hostSnapshot, hostSnapshot);
|
|
|
|
const alphaRematchWaiting = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1 }),
|
|
});
|
|
assert.equal(alphaRematchWaiting.body.status, "waiting");
|
|
|
|
const betaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${betaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1 }),
|
|
});
|
|
assert.equal(betaRematchReady.body.status, "matched");
|
|
assert.equal(betaRematchReady.body.match.generation, 2);
|
|
assert.ok(betaRematchReady.body.match.countdownEndsAtMs > Date.now());
|
|
|
|
const alphaRematchReady = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/rematch`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1 }),
|
|
});
|
|
assert.equal(alphaRematchReady.body.status, "matched");
|
|
assert.equal(alphaRematchReady.body.match.generation, 2);
|
|
assert.equal(alphaRematchReady.body.match.seed, betaRematchReady.body.match.seed);
|
|
assert.equal(alphaRematchReady.body.match.countdownEndsAtMs, betaRematchReady.body.match.countdownEndsAtMs);
|
|
|
|
const staleExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 1, snapshot: hostSnapshot }),
|
|
});
|
|
assert.equal(staleExchange.response.status, 409);
|
|
|
|
const freshExchange = await json(`/api/hockey-pvp/matches/${alphaMatched.body.match.id}/state`, {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${alphaToken}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ generation: 2, snapshot: hostSnapshot }),
|
|
});
|
|
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",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: "hunter_0", password: "incorrect-password" }),
|
|
});
|
|
assert.equal(login.response.status, 401);
|
|
const saves = await json("/api/saves");
|
|
assert.equal(saves.response.status, 401);
|
|
});
|