From f21b2c3b16d8ebb96aa8ef5e803892595b439ea4 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 20 Oct 2026 13:16:57 -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 duel began, can still be answered; waving-hands-say.sh sends it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- deploy/README.md | 3 +++ deploy/deploy.sh | 1 + deploy/waving-hands-say.sh | 18 ++++++++++++++++++ server/src/index.ts | 6 ++++++ server/src/rooms.test.ts | 10 ++++++++++ server/src/rooms.ts | 7 ++++++- server/src/store.ts | 2 +- src/lib/components/TableTalk.svelte | 2 +- src/lib/game/view.ts | 3 ++- 9 files changed, 48 insertions(+), 4 deletions(-) create mode 100755 deploy/waving-hands-say.sh diff --git a/deploy/README.md b/deploy/README.md index f75f10a..0414b3b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -57,6 +57,9 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny through the duel server's `/keeper` door, which Caddy does not proxy and which asks for the key in /etc/waving-hands/keeper.key, made by the deploy and readable by root and the waving-hands user alone. +- `waving-hands-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. - `waving-hands-pulse.sh`: the weekly health check (service, errors, rollup trend, backup, box). - `waving-hands-rollup.sh [day]`: one JSON line per day of counts, run diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 6ff62a2..130421a 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -44,6 +44,7 @@ ssh "root@$HOST" ' install -m 755 /opt/waving-hands/app/deploy/waving-hands-rollup.sh /usr/local/bin/waving-hands-rollup.sh install -m 755 /opt/waving-hands/app/deploy/waving-hands-backup.sh /usr/local/bin/waving-hands-backup.sh install -m 755 /opt/waving-hands/app/deploy/waving-hands-reseat.sh /usr/local/bin/waving-hands-reseat.sh + install -m 755 /opt/waving-hands/app/deploy/waving-hands-say.sh /usr/local/bin/waving-hands-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/waving-hands/keeper.key ]; then install -d -m 750 -o root -g waving-hands /etc/waving-hands diff --git a/deploy/waving-hands-say.sh b/deploy/waving-hands-say.sh new file mode 100755 index 0000000..8e256c7 --- /dev/null +++ b/deploy/waving-hands-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 duel server's keeper door. +# waving-hands-say.sh ROOM "text" +set -euo pipefail +ROOM="${1:?usage: waving-hands-say.sh ROOM \"text\"}" +TEXT="${2:?usage: waving-hands-say.sh ROOM \"text\"}" +DOOR="http://127.0.0.1:8788/keeper/rooms/${ROOM^^}" +[ -s /etc/waving-hands/keeper.key ] || { echo "No keeper's key at /etc/waving-hands/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/waving-hands/keeper.key)") +BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))') +CODE=$(curl -s -o /tmp/waving-hands-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/waving-hands-say.$$ >&2; echo >&2; rm -f /tmp/waving-hands-say.$$; exit 1; fi +rm -f /tmp/waving-hands-say.$$ diff --git a/server/src/index.ts b/server/src/index.ts index 8851d6c..bb1e427 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ // // GET /keeper/rooms/:id who sits at the table, and when each wizard 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'; @@ -234,6 +235,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, KEEPER, body.text); + return send(res, 204, {}); + } throw new RoomError('Not here.', 404); } diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 7e043d5..31a620d 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { Rooms } from './rooms'; import { chooseBotTurn } from '../../src/lib/game/bot'; import { Store } from './store'; +import { SPECTATOR } from '../../src/lib/game/state'; let dir: string; beforeEach(() => { @@ -38,6 +39,15 @@ describe('rooms', () => { expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/); }); + 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, 'Kestrel', 'I came too late for the seat; call me again.'); + const said = { id: SPECTATOR, name: 'Kestrel', keeper: true, text: 'I came too late for the seat; call me again.' }; + expect(room.chat.at(-1)).toMatchObject(said); + expect(new Rooms(new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said); + }); + it('re-seats only a wizard, and says when each last acted', () => { const { rooms, room, ann } = table(); expect(() => rooms.reseat(room, 'Z')).toThrow(/No wizard sits/); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 8e4b8e4..196317e 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -203,6 +203,11 @@ export class Rooms { this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name, text: cleanTalk(raw) }); } + /** 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, keeper: string, raw: unknown): void { + this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: keeper, text: cleanTalk(raw), keeper: true }); + } + /** * A seated wizard calls the keeper of the site to the table: a seat is * held under the keeper's name and the call goes on the ledger. Once per @@ -552,7 +557,7 @@ function apply(room: Room, line: LedgerLine): void { case 'over': break; case 'chat': - room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) }); + room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}), ...(line.keeper ? { keeper: true as const } : {}) }); if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP); break; } diff --git a/server/src/store.ts b/server/src/store.ts index 0849ea2..5fa01e4 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -28,7 +28,7 @@ export type LedgerLine = /** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: WizardId | null; reason: string } /** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */ - | { t: 'chat'; at: number; id: WizardId; text: string; name?: string } + | { t: 'chat'; at: number; id: WizardId; text: string; name?: string; keeper?: true } /** The host let the gallery talk, or hushed it. */ | { t: 'galleryTalk'; at: number; on: boolean; by: WizardId } /** A seat is held for the keeper of the site, called to the table. */ diff --git a/src/lib/components/TableTalk.svelte b/src/lib/components/TableTalk.svelte index 6d38e96..3147e03 100644 --- a/src/lib/components/TableTalk.svelte +++ b/src/lib/components/TableTalk.svelte @@ -49,7 +49,7 @@
  • Nobody has said a word.
  • {/if} {#each lines as line, i (line.at + ':' + i)} -
  • {line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)} {line.text}
  • +
  • {line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === duel.me ? 'You' : duel.speaker(line.id)} {line.text}
  • {/each} {#if remote.spectating && remote.galleryTalk} diff --git a/src/lib/game/view.ts b/src/lib/game/view.ts index 53033d7..60d216d 100644 --- a/src/lib/game/view.ts +++ b/src/lib/game/view.ts @@ -15,12 +15,13 @@ export function viewFor(state: GameState, viewer: WizardId): GameState { return view; } -/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`). */ +/** One line of table talk: from a seat, or from the Peanut Gallery when the host allows it (id SPECTATOR, signed with `name`), or from the keeper of the site. */ export interface ChatLine { id: WizardId; text: string; at: number; name?: string; + keeper?: true; } /** What a seated player, or the gallery, is told by the server. Other seats' moves and tokens never leave it. */