Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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();
80 changes: 80 additions & 0 deletions tests/multiplayer.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading