diff --git a/board-svg-tokens.png b/board-svg-tokens.png new file mode 100644 index 0000000..98f9971 Binary files /dev/null and b/board-svg-tokens.png differ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f30f2cc..dcc6c85 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -52,11 +52,10 @@ import { redactFor, roomCount, runCommand, - seatTokenValid, SPECTATOR, type CatchUpStep, startGame, - summarize, + peekSummary, viewForPlayer, type Room, kickSeat, @@ -846,14 +845,17 @@ wss.on("connection", (socket) => { 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 roomId = String(seat.roomId ?? "").toUpperCase(); const name = String(seat.name ?? ""); - if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) { - voided.push(`${room.id}:${name}`); + // A peek, never a wake: the 45-second poll must not keep every + // held room warm or drag sleeping ones out of their ledgers. + const result = peekSummary(roomId, name, typeof seat.token === "string" ? seat.token : null); + if (result === null) continue; + if (result === "badToken") { + voided.push(`${roomId}:${name}`); continue; } - games.push(summarize(room, name)); + games.push(result); } send(socket, { type: "games", games, voided }); break; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 390cf56..4694ed2 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -63,12 +63,7 @@ function hashToken(raw: string): string { return createHash("sha256").update(raw).digest("hex"); } -/** Public seat check for protocol handlers (timing-safe under the hood). */ -export function seatTokenValid(room: Room, playerId: PlayerId, raw: string | null): boolean { - return tokenMatches(room, playerId, raw); -} - -function tokenMatches(room: Room, playerId: PlayerId, raw: string | null): boolean { +function tokenMatches(room: { tokens: Map }, playerId: PlayerId, raw: string | null): boolean { if (!raw) return false; const stored = room.tokens.get(playerId); if (!stored) return false; @@ -142,6 +137,7 @@ export function getRoom(id: string): Room | undefined { if (!room) return undefined; room.touchedAt = Date.now(); rooms.set(code, room); + sleepingStubs.delete(code); return room; } catch (e) { console.error(`could not wake room ${code}:`, e); @@ -163,12 +159,64 @@ export function evictIdleRooms(hasSockets: (roomId: string) => boolean): number const idle = now - (room.touchedAt ?? now); const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS; if (idle < allowance) continue; + sleepingStubs.set(id, stubOf(room)); rooms.delete(id); evicted++; } return evicted; } +/** What the games ledger needs from a sleeping room, snapped at eviction — + * a sleeping room cannot change, so its stub stays true until it wakes. */ +interface RoomStub { + tokens: Map; + turnHolder: PlayerId | null; + waitKind: "counteract" | "discard" | "interrupt" | null; + playing: boolean; + base: Omit; +} +const sleepingStubs = new Map(); + +function stubOf(room: Room): RoomStub { + const { active, turnHolder, waitKind } = turnFacts(room.state); + return { + tokens: new Map(room.tokens), + turnHolder, + waitKind, + playing: room.state?.phase === "playing", + base: { + roomId: room.id, + players: [...room.players], + started: room.state !== null, + finished: room.state?.phase === "finished", + winner: room.state?.winner ?? null, + activePlayerId: active, + round: room.state?.turn.round ?? null, + lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, + chatCount: room.chat.length, + }, + }; +} + +/** The games ledger looks without waking: a live room answers live, a + * sleeping one answers from its stub, and neither look counts as a + * touch — polling must never keep a room warm or drag one out of bed. */ +export function peekSummary( + roomId: string, playerId: PlayerId, token: string | null, +): GameSummary | "badToken" | null { + const code = roomId.toUpperCase(); + const live = rooms.get(code); + if (live) { + if (!tokenMatches(live, playerId, token)) return "badToken"; + return summarize(live, playerId); + } + const stub = sleepingStubs.get(code); + if (!stub) return null; + if (!tokenMatches(stub, playerId, token)) return "badToken"; + const yourTurn = stub.playing && stub.turnHolder === playerId; + return { ...stub.base, name: playerId, yourTurn, attention: yourTurn ? (stub.waitKind ?? "turn") : null }; +} + export function joinRoom( room: Room, playerId: PlayerId, @@ -404,20 +452,27 @@ export interface GameSummary { } /** A seat-holder's one-line view of a room, for the lobby ledger. */ -export function summarize(room: Room, playerId: PlayerId): GameSummary { - const s = room.state; +/** Who holds the table's attention, and why — the summary's turn facts. */ +function turnFacts(s: GameState | null): { + active: PlayerId | null; + turnHolder: PlayerId | null; + waitKind: "counteract" | "discard" | "interrupt" | null; +} { const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null; const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null; - const turnHolder = waitingOn ?? active; - let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null; - if (s?.phase === "playing" && turnHolder === playerId) { - attention = - s.stack?.waitingOn === playerId ? "counteract" - : s.chaosPending?.queue[0] === playerId ? "counteract" - : s.pendingDiscard === playerId ? "discard" - : s.outOfTurnWindow?.playerId === playerId ? "interrupt" - : "turn"; - } + const waitKind = s == null ? null + : s.stack?.waitingOn != null ? "counteract" as const + : s.pendingDiscard != null ? "discard" as const + : s.chaosPending?.queue[0] != null ? "counteract" as const + : s.outOfTurnWindow?.playerId != null ? "interrupt" as const + : null; + return { active, turnHolder: waitingOn ?? active, waitKind }; +} + +export function summarize(room: Room, playerId: PlayerId): GameSummary { + const s = room.state; + const { active, turnHolder, waitKind } = turnFacts(s); + const yourTurn = s?.phase === "playing" && turnHolder === playerId; return { roomId: room.id, name: playerId, @@ -426,8 +481,8 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary { finished: s?.phase === "finished", winner: s?.winner ?? null, activePlayerId: active, - yourTurn: s?.phase === "playing" && turnHolder === playerId, - attention, + yourTurn, + attention: yourTurn ? (waitKind ?? "turn") : null, round: s?.turn.round ?? null, lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null, chatCount: room.chat.length, diff --git a/token-sheet.png b/token-sheet.png new file mode 100644 index 0000000..121c90f Binary files /dev/null and b/token-sheet.png differ