From 5cd0e04bcbeada543d8515c2e7bc9ff1afeaeb17 Mon Sep 17 00:00:00 2001 From: Innes Date: Sun, 25 Oct 2026 21:27:46 -0700 Subject: [PATCH 1/7] Prune expired multiplayer matches during startup --- server.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/server.js b/server.js index a80d83a..5d16282 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,7 @@ const MAX_HEALTH = 30; const MAX_ENERGY = 6; const MAX_HAND = 10; const COOKIE = "csyPlayerId"; +const MATCH_TTL_MS = 12 * 60 * 60 * 1000; const players = new Map(); const matches = new Map(); @@ -92,6 +93,23 @@ function loadState() { matches.set(match.id, match); } + + // Expire old matches before the server accepts requests. Otherwise a stale + // snapshot could briefly expose a match until the periodic cleanup runs. + if (pruneExpiredMatches()) saveState(); +} + +function pruneExpiredMatches(now = Date.now()) { + const cutoff = now - MATCH_TTL_MS; + let changed = false; + for (const [id, match] of matches) { + const updatedAt = Date.parse(match.updatedAt || match.createdAt || ""); + if (!Number.isFinite(updatedAt) || updatedAt < cutoff) { + matches.delete(id); + changed = true; + } + } + return changed; } const CARDS = [ @@ -466,13 +484,5 @@ const server = http.createServer(async (req, res) => { }); server.listen(PORT, HOST, () => console.log("Card Stuff Yes listening on http://" + HOST + ":" + PORT)); setInterval(() => { - const cutoff = Date.now() - 12 * 60 * 60 * 1000; - let changed = false; - for (const [id, match] of matches) { - if (new Date(match.updatedAt).getTime() < cutoff) { - matches.delete(id); - changed = true; - } - } - if (changed) saveState(); + if (pruneExpiredMatches()) saveState(); }, 15 * 60 * 1000).unref(); From 215f4c417fc34d3742f15659da272c3945837a1b Mon Sep 17 00:00:00 2001 From: Innes Date: Sun, 25 Oct 2026 21:27:50 -0700 Subject: [PATCH 2/7] Test startup cleanup of stale multiplayer matches --- tests/multiplayer.integration.test.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/multiplayer.integration.test.js b/tests/multiplayer.integration.test.js index 777bb94..b1203cc 100644 --- a/tests/multiplayer.integration.test.js +++ b/tests/multiplayer.integration.test.js @@ -503,3 +503,27 @@ test("private multiplayer state snapshot is never served as a static file", asyn const response = await fetch(baseUrl + "/private-data/multiplayer-state.json"); assert.equal(response.status, 404); }); + + +test("stale matches are pruned immediately when the server starts", async () => { + const host = new ApiClient(); + const created = await host.request("/api/multiplayer/matches", { method: "POST" }); + assert.equal(created.status, 201); + const matchId = created.data.match.id; + + const snapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); + const savedMatch = snapshot.matches.find(match => match.id === matchId); + assert.ok(savedMatch, "created match should be in the snapshot"); + savedMatch.updatedAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync(stateFile, JSON.stringify(snapshot), { mode: 0o600 }); + + await stopServer(); + await startServer(); + + const resumed = await host.request("/api/multiplayer/current"); + assert.equal(resumed.status, 200); + assert.equal(resumed.data.match, null); + + const cleanedSnapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); + assert.equal(cleanedSnapshot.matches.some(match => match.id === matchId), false); +}); From f849c15d67e1f44a8b61498761e5df836c202335 Mon Sep 17 00:00:00 2001 From: Innes Date: Mon, 26 Oct 2026 07:44:57 -0700 Subject: [PATCH 3/7] Validate restored multiplayer match integrity --- server.js | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/server.js b/server.js index 5d16282..41c0792 100644 --- a/server.js +++ b/server.js @@ -79,18 +79,42 @@ function loadState() { } // A saved hand/deck must only reference known card definitions. - const combatantsValid = match.players.every(item => !item || ( - typeof item.playerId === "string" && - players.has(item.playerId) && - Array.isArray(item.deck) && - Array.isArray(item.hand) && - Array.isArray(item.discard) && - [...item.deck, ...item.hand, ...item.discard].every(card => + const [first, second] = match.players; + const statusShapeValid = + (match.status === "waiting" && first && !second) || + (match.status === "active" && first && second) || + (match.status === "finished" && first && second); + if (!statusShapeValid) continue; + + const playerIds = match.players.filter(Boolean).map(item => item.playerId); + if (new Set(playerIds).size !== playerIds.length) continue; + + const combatantsValid = match.players.every(item => { + if (!item || typeof item.playerId !== "string" || !players.has(item.playerId) || + !Array.isArray(item.deck) || !Array.isArray(item.hand) || !Array.isArray(item.discard)) { + return false; + } + const cards = [...item.deck, ...item.hand, ...item.discard]; + return cards.every(card => card && typeof card.instanceId === "string" && CARD_BY_ID.has(card.cardId) - ) - )); + ); + }); if (!combatantsValid) continue; + if (match.status === "active" && !playerIds.includes(match.activePlayerId)) continue; + if (match.status !== "active" && match.activePlayerId !== null) continue; + + // A card instance can occupy only one zone across the whole match. + const instanceIds = match.players + .flatMap(item => [...item.deck, ...item.hand, ...item.discard]) + .map(card => card.instanceId); + if (new Set(instanceIds).size !== instanceIds.length) continue; + + const timestampsValid = [match.createdAt, match.updatedAt].every(value => + typeof value === "string" && Number.isFinite(Date.parse(value)) + ); + if (!timestampsValid) continue; + matches.set(match.id, match); } From 2b23137586a6d7c6054bb6e342ee8fc9e304f3bc Mon Sep 17 00:00:00 2001 From: Innes Date: Mon, 26 Oct 2026 07:45:03 -0700 Subject: [PATCH 4/7] Test rejection of corrupt persisted matches --- tests/multiplayer.integration.test.js | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/multiplayer.integration.test.js b/tests/multiplayer.integration.test.js index b1203cc..301f607 100644 --- a/tests/multiplayer.integration.test.js +++ b/tests/multiplayer.integration.test.js @@ -527,3 +527,34 @@ test("stale matches are pruned immediately when the server starts", async () => const cleanedSnapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); assert.equal(cleanedSnapshot.matches.some(match => match.id === matchId), false); }); + + +test("startup rejects a corrupt active match with a missing combatant", async () => { + const host = new ApiClient(); + const guest = new ApiClient(); + + const created = await host.request("/api/multiplayer/matches", { method: "POST" }); + assert.equal(created.status, 201); + const matchId = created.data.match.id; + const joined = await guest.request( + "/api/multiplayer/matches/" + matchId + "/join", + { method: "POST" } + ); + assert.equal(joined.status, 200); + + const snapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); + const savedMatch = snapshot.matches.find(match => match.id === matchId); + assert.ok(savedMatch); + savedMatch.players[1] = null; + fs.writeFileSync(stateFile, JSON.stringify(snapshot), { mode: 0o600 }); + + await stopServer(); + await startServer(); + + const hostResume = await host.request("/api/multiplayer/current"); + assert.equal(hostResume.status, 200); + assert.equal(hostResume.data.match, null); + const guestResume = await guest.request("/api/multiplayer/current"); + assert.equal(guestResume.status, 200); + assert.equal(guestResume.data.match, null); +}); From 0a2c59783ff94984f59b37e9687dec85a83a5c7c Mon Sep 17 00:00:00 2001 From: Innes Date: Mon, 26 Oct 2026 07:53:32 -0700 Subject: [PATCH 5/7] Rewrite snapshot when startup discards invalid matches --- server.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server.js b/server.js index 41c0792..f7c8375 100644 --- a/server.js +++ b/server.js @@ -118,9 +118,11 @@ function loadState() { matches.set(match.id, match); } - // Expire old matches before the server accepts requests. Otherwise a stale - // snapshot could briefly expose a match until the periodic cleanup runs. - if (pruneExpiredMatches()) saveState(); + // Expire old matches before the server accepts requests. Also rewrite the + // snapshot if validation rejected any entries, so corrupt records do not + // linger on disk and get reconsidered at every restart. + const expiredChanged = pruneExpiredMatches(); + if (expiredChanged || matches.size !== snapshot.matches.length) saveState(); } function pruneExpiredMatches(now = Date.now()) { From c0062ec1b85fb132c5fd3684cde44ba44e530f82 Mon Sep 17 00:00:00 2001 From: Innes Date: Mon, 26 Oct 2026 08:36:12 -0700 Subject: [PATCH 6/7] Rewrite snapshot when startup rejects invalid players --- server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server.js b/server.js index f7c8375..594a973 100644 --- a/server.js +++ b/server.js @@ -122,7 +122,7 @@ function loadState() { // snapshot if validation rejected any entries, so corrupt records do not // linger on disk and get reconsidered at every restart. const expiredChanged = pruneExpiredMatches(); - if (expiredChanged || matches.size !== snapshot.matches.length) saveState(); + if (expiredChanged || players.size !== snapshot.players.length || matches.size !== snapshot.matches.length) saveState(); } function pruneExpiredMatches(now = Date.now()) { From 96a8996612a3348ca7df607d3b35607b6f81fb3b Mon Sep 17 00:00:00 2001 From: Innes Date: Mon, 26 Oct 2026 08:36:24 -0700 Subject: [PATCH 7/7] Test cleanup of invalid persisted player records --- tests/multiplayer.integration.test.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/multiplayer.integration.test.js b/tests/multiplayer.integration.test.js index 301f607..86f06c9 100644 --- a/tests/multiplayer.integration.test.js +++ b/tests/multiplayer.integration.test.js @@ -558,3 +558,28 @@ test("startup rejects a corrupt active match with a missing combatant", async () assert.equal(guestResume.status, 200); assert.equal(guestResume.data.match, null); }); + +test("startup removes invalid player records from the saved snapshot", async () => { + const player = new ApiClient(); + const profile = await player.request("/api/multiplayer/name", { + method: "POST", body: { name: "Snapshot Player" } + }); + assert.equal(profile.status, 200); + + const snapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); + snapshot.players.push({ id: 42, name: "Invalid Player Record" }); + fs.writeFileSync(stateFile, JSON.stringify(snapshot), { mode: 0o600 }); + + await stopServer(); + await startServer(); + + const cleanedSnapshot = JSON.parse(fs.readFileSync(stateFile, "utf8")); + assert.equal( + cleanedSnapshot.players.some(item => item.id === 42), + false, + "invalid player record should be removed from the persisted snapshot" + ); + const resumed = await player.request("/api/multiplayer/me"); + assert.equal(resumed.status, 200); + assert.equal(resumed.data.player.name, "Snapshot Player"); +});