diff --git a/server.js b/server.js index a80d83a..594a973 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(); @@ -78,20 +79,63 @@ 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); } + + // 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 || players.size !== snapshot.players.length || matches.size !== snapshot.matches.length) 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 +510,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(); diff --git a/tests/multiplayer.integration.test.js b/tests/multiplayer.integration.test.js index 777bb94..86f06c9 100644 --- a/tests/multiplayer.integration.test.js +++ b/tests/multiplayer.integration.test.js @@ -503,3 +503,83 @@ 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); +}); + + +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); +}); + +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"); +});