The games ledger peeks without waking a single room
The 45-second myGames poll called getRoom per held seat — every open browser kept its whole game history warm and dragged sleeping rooms back out of their ledgers, hollowing the eviction it ran beside. peekSummary answers live rooms without touching their clocks and sleeping rooms from a stub snapped at eviction (a sleeping room cannot change, so the stub stays true until a real join or watch wakes it). Bad tokens void as before; tokenMatches takes any token map. 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
9aac790e71
commit
e8a7fa1b73
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
@@ -52,11 +52,10 @@ import {
|
|||||||
redactFor,
|
redactFor,
|
||||||
roomCount,
|
roomCount,
|
||||||
runCommand,
|
runCommand,
|
||||||
seatTokenValid,
|
|
||||||
SPECTATOR,
|
SPECTATOR,
|
||||||
type CatchUpStep,
|
type CatchUpStep,
|
||||||
startGame,
|
startGame,
|
||||||
summarize,
|
peekSummary,
|
||||||
viewForPlayer,
|
viewForPlayer,
|
||||||
type Room,
|
type Room,
|
||||||
kickSeat,
|
kickSeat,
|
||||||
@@ -846,14 +845,17 @@ wss.on("connection", (socket) => {
|
|||||||
const voided: string[] = [];
|
const voided: string[] = [];
|
||||||
for (const seat of seats) {
|
for (const seat of seats) {
|
||||||
if (typeof seat !== "object" || seat === null) continue;
|
if (typeof seat !== "object" || seat === null) continue;
|
||||||
const room = getRoom(String(seat.roomId ?? ""));
|
const roomId = String(seat.roomId ?? "").toUpperCase();
|
||||||
if (!room) continue;
|
|
||||||
const name = String(seat.name ?? "");
|
const name = String(seat.name ?? "");
|
||||||
if (!seatTokenValid(room, name, typeof seat.token === "string" ? seat.token : null)) {
|
// A peek, never a wake: the 45-second poll must not keep every
|
||||||
voided.push(`${room.id}:${name}`);
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
games.push(summarize(room, name));
|
games.push(result);
|
||||||
}
|
}
|
||||||
send(socket, { type: "games", games, voided });
|
send(socket, { type: "games", games, voided });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -63,12 +63,7 @@ function hashToken(raw: string): string {
|
|||||||
return createHash("sha256").update(raw).digest("hex");
|
return createHash("sha256").update(raw).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public seat check for protocol handlers (timing-safe under the hood). */
|
function tokenMatches(room: { tokens: Map<PlayerId, string> }, playerId: PlayerId, raw: string | null): boolean {
|
||||||
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 {
|
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const stored = room.tokens.get(playerId);
|
const stored = room.tokens.get(playerId);
|
||||||
if (!stored) return false;
|
if (!stored) return false;
|
||||||
@@ -142,6 +137,7 @@ export function getRoom(id: string): Room | undefined {
|
|||||||
if (!room) return undefined;
|
if (!room) return undefined;
|
||||||
room.touchedAt = Date.now();
|
room.touchedAt = Date.now();
|
||||||
rooms.set(code, room);
|
rooms.set(code, room);
|
||||||
|
sleepingStubs.delete(code);
|
||||||
return room;
|
return room;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`could not wake room ${code}:`, 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 idle = now - (room.touchedAt ?? now);
|
||||||
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
const allowance = room.state?.phase === "finished" ? FINISHED_MS : IDLE_MS;
|
||||||
if (idle < allowance) continue;
|
if (idle < allowance) continue;
|
||||||
|
sleepingStubs.set(id, stubOf(room));
|
||||||
rooms.delete(id);
|
rooms.delete(id);
|
||||||
evicted++;
|
evicted++;
|
||||||
}
|
}
|
||||||
return 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<PlayerId, string>;
|
||||||
|
turnHolder: PlayerId | null;
|
||||||
|
waitKind: "counteract" | "discard" | "interrupt" | null;
|
||||||
|
playing: boolean;
|
||||||
|
base: Omit<GameSummary, "name" | "yourTurn" | "attention">;
|
||||||
|
}
|
||||||
|
const sleepingStubs = new Map<string, RoomStub>();
|
||||||
|
|
||||||
|
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(
|
export function joinRoom(
|
||||||
room: Room,
|
room: Room,
|
||||||
playerId: PlayerId,
|
playerId: PlayerId,
|
||||||
@@ -404,20 +452,27 @@ export interface GameSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A seat-holder's one-line view of a room, for the lobby ledger. */
|
/** A seat-holder's one-line view of a room, for the lobby ledger. */
|
||||||
export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
/** Who holds the table's attention, and why — the summary's turn facts. */
|
||||||
const s = room.state;
|
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 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 waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.chaosPending?.queue[0] ?? s?.outOfTurnWindow?.playerId ?? null;
|
||||||
const turnHolder = waitingOn ?? active;
|
const waitKind = s == null ? null
|
||||||
let attention: "turn" | "counteract" | "discard" | "interrupt" | null = null;
|
: s.stack?.waitingOn != null ? "counteract" as const
|
||||||
if (s?.phase === "playing" && turnHolder === playerId) {
|
: s.pendingDiscard != null ? "discard" as const
|
||||||
attention =
|
: s.chaosPending?.queue[0] != null ? "counteract" as const
|
||||||
s.stack?.waitingOn === playerId ? "counteract"
|
: s.outOfTurnWindow?.playerId != null ? "interrupt" as const
|
||||||
: s.chaosPending?.queue[0] === playerId ? "counteract"
|
: null;
|
||||||
: s.pendingDiscard === playerId ? "discard"
|
return { active, turnHolder: waitingOn ?? active, waitKind };
|
||||||
: s.outOfTurnWindow?.playerId === playerId ? "interrupt"
|
|
||||||
: "turn";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
name: playerId,
|
name: playerId,
|
||||||
@@ -426,8 +481,8 @@ export function summarize(room: Room, playerId: PlayerId): GameSummary {
|
|||||||
finished: s?.phase === "finished",
|
finished: s?.phase === "finished",
|
||||||
winner: s?.winner ?? null,
|
winner: s?.winner ?? null,
|
||||||
activePlayerId: active,
|
activePlayerId: active,
|
||||||
yourTurn: s?.phase === "playing" && turnHolder === playerId,
|
yourTurn,
|
||||||
attention,
|
attention: yourTurn ? (waitKind ?? "turn") : null,
|
||||||
round: s?.turn.round ?? null,
|
round: s?.turn.round ?? null,
|
||||||
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
|
||||||
chatCount: room.chat.length,
|
chatCount: room.chat.length,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 563 KiB |
Reference in New Issue
Block a user