diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index ae9c05e..9e417e8 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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: diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts index cd6cb30..1c1785f 100644 --- a/packages/web/src/net.svelte.ts +++ b/packages/web/src/net.svelte.ts @@ -491,18 +491,18 @@ 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}`)); - if (kept.length !== this.seats.length) { - this.seats = kept; - saveSeats(this.seats); - } + // 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; }