Phase 2: async play-by-turn — the games ledger and turn signals

The lobby is now the front door to all your games. A "your games"
ledger lists every seat this browser holds — room code, whose turn it
is (including counteraction/discard/interrupt waits, which count as
your turn), the round, and how long since the last move — with
one-click resume, a forget control, and a green highlight when a game
waits on you. The server answers a token-validated myGames query with
per-seat summaries; the client polls every 45 seconds, so turns in
other rooms reach you wherever you are. Turn signals travel three
ways: the tab title flips to "● Your turn", the favicon grows a green
dot, and — opt-in via "notify me on my turn" — a browser notification
fires when a turn becomes yours anywhere. Landing in the app now
shows the ledger rather than teleporting into the last game;
mid-game socket drops still walk straight back to the table.
Verified live: two seeded games, ledger showing "gandalf's turn" and
YOUR TURN, one-click resume into the correct game with history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-15 23:53:13 -04:00
co-authored by Claude Fable 5
parent 7cfffb8ab5
commit bc8013863b
4 changed files with 294 additions and 16 deletions
+15
View File
@@ -21,6 +21,7 @@ import {
redactFor,
runCommand,
startGame,
summarize,
viewForPlayer,
type Room,
} from "./rooms";
@@ -130,6 +131,20 @@ wss.on("connection", (socket) => {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
const seats = Array.isArray(msg.seats) ? msg.seats : [];
const games = [];
for (const seat of seats) {
const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue;
const name = String(seat.name ?? "");
if (room.tokens.get(name) !== seat.token) continue;
games.push(summarize(room, name));
}
send(socket, { type: "games", games });
break;
}
default:
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
}
+33
View File
@@ -140,6 +140,39 @@ export function runCommand(
return { events: result.events };
}
export interface GameSummary {
roomId: string;
name: PlayerId;
players: PlayerId[];
started: boolean;
finished: boolean;
winner: PlayerId | null;
activePlayerId: PlayerId | null;
yourTurn: boolean;
round: number | null;
lastMoveAt: string | null;
}
/** 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;
const active = s && s.phase === "playing" ? s.players[s.turn.activeIndex]!.id : null;
const waitingOn = s?.stack?.waitingOn ?? s?.pendingDiscard ?? s?.outOfTurnWindow?.playerId ?? null;
const turnHolder = waitingOn ?? active;
return {
roomId: room.id,
name: playerId,
players: [...room.players],
started: s !== null,
finished: s?.phase === "finished",
winner: s?.winner ?? null,
activePlayerId: active,
yourTurn: s?.phase === "playing" && turnHolder === playerId,
round: s?.turn.round ?? null,
lastMoveAt: room.log.length > 0 ? room.log[room.log.length - 1]!.at : null,
};
}
export function viewForPlayer(room: Room, playerId: PlayerId): GameView | null {
return room.state ? viewFor(room.state, playerId) : null;
}