From ecab471122baa3956a595aa64c25b70c8db86a98 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 13 Oct 2026 18:20:36 -0400 Subject: [PATCH] The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused, and a link that carries it in the fragment, minted by -reseat.sh through a /keeper door Caddy does not proxy; a spectator at a game in play is told both ways back, and the hall asks before forgetting a game in play Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m --- CONVENTIONS.md | 9 ++++++ README.md | 11 ++++---- drift.sh | 2 +- template/deploy/README.md | 2 ++ template/deploy/__SLUG__-reseat.sh | 29 ++++++++++++++++++++ template/deploy/deploy.sh | 1 + template/server/src/index.ts | 21 +++++++++++++- template/server/src/rooms.test.ts | 24 ++++++++++++++++ template/server/src/rooms.ts | 29 ++++++++++++++++++++ template/server/src/store.ts | 2 ++ template/src/lib/components/Hall.svelte | 18 +++++++++++- template/src/lib/net/client.ts | 6 ++++ template/src/routes/join/[code]/+page.svelte | 15 ++++++++-- 13 files changed, 159 insertions(+), 10 deletions(-) create mode 100755 template/deploy/__SLUG__-reseat.sh diff --git a/CONVENTIONS.md b/CONVENTIONS.md index f70350e..ef4e29e 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -47,6 +47,13 @@ README. - **A seat travels by phrase**: *Transfer seat* in the masthead mints four words good for ten minutes and one use; the hall's *bring a seat from another device* claims them. The browser that claims holds the seat too. +- **The keeper can bring a seat back**: a player whose browser lost the + seat (Safari's seven days, an app's own browser, cleared data) writes to + the keeper, and `-reseat.sh ROOM SEAT` on the host gives the seat + a new token and prints a link carrying it in the fragment. The old token + opens nothing after. The script answers through the game server's + `/keeper` door, which Caddy does not proxy. A spectator at a game in play + is told both ways back, and the hall asks before forgetting a game in play. - **Held seats are doubted before they are dropped**: the hall forgets a seat only on the second refusal in a row from the server. A restart, a stale answer or the wrong backend answer with ignorance, and ignorance @@ -194,6 +201,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy: - `-visitors.sh` (skill `-visitors`): who is here now, who came today, every room opened and how far it got. Names seated for the first time are marked NEW. +- `-reseat.sh ROOM [SEAT]`: who sits at a table and when each last + acted; with a seat, a new token for a player locked out, as a link. - `-pulse.sh` (skill `-pulse`): service health, journal errors, the rollup's last week, the backup's last word, the box's vitals. - `-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts, diff --git a/README.md b/README.md index fa302fb..15540e2 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,12 @@ The shared infrastructure of Wiz-War and Waving Hands, extracted so the next game starts with all of it in place: the hall, room codes and tables, the Peanut Gallery (and the host's leave for it to talk), table talk, rematches, -seats that travel between devices by phrase, the keeper's bell, append-only -ledgers replayed through a -deterministic engine, the rules revision, the reports desk, the deploy -script with its determinism gate, nightly rollup and backup, Sentry with -Slack alerts, and the visitors, pulse and reports skills for Claude. +seats that travel between devices by phrase and come back by the keeper's +link when a browser loses them, the keeper's bell, append-only ledgers +replayed through a deterministic engine, the rules revision, the reports +desk, the deploy script with its determinism gate, nightly rollup and +backup, Sentry with Slack alerts, and the visitors, pulse and reports +skills for Claude. The game itself is one file. `src/lib/game/index.ts` implements `GameSpec` (see `src/lib/game/spec.ts`); the server, the client store, the hall and diff --git a/drift.sh b/drift.sh index 28b74c5..868224e 100755 --- a/drift.sh +++ b/drift.sh @@ -15,7 +15,7 @@ src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view. src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh -deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh" +deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh deploy/__SLUG__-reseat.sh" total=0 for t in $SHARED; do g="$t" diff --git a/template/deploy/README.md b/template/deploy/README.md index 995ff8c..f7bce38 100644 --- a/template/deploy/README.md +++ b/template/deploy/README.md @@ -53,6 +53,8 @@ own: on the hall's shared host, `hall-nightly.sh` (in the kit's `gamehall/ops`) runs every game's rollup and backup from /etc/cron.d/gamehall. - `__SLUG__-visitors.sh [day]`: who is here now and who came that day. +- `__SLUG__-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new + token for a player locked out of it, as a link to send them. - `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup trend, backup, box). - `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run diff --git a/template/deploy/__SLUG__-reseat.sh b/template/deploy/__SLUG__-reseat.sh new file mode 100755 index 0000000..6baeaf1 --- /dev/null +++ b/template/deploy/__SLUG__-reseat.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# A way back for a player whose browser lost their seat: the seat gets a new +# token, the old one stops working, and the link printed carries the new one. +# Whether the one asking holds that seat is the keeper's call; send the link +# only to them. Runs ON the droplet, through the game server's keeper door. +# __SLUG__-reseat.sh ROOM who sits at the table, and when each person last moved or spoke +# __SLUG__-reseat.sh ROOM SEAT re-seat SEAT and print the link +set -euo pipefail +ROOM="${1:?usage: __SLUG__-reseat.sh ROOM [SEAT]}" +DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}" +if [ $# -lt 2 ]; then + curl -s "$DOOR" | python3 -c ' +import datetime, json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun")) +for s in r["seats"]: + when = "bot" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + print(" %-3s %-24s %s" % (s["id"], s["name"], when)) +' +else + curl -s -X POST -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"]) +print(r["link"]) +' +fi diff --git a/template/deploy/deploy.sh b/template/deploy/deploy.sh index 248c382..5f1e753 100755 --- a/template/deploy/deploy.sh +++ b/template/deploy/deploy.sh @@ -42,6 +42,7 @@ ssh "root@$HOST" ' install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh + install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-reseat.sh /usr/local/bin/__SLUG__-reseat.sh install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py # On a host the hall shares, its hall-nightly.sh runs the rollup and backup # of every game and checks in with Sentry once for all; a game on a box of diff --git a/template/server/src/index.ts b/template/server/src/index.ts index e2c4a4b..44364e9 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -25,7 +25,11 @@ // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // a seat in the Peanut Gallery, counted for the table // -// Caddy serves the static site and proxies /api and /ws here. +// Caddy serves the static site and proxies /api and /ws here. The keeper's +// door answers only on the host itself (deploy/__SLUG__-reseat.sh): +// +// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke +// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; @@ -186,9 +190,24 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s throw new RoomError('Not here.', 404); } +/** The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy forwarded is turned away besides. */ +async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise { + if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); + const room = rooms.get(parts[2] ?? ''); + if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) }); + if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') { + const body = await readBody(req); + const { seat, token } = rooms.reseat(room, body.seat); + // The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server. + return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` }); + } + throw new RoomError('Not here.', 404); +} + async function handle(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? '/', 'http://localhost'); const parts = url.pathname.split('/').filter(Boolean); + if (parts[0] === 'keeper') return handleKeeper(req, res, parts); if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts); if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get()); if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') { diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 7b965e6..c4840cf 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -35,6 +35,30 @@ describe('rooms', () => { expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); }); + it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => { + const { rooms, room, bo } = table(); + const phrase = rooms.transfer(room, bo).phrase; + const { seat, token } = rooms.reseat(room, 'B'); + expect(seat.name).toBe('Bo'); + expect(rooms.seatOf(room, token).id).toBe('B'); + expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); + // A phrase minted with the old token carries nothing now. + expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); + const reopened = new Rooms(game, new Store(dir)).get(room.id); + expect(rooms.seatOf(reopened, token).id).toBe('B'); + expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/); + }); + + it('re-seats only a person, and says when each person last acted', () => { + const { rooms, room, ann } = table(); + expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/); + rooms.submit(room, ann, { pick: 5 }, 0); + const seats = rooms.seatsForKeeper(room); + expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]); + expect(seats[0].lastActed).toBeGreaterThan(0); + expect(seats[1].lastActed).toBeNull(); + }); + it('leaves a room with an unreadable ledger unloaded and opens the rest', () => { const { room } = table(); appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn'); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 3fb8382..cd9baeb 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -406,6 +406,30 @@ export class Rooms { return { seat, token }; } + /** + * The keeper gives a person's seat a new token, for a player whose browser lost the old one. Whether the one + * asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after. + */ + reseat(room: Room, seatId: unknown): { seat: Seat; token: string } { + const seat = room.seats.find((s) => s.id === seatId && !s.bot); + if (!seat) throw new RoomError('No person sits in that seat.', 404); + const token = newId(18); + this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) }); + // A phrase minted with the old token would carry a seat that no longer answers to it. + for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase); + return { seat, token }; + } + + /** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */ + seatsForKeeper(room: Room): { id: SeatId; name: string; bot: boolean; lastActed: number | null }[] { + const last = new Map(); + for (const line of this.store.read(room.id)) { + if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at); + else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at); + } + return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, lastActed: s.bot ? null : (last.get(s.id) ?? null) })); + } + /** The host sends a bot away before the game begins. People leave by not coming back. */ unseat(room: Room, token: string | undefined, seatId: unknown): void { const host = this.seatOf(room, token); @@ -609,6 +633,11 @@ export class Rooms { case 'seat': room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); break; + case 'reseat': { + const seat = room.seats.find((s) => s.id === line.id); + if (seat) seat.tokenHash = line.tokenHash; + break; + } case 'galleryTalk': room.galleryTalk = line.on; break; diff --git a/template/server/src/store.ts b/template/server/src/store.ts index ea1ab88..e9c7c89 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -11,6 +11,8 @@ export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions } /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */ | { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean } + /** The keeper gave a person's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */ + | { t: 'reseat'; at: number; id: SeatId; tokenHash: string } /** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */ | { t: 'input'; at: number; id: SeatId; input: unknown } /** A bot sent away by the host before the game began; its seat id is free again. */ diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 682c19e..1480243 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -143,7 +143,14 @@ if (c) void goto(`/join/${c}`); } + /** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */ + let forgetting = $state(null); function forget(roomId: string) { + if (!games[roomId]?.over && forgetting !== roomId) { + forgetting = roomId; + return; + } + forgetting = null; forgetSeat(roomId); seats = allSeats(); } @@ -252,7 +259,11 @@ {held.roomId} {status(held)}{#if unread(held) > 0} · {unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk{/if} - + {#if forgetting === held.roomId} + This browser may be all that holds your seat. + {:else} + + {/if} {/snippet} @@ -520,6 +531,11 @@ background: rgba(240, 165, 58, 0.08); } + .forget-ask { + font-size: 0.85em; + flex: none; + } + .ledger-row.your-move .ledger-info { color: var(--ember); } diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index 603a54d..0ef362f 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -19,6 +19,12 @@ function heldSeats(): Record { } } +/** A seat the keeper sent in a link, `#seat=.`: a fragment stays in the browser, out of every log. */ +export function seatInLink(hash: string): HeldSeat | null { + const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash); + return m ? { seat: m[1], token: m[2] } : null; +} + export function seatFor(roomId: string): HeldSeat | null { return heldSeats()[roomId] ?? null; } diff --git a/template/src/routes/join/[code]/+page.svelte b/template/src/routes/join/[code]/+page.svelte index adc63e1..4929f5d 100644 --- a/template/src/routes/join/[code]/+page.svelte +++ b/template/src/routes/join/[code]/+page.svelte @@ -1,11 +1,11 @@