Absence is not evidence: the seat wallet trusts only verdicts
The games-list reply pruned any wallet seat the server did not vouch for — so one poll against a restarting (or wrong) server silently deleted live credentials, the second seat-burning path this session's server-squatting exposed. myGames now returns explicit verdicts: a seat is voided only when its room EXISTS and refused that exact token. Absent rooms earn no verdict and absent seats survive; the wallet caps by age at fifty instead of by trust. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
co-authored by
Claude Fable 5
parent
72148dd7c2
commit
3d2bf17585
@@ -732,18 +732,26 @@ wss.on("connection", (socket) => {
|
||||
break;
|
||||
}
|
||||
case "myGames": {
|
||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
|
||||
// {seats: [{roomId, name, token}]} -> summaries for valid seats,
|
||||
// plus explicit verdicts on seats PROVEN dead: the room exists
|
||||
// and refused this exact token. A room this server simply does
|
||||
// not know earns no verdict — absence is not evidence (a
|
||||
// restarting or wrong server knows nothing about anything).
|
||||
const seats = Array.isArray(msg.seats) ? msg.seats.slice(0, MAX_MYGAMES_SEATS) : [];
|
||||
const games = [];
|
||||
const voided: string[] = [];
|
||||
for (const seat of seats) {
|
||||
if (typeof seat !== "object" || seat === null) continue;
|
||||
const room = getRoom(String(seat.roomId ?? ""));
|
||||
if (!room) continue;
|
||||
const name = String(seat.name ?? "");
|
||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) continue;
|
||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
|
||||
voided.push(`${room.id}:${name}`);
|
||||
continue;
|
||||
}
|
||||
games.push(summarize(room, name));
|
||||
}
|
||||
send(socket, { type: "games", games });
|
||||
send(socket, { type: "games", games, voided });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -491,19 +491,19 @@ class Net {
|
||||
if (g.yourTurn && !was) this.notifyTurn(g);
|
||||
this.lastYourTurn.set(key, g.yourTurn);
|
||||
}
|
||||
// A seat the server answered for but did not list is gone — the
|
||||
// room was deleted (or the seat's token voided). Quietly drop it
|
||||
// from the ledger rather than showing "unreachable" forever.
|
||||
// (The server checks at most MAX_MYGAMES_SEATS = 50 per ask;
|
||||
// never prune blind past that cap.)
|
||||
if (this.seats.length <= 50) {
|
||||
const listed = new Set((msg.games as GameSummary[]).map((g) => `${g.roomId}:${g.name}`));
|
||||
const kept = this.seats.filter((s) => listed.has(`${s.roomId}:${s.name}`));
|
||||
// Prune ONLY seats the server proved dead: the room exists and
|
||||
// refused this exact token. A seat merely ABSENT from the reply
|
||||
// stays — a restarting server, a stale restore, or the wrong
|
||||
// backend all answer with ignorance, and treating ignorance as
|
||||
// deletion once cost a live player his seat mid-game. The
|
||||
// wallet is capped by age instead of by trust.
|
||||
const voided = new Set((msg.voided as string[] | undefined) ?? []);
|
||||
let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
|
||||
if (kept.length > 50) kept = kept.slice(kept.length - 50);
|
||||
if (kept.length !== this.seats.length) {
|
||||
this.seats = kept;
|
||||
saveSeats(this.seats);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
|
||||
Reference in New Issue
Block a user