From e3f8a03215aeee1d807d8b44f6d61c01128a7368 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 10 Nov 2026 21:20:27 -0500 Subject: [PATCH] The template's lost-seat search counts the tables it cannot give back rather than naming them, leaves a seat a bot stands in for to the host, and the keeper's door looks a name up across tables Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx --- template/deploy/__SLUG__-reseat.sh | 15 +++++- template/server/src/index.ts | 13 ++++-- template/server/src/rooms.test.ts | 17 +++++-- template/server/src/rooms.ts | 48 +++++++++++++------- template/src/lib/components/Hall.svelte | 4 +- template/src/lib/net/client.ts | 5 +- template/src/routes/join/[code]/+page.svelte | 2 +- 7 files changed, 69 insertions(+), 35 deletions(-) diff --git a/template/deploy/__SLUG__-reseat.sh b/template/deploy/__SLUG__-reseat.sh index b785ed2..c89e495 100755 --- a/template/deploy/__SLUG__-reseat.sh +++ b/template/deploy/__SLUG__-reseat.sh @@ -3,16 +3,27 @@ # 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 find NAME every table not yet over where a person of that name sits, for one who lost the codes too # __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]}" +ROOM="${1:?usage: __SLUG__-reseat.sh find NAME | ROOM [SEAT]}" DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}" [ -s /etc/__SLUG__/keeper.key ] || { echo "No keeper's key at /etc/__SLUG__/keeper.key; a deploy makes it." >&2; exit 1; } # The key goes as a header read from a file descriptor, never on a command line another user could see. KEY="@/dev/fd/3" exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/__SLUG__/keeper.key)") -if [ $# -lt 2 ]; then +if [ "$ROOM" = find ]; then + curl -s -H "$KEY" --get --data-urlencode "name=${2:?a name to look for}" "http://127.0.0.1:__PORT__/keeper/seats" | python3 -c ' +import datetime, json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +if not r["tables"]: print("no table still in play has a seat under that name") +for t in r["tables"]: + when = datetime.datetime.fromtimestamp(t["updatedAt"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + print(" %s seat %-3s %s with %s, last moved %s" % (t["roomId"], t["seat"], "(a bot stands in)" if t["standIn"] else "", ", ".join(t["players"]) or "nobody yet", when)) +' +elif [ $# -lt 2 ]; then curl -s -H "$KEY" "$DOOR" | python3 -c ' import datetime, json, sys r = json.load(sys.stdin) diff --git a/template/server/src/index.ts b/template/server/src/index.ts index 4bde5f5..eb22fab 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -23,7 +23,7 @@ // sets, and get back every seat the cookie holds; a browser whose storage was // cleared gets its seats from the cookie, which clearing storage leaves alone // POST /api/seats/find {name, roomId?} the seats a name holds at tables not yet over: those last used from this -// address are re-seated and returned; the rest named, to ask the host for a phrase +// address are re-seated and returned; the rest only counted // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // POST /api/reports/:id/image a screenshot for a report just filed // POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply @@ -36,6 +36,7 @@ // door answers only on the host itself, and only to the key in KEEPER_KEY_FILE // (deploy/__SLUG__-reseat.sh sends it): // +// GET /keeper/seats?name= every table not yet over where a person of that name sits, for a player who lost the codes too // 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 // POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk @@ -291,8 +292,10 @@ function keeperKeyGiven(req: IncomingMessage): boolean { * 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, and so is one without the keeper's key. */ -async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise { - if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404); +async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[], url: URL): Promise { + if (req.headers['x-forwarded-for'] || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404); + if (parts[1] === 'seats' && parts.length === 2 && req.method === 'GET') return send(res, 200, { tables: rooms.tablesForKeeper(url.searchParams.get('name') ?? '') }); + if (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') { @@ -312,7 +315,7 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st 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] === 'keeper') return handleKeeper(req, res, parts, url); 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') { @@ -331,7 +334,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise if (!claims.allow(clientOf(req))) throw new RoomError('Too many searches from here just now; try again later.', 429); const found = rooms.findSeats(String(body.name ?? ''), addrOf(req), typeof body.roomId === 'string' ? body.roomId : undefined); // The seats given back are banked at once, so the cookie and the browser agree. - bankSeats(req, res, provenBank(req, found.seated, [])); + if (found.seated.length) bankSeats(req, res, provenBank(req, found.seated, [])); return send(res, 200, found); } throw new RoomError('Not here.', 404); diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 46a038d..4533151 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -345,7 +345,7 @@ describe('a seat a browser lost', () => { const { rooms, room, bo } = table(); rooms.seen(room, bo, 'home'); const found = rooms.findSeats('bo', 'home'); - expect(found.elsewhere).toEqual([]); + expect(found.elsewhere).toBe(0); expect(found.seated).toHaveLength(1); const { roomId, seat, token } = found.seated[0]; expect([roomId, seat]).toEqual([room.id, 'B']); @@ -353,15 +353,22 @@ describe('a seat a browser lost', () => { expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/); }); - it('only names the table when the address differs, or another person at it shares the address, or the name is nobody\'s', () => { + it('only counts the table when the address differs, or another person at it shares the address, or a bot stands in', async () => { const { rooms, room, ann, bo } = table(); rooms.seen(room, bo, 'home'); - expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: [{ roomId: room.id, seat: 'B', players: ['Ann'] }] }); + expect(rooms.findSeats('Bo', 'away')).toEqual({ seated: [], elsewhere: 1 }); // Ann and Bo play from one address: a name alone cannot tell them apart, so neither is re-seated by it. rooms.seen(room, ann, 'home'); - expect(rooms.findSeats('Bo', 'home').seated).toEqual([]); - expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: [] }); + expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 }); + expect(rooms.findSeats('Dee', 'home')).toEqual({ seated: [], elsewhere: 0 }); expect(rooms.seatOf(room, bo).id).toBe('B'); + // The host gave Bo's seat to a bot: it is the host's phrase that gives it back, not Bo's name. + rooms.seen(room, ann, 'office'); + rooms.standIn(room, ann, 'B'); + await answered(); + expect(rooms.findSeats('Bo', 'home')).toEqual({ seated: [], elsewhere: 1 }); + expect(rooms.tablesForKeeper('bo')).toEqual([{ roomId: room.id, seat: 'B', standIn: true, players: ['Ann'], updatedAt: room.updatedAt }]); + expect(rooms.tablesForKeeper('Dee')).toEqual([]); }); it('looks only at the table named when one is, and passes over a table that is over', () => { diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index fb3bc38..4849cd6 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -337,17 +337,12 @@ export class Rooms { this.apply(room, line); } - /** - * The seats a name holds at tables not yet over, for a browser that lost them. A seat last used from the - * asker's own address, at a table where no other person shares that address, is theirs again with a new - * token, the old one dead; the rest are named, so the asker can get a phrase from the host there. - */ - findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] } { - const name = cleanName(rawName).toLowerCase(); - const seated: { roomId: string; seat: SeatId; token: string }[] = []; - const elsewhere: { roomId: string; seat: SeatId; players: string[] }[] = []; + /** The tables not yet over, written to lately, where a person of this name sits; one table alone when it is named. */ + private tablesOf(name: string, only?: string): { room: Room; seat: Seat }[] { + const wanted = cleanName(name).toLowerCase(); const since = Date.now() - LOST_SEAT_LOOKBACK_MS; const ids = only ? [normalizeCode(String(only))] : this.store.roomIds().filter((id) => this.store.updatedAt(id) > since); + const found: { room: Room; seat: Seat }[] = []; for (const id of ids) { let room: Room; try { @@ -356,18 +351,37 @@ export class Rooms { continue; } if (room.over) continue; - const person = (s: Seat) => !s.bot || s.standIn; - const mine = room.seats.find((s) => person(s) && s.name.toLowerCase() === name); - if (!mine) continue; - const shared = room.seats.some((s) => s !== mine && person(s) && s.addr === addr); - if (mine.addr === addr && !shared) { - const { token } = this.reseat(room, mine.id); - seated.push({ roomId: room.id, seat: mine.id, token }); - } else elsewhere.push({ roomId: room.id, seat: mine.id, players: room.seats.filter((s) => s !== mine).map((s) => s.name) }); + const seat = room.seats.find((s) => (!s.bot || s.standIn) && s.name.toLowerCase() === wanted); + if (seat) found.push({ room, seat }); + } + return found; + } + + /** + * The seats a name holds at tables not yet over, for a browser that lost them. A seat last used from the + * asker's own address, at a table where no other person shares that address, is theirs again with a new + * token, the old one dead. The rest are only counted: a name proves nothing, so neither the tables nor the + * company at them are told to the asker, who can get a phrase from the host, or a link from the keeper. A + * seat the host gave to a bot is the host's to give back, by a phrase, never taken back by name. + */ + findSeats(rawName: string, addr: string, only?: string): { seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: number } { + const seated: { roomId: string; seat: SeatId; token: string }[] = []; + let elsewhere = 0; + for (const { room, seat } of this.tablesOf(rawName, only)) { + const shared = room.seats.some((s) => s !== seat && (!s.bot || s.standIn) && s.addr === addr); + if (seat.addr === addr && !shared && !seat.standIn) { + const { token } = this.reseat(room, seat.id); + seated.push({ roomId: room.id, seat: seat.id, token }); + } else elsewhere += 1; } return { seated, elsewhere }; } + /** For the keeper: every table not yet over where a person of this name sits, with the company there, to re-seat them by. */ + tablesForKeeper(name: string): { roomId: string; seat: SeatId; standIn: boolean; players: string[]; updatedAt: number }[] { + return this.tablesOf(name).map(({ room, seat }) => ({ roomId: room.id, seat: seat.id, standIn: !!seat.standIn, players: room.seats.filter((s) => s !== seat).map((s) => s.name), updatedAt: room.updatedAt })); + } + /** Claim a phrase: the seat and its token, once. Too many misses void every pending phrase. */ claim(raw: unknown): { roomId: string; seat: SeatId; token: string } { const now = Date.now(); diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index ecacf7f..98d1a89 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -134,8 +134,8 @@ for (const s of r.seated) rememberSeat(s.roomId, { seat: s.seat, token: s.token }); if (r.seated.length) seats = allSeats(); const back = r.seated.length ? `Seated again at ${r.seated.map((s) => s.roomId).join(', ')}.` : ''; - const others = r.elsewhere.map((s) => `${s.roomId} with ${s.players.join(', ') || 'nobody yet'}`); - const ask = others.length ? ` ${r.seated.length ? 'Also at' : 'At'} ${others.join('; ')}: that seat was last used from another address, so ask the host there for a transfer phrase, or write to the keeper.` : ''; + const n = r.elsewhere; + const ask = n ? ` ${n === 1 ? 'One more table' : `${n} more tables`} in play ${n === 1 ? 'has' : 'have'} a seat under your name, last used from another address, or given to a bot by the host: ask the host there for a transfer phrase, or write to the keeper.` : ''; found = back + ask || 'No table still in play has a seat under that name.'; } catch (e) { error = e instanceof Error ? e.message : 'The search did not go through.'; diff --git a/template/src/lib/net/client.ts b/template/src/lib/net/client.ts index d04aebd..011e056 100644 --- a/template/src/lib/net/client.ts +++ b/template/src/lib/net/client.ts @@ -181,9 +181,8 @@ export function makeApi() { hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }), standIn: (roomId: string, token: string, seat: SeatId) => call('POST', `/api/rooms/${roomId}/standin`, { token, seat }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }), - /** The seats a name holds at tables not over: those last used from this address come back seated; the rest are named. */ - findSeats: (name: string, roomId?: string) => - call<{ seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: { roomId: string; seat: SeatId; players: string[] }[] }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }), + /** The seats a name holds at tables not over: those last used from this address come back seated; the rest are only counted. */ + findSeats: (name: string, roomId?: string) => call<{ seated: { roomId: string; seat: SeatId; token: string }[]; elsewhere: number }>('POST', '/api/seats/find', { name, ...(roomId ? { roomId } : {}) }), report: (roomId: string, token: string, happened: string, expected: string) => call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), reportImage: (id: string, file: File) => diff --git a/template/src/routes/join/[code]/+page.svelte b/template/src/routes/join/[code]/+page.svelte index c7b647f..dc399c8 100644 --- a/template/src/routes/join/[code]/+page.svelte +++ b/template/src/routes/join/[code]/+page.svelte @@ -130,7 +130,7 @@ const r = await api.findSeats(name.trim(), roomId).catch(() => null); const mine = r?.seated.find((s) => s.roomId === roomId); if (!mine) { - if (r?.elsewhere.length) error = `A seat here is under that name, last used from another address. Ask the host for a transfer phrase, or write to the keeper.`; + if (r?.elsewhere) error = 'A seat here is under that name, but it was last used from another address, or the host gave it to a bot. Ask the host for a transfer phrase, or write to the keeper.'; return false; } rememberSeat(roomId, { seat: mine.seat, token: mine.token });