From e91d209cbe0030004b42b748c8df7b1666e4a027 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 20 Oct 2026 13:21:24 -0400 Subject: [PATCH] The keeper can say a word at a table through the keeper's door, signed as the keeper and heard whether or not the gallery may talk, so a call answered too late, after the game began, can still be answered; -say.sh sends it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- CONVENTIONS.md | 2 ++ drift.sh | 2 +- template/deploy/README.md | 3 +++ template/deploy/__SLUG__-say.sh | 18 ++++++++++++++++++ template/deploy/deploy.sh | 1 + template/server/src/index.ts | 6 ++++++ template/server/src/rooms.test.ts | 10 ++++++++++ template/server/src/rooms.ts | 6 ++++++ template/server/src/store.ts | 2 +- template/src/lib/components/TableTalk.svelte | 2 +- template/src/lib/net/view.ts | 2 ++ 11 files changed, 51 insertions(+), 3 deletions(-) create mode 100755 template/deploy/__SLUG__-say.sh diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 16ecf82..66d8559 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -217,6 +217,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy: 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. +- `-say.sh ROOM "text"`: a word from the keeper in a table's talk, + signed as the keeper, whether or not the gallery may talk. - `-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/drift.sh b/drift.sh index 868224e..9b15985 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__-reseat.sh" +deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh deploy/__SLUG__-reseat.sh deploy/__SLUG__-say.sh" total=0 for t in $SHARED; do g="$t" diff --git a/template/deploy/README.md b/template/deploy/README.md index f7bce38..a47f821 100644 --- a/template/deploy/README.md +++ b/template/deploy/README.md @@ -55,6 +55,9 @@ 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__-say.sh ROOM "text"`: a word from the keeper in a table's talk, + signed as the keeper, through the same door; it is heard whether or not + the gallery may talk, so a call that came too late can be answered. - `__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__-say.sh b/template/deploy/__SLUG__-say.sh new file mode 100755 index 0000000..69d4b4a --- /dev/null +++ b/template/deploy/__SLUG__-say.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# A word from the keeper in a table's talk, signed as the keeper, whether or +# not the host lets the gallery talk: the answer to a call that came too late, +# or anything else the table should hear from the keeper. Runs ON the droplet, +# through the game server's keeper door. +# __SLUG__-say.sh ROOM "text" +set -euo pipefail +ROOM="${1:?usage: __SLUG__-say.sh ROOM \"text\"}" +TEXT="${2:?usage: __SLUG__-say.sh ROOM \"text\"}" +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)") +BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))') +CODE=$(curl -s -o /tmp/__SLUG__-say.$$ -w '%{http_code}' -X POST -H "$KEY" -H 'content-type: application/json' -d "$BODY" "$DOOR/say") +if [ "$CODE" = 204 ]; then echo "said at ${ROOM^^}"; else cat /tmp/__SLUG__-say.$$ >&2; echo >&2; rm -f /tmp/__SLUG__-say.$$; exit 1; fi +rm -f /tmp/__SLUG__-say.$$ diff --git a/template/deploy/deploy.sh b/template/deploy/deploy.sh index 68b7089..b2c9bc7 100755 --- a/template/deploy/deploy.sh +++ b/template/deploy/deploy.sh @@ -43,6 +43,7 @@ ssh "root@$HOST" ' 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 755 /opt/__SLUG__/app/deploy/__SLUG__-say.sh /usr/local/bin/__SLUG__-say.sh # The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone. if [ ! -s /etc/__SLUG__/keeper.key ]; then install -d -m 750 -o root -g __SLUG__ /etc/__SLUG__ diff --git a/template/server/src/index.ts b/template/server/src/index.ts index a99e1d1..5cd0acd 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -33,6 +33,7 @@ // // 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 import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; @@ -225,6 +226,11 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st // 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}` }); } + if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') { + const body = await readBody(req); + rooms.keeperSays(room, body.text); + return send(res, 204, {}); + } throw new RoomError('Not here.', 404); } diff --git a/template/server/src/rooms.test.ts b/template/server/src/rooms.test.ts index 02e6bfd..f460bf6 100644 --- a/template/server/src/rooms.test.ts +++ b/template/server/src/rooms.test.ts @@ -6,6 +6,7 @@ import { game, type Input, type State } from '../../src/lib/game'; import type { GameSpec } from '../../src/lib/game/spec'; import { Rooms } from './rooms'; import { Store } from './store'; +import { SPECTATOR } from '../../src/lib/game/spec'; let dir: string; beforeEach(() => { @@ -35,6 +36,15 @@ describe('rooms', () => { expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); }); + it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => { + const { rooms, room } = table(); + expect(room.galleryTalk).toBe(false); + rooms.keeperSays(room, 'I came too late for the seat; call me again.'); + const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' }; + expect(room.chat.at(-1)).toMatchObject(said); + expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said); + }); + 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; diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 900792e..56e0d9f 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -242,6 +242,11 @@ export class Rooms { this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id }); } + /** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */ + keeperSays(room: Room, raw: unknown): void { + this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true }); + } + /** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */ sayFromGallery(room: Room, rawName: unknown, raw: unknown): void { if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403); @@ -739,6 +744,7 @@ export class Rooms { at: line.at, ...(room.state ? { turn: this.game.turn(room.state) } : {}), ...(line.name ? { name: line.name } : {}), + ...(line.keeper ? { keeper: true as const } : {}), ...(line.to ? { to: line.to } : {}), ...(line.note !== undefined ? { note: line.note } : {}) }); diff --git a/template/server/src/store.ts b/template/server/src/store.ts index 1c043ff..c035278 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -37,7 +37,7 @@ export type LedgerLine = * A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). * `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown. */ - | { t: 'chat'; at: number; id: SeatId; text: string; name?: string; to?: SeatId[]; note?: unknown } + | { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true; to?: SeatId[]; note?: unknown } /** The host let the gallery talk, or hushed it. */ | { t: 'galleryTalk'; at: number; on: boolean; by: SeatId } /** A seat is held for the keeper of the site, called to the table. */ diff --git a/template/src/lib/components/TableTalk.svelte b/template/src/lib/components/TableTalk.svelte index b2e0f1f..6b9a4e6 100644 --- a/template/src/lib/components/TableTalk.svelte +++ b/template/src/lib/components/TableTalk.svelte @@ -48,7 +48,7 @@ {/if} {#each lines as line, i (line.at + ':' + i)}
  • - {line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone{/if} + {line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone{/if} {line.text}
  • {/each} diff --git a/template/src/lib/net/view.ts b/template/src/lib/net/view.ts index 93207b2..c4f14af 100644 --- a/template/src/lib/net/view.ts +++ b/template/src/lib/net/view.ts @@ -9,6 +9,8 @@ export interface ChatLine { text: string; at: number; name?: string; + /** Said by the keeper of the site, through the keeper's door. */ + keeper?: true; /** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */ to?: SeatId[]; /** The round the line was said in, counted from one; absent before the game begins. */