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; <slug>-say.sh sends it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
1 parent
21de94869d
commit
82107f73eb
10 files changed
+50
-3
No files matched your search
@@ -55,6 +55,9 @@ deploy, so the live copies are the repo copies:
|
|||||||
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
|
- `hnefatafl-visitors.sh [day]`: who is here now and who came that day.
|
||||||
- `hnefatafl-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
- `hnefatafl-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.
|
token for a player locked out of it, as a link to send them.
|
||||||
|
- `hnefatafl-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.
|
||||||
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
|
- `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup
|
||||||
trend, backup, box).
|
trend, backup, box).
|
||||||
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
|
- `hnefatafl-rollup.sh [day]`: one JSON line per day of counts, run
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ ssh "root@$HOST" '
|
|||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-rollup.sh /usr/local/bin/hnefatafl-rollup.sh
|
||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-backup.sh /usr/local/bin/hnefatafl-backup.sh
|
||||||
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-reseat.sh /usr/local/bin/hnefatafl-reseat.sh
|
||||||
|
install -m 755 /opt/hnefatafl/app/deploy/hnefatafl-say.sh /usr/local/bin/hnefatafl-say.sh
|
||||||
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
||||||
if [ ! -s /etc/hnefatafl/keeper.key ]; then
|
if [ ! -s /etc/hnefatafl/keeper.key ]; then
|
||||||
install -d -m 750 -o root -g hnefatafl /etc/hnefatafl
|
install -d -m 750 -o root -g hnefatafl /etc/hnefatafl
|
||||||
|
|||||||
Executable
+18
@@ -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.
|
||||||
|
# hnefatafl-say.sh ROOM "text"
|
||||||
|
set -euo pipefail
|
||||||
|
ROOM="${1:?usage: hnefatafl-say.sh ROOM \"text\"}"
|
||||||
|
TEXT="${2:?usage: hnefatafl-say.sh ROOM \"text\"}"
|
||||||
|
DOOR="http://127.0.0.1:8789/keeper/rooms/${ROOM^^}"
|
||||||
|
[ -s /etc/hnefatafl/keeper.key ] || { echo "No keeper's key at /etc/hnefatafl/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/hnefatafl/keeper.key)")
|
||||||
|
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
|
||||||
|
CODE=$(curl -s -o /tmp/hnefatafl-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/hnefatafl-say.$$ >&2; echo >&2; rm -f /tmp/hnefatafl-say.$$; exit 1; fi
|
||||||
|
rm -f /tmp/hnefatafl-say.$$
|
||||||
@@ -147,6 +147,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
|||||||
time are marked NEW.
|
time are marked NEW.
|
||||||
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
|
- `<slug>-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.
|
acted; with a seat, a new token for a player locked out, as a link.
|
||||||
|
- `<slug>-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.
|
||||||
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
||||||
the rollup's last week, the backup's last word, the box's vitals.
|
the rollup's last week, the backup's last word, the box's vitals.
|
||||||
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
//
|
//
|
||||||
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
// 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/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 * as Sentry from '@sentry/node';
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
@@ -223,6 +224,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.
|
// 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}` });
|
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);
|
throw new RoomError('Not here.', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||||||
import { game } from '../../src/lib/game';
|
import { game } from '../../src/lib/game';
|
||||||
import { Rooms } from './rooms';
|
import { Rooms } from './rooms';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
|
import { SPECTATOR } from '../../src/lib/game/spec';
|
||||||
|
|
||||||
let dir: string;
|
let dir: string;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -24,6 +25,15 @@ function table() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('rooms', () => {
|
describe('rooms', () => {
|
||||||
|
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", () => {
|
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 { rooms, room, bo } = table();
|
||||||
const phrase = rooms.transfer(room, bo).phrase;
|
const phrase = rooms.transfer(room, bo).phrase;
|
||||||
|
|||||||
+6
-1
@@ -229,6 +229,11 @@ export class Rooms<State, Input> {
|
|||||||
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
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<State>, 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. */
|
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
||||||
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
|
sayFromGallery(room: Room<State>, 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);
|
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
||||||
@@ -599,7 +604,7 @@ export class Rooms<State, Input> {
|
|||||||
room.resolved += 1;
|
room.resolved += 1;
|
||||||
break;
|
break;
|
||||||
case 'chat':
|
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);
|
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ export type LedgerLine =
|
|||||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
||||||
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true }
|
||||||
/** The host let the gallery talk, or hushed it. */
|
/** The host let the gallery talk, or hushed it. */
|
||||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||||
/** A seat is held for the keeper of the site, called to the table. */
|
/** A seat is held for the keeper of the site, called to the table. */
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
<li class="muted">Nobody has said a word.</li>
|
<li class="muted">Nobody has said a word.</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#each lines as line, i (line.at + ':' + i)}
|
{#each lines as line, i (line.at + ':' + i)}
|
||||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
<li class:you={!line.name && line.id === room.me}><em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{#if room.spectating && room.view.galleryTalk}
|
{#if room.spectating && room.view.galleryTalk}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface ChatLine {
|
|||||||
text: string;
|
text: string;
|
||||||
at: number;
|
at: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Said by the keeper of the site, through the keeper's door. */
|
||||||
|
keeper?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomView<State> {
|
export interface RoomView<State> {
|
||||||
|
|||||||
Reference in new issue
Block a user