Release v0.1.20 2026-07-19
This commit is contained in:
@@ -651,6 +651,7 @@ export function createGameApiHandler(options = {}) {
|
||||
match: {
|
||||
id: match.id,
|
||||
seed: match.seed,
|
||||
generation: match.generation,
|
||||
countdownEndsAtMs: match.countdownEndsAtMs,
|
||||
opponentName: opponent.hunterName,
|
||||
role: ticket.side,
|
||||
@@ -694,10 +695,12 @@ export function createGameApiHandler(options = {}) {
|
||||
const match = {
|
||||
id: matchId,
|
||||
seed: randomBytes(4).readUInt32BE(0) || 1,
|
||||
generation: 1,
|
||||
createdAt: now,
|
||||
countdownEndsAtMs: now + HOCKEY_PVP_COUNTDOWN_MS,
|
||||
players: { host: opponent, guest: ticket },
|
||||
snapshots: { host: null, guest: null },
|
||||
rematch: null,
|
||||
};
|
||||
opponent.matchId = matchId;
|
||||
opponent.side = "host";
|
||||
@@ -725,6 +728,72 @@ export function createGameApiHandler(options = {}) {
|
||||
return { match, side };
|
||||
}
|
||||
|
||||
function hockeyPvpRematchResult(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,
|
||||
seed: rematch.seed,
|
||||
generation: rematch.toGeneration,
|
||||
countdownEndsAtMs: rematch.countdownEndsAtMs,
|
||||
opponentName: match.players[opponentSide].hunterName,
|
||||
role: side,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requestHockeyPvpRematch(session, matchId, payload) {
|
||||
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||
const generation = Number(payload?.generation);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||
|
||||
if (generation < match.generation) {
|
||||
if (match.rematch?.fromGeneration !== generation || !match.rematch.ready) {
|
||||
throw apiError("PVP match generation is stale.", 409);
|
||||
}
|
||||
return hockeyPvpRematchResult(match, side, generation);
|
||||
}
|
||||
if (generation > match.generation) throw apiError("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) {
|
||||
const now = Date.now();
|
||||
match.rematch.ready = true;
|
||||
match.rematch.seed = randomBytes(4).readUInt32BE(0) || 1;
|
||||
match.rematch.countdownEndsAtMs = now + HOCKEY_PVP_COUNTDOWN_MS;
|
||||
match.seed = match.rematch.seed;
|
||||
match.generation = match.rematch.toGeneration;
|
||||
match.countdownEndsAtMs = match.rematch.countdownEndsAtMs;
|
||||
match.snapshots = { host: null, guest: null };
|
||||
}
|
||||
return hockeyPvpRematchResult(match, side, generation);
|
||||
}
|
||||
|
||||
function cancelHockeyPvpRematch(session, matchId, payload) {
|
||||
const { match, side } = requireHockeyPvpMatch(session, matchId);
|
||||
const generation = Number(payload?.generation);
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) throw apiError("PVP match generation is invalid.");
|
||||
if (match.rematch?.fromGeneration === generation && !match.rematch.ready) {
|
||||
match.rematch.requested[side] = false;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function handle(request, response, next) {
|
||||
if (!request.url?.startsWith("/api/")) return next();
|
||||
setCorsHeaders(request, response);
|
||||
@@ -779,6 +848,7 @@ export function createGameApiHandler(options = {}) {
|
||||
if (pvpStateMatch && request.method === "PUT") {
|
||||
const { match, side } = requireHockeyPvpMatch(session, pvpStateMatch[1]);
|
||||
const payload = await readJson(request);
|
||||
if (payload?.generation !== match.generation) throw apiError("PVP match generation is stale.", 409);
|
||||
if (!payload?.snapshot || typeof payload.snapshot !== "object") throw apiError("PVP snapshot is invalid.");
|
||||
match.snapshots[side] = payload.snapshot;
|
||||
return sendJson(response, 200, {
|
||||
@@ -786,6 +856,13 @@ export function createGameApiHandler(options = {}) {
|
||||
hostSnapshot: match.snapshots.host,
|
||||
});
|
||||
}
|
||||
const pvpRematchMatch = path.match(/^\/api\/hockey-pvp\/matches\/([A-Za-z0-9_-]+)\/rematch$/);
|
||||
if (pvpRematchMatch && request.method === "POST") {
|
||||
return sendJson(response, 200, requestHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||
}
|
||||
if (pvpRematchMatch && request.method === "DELETE") {
|
||||
return sendJson(response, 200, cancelHockeyPvpRematch(session, pvpRematchMatch[1], await readJson(request)));
|
||||
}
|
||||
if (path === "/api/saves" && request.method === "GET") {
|
||||
return sendJson(response, 200, { slots: listSaves(database, session.accountId) });
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
||||
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}`, {
|
||||
@@ -237,21 +238,62 @@ test("Healing Hockey PVP queue pairs players and relays match snapshots", async
|
||||
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({ snapshot: hostSnapshot }),
|
||||
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({ snapshot: { sequence: 1, party: [] } }),
|
||||
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("invalid credentials cannot access server saves", async () => {
|
||||
|
||||
Reference in New Issue
Block a user