Compare commits

...
21 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 294b1d5460 The drift check compares the contract, the room store and view, the tally, and the shared dialogs too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:27:48 -04:00
Eric WagonerandClaude Opus 5.5 59494aa29f A settled move may be taken back as a draft while the round still waits on others
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:26:32 -04:00
Eric WagonerandClaude Opus 5.5 af25b672f1 The README names the optional hooks a game may implement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:25:49 -04:00
Eric WagonerandClaude Opus 5.5 60b3c0e60a A deploy waits for the game server to answer and fails with its journal if it does not; a report id is checked whole; the backup's notes say what it keeps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:25:38 -04:00
Eric WagonerandClaude Opus 5.5 f78ff3ef8c The hall puts games in play first for someone coming back, finished ones further down, and reads a game looking up as looking, not unreachable
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:25:07 -04:00
Eric WagonerandClaude Opus 5.5 f131f292ae A dialog in a short window scrolls within it, the tabbed one stays put as its tabs change, and the report slip closes with Escape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:21:27 -04:00
Eric WagonerandClaude Opus 5.5 8ba02006ca The server's doors hold: a ceiling on a seat's actions, twenty sockets an address, silent sockets closed, slow requests cut off, and rooms kept beside the project wherever it is started
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:20:55 -04:00
Eric WagonerandClaude Opus 5.5 d97d709212 A game may count its own lines from every game begun, not only those finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:20:03 -04:00
Eric WagonerandClaude Opus 5.5 403232087d Bots that play on alone do it at a pace to watch, a round every two seconds after the first few, and a seat may hurry them to the end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:19:38 -04:00
Eric WagonerandClaude Opus 5.5 99a0b23f59 A game may give its bots words: they speak as a round opens and when spoken to, remember by notes nobody sees, and read the talk when they move
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:18:46 -04:00
Eric WagonerandClaude Opus 5.5 8c99126aee A table may ask each seat to rank its choices before the deal, in secret; the start line keeps the rankings and create reads them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:17:18 -04:00
Eric WagonerandClaude Opus 5.5 eca8c92964 Table talk may be private, to the seats named and never the gallery, and a line says the round it was said in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:16:08 -04:00
Eric WagonerandClaude Opus 5.5 8e35d3a3a6 A seat's view carries its own move in waiting and its move in the last round it played
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:15:05 -04:00
Eric WagonerandClaude Opus 5.5 927e49cf79 A game may take drafts: an input it does not call settled can be replaced, and the round waits for settled ones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:14:33 -04:00
Eric WagonerandClaude Opus 5.5 6133a2469a A table's options and its size are read one way, in spec.ts, by the server, the tally and the replay; a game may seat fewer for some options
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:13:54 -04:00
Eric WagonerandClaude Opus 5.5 faa2845e70 A room whose ledger cannot be read is logged and left unloaded, not the server down; the server has tests of its own
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 12:13:11 -04:00
Eric WagonerandClaude Opus 5.5 2810076677 The template's about panel is a TabbedDialog, opened by its address
About.svelte goes; #about opens the panel through the router, so it
closes and reopens cleanly and a tab can be linked to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 14:49:39 -04:00
Eric WagonerandClaude Opus 5.5 c3fca6c5c7 TabbedDialog and the boxed tab strip, as Dip has them; the tally's comment describes the tally
TabbedDialog keeps its own backdrop here, since the template has no
shared one; a game that has one may drop it. The template's hall still
opens its about panel with About.svelte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 14:47:11 -04:00
Eric WagonerandClaude Opus 5.5 6039b6c248 Tabs.svelte: the about panel's strip, shared with long reading pages
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 13:52:15 -04:00
Eric WagonerandClaude Opus 5.5 c55534dbd6 dev:all stops both sides when the server's port is taken
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 13:43:15 -04:00
Eric WagonerandClaude Opus 5.5 cf5af67505 npm run dev:all starts the server and the site together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-28 12:37:56 -04:00
32 changed files with 1143 additions and 265 deletions

No files matched your search

+60 -7
View File
@@ -10,10 +10,14 @@ README.
- **The hall** is the landing page (`/`). Name, *Play now against the bot*,
*Open a table*, *or join one* with a four-letter code, then *your games*
(the ledger of tables this browser holds a seat at, with whose move it
is and unread talk), *your reports to the keeper*, and *about this game —
is and unread talk; for someone coming back it comes first, and finished
games wait further down), *your reports to the keeper*, and *about this game —
a labor of love*, which opens the about panel: a dialog of tabs (The
game, The tally, More games, Send word; a game may add its own, as Dip's
Playing here), `About.svelte`, with each game's words in its hall.
Playing here), `TabbedDialog.svelte`, with each game's words in its hall.
The address names the dialog (`#about`, `#about/tally`), so a plain link
opens it; a game may put its guide and rules in dialogs the same way, as
Dip does. Its strip of tabs is `Tabs.svelte`.
- **A table** is a room. Its code is four letters or digits from an alphabet
with no look-alikes, and its link is `/join/CODE`. The masthead button
reads *invite friends · room CODE* and copies the link. Whoever opened
@@ -27,6 +31,10 @@ README.
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
and their lines read *Name (gallery)*. Each line is a ledger line, so it
replays with the game, and the hall counts lines said while you were away.
A line said while a game is running carries its round (`turn`). A seat
may also speak privately, naming seats (`room.say(text, to)`): the line
is shown to its sender and those seats alone, never to the gallery, and
a game whose talk is its heart puts the choice of whom in its talk box.
- **A rematch**: a finished table may call for one. A new table of the same
size opens with the same bots, seats held for the same people, and the
caller as host; a second call finds it already open. Held seats refuse
@@ -92,11 +100,16 @@ README.
- **The tally** sits in the about panel: what the ledgers add up to
(tables, games begun and finished, names seated, turns, time at the
table, games with a bot, talk), counted on demand from the files and
cached a minute. A game adds its own lines through `GameSpec.tally`.
cached a minute. A game adds its own lines through `GameSpec.tally` and
`GameSpec.tallyBegun`.
- **Table options** are the host's choices when opening a table (a
variant, a side, a length): the game declares them in `GameSpec.options`,
the hall renders them as selects, the room line records them, and
`create` receives them. A game with one way to play declares none.
`create` receives them. A game with one way to play declares none. An
option that changes how many can sit (a smaller map) says so through
`GameSpec.seatsFor`. The server, the tally and the ledger replay all read
a room line through `cleanOptions` and size a table with `tableSize`,
both in `spec.ts`, so each creates the same game from the same ledger.
## The engine
@@ -107,7 +120,12 @@ README.
- Simultaneous by default: every seat that `needsInput` submits, then the
round resolves at once, bots included. A seat that owes an extra turn
alone (a time stop, a bonus move) is just a state where only it needs
input; the server resolves rounds owed only to bots straight away.
input; the server resolves rounds owed only to bots straight away. A few
such rounds are played at once; past that (a table every person is out
of), the bots play on a round every two seconds so the table can be
watched and the server answers everyone else between rounds. The view
says so (`playingOn`), and a seat may ask for the rest at once
(`room.hurry()`).
- Hidden information is the engine's business: `view(state, viewer)`
returns what one seat may see, and the SPECTATOR view is the
intersection of everyone's. The server sends nothing else.
@@ -116,14 +134,49 @@ README.
round resolves; keep the old path behind `state.rules < N` and pin both
with tests. A fix that diverges from no ledger ships without a bump.
## Optional hooks
A game implements only the hooks it needs; without one, the kit behaves as
if it were never there.
- `validate(state, seat, input)`: a reason to refuse a move, shown to the player.
- `seatsFor(options)`: fewer seats for some table options.
- `settled(input)`: whether an input is final. A game with drafts answers
false until the player says it is done; a draft may be replaced (and a
settled move taken back as a draft) until the round resolves, and the
round waits for every seat's settled input. A seat's view carries its
own input in waiting (`yourInput`), so a page reopened shows the draft,
and its input in the last round it played (`yourLastInput`), so the page
can tell it what became of its own choice.
- `rankable(options)`: the choices a table asks each seat to rank before
the deal (sides, roles, colours). The lobby shows a seat its own ranking
and how many have ranked, nothing more; the start line records every
ranking and `create` receives them. A solo table asking for one waits in
the lobby for the player's.
- `botPress(state, seat, press, people, trigger)`: bots that talk. A bot
speaks when a round opens and when a person writes to it (or says a first
word to the table); its lines go privately to the people it names, as
ledger lines, with a `note` for its own memory that no view ever carries.
`botInput` receives the same press, so a bot can keep what was agreed.
`parting(press, viewer)` gives the bots' last words once the game is
over. Without `botPress`, private talk to a bot is refused.
- `tally(state)`: the game's own lines for the tally, from a finished game;
`tallyBegun(options)`: lines from every game begun, finished or not, by
its table's options (games begun on each variant).
- `preferences`, `options`: the preferences panel's settings and the
host's table choices (above).
## The server
One Node process per game, on its own port, run from source with `tsx` as
a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
and `/ws` before the static files. Rooms in memory are evicted after a week
idle and come back from disk on the next visit. Rate limits sit on the doors
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
never nears them. A seat token is minted once, sent once, and compared by
a stranger can knock on (rooms, seats, talk, the desk), and a looser one
on a seat's own actions, each a ledger line; a table of friends never nears
them. An address holds at most twenty sockets, a socket that stops
answering pings is closed, and a request has seconds, not minutes, to
arrive. A seat token is minted once, sent once, and compared by
hash in constant time ever after; a shared link carries none. Room codes
are checked against the disk as well as memory, so an evicted room's code
is never reissued over its ledger. A game may refuse a move with a reason
+11 -1
View File
@@ -14,6 +14,15 @@ The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
the ops tools never look past it. The template ships with a demo game,
High Card, so everything runs before you have written a line.
Beyond the required methods, a game opts into what it needs through
optional hooks, and without one the kit behaves as if it were not there:
`validate` (refuse a move with a reason), `seatsFor` (fewer seats for some
options), `settled` (drafts a player may replace until final), `rankable`
(a secret ranking before the deal, read by `create`), `botPress` and
`parting` (bots that talk, remember by notes and read the talk in
`botInput`), and `tally` / `tallyBegun` (the game's own lines in the tally).
`CONVENTIONS.md` says what each does.
## Start a game
./new-game.sh <slug> "<Name>" [port] [domain]
@@ -26,6 +35,7 @@ the new project as `docs/conventions.md`; it is the house style.
Run the demo locally from a new project:
npm run dev:all # server and site together; Ctrl-C stops both
npm run server # the game server on its port
npm run dev -- --host
@@ -37,7 +47,7 @@ Run the demo locally from a new project:
src/lib/net/ client.ts (calls, held seats, socket), room.svelte.ts (the room store), talk.ts, view.ts
src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, Board (demo; replace it)
src/routes/ the hall, /join/[code], /rules, /guide
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), ratelimit
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests
deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
.claude/skills/ <slug>-visitors, <slug>-pulse, <slug>-reports
+3 -2
View File
@@ -10,8 +10,9 @@ GAME="${1:?usage: drift.sh <game-dir> [slug]}"
SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
KIT="$(cd "$(dirname "$0")" && pwd)"
MAP="$KIT/drift-map/$SLUG.txt"
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts
src/lib/net/client.ts src/lib/net/talk.ts src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.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"
+7 -5
View File
@@ -57,7 +57,7 @@ deploy, so the live copies are the repo copies:
nightly at 00:12 UTC into /var/lib/__SLUG__/rollup.jsonl.
- `__SLUG__-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/__SLUG__
to the `kestrel-wizwar-backups` Space under `__SLUG__/` (a current copy
and dated snapshots kept 90 days). It needs rclone with the Spaces
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
credentials in /root/.config/rclone/rclone.conf, copied by hand from the
wizwar droplet; until then it logs "skipped".
@@ -84,14 +84,16 @@ deploy: the server would rewrite that game on restart.
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
the droplet keeping the previous week's hashed assets, rsyncs the server and
engine sources, installs dependencies, and restarts the game server. A room
is replayed from its ledger when the server comes back, which takes a few
seconds; Caddy holds requests that land in the gap.
engine sources, installs dependencies, and restarts the game server, then
waits up to twenty seconds for it to answer HTTP and fails with the tail of
its journal if it does not. A room is replayed from its ledger when the
server comes back, which takes a few seconds; Caddy holds requests that
land in the gap.
## Operations
- Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
`journalctl -u caddy -f` and /var/lib/caddy/__SLUG__-access.log for the web side.
- Restart: `ssh root@<ip> systemctl restart __SLUG__`
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
nightly backup keeps them in the Space.
+2 -2
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env bash
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
# current/ - exact mirror of /var/lib/__SLUG__
# current/ - mirror of /var/lib/__SLUG__, less the rollup (rebuilt from logs)
# snapshots/ - one dated copy per day, pruned after 90 days
# Ledgers are append-only JSONL; the game server never needs stopping.
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron holds the check-in
# URL (absent file = no check-ins). SCHEDULE must match /etc/cron.d/__SLUG__.
set -u
SCHEDULE="23 7 * * *"
BUCKET="kestrel-wizwar-backups"
BUCKET="kestrel-wizwar-backups" # the hall's shared Space, named for its first tenant
PREFIX="__SLUG__"
SRC="/var/lib/__SLUG__"
LOG="/var/log/__SLUG__/backup.log"
+12 -2
View File
@@ -49,7 +49,17 @@ ssh "root@$HOST" '
systemctl enable --now __SLUG__
systemctl restart __SLUG__
systemctl reload caddy
sleep 1
systemctl --no-pager -l status __SLUG__ | head -3
# "active" means only that the process started: wait for it to answer HTTP,
# so a server that dies loading its code fails the deploy instead of looping.
for i in $(seq 20); do
if curl -s -o /dev/null http://127.0.0.1:__PORT__/api/rooms/ZZZZ; then
systemctl --no-pager -l status __SLUG__ | head -3
exit 0
fi
sleep 1
done
echo "the game server is not answering:" >&2
journalctl -u __SLUG__ -n 25 --no-pager >&2
exit 1
'
echo "deployed."
+8 -5
View File
@@ -8,7 +8,7 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { game } from '../src/lib/game';
import type { SeatId } from '../src/lib/game/spec';
import { cleanOptions, type SeatId } from '../src/lib/game/spec';
interface Seat {
id: SeatId;
@@ -34,13 +34,16 @@ async function main(): Promise<void> {
const seats: Seat[] = [];
let state: ReturnType<typeof game.create> | null = null;
let turns = 0;
// The host's table options ride the room line; a game created without them is a different game.
const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record<string, string> | undefined;
// The host's table options ride the room line, read exactly as the server reads them.
const options = cleanOptions(game, lines[0]?.t === 'room' ? lines[0].options : undefined);
try {
for (const line of lines) {
if (line.t === 'seat') seats.push(line);
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
else if (line.t === 'unseat') {
const i = seats.findIndex((s) => s.id === line.id);
if (i >= 0) seats.splice(i, 1);
}
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options, line.rankings);
else if (line.t === 'turn' && state) {
state = game.resolve(state, line.inputs);
turns += 1;
+1 -1
View File
@@ -8,7 +8,7 @@ STATUS="${3:?usage: report-reply.sh <host> <reportId> <status> <text...>}"
shift 3
TEXT="$*"
case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac
case "$REPORT_ID" in *[!0-9a-f]*|"") echo "a report id is eight hex characters" >&2; exit 1;; esac
[[ "$REPORT_ID" =~ ^[0-9a-f]{8}$ ]] || { echo "a report id is eight hex characters" >&2; exit 1; }
LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" python3 -c '
import json, os, datetime
print(json.dumps({"reportId": os.environ["REPORT_ID"], "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "status": os.environ["STATUS"], "text": os.environ["TEXT"]}))')
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite dev",
"dev:all": "node scripts/dev-all.mjs",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
+39
View File
@@ -0,0 +1,39 @@
// The game server and the site together, for playing locally: one terminal,
// each line marked with its side, and Ctrl-C (or either one stopping or
// failing to get its port) stops both.
import { spawn } from 'node:child_process';
const sides = [
['server', ['run', 'server']],
['site', ['run', 'dev', '--', ...process.argv.slice(2)]]
];
const children = sides.map(([name, args]) => {
const child = spawn('npm', args, { stdio: ['inherit', 'pipe', 'pipe'] });
for (const stream of [child.stdout, child.stderr]) {
let rest = '';
stream.on('data', (chunk) => {
const lines = (rest + chunk).split('\n');
rest = lines.pop();
for (const line of lines) {
console.log(`[${name}] ${line}`);
// A watcher outlives the crash it watches, so a port already taken would leave one side running alone.
if (line.includes('EADDRINUSE')) {
console.log(`[${name}] its port is taken: is another copy already running?`);
stop(1);
}
}
});
}
child.on('exit', (code) => stop(code ?? 0));
return child;
});
let stopping = false;
function stop(code) {
if (stopping) return;
stopping = true;
for (const c of children) if (c.exitCode === null) c.kill('SIGTERM');
process.exitCode = code;
}
process.on('SIGINT', () => stop(0));
process.on('SIGTERM', () => stop(0));
+12 -2
View File
@@ -7,8 +7,10 @@
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
// POST /api/rooms/:id/rank {token, ranking} a seat ranks the table's choices, best first, where the table asks for it
// POST /api/rooms/:id/turn {token, input} this seat's move
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
// POST /api/rooms/:id/hurry {token} a table the bots are playing on plays the rest at once
// POST /api/rooms/:id/say {token, text, to?} table talk, from a seat to the whole room, or privately to the seats in `to`;
// {name, text} or from the gallery, signed, when the host allows it
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
@@ -230,6 +232,14 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
rooms.begin(room, token);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'rank') {
rooms.rank(room, token, body.ranking);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'hurry') {
rooms.hurry(room, token);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'turn') {
rooms.submit(room, token, body.input, body.resolved);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
@@ -241,7 +251,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
return send(res, 200, rooms.view(room, SPECTATOR));
}
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
rooms.say(room, token, body.text);
rooms.say(room, token, body.text, body.to);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'gallery') {
+209
View File
@@ -0,0 +1,209 @@
import { appendFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
function table(spec: GameSpec<State, Input> = game, withCy = false) {
const rooms = new Rooms(spec, new Store(dir));
const host = rooms.create('Ann', 3);
const guest = rooms.join(host.room, 'Bo');
const third = withCy ? rooms.join(host.room, 'Cy') : null;
rooms.begin(host.room, host.token);
return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token };
}
describe('rooms', () => {
it('resolves a round once every seat has moved, and refuses a move for a round already played', () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0);
expect(rooms.waitingOn(room)).toEqual(['B']);
rooms.submit(room, bo, { pick: 2 }, 0);
expect(room.resolved).toBe(1);
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
});
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');
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
const reopened = new Rooms(game, new Store(dir));
expect(errors).toHaveBeenCalledWith(expect.stringMatching(/ZZZZ: ledger unreadable/));
expect(reopened.get(room.id).state).not.toBeNull();
errors.mockRestore();
});
it('holds a draft until it is settled, and lets it be replaced until then', () => {
type Draft = Input & { final?: boolean };
const drafts: GameSpec<State, Input> = {
...game,
cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft,
settled: (input) => (input as Draft).final === true
};
const { rooms, room, ann, bo } = table(drafts);
rooms.submit(room, ann, { pick: 5 }, 0);
rooms.submit(room, bo, { pick: 2, final: true }, 0);
expect(rooms.waitingOn(room)).toEqual(['A']);
rooms.submit(room, ann, { pick: 4 }, 0);
expect(room.resolved).toBe(0);
rooms.submit(room, ann, { pick: 3, final: true }, 0);
expect(room.resolved).toBe(1);
expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 });
rooms.submit(room, bo, { pick: 1, final: true }, 1);
expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/);
rooms.submit(room, bo, { pick: 2 }, 1);
expect(rooms.waitingOn(room)).toEqual(['A', 'B']);
});
it("shows a seat its own move in waiting and its last move played, and nobody else's", () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0);
expect(rooms.view(room, 'A').yourInput).toEqual({ pick: 5 });
expect(rooms.view(room, 'B').yourInput).toBeNull();
expect(rooms.view(room, '').yourInput).toBeNull();
rooms.submit(room, bo, { pick: 2 }, 0);
expect(rooms.view(room, 'A').yourInput).toBeNull();
expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 });
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
expect(rooms.view(room, '').yourLastInput).toBeNull();
});
it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => {
const { rooms, room, ann, bo } = table(game, true);
rooms.say(room, ann, 'Everyone, hello.');
rooms.say(room, ann, 'Bo, lay a five.', ['B']);
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
expect(heard('C')).toEqual(['Everyone, hello.']);
expect(heard('')).toEqual(['Everyone, hello.']);
expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
});
it('keeps each ranking secret until the deal, which reads them all from the start line', () => {
const dealt: Record<string, string[]>[] = [];
const ranked: GameSpec<State, Input> = {
...game,
rankable: () => [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' }
],
create: (names, seed, rules, options, rankings) => {
dealt.push(rankings ?? {});
return game.create(names, seed, rules, options);
}
};
const rooms = new Rooms(ranked, new Store(dir));
const host = rooms.create('Ann', 2);
const guest = rooms.join(host.room, 'Bo');
const room = host.room;
expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/);
rooms.rank(room, host.token, ['blue', 'red']);
expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']);
expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 });
rooms.begin(room, host.token);
expect(dealt).toEqual([{ A: ['blue', 'red'] }]);
expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/);
new Rooms(ranked, new Store(dir));
expect(dealt[1]).toEqual({ A: ['blue', 'red'] });
});
it('refuses a ranking at a table that asks for none', () => {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
});
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
const heard: string[][] = [];
const talking: GameSpec<State, Input> = {
...game,
botInput: (state, seat, press) => {
heard.push((press ?? []).map((l) => l.text));
return game.botInput(state, seat);
},
botPress: (state, seat, press, people, trigger) =>
trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }],
parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }]
};
const rooms = new Rooms(talking, new Store(dir));
const host = rooms.create('Ann', 3);
rooms.addBot(host.room, host.token);
const cy = rooms.join(host.room, 'Cy');
const room = host.room;
rooms.begin(room, host.token);
rooms.say(room, host.token, 'Psst.', ['B']);
rooms.say(room, cy.token, 'Hello, all.');
const annSees = rooms.view(room, 'A').chat;
expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']);
expect(annSees.some((l) => 'note' in l)).toBe(false);
expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']);
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
rooms.submit(room, host.token, { pick: 1 }, 0);
rooms.submit(room, cy.token, { pick: 2 }, 0);
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
expect(rooms.view(room, 'A').parting).toEqual([]);
});
it('refuses private talk to a bot when the game gives bots no words', () => {
const rooms = new Rooms(game, new Store(dir));
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token);
expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/);
});
describe('a table the bots play on alone', () => {
/** A count to ten that only the bots play: the host is never owed a move. */
type Count = { n: number; over: { winner: null; reason: string } | null };
const count: GameSpec<Count, null> = {
seatIds: ['A', 'B'],
minSeats: 2,
botNames: ['Aldric'],
currentRules: 1,
create: () => ({ n: 0, over: null }),
resolve: (s) => ({ n: s.n + 1, over: s.n + 1 >= 10 ? { winner: null, reason: 'Ten.' } : null }),
needsInput: (s, seat) => seat === 'B' && !s.over,
over: (s) => s.over,
turn: (s) => s.n + 1,
view: (s) => s,
botInput: () => null,
cleanInput: () => null,
nameOf: (_s, seat) => seat
};
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('plays a few rounds at once, then one at a pace to watch, and the rest at once when hurried', () => {
const rooms = new Rooms(count, new Store(dir));
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token);
const room = host.room;
expect(room.state!.n).toBe(4);
expect(rooms.view(room, 'A').playingOn).toBe(true);
vi.advanceTimersByTime(2000);
expect(room.state!.n).toBe(5);
rooms.hurry(room, host.token);
vi.runAllTimers();
expect(room.state!.n).toBe(10);
expect(room.over).toBe(true);
expect(rooms.view(room, 'A').playingOn).toBe(false);
});
});
});
+169 -33
View File
@@ -5,13 +5,20 @@
// game beyond the GameSpec it is given.
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
import type { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store';
/** The longest line of talk. A game of long negotiation raises this, and the talk box's maxlength with it. */
const CHAT_MAX_LENGTH = 300;
/** Lines of talk kept and sent; the ledger keeps them all. */
/** Lines of talk kept and sent; the ledger keeps them all. A game that runs for weeks of talk raises this. */
const CHAT_KEEP = 200;
/** Bots' rounds played in one go, when nobody else owes a move, before the rest are paced. */
const PLAYED_AT_ONCE = 4;
/** Between paced bots' rounds: long enough to watch the table move. */
const PACE_MS = 2000;
/** Bots' rounds played between answers to everyone else, once hurried. */
const HURRIED_STEP = 3;
export { SPECTATOR };
export interface Seat {
@@ -48,6 +55,10 @@ export interface Room<State> {
state: State | null;
/** Inputs received for the round in progress, by seat. */
pending: Record<SeatId, unknown>;
/** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */
rankings: Record<SeatId, string[]>;
/** Each seat's input in the last round it played, kept through rounds it had no part in; each seat is shown only its own. */
played: Record<SeatId, unknown>;
chat: ChatLine[];
/** Rounds played so far; a move names the count it was made against. */
resolved: number;
@@ -112,6 +123,10 @@ export class Rooms<State, Input> {
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
private failedClaims = 0;
private failWindowStart = 0;
/** Tables whose bots are playing on, paced: the next round's timer. */
private pacing = new Map<string, ReturnType<typeof setTimeout>>();
/** Tables asked to play the rest at once. */
private hurried = new Set<string>();
constructor(
private game: GameSpec<State, Input>,
@@ -120,7 +135,14 @@ export class Rooms<State, Input> {
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
) {
for (const id of store.roomIds()) {
const room = this.replay(id, store.read(id));
let room: Room<State> | null;
try {
room = this.replay(id, store.read(id));
} catch (e) {
// One unreadable ledger (a torn last line) keeps its room closed, not the server down.
console.error(`room ${id}: ledger unreadable, left unloaded: ${e instanceof Error ? e.message : String(e)}`);
continue;
}
if (!room) continue;
this.rooms.set(id, room);
// A table that stopped with a bot's move owed picks up where it left off.
@@ -175,26 +197,17 @@ export class Rooms<State, Input> {
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
const { minSeats } = this.game;
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
const options = cleanOptions(this.game, rawOptions);
const most = tableSize(this.game, options);
if (!Number.isInteger(size) || size < minSeats || size > most) {
throw new RoomError(`A table seats ${minSeats} to ${most} players.`);
}
return this.open(name, size, this.cleanOptions(rawOptions));
}
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
private cleanOptions(raw: unknown): TableOptions {
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const options: TableOptions = {};
for (const o of this.game.options ?? []) {
const v = given[o.key];
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
}
return options;
return this.open(name, size, options);
}
/** A table with nobody seated yet. */
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], resolved: 0, seq, updatedAt: createdAt };
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, rankings: {}, chat: [], resolved: 0, seq, updatedAt: createdAt };
}
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
@@ -306,23 +319,66 @@ export class Rooms<State, Input> {
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
if (room.state) throw new RoomError('The game has already begun.', 409);
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
this.driveBots(room);
this.botsSpeak(room, 'phase');
}
/** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */
private driveBots(room: Room<State>): void {
/**
* Rounds only bots owe (an opening move, an extra turn, a table the
* people are out of) resolve without waiting; a human's waits for them.
* A few are played at once; past that, the bots play on one round at a
* time, so the table can be watched and the server answers everyone else
* between rounds. Hurried, they play in larger steps.
*/
private driveBots(room: Room<State>, burst = PLAYED_AT_ONCE): void {
if (this.pacing.has(room.id)) return;
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
let played = 0;
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
if (played === burst) return this.playOnLater(room);
const inputs: Record<SeatId, unknown> = {};
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, seat.id));
this.commit(room, { t: 'turn', at: Date.now(), inputs });
played += 1;
}
this.hurried.delete(room.id);
const outcome = room.state && this.game.over(room.state);
if (outcome && !room.over) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason });
}
/** The bots' next round, after a pause to watch it by, or at once when hurried. */
private playOnLater(room: Room<State>): void {
const hurried = this.hurried.has(room.id);
const next = setTimeout(() => {
this.pacing.delete(room.id);
// A room forgotten and loaded again drives itself; this one is no longer the table.
if (this.rooms.get(room.id) !== room) return;
this.driveBots(room, hurried ? HURRIED_STEP : 1);
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
}, hurried ? 0 : PACE_MS);
next.unref();
this.pacing.set(room.id, next);
}
/** A seat at a table the bots are playing on asks for the rest at once. */
hurry(room: Room<State>, token: string | undefined): void {
this.seatOf(room, token);
this.hurried.add(room.id);
const waiting = this.pacing.get(room.id);
if (!waiting) return;
clearTimeout(waiting);
this.pacing.delete(room.id);
this.playOnLater(room);
}
/** Whether the bots are playing on by themselves at this table, one paced round at a time. */
playingOn(room: Room<State>): boolean {
return this.pacing.has(room.id);
}
/** The host seats a bot in an empty chair. */
addBot(room: Room<State>, token: string | undefined): Seat {
const host = this.seatOf(room, token);
@@ -360,8 +416,22 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'unseat', id: seat.id });
}
/** A seat's ranking of the choices the table asks for, best first, before the game begins. */
rank(room: Room<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
if (room.state) throw new RoomError('The game has begun; the ranking is closed.', 409);
const choices = this.game.rankable?.(room.options);
if (!choices) throw new RoomError('This table asks for no ranking.', 409);
const ranking = Array.isArray(raw) ? raw.map(String) : [];
const values = choices.map((c) => c.value);
if (ranking.length !== values.length || new Set(ranking).size !== values.length || !ranking.every((r) => values.includes(r))) throw new RoomError('Rank every choice once.');
this.commit(room, { t: 'rank', id: seat.id, ranking });
}
/**
* Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited.
* A draft (an input the game does not call settled) may be replaced, and a settled move taken back
* as a draft, until the round resolves.
* `resolved` is how many rounds the page had seen played when the move was made: a move that
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
* rather than taken as the move for the next round.
@@ -373,8 +443,10 @@ export class Rooms<State, Input> {
if (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the table as it stands.', 409);
if (resolved !== room.resolved) throw new RoomError('That move was made for a turn that has already been played. Your page will catch up.', 409);
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
if (!this.game.needsInput(room.state, seat.id)) throw new RoomError('It is not your move.', 409);
const input = this.game.cleanInput(raw);
// A settled move is final; a draft may replace it while the round still waits on others.
if (seat.id in room.pending && this.settled(room.pending[seat.id]) && this.settled(input)) throw new RoomError('Your move is already in.', 409);
const why = this.game.validate?.(room.state, seat.id, input);
if (why) throw new RoomError(why);
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
@@ -383,15 +455,58 @@ export class Rooms<State, Input> {
private resolve(room: Room<State>): void {
const inputs: Record<SeatId, unknown> = { ...room.pending };
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
this.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room);
this.botsSpeak(room, 'phase');
}
/** Table talk, from a seat to everyone at the table and in the gallery. */
say(room: Room<State>, token: string | undefined, raw: unknown): void {
/**
* Bots' talk, for a game that gives them words: when a round opens, and to
* a person who has just written to them. Their lines go on the ledger like
* anyone's, so a replay repeats them rather than asking again.
*/
private botsSpeak(room: Room<State>, trigger: 'phase' | 'spoken', only?: SeatId[]): void {
if (!this.game.botPress || !room.state) return;
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
for (const seat of room.seats) {
if (!seat.bot || (only && !only.includes(seat.id))) continue;
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
const to = line.to.filter((id) => people.includes(id));
if (!to.length || !line.text.trim()) continue;
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(line.text), to, ...(line.note !== undefined ? { note: line.note } : {}) });
}
}
}
/** The talk a seat has heard: the table's, and private lines to it or from it. */
private heardBy(room: Room<State>, seatId: SeatId): ChatLine[] {
return room.chat.filter((line) => !line.to || line.id === seatId || line.to.includes(seatId));
}
/** Whether an input is final; a game without drafts makes every input final. */
private settled(input: unknown): boolean {
return this.game.settled ? this.game.settled(input as Input) : true;
}
/**
* Table talk, from a seat: to everyone at the table and in the gallery,
* or, naming seats, privately to them alone. A private line is never
* shown to anyone else, the gallery included.
*/
say(room: Room<State>, token: string | undefined, raw: unknown, rawTo?: unknown): void {
const seat = this.seatOf(room, token);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
const to = Array.isArray(rawTo)
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
: [];
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot);
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id));
}
private cleanTalk(raw: unknown): string {
@@ -404,16 +519,17 @@ export class Rooms<State, Input> {
return text;
}
/** Seats whose input the next resolution needs and has not yet received. */
/** Seats whose input the next resolution needs and has not yet received settled; a draft still waits. */
waitingOn(room: Room<State>): SeatId[] {
if (!room.state || this.game.over(room.state)) return [];
return room.seats
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending))
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending && this.settled(room.pending[s.id])))
.map((s) => s.id);
}
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
view(room: Room<State>, seatId: SeatId): RoomView<State> {
const seated = seatId !== SPECTATOR;
return {
roomId: room.id,
seq: room.seq,
@@ -426,7 +542,14 @@ export class Rooms<State, Input> {
resolved: room.resolved,
over: room.state ? this.game.over(room.state) : null,
state: room.state ? this.game.view(room.state, seatId) : null,
chat: room.chat,
yourRanking: seated ? (room.rankings[seatId] ?? null) : null,
ranked: Object.keys(room.rankings).length,
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
chat: this.heardBy(room, seatId).map(({ note: _, ...line }) => line),
parting: room.state && seated && this.game.parting && this.game.over(room.state) ? this.game.parting(room.chat, seatId) : [],
playingOn: this.playingOn(room),
audience: this.gallery.get(room.id) ?? 0,
galleryTalk: room.galleryTalk,
challenge: room.challenge,
@@ -499,20 +622,33 @@ export class Rooms<State, Input> {
case 'input':
room.pending[line.id] = this.game.cleanInput(line.input);
break;
case 'rank':
room.rankings[line.id] = line.ranking;
break;
case 'unseat':
room.seats = room.seats.filter((s) => s.id !== line.id);
delete room.rankings[line.id];
break;
case 'start':
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options);
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options, line.rankings);
break;
case 'turn':
if (!room.state) return;
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
room.played = { ...room.played, ...(line.inputs as Record<SeatId, unknown>) };
room.pending = {};
room.resolved += 1;
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,
...(room.state ? { turn: this.game.turn(room.state) } : {}),
...(line.name ? { name: line.name } : {}),
...(line.to ? { to: line.to } : {}),
...(line.note !== undefined ? { note: line.note } : {})
});
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
break;
}
@@ -521,7 +657,7 @@ export class Rooms<State, Input> {
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], cleanOptions(this.game, first.options), lines.length);
for (const line of lines) {
this.apply(room, line);
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
+13 -4
View File
@@ -15,13 +15,22 @@ export type LedgerLine =
| { 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. */
| { t: 'unseat'; id: SeatId }
/** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */
| { t: 'start'; seed: number; rules: number }
/** A seat's ranking of the table's choices, best first, before the game begins; the latest for a seat stands. */
| { t: 'rank'; id: SeatId; ranking: string[] }
/**
* The seed and the rules revision the game began under (a later fix keeps
* this revision's path for it), and at a table that asks for rankings,
* the seats' rankings as the deal read them.
*/
| { t: 'start'; seed: number; rules: number; rankings?: Record<SeatId, string[]> }
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
/** 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 }
/** 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 }
/**
* 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 }
/** 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. */
+33
View File
@@ -0,0 +1,33 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
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 { Tallies } from './tally';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'tally-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('the tally', () => {
it("adds a game's own lines from every game begun, finished or not", () => {
const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) };
const rooms = new Rooms(counted, new Store(dir));
for (let i = 0; i < 2; i++) {
const host = rooms.create('Ann', 2);
rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token);
}
rooms.create('Bo', 2);
const t = new Tallies(counted, new Store(dir)).get();
expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 });
expect(t.byGame).toEqual({ 'games of High Card begun': 2 });
});
});
+15 -6
View File
@@ -1,9 +1,10 @@
// The tally: what the ledgers add up to, for the hall's about panel. Read
// from the files on demand and cached for a minute, so a curious visitor
// costs nothing and a busy hall costs one scan a minute. The counts are
// the kit's; a game may add its own lines through GameSpec.tally.
// the kit's; a game may add its own lines through GameSpec.tally (from a
// finished game) and GameSpec.tallyBegun (from any game begun).
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
import type { Tally } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store';
@@ -30,7 +31,12 @@ export class Tallies<State, Input> {
const names = new Set<string>();
let firstStart = Infinity;
for (const id of this.store.roomIds()) {
const lines = this.store.read(id);
let lines: LedgerLine[];
try {
lines = this.store.read(id);
} catch {
continue; // an unreadable ledger is left out of the count, as its room is left unloaded
}
if (lines[0]?.t !== 'room') continue;
t.tablesOpened += 1;
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
@@ -44,13 +50,16 @@ export class Tallies<State, Input> {
seats.push(line);
if (line.bot) hasBot = true;
else names.add(line.name.toLowerCase());
} else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
else if (line.t === 'start') {
} else if (line.t === 'unseat') {
const i = seats.findIndex((s) => s.id === line.id);
if (i >= 0) seats.splice(i, 1);
} else if (line.t === 'start') {
t.gamesBegun += 1;
if (hasBot) t.gamesWithBot += 1;
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
for (const [k, v] of Object.entries(this.game.tallyBegun?.(cleanOptions(this.game, lines[0].options)) ?? {})) t.byGame[k] = (t.byGame[k] ?? 0) + v;
try {
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options);
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings);
} catch {
state = null;
}
-148
View File
@@ -1,148 +0,0 @@
<script lang="ts" module>
import type { Snippet } from 'svelte';
/** One section of the about panel: a tab and what it shows. */
export interface AboutTab {
id: string;
label: string;
body: Snippet;
}
</script>
<script lang="ts">
// The about panel: the game's story, its keeping and its company, a tab
// at a time rather than one long page. Opened from the hall's "about this
// game" link (and #about); closes with its button, a click outside it, or
// Escape. The tab last read is the one it reopens on.
import { tick } from 'svelte';
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
let current = $state('');
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
let tabButtons: HTMLButtonElement[] = $state([]);
function onKey(e: KeyboardEvent) {
if (open && e.key === 'Escape') open = false;
}
/** Arrow keys walk the tabs, as a tab list should; Home and End go to the ends. */
async function onTabKey(e: KeyboardEvent, i: number) {
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
if (to === null) return;
e.preventDefault();
const next = (to + tabs.length) % tabs.length;
current = tabs[next].id;
await tick();
tabButtons[next]?.focus();
}
</script>
<svelte:window onkeydown={onKey} />
{#if open}
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
<header>
<h2 id="about-title">{title}</h2>
<button type="button" class="close" aria-label="Close" onclick={() => (open = false)}>×</button>
</header>
<div class="tabs" role="tablist" aria-label="About this game">
{#each tabs as t, i (t.id)}
<button
type="button"
role="tab"
id="about-tab-{t.id}"
aria-controls="about-panel"
aria-selected={shown.id === t.id}
tabindex={shown.id === t.id ? 0 : -1}
class:on={shown.id === t.id}
bind:this={tabButtons[i]}
onclick={() => (current = t.id)}
onkeydown={(e) => onTabKey(e, i)}>{t.label}</button
>
{/each}
</div>
<div class="body" id="about-panel" role="tabpanel" aria-labelledby="about-tab-{shown.id}" tabindex="0">
{@render shown.body()}
</div>
</div>
</div>
{/if}
<style>
/* Its own backdrop: not every game has another dialog to share one with. */
.about-scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.panel {
width: min(100%, 44rem);
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.4rem 1.2rem;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.6rem;
}
.close {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.4rem;
border-bottom: 1px solid var(--rule-strong);
margin-bottom: 0.8rem;
}
.tabs button {
background: none;
border: none;
border-bottom: 3px solid transparent;
color: inherit;
opacity: 0.75;
font: inherit;
padding: 0.35rem 0.5rem 0.4rem;
cursor: pointer;
}
.tabs button.on {
opacity: 1;
border-bottom-color: currentColor;
}
.body {
overflow: auto;
min-height: 12rem;
}
.body :global(p + p) {
margin-top: 0.7rem;
}
.body :global(h3) {
font-size: 1.05rem;
margin: 1rem 0 0.4rem;
}
.body :global(h3:first-child) {
margin-top: 0;
}
</style>
+7
View File
@@ -51,8 +51,15 @@
<div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
<a class="commit" href="/">Back to the hall</a>
</div>
{:else if room.view.playingOn}
<section class="move">
<h2>The bots play on</h2>
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
</section>
{:else if room.spectating}
<section class="move">
<h2>You watch from the Peanut Gallery</h2>
+63 -36
View File
@@ -4,7 +4,7 @@
// about panel with the rules, the guide and a way to reach the keeper.
import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte';
import TabbedDialog from './TabbedDialog.svelte';
import TallyBoard from './Tally.svelte';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
@@ -14,6 +14,7 @@
import type { Report, RoomView, Tally } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
import { tableSize } from '$lib/game/spec';
let name = $state(playerName());
let code = $state('');
@@ -27,9 +28,6 @@
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
$effect(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** The ledgers' tally, fetched when the about panel opens. */
let tally = $state<Tally | null>(null);
$effect(() => {
@@ -77,12 +75,14 @@
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it
// is. A seat the server refuses outright is doubted; a second refusal in
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
// is; until the answer comes, its game is absent and reads "looking". A
// seat the server refuses outright is doubted; a second refusal in a row
// forgets it. Silence (a restart, a stale answer) forgets nothing.
const asked = new Set<string>();
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
if (asked.has(held.roomId)) continue;
asked.add(held.roomId);
api.view(held.roomId, held.token)
.then((v) => {
trustSeat(held.roomId);
@@ -149,6 +149,10 @@
seats = allSeats();
}
/** A game counts as finished once its table says so; until then, even while loading, it is in play. */
const finished = $derived(seats.filter((h) => !!games[h.roomId]?.over));
const active = $derived(seats.filter((h) => !games[h.roomId]?.over));
function status(held: { roomId: string; seat: string }): string {
const g = games[held.roomId];
if (g === undefined) return 'looking';
@@ -190,12 +194,12 @@
}
</script>
<section class="lid">
<section class="lid" class:returning={active.length > 0}>
<div class="lid-head">
<h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</p>
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
@@ -222,7 +226,7 @@
{/if}
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length, options))}>Open a table</button>
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
@@ -243,18 +247,30 @@
<p class="caption">A picture of the game goes here.</p>
</div>
{#if seats.length}
{#snippet gameRow(held: (typeof seats)[number])}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div>
{/snippet}
<!-- The games still in play, first thing for someone coming back; finished ones wait further down. -->
{#if active.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
</a>
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
{#each active as held (held.roomId)}{@render gameRow(held)}{/each}
</div>
{/if}
{#if finished.length || reports.length}
<div class="ledger past">
{#if finished.length}
<div class="ledger-head">finished games</div>
{#each finished as held (held.roomId)}{@render gameRow(held)}{/each}
{/if}
{#if reports.length}
<div class="ledger-head reports-head">your reports to the keeper</div>
{#each reports as r (r.id)}
@@ -325,7 +341,18 @@
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About __NAME__" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
<TabbedDialog
address="about"
bind:open={aboutOpen}
title="About __NAME__"
label="About this game"
tabs={[
{ id: 'game', label: 'The game', body: aboutGame },
{ id: 'tally', label: 'The tally', body: aboutTally },
{ id: 'family', label: 'More games', body: aboutFamily },
{ id: 'word', label: 'Send word', body: aboutWord }
]}
/>
</section>
<style>
@@ -338,14 +365,21 @@
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger';
grid-template-areas: 'head' 'play' 'look' 'ledger' 'past';
gap: 1.2rem 2.2rem;
}
/* Someone coming back sees their games first. */
.lid.returning {
grid-template-areas: 'head' 'ledger' 'play' 'look' 'past';
}
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger';
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
}
.lid.returning {
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
}
}
@@ -465,6 +499,10 @@
padding-top: 0.5rem;
}
.ledger.past {
grid-area: past;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
@@ -570,7 +608,7 @@
min-width: 0;
}
/* about: the panel's own words; About.svelte is the dialog around them */
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
.about-body {
max-width: 44em;
color: var(--bone-dim);
@@ -581,17 +619,6 @@
margin-top: 0.6rem;
}
.about-body a {
color: var(--frost);
}
.about-body h3 {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.about-body .made {
font-size: 0.85rem;
color: var(--bone-faint);
+85
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import TableTalk from './TableTalk.svelte';
import { game } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
@@ -8,6 +10,28 @@
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
const choices = $derived(game.rankable?.(v.options) ?? null);
const people = $derived(v.seats.filter((s) => !s.bot).length);
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
let order = $state<string[]>([]);
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
$effect(() => {
if (!choices) return;
const values = choices.map((c) => c.value);
const mine = v.yourRanking;
if (mine && mine.length === values.length) order = [...mine];
else if (untrack(() => order.length) !== values.length) order = values;
});
/** Move the choice at `i` to place `j`, the others closing up behind it. */
async function move(i: number, j: number) {
if (j < 0 || j >= order.length || j === i) return;
const next = [...order];
next.splice(j, 0, ...next.splice(i, 1));
order = next;
await room.rank(next);
}
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
let minute = $state(0);
$effect(() => {
@@ -62,6 +86,28 @@
{#if v.rematchOf}
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
{/if}
{#if choices && !room.spectating}
<section class="ranking" aria-label="Your ranking">
<h3>Your ranking</h3>
<p class="muted small">
Best first. When the game begins, the deal reads everyone's ranking; nobody sees another player's.
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
</p>
<ol>
{#each order as value, i (value)}
<li>
<span class="place">{i + 1}.</span>
<span class="choice">{labelOf(value)}</span>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
</li>
{/each}
</ol>
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
</section>
{/if}
{#if !room.spectating}
<div class="ways">
{#if room.seatEmpty && room.isHost}
@@ -170,6 +216,45 @@
font-size: 0.9rem;
}
.ranking {
max-width: 26rem;
margin: 1rem 0;
}
.ranking h3 {
margin: 0 0 0.2rem;
font-size: 1.05rem;
}
.ranking ol {
list-style: none;
margin: 0.4rem 0;
padding: 0;
}
.ranking li {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
}
.ranking .place {
width: 1.6rem;
color: var(--bone-dim);
font-variant-numeric: tabular-nums;
}
.ranking .choice {
flex: 1;
}
.ranking .quiet {
min-width: 2.6rem;
min-height: 2.4rem;
padding: 0.2rem 0.5rem;
}
.keeper {
margin-top: 0.8rem;
max-width: 38em;
@@ -48,6 +48,8 @@
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
@@ -54,6 +54,8 @@
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
@@ -92,6 +94,8 @@
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
@@ -0,0 +1,147 @@
<script lang="ts">
// A dialog of tabs: the about panel, how to play, the rules. The address
// names it, so a plain link opens it: #rules opens the rules, #guide/notation
// how to play at its notation. It closes with its button, a click outside
// it, or Escape. The tab last read is the one it reopens on. `open` tells
// the page whether it is showing.
import { untrack, type Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Tabs, { type Tab } from './Tabs.svelte';
let {
address,
title,
label = title,
tabs,
intro,
open = $bindable(false)
}: { address: string; title: string; label?: string; tabs: Tab[]; intro?: Snippet; open?: boolean } = $props();
let current = $state('');
// The dialog is open exactly while the router's address names it. Opening, choosing a tab and
// closing all navigate, so the address and the dialog cannot disagree.
const [named, tab] = $derived.by(() => {
const [name, t] = page.url.hash.slice(1).split('/');
return [name === address, t] as const;
});
$effect(() => {
open = named;
});
$effect(() => {
if (named && tabs.some((t) => t.id === tab)) untrack(() => (current = tab));
});
// The address follows the chosen tab, so what is being read can be linked to.
$effect(() => {
if (named && current) untrack(() => current !== tab && show(current));
});
const bare = () => `${page.url.pathname}${page.url.search}`;
const quietly = { replaceState: true, noScroll: true, keepFocus: true };
function show(id: string) {
goto(`${bare()}#${address}/${id}`, quietly);
}
function close() {
goto(bare(), quietly);
}
function onKey(e: KeyboardEvent) {
if (named && e.key === 'Escape') close();
}
</script>
<svelte:window onkeydown={onKey} />
{#if named}
<div class="scrim anchored" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="{address}-title">
<header>
<h2 id="{address}-title">{title}</h2>
<button type="button" class="close" aria-label="Close" onclick={close}>×</button>
</header>
<div class="body">
{#if intro}<div class="intro">{@render intro()}</div>{/if}
<Tabs {tabs} {label} bind:current />
</div>
</div>
</div>
{/if}
<style>
/* Its own backdrop: not every game has another dialog to share one with. */
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
/* Held near the top, not centered: a tab of another length must not move the tabs under the pointer. */
.anchored {
place-items: start center;
padding-top: max(16px, 6vh);
}
/* min-width 0 all the way down, so wide content scrolls in place instead of widening the dialog past a phone's screen. */
.panel {
width: min(100%, 44rem);
min-width: 0;
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.4rem 1.2rem;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.6rem;
}
.close {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
.body {
overflow: auto;
min-height: 12rem;
min-width: 0;
}
.intro {
color: var(--bone-dim);
}
.body :global(a) {
color: var(--frost);
}
.body :global(h3) {
font-size: 1rem;
font-style: italic;
margin: 0.9rem 0 0.2rem;
color: var(--bone);
}
.body :global(h3:first-child) {
margin-top: 0;
}
/* The tab strip stays in view while a long tab scrolls beneath it. */
.body :global([role='tablist']) {
position: sticky;
top: 0;
z-index: 1;
background: var(--slate);
}
</style>
+8 -1
View File
@@ -47,7 +47,10 @@
<li class="muted">Nobody has said a word.</li>
{/if}
{#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} class:private={!!line.to}>
<em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
{line.text}
</li>
{/each}
</ul>
{#if room.spectating && room.view.galleryTalk}
@@ -110,6 +113,10 @@
color: var(--frost);
}
li.private {
font-style: italic;
}
form {
display: flex;
gap: 0.4rem;
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts" module>
import type { Snippet } from 'svelte';
/** One tab and what it shows. */
export interface Tab {
id: string;
label: string;
body: Snippet;
}
</script>
<script lang="ts">
// A strip of tabs over one panel, for a dialog that would otherwise be one
// long read. Arrow keys walk the tabs, Home and End go to the ends.
import { tick } from 'svelte';
let { tabs, label, current = $bindable('') }: { tabs: Tab[]; label: string; current?: string } = $props();
const uid = $props.id();
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
let tabButtons: HTMLButtonElement[] = $state([]);
async function onKey(e: KeyboardEvent, i: number) {
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
if (to === null) return;
e.preventDefault();
const next = (to + tabs.length) % tabs.length;
current = tabs[next].id;
await tick();
tabButtons[next]?.focus();
}
</script>
<div class="tabs" role="tablist" aria-label={label}>
{#each tabs as t, i (t.id)}
<button
type="button"
role="tab"
id="{uid}-{t.id}"
aria-controls="{uid}-panel"
aria-selected={shown.id === t.id}
tabindex={shown.id === t.id ? 0 : -1}
class:on={shown.id === t.id}
bind:this={tabButtons[i]}
onclick={() => (current = t.id)}
onkeydown={(e) => onKey(e, i)}>{t.label}</button
>
{/each}
</div>
<div class="panel" id="{uid}-panel" role="tabpanel" aria-labelledby="{uid}-{shown.id}" tabindex="0">
{@render shown.body()}
</div>
<style>
/* Small capitals in a row that may wrap, the open tab boxed: a second row reads as more buttons, not a broken line. */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--rule-strong);
margin: 0.6rem 0 0.8rem;
}
.tabs button {
font-family: var(--font-ui);
font-size: 0.74rem;
letter-spacing: 0.1em;
text-transform: uppercase;
background: none;
border: 1.5px solid transparent;
border-radius: 3px;
color: var(--bone-dim);
padding: 0.3rem 0.6rem;
cursor: pointer;
}
.tabs button:hover {
color: var(--bone);
}
.tabs button.on {
border-color: var(--bone);
color: var(--bone);
background: var(--slate-deep);
}
.panel :global(p + p) {
margin-top: 0.7rem;
}
</style>
+3 -3
View File
@@ -7,9 +7,9 @@
</script>
<script lang="ts">
// What the ledgers add up to, told as Wiz-War tells it: a few counts that
// are the game's own story, large, on cards; then the rest in a column of
// figures; then the fine print. Each game chooses its counts and words.
// What the ledgers add up to: a few counts that are the game's own story,
// large, on cards; then the rest in a column of figures; then the fine
// print. Each game chooses its counts and words.
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
$props();
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { cleanOptions, tableSize, type TableOption } from './spec';
const options: TableOption[] = [
{ key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' }
];
describe('table options', () => {
it('keeps only declared options and offered choices, the rest at their defaults', () => {
expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' });
expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' });
expect(cleanOptions({ options }, null)).toEqual({ length: 'short' });
});
it('seats as many as the options allow, never more than the seats there are', () => {
const seatIds = ['A', 'B', 'C', 'D'];
expect(tableSize({ seatIds, options })).toBe(4);
const seatsFor = (o: Record<string, string>) => (o.length === 'short' ? 2 : 9);
expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2);
expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4);
});
});
+82 -2
View File
@@ -13,6 +13,29 @@
/** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string;
/**
* A line of table talk as a game reads it: who, what, when, the round it was
* said in, to whom (absent: everyone), and a bot's note on its own line.
*/
export interface PressLine {
id: SeatId;
text: string;
at: number;
/** The seats a private line was said to. */
to?: SeatId[];
/** The round the line was said in, from one; absent before the game begins. */
turn?: number;
/** What a bot noted about its own line; kept in the ledger, never shown. */
note?: unknown;
}
/** A bot's line of talk: to the people it names, with a note for its own memory. */
export interface BotLine {
to: SeatId[];
text: string;
note?: unknown;
}
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
export const SPECTATOR: SeatId = '';
@@ -48,6 +71,8 @@ export interface Preference {
export interface GameSpec<State, Input> {
/** Seats in table order; the table takes at most this many. */
seatIds: SeatId[];
/** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */
seatsFor?(options: TableOptions): number;
/** What the host may choose when opening a table; empty for a game with one way to play. */
options?: TableOption[];
/** The game's own settings for the preferences panel. */
@@ -63,17 +88,49 @@ export interface GameSpec<State, Input> {
*/
currentRules: number;
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions): State;
/**
* Begin the game. `rankings` are the seats' preferences, best first, at a
* table that asks for them (`rankable`); the ledger's start line keeps them.
*/
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions, rankings?: Record<SeatId, string[]>): State;
/**
* Whether a table with these options asks each seat to rank something
* before the deal (sides, roles, colours): the choices to rank, or null.
* Each seat ranks in secret; `create` reads every ranking at once.
*/
rankable?(options: TableOptions): { value: string; label: string }[] | null;
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
resolve(state: State, inputs: Record<SeatId, Input>): State;
/** Whether this seat's input is needed before the next resolution. */
needsInput(state: State, seat: SeatId): boolean;
/**
* Whether a submitted input is final. A game that lets a player save a
* draft and change it answers false until the player says it is done;
* the round waits for every seat's settled input. Absent, every input is final.
*/
settled?(input: Input): boolean;
over(state: State): Outcome | null;
/** The round being written, counted from one, for the ledger and the report pin. */
turn(state: State): number;
/** What one viewer may see. The server sends nothing else. */
view(state: State, viewer: SeatId): State;
botInput(state: State, seat: SeatId): Input;
/**
* A bot's move. `press` is the talk its seat has heard (the table's, and
* private lines to it or from it, notes included), for a game whose bots
* keep what was agreed; a function of the state and the press, which the
* turn line records, so a replay never asks again.
*/
botInput(state: State, seat: SeatId, press?: PressLine[]): Input;
/**
* What a bot says: when a round opens ('phase'), and when a person has
* just written to it, or said a first word to the table ('spoken').
* `press` is what its seat has heard; `people` the seats played by people.
* Each line goes on the ledger, to the people it names and no one else.
* Absent, bots say nothing and a private line to one is refused.
*/
botPress?(state: State, seat: SeatId, press: PressLine[], people: SeatId[], trigger: 'phase' | 'spoken'): BotLine[];
/** The bots' last words to `viewer` once the game is over, read from the whole of the talk. */
parting?(press: PressLine[], viewer: SeatId): { id: SeatId; text: string }[];
/** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input;
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
@@ -82,4 +139,27 @@ export interface GameSpec<State, Input> {
nameOf(state: State, seat: SeatId): string;
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
tally?(state: State): Record<string, number>;
/** The game's own lines counted from every game begun, finished or not, by its table's options (games begun on each variant). */
tallyBegun?(options: TableOptions): Record<string, number>;
}
/**
* A table's options as the game will play them: only the options it
* declares, only the choices it offers, the rest at their defaults. The
* server, the tally and the ledger replay all read a room line through this,
* so each creates the same game from the same ledger.
*/
export function cleanOptions(game: Pick<GameSpec<unknown, unknown>, 'options'>, raw: unknown): TableOptions {
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
const options: TableOptions = {};
for (const o of game.options ?? []) {
const v = given[o.key];
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
}
return options;
}
/** The most seats a table with these options takes. */
export function tableSize(game: Pick<GameSpec<unknown, unknown>, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number {
return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length);
}
+3 -1
View File
@@ -132,7 +132,9 @@ export function makeApi<State, Input>() {
/** The gallery's view: no token, no seat, nothing any player could not see. */
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
hurry: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/hurry`, { token }),
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
/** A word from the gallery, signed with a name, when the host allows it. */
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
+16 -3
View File
@@ -112,9 +112,20 @@ export class Room {
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
}
say(text: string): Promise<boolean> {
/** Out of the game, with the bots playing on: play the rest at once. */
hurry(): Promise<boolean> {
return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.');
}
/** This seat's ranking of the table's choices, best first, before the game begins. */
rank(ranking: string[]): Promise<boolean> {
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
}
/** Talk to the whole table, or, naming seats, to them alone. */
say(text: string, to?: SeatId[]): Promise<boolean> {
if (this.spectating) return Promise.resolve(false);
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
}
/** A word from the gallery, signed; only when the host has let the gallery talk. */
@@ -165,7 +176,9 @@ export class Room {
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
const seated = await api.create(name, 2, options);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
await api.addBot(seated.view.roomId, seated.token);
const view = await api.addBot(seated.view.roomId, seated.token);
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
if (game.rankable?.(view.options)) return new Room(view, seated.token);
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
}
+18
View File
@@ -9,6 +9,12 @@ export interface ChatLine {
text: string;
at: number;
name?: string;
/** 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. */
turn?: number;
/** A bot's memory of the line (an offer made, an answer heard). Kept in the ledger; never sent. */
note?: unknown;
}
export interface RoomView<State> {
@@ -29,7 +35,19 @@ export interface RoomView<State> {
turn: number | null;
over: Outcome | null;
state: State | null;
/** This seat's ranking of the table's choices, at a table that asks for one; nobody sees another's. */
yourRanking: string[] | null;
/** How many seats have ranked, and nothing more about them. */
ranked: number;
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
yourInput: unknown;
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
yourLastInput: unknown;
chat: ChatLine[];
/** The bots' last words to this seat once the game is over, for a game that gives them some. */
parting: { id: SeatId; text: string }[];
/** The bots are playing on by themselves, a round at a time, with no person owing a move. */
playingOn: boolean;
/** How many watch from the Peanut Gallery. */
audience: number;
/** The host lets the gallery talk. */
+1 -1
View File
@@ -21,6 +21,6 @@ export default defineConfig({
}
},
test: {
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
}
});