Compare commits

..
65 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 ee7b1d68ec From Hnefatafl: a link counts as shared when a preview app fetches a page, not when Meta's crawlers or robots.txt checks pass by
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-11-06 20:02:52 -05:00
Eric WagonerandClaude Fable 5.1 c6366853cd The root address is rewritten to index.html, since try_files passes the root over as a directory and served the shell in place of the hall
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 11:22:35 -04:00
Eric WagonerandClaude Fable 5.1 647e8cb2bf The hall's game cards no longer lift on hover or end in "Play X →" and a domain line, and the first game, the one newcomers are pointed at, takes the whole width with its art beside the words, so five games fill the grid without an orphan; the template renders the hall, the rules and the guide at build time behind a fallback named shell.html, and its deploy renders the Caddy site file from the template each time; the family list carries Wyrm and Dip's larger maps; the tally heading and the hosting line are said plainly; and the writing checklist names one sentence shape for a whole list as a tell
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 10:37:40 -04:00
Eric WagonerandClaude Fable 5.1 40a52dfe37 The hall's Wyrm card is the glass board at the opening, seen from a corner, as a JPEG; its alt text says what the picture shows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 17:36:19 -04:00
Eric WagonerandClaude Fable 5.1 75428b5a9b The template's room server guards a stale room once, by name; the test's tick helper is named for what it waits on; the port list and the hall's tally lose a set that was always empty
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 22:38:08 -04:00
Eric WagonerandClaude Fable 5.1 62cb00be33 The template's bots move once the player's request is answered: a slow bot no longer holds up the reply to the move it is answering
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 17:32:23 -04:00
Eric WagonerandClaude Fable 5.1 dcd2293b42 The template's lobby reads well with no KEEPER set: challenge the keeper, not the keeper, the keeper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 17:20:02 -04:00
Eric WagonerandClaude Fable 5.1 cbe85d2d31 The template no longer links a favicon.ico it does not ship, which failed a new game's first build at prerender
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 16:56:18 -04:00
Eric WagonerandClaude Fable 5.1 5e7ac999db Wyrm joins the family: its card in the hall, its line in family.json, and the hall's visitor count follows the people it sends there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 16:41:29 -04:00
Eric WagonerandClaude Opus 5.5 e91d209cbe The keeper can say a word at a table through the keeper's door, signed as the keeper and heard whether or not the gallery may talk, so a call answered too late, after the game began, can still be answered; <slug>-say.sh sends it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-20 13:21:24 -04:00
Eric WagonerandClaude Opus 5.5 8c37cc5210 The host is asked before beginning while a seat is still held for someone on their way, since the seat goes once the game begins; and the gallery says the game began before they arrived, so whoever was called and finds the table under way knows why there is no seat
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-20 13:04:28 -04:00
Eric WagonerandClaude Opus 5.5 09a8b675ad The host has a hand on the other seats: once the game begins, Seats makes a transfer phrase for another person's seat, which gives it a new token when claimed, and gives the seat of a player who cannot go on to a bot, which plays the move the seat owes at once and speaks nothing in their name; the person's token opens nothing while it stands in, their page goes to the gallery with the reason and a claim box, and a rematch holds their seat without the stand-in; the server pings every socket and closes one a dead browser left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-16 19:04:33 -04:00
Eric WagonerandClaude Opus 5.5 c439c15e36 A seat link replaces a held seat only when the server refuses the held token, not when the connection drops, so a link cannot take a working seat away on a bad connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:31:36 -04:00
Eric WagonerandClaude Opus 5.5 7b4080c91f The keeper's door asks for a key only root and the game's user can read, so another service on the host cannot re-seat anyone; the deploy makes the key once and the reseat script sends it from a file descriptor; and a seat link is taken out of the address bar with the browser's own call, since the page reads it before the router starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:27:13 -04:00
Eric WagonerandClaude Opus 5.5 4721b0e97b A seat a link carries is kept only once the server takes it, and never over a seat this browser holds that still opens, so a link someone else sends cannot take a working seat away
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:22:54 -04:00
Eric WagonerandClaude Opus 5.5 ecab471122 The keeper can bring back a seat a browser lost: a new token on the ledger, the old one refused, and a link that carries it in the fragment, minted by <slug>-reseat.sh through a /keeper door Caddy does not proxy; a spectator at a game in play is told both ways back, and the hall asks before forgetting a game in play
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 18:20:36 -04:00
Eric WagonerandClaude Opus 5.5 98e61dc756 The hosting line says what it promises: chipping in unlocks nothing, and nothing is held back without it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-13 09:23:44 -04:00
Eric WagonerandClaude Fable 5.1 60435fef84 The hall keeps the night on the shared host: one job runs every game's rollup and backup and checks in with Sentry once for each
Eight cron monitors, two per game, watched eight scripts that run on the
same box at the same two minutes. hall-nightly.sh now runs the hall's own
rollup and every game's rollup at 00:12, and every game's backup at 07:23,
finding the games by their scripts in /usr/local/bin, and checks in once
for each job to the gamehall project's hall-rollup and hall-backup
monitors. The template's rollup and backup say by their exit status
whether they managed, the skipped backup included, and its deploy leaves
the game's own cron file off a host the hall schedules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 10:07:05 -04:00
Eric WagonerandClaude Fable 5.1 87c2215e5f The hall's cards say who plays on one line and where the game came from on a quieter one, with a word for newcomers under the intro
Each card's players and origin are two lines, the origin a step
smaller. Dip's description is shorter and its players line says "2 to
10, depending on the map". The intro says "start a table" like the list
beneath it, and a line under it points newcomers at Wiz-War. The Waving
Hands picture is re-cropped from the same capture, the grid centered
and the stray edge gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:59:53 -04:00
Eric WagonerandClaude Fable 5.1 7301e98802 The hall's Waving Hands card shows the duel in hands, not letters
The lobby's four-turn grid, captured from the game with each letter
replaced by the game's own hand silhouette, mirrored for the right hand
as the picker draws it. The game itself still writes the grid in letters.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:48:25 -04:00
Eric WagonerandClaude Fable 5.1 c162625d51 The hall's cards name each game's house player and lead with "Play Dip →", Waving Hands shows its gesture grid, and the small text reads at 4.5:1
The intro says how to start before the cards. Each card's metadata line
names the house player, the call to action is "Play [Game] →" with the
game's address beneath, and an empty length line waits, hidden, for real
play data. Waving Hands' card shows the lobby's gesture grid instead of
the share card. "How a table works" says Start a table and how a seat
crosses devices, and the credits run in the cards' order. The faint bone
is gone: the metadata lines and the footer use the dimmed bone, 6.5:1
and 7.2:1 where they were 3.7:1 and 4.0:1, and the ko-fi link no longer
wraps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 20:41:55 -04:00
Eric WagonerandClaude Opus 5.5 c2582a4822 The hall's Dip card shows the board as it is drawn now, real coastlines and each power's lands in its color, cropped so all seven powers fit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
2026-10-11 11:39:32 -04:00
Eric WagonerandClaude Opus 5.5 0456ac39f1 Dip's new tagline in the family, and the writing rules learned from the Dip and Wiz-War passes
A deliberate tagline triplet may stay (Wiz-War's), a button may name its
action and its cost, a game may voice a template line its own way, and
the list of load-bearing words grows: client-matched errors, replay
goldens, stored shares, and bot lines told apart by shape. Bots should
not share scaffolding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-11 11:31:20 -04:00
Eric WagonerandClaude Opus 5.5 bbbe8ec312 Writing for players: the editing passes' lessons, and the template written by them
The conventions gain a list of the habits players read as generated text
(a semicolon in every paragraph, statement-colon-explanation, "The" plus
an abstract noun as a heading, clipped fragments in threes, closing
aphorisms, "X, not Y", the page talking about itself), and of the errors
that come from inherited text: hidden information where there is none,
points where there are none, "both" at a larger table, unchecked claims,
and ledger-stored words reworded. new-game.sh points at the list.

The template follows it: the about link loses 'a labor of love', the
tabs are Origins and Games so far, and the hall, lobby, room, report
slip, table talk, guide and demo board read as Waving Hands' and
Hnefatafl's now do. The landing page and family.json carry the two
games' new taglines and lose their semicolons and the tip jar's aphorism.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-11 10:34:08 -04:00
Eric WagonerandClaude Opus 5.5 f5b7416a9e From Hnefatafl's second credibility pass: traffic.py parses each request once and counts visits one way, the rollup's check-in has one comment, and the hall's tally drops its leftovers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-11 10:18:10 -04:00
Eric WagonerandClaude Opus 5.5 169ec4ad55 Conventions: plain chrome, and how a move is made
From feedback on Waving Hands' BoardGameGeek thread: no label-and-colon
lines or abstract headings around the game; histories read oldest
first; the move and its button stay together and in view; warn before
a wasted move rather than after; let a move be typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 18:30:25 -04:00
Eric WagonerandClaude Opus 5.5 f0d7237c2b The rollup and the backup check in once, when they end, with the run's duration
A start and a finish sent seconds apart can reach Sentry in either order,
and a start that lands second is never closed, so a fast rollup was
reported as a timeout on nights it succeeded. Hnefatafl's monitor did so
nine nights in fifteen. One check-in at the end cannot race; a run that
hangs sends nothing and is marked missed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-10-10 17:44:43 -04:00
Eric WagonerandClaude Opus 5.5 567590464e hall-announce --clear leaves an empty notice rather than no file, so no page's check is a failed request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-10-05 21:55:28 -04:00
Eric WagonerandClaude Opus 5.5 96ec6861d5 A notice the whole hall shows: hall-announce on the host, served by every game's site, shown at the top of each page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-29 13:03:15 -04:00
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
Eric WagonerandClaude Opus 5.5 d10c17da9b The about panel no longer credits its making
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 20:58:53 -04:00
Eric WagonerandClaude Opus 5.5 7207cd9c29 The tally is told as Wiz-War tells it: the game's own story on cards, then the ledgers in detail
A few counts that are the game's story (games finished, how they were
won, hours at the table) stand large on cards in the accent color, the
rest follow in a column of figures, then the fine print: Tally.svelte,
shared through the kit, with each game choosing its counts and words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 20:48:05 -04:00
Eric WagonerandClaude Opus 5.5 813d111bbd The about panel is a dialog of tabs, as Wiz-War's is, not a wall of text
"About this game — a labor of love" opens a dialog with the game, the
tally, more games and a way to send word each on a tab of its own
(About.svelte, shared through the kit), instead of one long fold at the
foot of the hall. Arrow keys walk the tabs; Escape or a click outside
closes it; a link to #about opens it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 20:40:39 -04:00
Eric WagonerandClaude Opus 5.5 ae904aa88c Traffic counts what matters: rooms opened from the site, where links were shared, scanners and monitors as bots
- A room opened with Play now never loads /join: the API's answer to
  POST /api/rooms is counted as rooms opened here.
- Link previews (Slack, Discord, Facebook, iMessage, WhatsApp and the
  rest) are reported as where the link was shared, since the people who
  click them arrive with no referrer. WhatsApp's fetcher is a bot now.
- An address that asks for a scanner's paths, or keeps coming back
  without opening a game, is a bot's all day; so is a browser that loads
  pages at a steady interval from a new address each time, never
  playing. A declared bot is still judged request by request.
- The site's own addresses, sslip.io included, are not referrers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 09:59:36 -04:00
Eric WagonerandClaude Opus 5.5 5f11bec96d Dip joins the family
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 01:29:26 -04:00
Eric WagonerandClaude Opus 5.5 e521a66f5c A socket's own error closes that socket, not the server
A message over the size limit or a malformed frame raised an error on
the socket, which had no listener, and Node ended the process: one
message from anyone could take the server down until systemd restarted
it. The socket is terminated instead. Messages are capped at 1 KB, since the server never reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-27 01:21:44 -04:00
Eric WagonerandClaude Opus 5.5 1dbf5763e1 The hall counts its visitors: a digest, a nightly rollup, and the games it sends people to
gamehall/ops holds the traffic counter, the visitors digest and the
nightly rollup, installed by gamehall/deploy.sh; the gamehall-visitors
skill reads them. The hall now sends its origin as referrer, so a game
can tell who came from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 12:41:55 -04:00
Eric WagonerandClaude Opus 5.5 8ab1e0b794 The hall seats Dip: a card of its own chart, four games two by two, and a new share image
Dip is not in family.json yet, so the games' about panels do not list it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 12:15:35 -04:00
Eric WagonerandClaude Opus 5.5 7268c3f1ae Kestrel's Hall moves into the kit and onto the shared host
The landing page was a static page in the blog, served by the Yunohost
box through an nginx drop-in and an SSOwat rule. It now lives in
gamehall/site beside family.json, is served by Caddy on the shared host
with the games (gamehall/gamehall.caddy), and publishes with
gamehall/deploy.sh. Its share image is its own, not the blog's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 12:11:00 -04:00
Eric WagonerandClaude Opus 5.5 a9c0f68cb1 Conventions: create receives the table options
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-26 00:35:35 -04:00
Eric WagonerandClaude Opus 5.5 6aac3f9068 The space after Back to your game is kept
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-25 22:56:48 -04:00
Eric WagonerandClaude Opus 5.5 7f340698d5 A move names the round it was made for; a stale one is refused
The room counts rounds played and sends the count in its view; a page
sends it back with each move, and the server refuses a move whose count
is behind (a slow connection, a second click, a stale tab), or that has
no count at all (an old page, told to reload). Before, such a move was
taken as the move for the next round. Found in Dip's acceptance run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-25 22:19:43 -04:00
Eric WagonerandClaude Opus 5.5 af5a8518c7 The Slack alert script carries the workspace's Slack integration id
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-25 17:19:23 -04:00
Eric WagonerandClaude Opus 5.5 bc5f93c2fa The kit sets games up on the shared hall host
setup-droplet.sh prepares a host several games share and adds this game
to it: its own Caddy site file, port, user, trees and access log, with
/etc/caddy/Caddyfile only importing sites/*.caddy. setup-server.sh is
folded in. A slug that is already a system group stops the setup with a
clear word. new-game.sh now stamps .py files too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
2026-09-25 16:49:03 -04:00
Eric WagonerandClaude Opus 5.5 cd9d14ec35 The voice: every game speaks in its own world
Conventions gain a section on in-game language: an image with every
fact, the fact exact, unfolding effects told a turn at a time and naming
the turn they end on, our own words rather than an older version's, and
chronicle wording shipped without a rules revision. new-game.sh points
at it, and the demo game's lines follow it. Born in Waving Hands'
atmospheric chronicle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-09-24 23:57:59 -04:00
80 changed files with 3465 additions and 365 deletions

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
---
name: gamehall-visitors
description: Who is visiting Kestrel's Hall, the landing page at gamehall.kestrelsnest.social - the last hour, today, where they came from, how many went on into each game, and the week's trend. Use when Eric asks about hall traffic or visitors.
---
# Kestrel's Hall visitors
One command on the shared host prints the digest:
ssh root@159.203.115.252 /usr/local/bin/gamehall-visitors.sh [YYYY-MM-DD]
Default is today, UTC. It reads Caddy's access log for the hall's own
name (the sslip test address is left out) and prints:
- **last hour** (today only): people and page views, the "right now".
- **today**: people (distinct addresses), page views, bot addresses.
- **referrers** and **campaigns**: the sites that sent people, and the
tags links carry (`?ref=`, utm_source, Facebook's fbclid). Reddit and
some apps send no referrer.
- **went on to**: people each game on this host (Dip, Waving Hands,
Hnefatafl) received with the hall as referrer. The hall sends its
origin as referrer, nothing more. Wiz-War is on its own droplet; its
own digest (/wizwar-visitors) lists gamehall.kestrelsnest.social among
its referrers.
- **last 7 days** from `/var/lib/gamehall/rollup.jsonl`, written nightly
at 00:12 UTC by `gamehall-rollup.sh` (run by `hall-nightly.sh`) for the day before (counts only,
no addresses).
## Read it
Lead with whether anyone is here now and today's people, then where they
came from, then how many went on into a game (the hall's job is to send
people to tables). Give the week's direction from the rollup in one
sentence. Eric's own devices count as people; a handful on a quiet day
may be him.
The source is `gamehall/ops/` in game-kit; `gamehall/deploy.sh` installs
it on every publish.
+208 -13
View File
@@ -10,8 +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 —
a labor of love*.
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*,
which opens the about panel: a dialog of tabs (Origins, Games so far,
More games, Send word; a game may rename the first two in its own world,
as Waving Hands' Duels so far, or add its own, as Dip's 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
@@ -25,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
@@ -37,6 +47,27 @@ README.
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
words good for ten minutes and one use; the hall's *bring a seat from
another device* claims them. The browser that claims holds the seat too.
- **The keeper can bring a seat back**: a player whose browser lost the
seat (Safari's seven days, an app's own browser, cleared data) writes to
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
a new token and prints a link carrying it in the fragment. The old token
opens nothing after. The script answers through the game server's
`/keeper` door, which Caddy does not proxy and which asks for the key in
`/etc/<slug>/keeper.key`, made by the deploy and readable by root and the
game's user alone. A spectator at a game in play
is told the ways back, and the hall asks before forgetting a game in play.
- **The host has a hand on the other seats**: once the game begins, *Seats*
in the host's masthead makes a transfer phrase for another person's seat
(claimed, it gives the seat a new token, as the keeper's link does), and
gives the seat of a player who cannot go on to a bot, which plays it
from the move the seat owes and does not speak in the person's name.
The person's token opens nothing while it stands in, with the reason;
their page goes to the gallery, where a claim box takes the host's
phrase. A rematch holds the person's seat and leaves the stand-in out.
- **A dead browser's socket is closed**: the server pings every socket on
the protocol's own ping, which browsers answer by themselves, and closes
one silent for a whole round, so a crashed tab does not linger in the
gallery's count.
- **Held seats are doubted before they are dropped**: the hall forgets a
seat only on the second refusal in a row from the server. A restart, a
stale answer or the wrong backend answer with ignorance, and ignorance
@@ -66,12 +97,17 @@ README.
- **The family list** in the about panel, *More games in Kestrel's Hall*,
comes from `family.json` and its heading links to the family's landing
page, gamehall.kestrelsnest.social. A new game is added to the kit's
`family.json` and given a card on that page in the blog repo; both are
hand-kept.
`family.json` and given a card on that page (`gamehall/` in the kit);
both are hand-kept.
- **A move names the round it was made for.** The room counts rounds
played (`resolved` in its view), and a page sends that count with each
move; the server refuses a move whose count is behind, so a slow
connection, a second click or a stale tab can never be taken as the
move for the next round. A page without the count is told to reload.
- **The hosting line** follows the family list in the about panel: the
games are free and will stay free, the servers cost a few dollars a
month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says
nothing is unlocked by it and nothing withheld without it, and it
chipping in unlocks nothing and nothing is held back without it, and it
never names a game's rights holder: a tip is for the hosting, not the
game.
@@ -85,22 +121,32 @@ 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
- Pure and deterministic: `create(names, seed, rules)` and
- Pure and deterministic: `create(names, seed, rules, options)` and
`resolve(state, inputs)` are functions of their arguments with a seeded
generator inside the state. Bots are functions of the state too. This is
what lets a ledger be the whole record.
- 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.
@@ -109,14 +155,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
@@ -134,12 +215,17 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
today, every room opened and how far it got. Names seated for the first
time are marked NEW.
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
acted; with a seat, a new token for a player locked out, as a link.
- `<slug>-say.sh ROOM "text"`: a word from the keeper in a table's talk,
signed as the keeper, whether or not the gallery may talk.
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
the rollup's last week, the backup's last word, the box's vitals.
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
no addresses. `<slug>-backup.sh` nightly at 07:23 UTC: rclone to the
shared Space, mirror plus dated snapshots pruned after 90 days. Both check
in with Sentry Crons so a missed night is noticed.
shared Space, mirror plus dated snapshots pruned after 90 days. On the
shared host the hall's `hall-nightly.sh` runs both for every game and
checks in with Sentry Crons once for all, so a missed night is noticed.
- The reports desk (skill `<slug>-reports`): `pull-reports.sh` mirrors the
desk to the Desktop with a digest; `report-reply.sh <ip> <id> <status>
"text"` answers with `resolved`, `by-design` or `open`. A fix goes live
@@ -158,6 +244,110 @@ emoji in the interface; the words do the work. Phone width first: a sticky
bar for the move if the board runs long, lists that shorten, gutters of
16px, no horizontal scroll.
## The voice
Every game speaks in its own world. Wherever the page tells a player what
happened, it gives an image as well as the fact: not "Corwin takes 5 from
a fireball" but "A fireball bursts over Corwin, for 5". The fact stays
exact and whole (the number, the duration, who is affected, the turn a
thing will happen), and the image carries it.
- **The chronicle** is where the voice matters most. Each move, blow,
enchantment, arrival and death gets its own picture. Something that
unfolds over turns (an illness, a siege, a fuse) is told a turn at a
time, names the turn it ends on when it begins, and warns on the last
turn anything can be done about it.
- **The rest of the surround** speaks the same way: the lobby, the guide,
the status lines, the verdict. Plain words for controls and buttons;
the images belong to what happens, not to what you press.
- **Our own words.** An older implementation's prose is a guide to the
tone, never a source to copy.
- **Plain chrome.** Headings, tabs, captions and notes say what is there
in ordinary words. The images live in what happens, not in the labels
around it. The patterns readers take as generated text are listed under
"Writing for players" below.
- **Changing text is not changing rules.** Chronicle wording is not part
of the ledger, so it ships without a rules revision; the deploy gate
compares outcomes, not sentences. A test that pins a phrase should pin
the fact inside it.
## Writing for players
Players have learned the habits of generated text, and once they spot one
they trust the rest of the page less. Every game built from this kit has
needed an editing pass to remove them. Write without them from the start,
and read each new page against this list before it ships.
- **One semicolon is fine. A semicolon in every paragraph is a tell.** Use
two sentences, or join with "and" or "so". Lists of rules may keep their
semicolons.
- **No statement, colon, explanation.** "The table is yours: seat a bot,
invite a friend" becomes "You can seat a bot or invite a friend". The
same goes for a label and a colon at the start of a line ("Shieldwall:
two or more pieces..."), and for a heading that ends in a colon.
- **No headings made of "The" and an abstract noun** ("The game", "The
tally"). Name what is there ("Origins", "Games so far").
- **A button may name its action and its cost** ("Absorb: take 2
instead"). The colon rule is for sentences, not for controls.
- **No clipped fragments in threes** as a habit. "Four towers. One crown.
No second chances." Write one sentence instead. A game may keep one
deliberate triplet as its tagline when the energy is the point, as
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
does. Once is a choice, and a pattern is a tell.
- **No one sentence shape for a whole list.** Thirty chronicle lines that
each run image, colon, consequence read as a template however good the
images are. Let the fact come first in some, in its own sentence in others,
and joined by "and" or "which" in the rest.
- **No closing aphorisms.** A paragraph ends on its last fact, not on a line
meant to sound wise ("This is the whole art", "Nothing is unlocked by it
and nothing is withheld without it").
- **No "X, not Y" flourishes** ("drawn, not borrowed"). Say what it is.
- **No page that talks about itself.** Avoid "This page walks the room", "the
page keeps watch" and "a walk through the page". Describe the game and
what the player does.
- **No spaced em dashes** in the interface, and few anywhere.
- **Nothing copied from another game that is untrue here.** Text carried over
from the kit or a sibling game is the most common source of errors. Look
for hidden information in a game with none, points in a game without
them, a bot's name from another game, and "both" at a table that seats
more than two. Read every inherited line as if it were new.
- **Every claim checked.** A date, a count, "only" or "never" is checked
against the source and the engine. "A thousand years before chess" was
wrong, and so was "a shield is the only defence".
- **Words for any table size.** Use "every player" for what each does on a
turn, and plurals for opponents ("your rivals"), unless the game seats
exactly two.
- **Some words are load-bearing.** Before rewording, grep for the string.
An end-of-game reason written to a ledger line is matched later by exact
text, and rewording one needs a migration. The client may match a server
error by its words. Replay screenshots kept as goldens include captions,
and stored shares carry event text. A bot's lines may be told apart by
their shape, and the examples it gives players must still parse.
- **Bots should not share scaffolding.** When each personality recites the
same clause ("I keep out of X, you keep out of Y, through 1903"), they
stop sounding like people. Give each its own way of saying the terms, and
avoid assistant phrasing ("Certainly", "Happy to help", "I hope you
understand").
The game's own voice (above) is not on this list. The chronicle's images,
the in-world names, the keeper and a game's whimsy are what the editing
passes protect. The template's plain lines are a default. A game may say
the same thing in its own voice ("The gallery listens. The host has not
opened its mouth.") as long as the fact survives.
## The move
- **Every history reads top to bottom**, oldest first, and scrolls to the
newest line: the ledger, the chronicle, table talk. That is the order
people already know from chat.
- **The move and its button stay together and in view.** Whatever the
player has chosen this turn, what it will do, any warning, and the
button that commits it sit in one row that does not scroll away.
- **Warn before, not after.** A move that is legal but wasted is pointed
out while it can still be changed, and left legal.
- **A move can be typed** when the game's moves have short names, with
Enter to commit and Backspace to take one back.
## Things learned the hard way
- Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the
@@ -165,6 +355,11 @@ bar for the move if the board runs long, lists that shorten, gutters of
- `npm install --omit=optional` on the droplet breaks Vite; the server
carries its own small `package.json` and installs only that.
- A dynamic `/join/[code]` route must set `prerender = false`.
- The hall, the rules and the guide set `ssr = true` in their own `+page.ts`,
so view-source, a crawler and a reader without scripts get the text; the
rest stays an empty shell. The adapter's fallback is `shell.html`, not
`index.html`, because a fallback named `index.html` overwrites the
prerendered hall, and Caddy's `try_files` ends in `/shell.html`.
- Two tabs on one origin share a seat; test a second player from
`127.0.0.1` against `localhost` (Vite needs `--host`).
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
+53 -10
View File
@@ -3,17 +3,28 @@
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next
game starts with all of it in place: the hall, room codes and tables, the
Peanut Gallery (and the host's leave for it to talk), table talk, rematches,
seats that travel between devices by phrase, the keeper's bell, append-only
ledgers replayed through a
deterministic engine, the rules revision, the reports desk, the deploy
script with its determinism gate, nightly rollup and backup, Sentry with
Slack alerts, and the visitors, pulse and reports skills for Claude.
seats that travel between devices by phrase and come back by the keeper's
link or the host's phrase when a browser loses them, a bot to stand in
for a player who cannot go on, the keeper's bell, append-only ledgers
replayed through a deterministic engine, the rules revision, the reports
desk, the deploy script with its determinism gate, nightly rollup and
backup, Sentry with Slack alerts, and the visitors, pulse and reports
skills for Claude.
The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
(see `src/lib/game/spec.ts`); the server, the client store, the hall and
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 +37,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
@@ -35,9 +47,9 @@ Run the demo locally from a new project:
src/lib/game/spec.ts the contract a game implements
src/lib/game/index.ts the demo game; replace it
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/lib/components/ Hall, Lobby, TableTalk, ReportSlip, SeatsSlip, 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
@@ -68,6 +80,21 @@ then the round resolves; turn-at-a-time games fit as the case where one
seat needs input at a time. A game of single commands with out-of-turn
interruptions, Wiz-War's shape, does not fit and keeps its own server.
## The hall-wide notice
Every game on the shared host shows the same notice at the top of its
pages when there is one: maintenance, a new game, anything the whole
family should hear. The keeper sets it on the host,
hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC"
hall-announce --clear
which writes /var/lib/hall/announcement.json; each game's site serves it at
`/hall/announcement.json` (a `handle_path` in its Caddy site file), and
`Announcement.svelte`, in the template's layout, shows it with the time in
each reader's own clock. A reader may dismiss one notice; the next still
shows. `hall-announce` is installed by `gamehall/deploy.sh`.
## The family
`family.json` lists every game of Kestrel's Hall: slug, name, address,
@@ -79,6 +106,22 @@ heading linking to the family's landing page. Add a game here, then
deploy the games.
That landing page, https://gamehall.kestrelsnest.social, is one static page
in the blog repo (`~/Sites/blog/static/gamehall/index.html`) with a card
per game, written by hand, and it deploys with the blog. A new game is not
in the family until it has its card there too.
in `gamehall/site/` with a card per game, written by hand. It is served by
Caddy on the shared host beside the games (`gamehall/gamehall.caddy`) and
published with `gamehall/deploy.sh 159.203.115.252`. A new game is not in
the family until it has its card there too.
Its visitors are counted from Caddy's log by `gamehall/ops/` (installed by
the same deploy): `gamehall-visitors.sh` on the host for today, and a
nightly rollup line in /var/lib/gamehall/rollup.jsonl. The skill
`gamehall-visitors` reads them. A new game on the shared host is added to
`GAMES` in `gamehall/ops/traffic.py` so the digest counts the people the
hall sends it.
The hall also keeps the night on the shared host: `hall-nightly.sh rollup`
at 00:12 UTC and `hall-nightly.sh backup` at 07:23 UTC (`gamehall.cron`)
run the hall's own rollup and every game's rollup and backup found in
/usr/local/bin, and check in with Sentry Crons once for each job, monitors
`hall-rollup` and `hall-backup` in the `gamehall` project, from the URLs in
/root/.hall-sentry-cron-rollup and /root/.hall-sentry-cron-backup. A game's
own cron file and check-ins are for a box of its own.
+5 -3
View File
@@ -10,11 +10,13 @@ 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/routes/+layout.ts src/routes/+page.ts src/routes/rules/+page.ts src/routes/guide/+page.ts vite.config.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 src/lib/components/Announcement.svelte
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh deploy/__SLUG__-reseat.sh deploy/__SLUG__-say.sh"
total=0
for t in $SHARED; do
g="$t"
+18 -2
View File
@@ -14,7 +14,7 @@
"slug": "waving-hands",
"name": "Waving Hands",
"url": "https://hands.kestrelsnest.social",
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
"players": "2 to 12",
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
},
@@ -22,9 +22,25 @@
"slug": "hnefatafl",
"name": "Hnefatafl",
"url": "https://tafl.kestrelsnest.social",
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
"pitch": "The Viking board game of a king breaking for safety and a ring of attackers closing in.",
"players": "2",
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
},
{
"slug": "dip",
"name": "Dip",
"url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 10, depending on the map",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
},
{
"slug": "wyrm",
"name": "Wyrm",
"url": "https://wyrm.kestrelsnest.social",
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
"players": "2",
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
}
]
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Publish Kestrel's Hall to the shared host: the page to /opt/gamehall and
# its Caddy site file beside the games'. Usage: gamehall/deploy.sh <host>
set -euo pipefail
HOST="${1:?usage: gamehall/deploy.sh <host>}"
HERE="$(cd "$(dirname "$0")" && pwd)"
rsync -az --delete "$HERE/site/" "root@$HOST:/opt/gamehall/"
scp -q "$HERE/gamehall.caddy" "root@$HOST:/etc/caddy/sites/gamehall.caddy"
rsync -az "$HERE/ops/" "root@$HOST:/tmp/gamehall-ops/"
ssh "root@$HOST" '
set -e
chown -R root:caddy /opt/gamehall
chmod -R g+rX /opt/gamehall
# Validating as root would create the access log as root; Caddy runs as caddy and must own it.
touch /var/lib/caddy/gamehall-access.log
chown caddy:caddy /var/lib/caddy/gamehall-access.log
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1
chown caddy:caddy /var/lib/caddy/gamehall-access.log
systemctl reload caddy
# The counting tools: installed on every publish so the live copies are the repo copies.
install -m 755 /tmp/gamehall-ops/gamehall-visitors.sh /usr/local/bin/gamehall-visitors.sh
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.sh
# The nightly job that runs the rollup and backup of every game here and checks in once for all.
install -m 755 /tmp/gamehall-ops/hall-nightly.sh /usr/local/bin/hall-nightly.sh
install -m 644 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
# The hall-wide notice every game serves at /hall/announcement.json.
install -m 755 /tmp/gamehall-ops/hall-announce.sh /usr/local/bin/hall-announce
install -d -m 755 /var/lib/hall
mkdir -p /var/lib/gamehall /var/log/gamehall
rm -r /tmp/gamehall-ops
'
echo "published."
+31
View File
@@ -0,0 +1,31 @@
# Kestrel's Hall, the front door of the family: a static page, no server.
gamehall.kestrelsnest.social, gamehall.159.203.115.252.sslip.io {
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
handle_path /hall/* {
root * /var/lib/hall
header Cache-Control "no-store"
file_server
}
root * /opt/gamehall
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
# A card followed into a game says it came from the hall, and nothing more.
Referrer-Policy "strict-origin"
# Revalidated on every load, so a deploy shows up at once.
Cache-Control "no-cache"
}
log {
output file /var/lib/caddy/gamehall-access.log {
roll_size 10MiB
roll_keep 30
}
}
file_server
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Nightly rollup: one JSON line per day in /var/lib/gamehall/rollup.jsonl,
# the hall's counts for that day (people, views, bots, referrers, campaigns,
# and the people sent on to each game here). Counts only: no addresses are
# kept, so the trend outlives the self-rotating access log.
# gamehall-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC)
# Run nightly by hall-nightly.sh rollup (/etc/cron.d/gamehall).
set -u
OUT="/var/lib/gamehall/rollup.jsonl"
LOG="/var/log/gamehall/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
mkdir -p "$(dirname "$OUT")" "$(dirname "$LOG")"
python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
import datetime, json, os, sys
sys.path.insert(0, "/usr/local/lib/gamehall")
import traffic
day, out = sys.argv[1], sys.argv[2]
t = traffic.scan(day)
row = dict(day=day, people=len(t["addresses"]), views=t["views"], botAddresses=len(t["botAddresses"]),
scanners=t["scanners"], monitors=t["monitors"], sharedOn=dict(t["shared"]),
referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]), sent=t["sent"],
at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
rows = [json.loads(l) for l in open(out) if l.strip()] if os.path.exists(out) else []
rows = sorted([r for r in rows if r.get("day") != day] + [row], key=lambda r: r["day"])
with open(out + ".tmp", "w") as fh:
for r in rows:
fh.write(json.dumps(r) + "\n")
os.replace(out + ".tmp", out)
print("%s rolled: %d people, %d views" % (day, row["people"], row["views"]))
PY
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# The hall's visitors: who came in the last hour and today, where from, how
# many went on into each game here, and the last week from the rollup.
# Runs ON the shared host: gamehall-visitors.sh [YYYY-MM-DD] (default: today, UTC)
set -u
DAY="${1:-$(date -u +%F)}"
python3 - "$DAY" <<'PY'
import datetime, json, os, sys
sys.path.insert(0, "/usr/local/lib/gamehall")
import traffic
day = sys.argv[1]
now = datetime.datetime.now(datetime.timezone.utc)
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
if day == now.strftime("%F"):
h = traffic.scan(day, since=now.timestamp() - 3600)
print("last hour: %d people, %d page views" % (len(h["addresses"]), h["views"]))
t = traffic.scan(day)
print("today: %d people, %d page views, %d bot addresses (of them %d scanners, %d monitors)" % (len(t["addresses"]), t["views"], len(t["botAddresses"]), t["scanners"], t["monitors"]))
print("shared on: %s" % (dict(t["shared"].most_common()) or "none"))
print("referrers: %s" % (dict(t["referrers"].most_common()) or "none"))
print("campaigns: %s" % (dict(t["campaigns"].most_common()) or "none"))
print("went on to: %s (Wiz-War is on its own host; its digest shows the hall as a referrer)" % (", ".join("%s %d" % kv for kv in t["sent"].items())))
out = "/var/lib/gamehall/rollup.jsonl"
if os.path.exists(out):
rows = [json.loads(l) for l in open(out) if l.strip()][-7:]
if rows:
print("last %d days:" % len(rows))
for r in rows:
sent = ", ".join("%s %d" % kv for kv in r.get("sent", {}).items())
print(" %s %3d people %3d views refs %s on to %s" % (r["day"], r["people"], r["views"], r.get("referrers") or "-", sent or "-"))
PY
+4
View File
@@ -0,0 +1,4 @@
# Kestrel's Hall: the nightly work of every game on this host, and the hall's own count. UTC.
# The times are also SCHEDULE in hall-nightly.sh, which tells Sentry to expect them; change both.
12 0 * * * root /usr/local/bin/hall-nightly.sh rollup
23 7 * * * root /usr/local/bin/hall-nightly.sh backup
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# The notice every game of Kestrel's Hall shows at the top of its pages.
# Each site serves /var/lib/hall/announcement.json at /hall/announcement.json;
# a page checks it on load and every few minutes. Run on the host as root:
# hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC" [--until "2026-09-30 11:15 UTC"]
# hall-announce --show
# hall-announce --clear
# --at is when it happens, told in each reader's own time; --until is when the
# notice stops showing (an hour after --at when not given).
set -euo pipefail
FILE=/var/lib/hall/announcement.json
# No notice is an empty one, {}, never a missing file: a page's check for a missing file
# is a failed request, and the browser reports it in every visitor's console.
case "${1:-}" in
--clear) mkdir -p "$(dirname "$FILE")"; echo '{}' > "$FILE.tmp"; chmod 644 "$FILE.tmp"; mv "$FILE.tmp" "$FILE"; echo "cleared."; exit 0 ;;
--show) if grep -q '"text"' "$FILE" 2>/dev/null; then cat "$FILE"; else echo "no announcement."; fi; exit 0 ;;
''|-*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac
TEXT="$1"; shift
AT=""; UNTIL=""
while [ $# -gt 0 ]; do
case "$1" in
--at) AT="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
--until) UNTIL="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
*) echo "unknown option: $1" >&2; exit 2 ;;
esac
done
[ -n "$AT" ] && [ -z "$UNTIL" ] && UNTIL="$(date -u -d "$AT + 1 hour" +%Y-%m-%dT%H:%M:%SZ)"
mkdir -p "$(dirname "$FILE")"
# A new id each time, so a reader who dismissed the last notice still sees this one.
TEXT="$TEXT" AT="$AT" UNTIL="$UNTIL" ID="$(date -u +%Y%m%dT%H%M%S)" python3 -c '
import json, os
a = {"id": os.environ["ID"], "text": os.environ["TEXT"]}
if os.environ["AT"]: a["at"] = os.environ["AT"]
if os.environ["UNTIL"]: a["until"] = os.environ["UNTIL"]
print(json.dumps(a))' > "$FILE.tmp"
chmod 644 "$FILE.tmp"
mv "$FILE.tmp" "$FILE"
cat "$FILE"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# The hall's nightly work for every game on this host, with one Sentry Crons
# check-in for the lot: the rollups just after midnight, the backups at
# breakfast. Each game's own script does its work and says by its exit
# status whether it managed; the games are found by their scripts in
# /usr/local/bin, so a game deployed here is covered without being listed.
# hall-nightly.sh rollup|backup
# Cron: /etc/cron.d/gamehall, installed by gamehall/deploy.sh. SCHEDULE
# below must match it, because Sentry is told the same shape.
# Sentry Crons check-in: /root/.hall-sentry-cron-<job> holds the check-in
# URL (absent = no check-ins).
set -u
JOB="${1:-}"
case "$JOB" in
rollup) SCHEDULE="12 0 * * *" ;;
backup) SCHEDULE="23 7 * * *" ;;
*) echo "usage: hall-nightly.sh rollup|backup" >&2; exit 2 ;;
esac
LOG="/var/log/gamehall/nightly.log"
CRON_URL=$(cat "/root/.hall-sentry-cron-$JOB" 2>/dev/null || true)
# One check-in, when the run ends, carrying its duration and the schedule
# (Sentry keeps a monitor only once told its shape). A start and a finish
# sent seconds apart can reach Sentry in either order, and a start that lands
# second is never closed. A run that never ends sends nothing, and Sentry
# marks the night missed.
START=$SECONDS
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":30,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
mkdir -p "$(dirname "$LOG")"
# The hall's own count first, then each game's script, in name order.
scripts=()
[ "$JOB" = rollup ] && scripts+=(/usr/local/bin/gamehall-rollup.sh)
for s in /usr/local/bin/*-"$JOB".sh; do
[ "$s" = /usr/local/bin/gamehall-rollup.sh ] || scripts+=("$s")
done
failed=()
for s in "${scripts[@]}"; do
name=$(basename "$s" "-$JOB.sh")
"$s" || failed+=("$name")
done
if [ ${#failed[@]} -eq 0 ]; then
echo "$(date -Is) $JOB: ${#scripts[@]} done" >> "$LOG"
checkin ok
else
echo "$(date -Is) $JOB: ${#scripts[@]} run, failed: ${failed[*]}" >> "$LOG"
checkin error
exit 1
fi
+153
View File
@@ -0,0 +1,153 @@
"""Caddy's access logs for one UTC day: the hall's visitors, people and
bots apart, and the people each game on this host received from the hall.
The digest and the nightly rollup both count through here, so they agree."""
import datetime
import glob
import gzip
import json
import os
import re
from collections import Counter
# The bot, link-preview and scanner patterns are kept in step with template/deploy/traffic.py.
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
# Link previews: the app that fetched the page to show it in a conversation. The people
# who click such a link arrive with no referrer, so this is the best sign of where it went.
UNFURL = [
("Slack", re.compile(r"slack", re.I)),
("Discord", re.compile(r"discord", re.I)),
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
("WhatsApp", re.compile(r"whatsapp", re.I)),
("Telegram", re.compile(r"telegram", re.I)),
("Reddit", re.compile(r"redditbot", re.I)),
("LinkedIn", re.compile(r"linkedinbot", re.I)),
("Mastodon", re.compile(r"mastodon", re.I)),
("Bluesky", re.compile(r"bluesky|cardyb", re.I)),
("X", re.compile(r"twitterbot", re.I)),
]
# Paths nobody asks the hall for but a vulnerability scanner: its address is a bot's all day.
SCANNER = re.compile(r"\.php$|(^|/)(\.env|\.git|\.aws|wp-admin|wp-login|wp-content|wp-includes|wordpress|phpmyadmin|cgi-bin|vendor|actuator|boaform|owa|admin)(/|$)", re.I)
# Requests from one address more than this far apart are separate visits.
VISIT_GAP = 30 * 60
SITE = "gamehall.kestrelsnest.social"
LOGS = "/var/lib/caddy/gamehall-access*.log*"
# The games that share this host, by the name the hall links to. A visitor
# who follows a card arrives with the hall as referrer (the hall sends its
# origin and nothing more). Wiz-War keeps its own host and its own count.
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social", "wyrm": "wyrm.kestrelsnest.social"}
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
def unfurled_by(ua):
"""The app a link preview came from, or None."""
for name, pattern in UNFURL:
if pattern.search(ua):
return name
return None
def _visits(times):
n, last = 0, None
for ts in sorted(times):
if last is None or ts - last > VISIT_GAP:
n += 1
last = ts
return n
def _lines(pattern, t0):
for f in sorted(glob.glob(pattern)):
if os.path.getmtime(f) < t0:
continue
opener = gzip.open if f.endswith(".gz") else open
try:
with opener(f, "rt", errors="ignore") as fh:
for line in fh:
try:
yield json.loads(line)
except ValueError:
continue
except OSError:
continue
def _bounds(day):
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
return d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
def scan(day, since=None):
"""Counts for one UTC day (YYYY-MM-DD), or from `since` (a timestamp)
to the end of it: page views and addresses of people and of bots, where
links to the hall were shared, referrers, campaign tags (once per
address), and the people each game here received from the hall.
A request is a bot's when its browser says so. An address is a bot's for
the day when it asks for a scanner's paths, or loads the hall three
visits or more without ever following a card to a game: a monitor."""
t0, t1 = _bounds(day)
if since is not None:
t0 = max(t0, since)
t = dict(views=0, addresses=set(), botAddresses=set(), shared=Counter(), scanners=0, monitors=0, referrers=Counter(), campaigns=Counter(), sent={})
followed = set()
for slug, host in GAMES.items():
people = set()
for r in _lines("/var/lib/caddy/%s-access*.log*" % slug, t0):
if not (t0 <= r.get("ts", 0) < t1):
continue
q = r.get("request", {})
h = q.get("headers", {})
if SITE not in " ".join(h.get("Referer", [""])) or BOT.search(" ".join(h.get("User-Agent", [""]))):
continue
if q.get("uri", "").split("?")[0].endswith(ASSET):
continue
people.add(q.get("client_ip") or q.get("remote_ip") or "?")
t["sent"][slug] = len(people)
followed |= people
hall = [r for r in _lines(LOGS, t0) if t0 <= r.get("ts", 0) < t1 and r.get("request", {}).get("host", "").split(":")[0] == SITE]
times, scanning = {}, set()
for r in hall:
q = r.get("request", {})
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
if BOT.search(ua) or not ua:
continue
ip = q.get("client_ip") or q.get("remote_ip") or "?"
times.setdefault(ip, []).append(r.get("ts", 0))
if SCANNER.search(q.get("uri", "").split("?")[0]):
scanning.add(ip)
monitors = {ip for ip, ts in times.items() if ip not in scanning and ip not in followed and _visits(ts) >= 3}
posing = scanning | monitors
t["scanners"], t["monitors"] = len(scanning), len(monitors)
seen_campaign = set()
seen_share = set()
for r in hall:
q = r.get("request", {})
h = q.get("headers", {})
ua = " ".join(h.get("User-Agent", [""]))
ip = q.get("client_ip") or q.get("remote_ip") or "?"
uri = q.get("uri", "")
path = uri.split("?")[0]
if ip in posing or BOT.search(ua) or not ua:
t["botAddresses"].add(ip)
service = unfurled_by(ua)
# One share per app and page: a preview is often fetched twice.
if service and (service, path) not in seen_share:
seen_share.add((service, path))
t["shared"][service] += 1
continue
if path not in ("/", "/index.html"):
continue
t["views"] += 1
t["addresses"].add(ip)
ref = " ".join(h.get("Referer", [""]))
if ref and SITE not in ref:
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m and (ip, m.group(1)) not in seen_campaign:
seen_campaign.add((ip, m.group(1)))
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
return t
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#14181a" stroke="#d2a94a" stroke-width="3"/><text x="32" y="44" text-anchor="middle" font-family="Iowan Old Style, Georgia, serif" font-size="36" fill="#e6dfcf">K</text></svg>

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+286
View File
@@ -0,0 +1,286 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kestrel's Hall</title>
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, Dip and Wyrm. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
<meta property="og:type" content="website">
<meta property="og:title" content="Kestrel's Hall">
<meta property="og:description" content="Free board games in the browser. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
<meta property="og:url" content="https://gamehall.kestrelsnest.social/">
<meta property="og:image" content="https://gamehall.kestrelsnest.social/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="favicon.svg">
<style>
:root {
--ink: #14181a;
--panel: #1c2124;
--line: #2c3236;
--bone: #e6dfcf;
--bone-dim: #a9a494;
--gold: #d2a94a;
--gutter: 16px;
}
* { box-sizing: border-box; }
html { background: var(--ink); }
body {
margin: 0;
background: var(--ink);
color: var(--bone);
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
font-size: 18px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--gold); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 var(--gutter); }
header.mast { padding: 56px 0 28px; text-align: center; }
header.mast h1 {
margin: 0;
font-size: clamp(2.4rem, 6vw, 4rem);
font-weight: 400;
letter-spacing: 0.01em;
line-height: 1.05;
}
header.mast p.lede {
margin: 14px auto 0;
max-width: 38em;
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
color: var(--bone-dim);
}
header.mast p.lede b { color: var(--bone); font-weight: 500; }
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
.game {
display: flex; flex-direction: column;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
color: inherit;
text-decoration: none;
}
.game:hover { border-color: var(--gold); text-decoration: none; }
.game:hover h2 { color: var(--gold); }
/* The first game is the one newcomers are pointed at, so it has the whole width: art beside the words. */
.game.first { grid-column: 1 / -1; flex-direction: row; }
.game.first .art { flex: 0 0 55%; aspect-ratio: 2 / 1; }
.game.first .body { justify-content: center; padding: 22px 26px; }
.game.first h2 { font-size: 1.9rem; }
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
.game .art img.tafl { object-position: 50% 50%; }
.game .art img.dip { object-position: 50% 80%; }
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
.game .pitch { margin: 0; color: var(--bone); }
.game .players { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
@media (max-width: 860px) {
.games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
.game.first { flex-direction: column; }
.game.first .art { flex: none; aspect-ratio: 5 / 3; }
.game.first .body { padding: 16px 18px 18px; }
.game.first h2 { font-size: 1.45rem; }
}
.nowrap { white-space: nowrap; }
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
.maze {
width: 100%; height: 100%;
background: #2a2e33;
background-image:
linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px),
radial-gradient(circle at 30% 40%, rgba(255,240,200,.05), transparent 60%);
background-size: 12.5% 20%, 12.5% 20%, 100% 100%;
}
.maze .w { position: absolute; background: #b8a77d; box-shadow: 0 0 0 1px #5c5238, 0 2px 4px rgba(0,0,0,.5); border-radius: 2px; }
.maze .h { height: 3%; }
.maze .v { width: 1.8%; }
.maze .t { position: absolute; width: 12.5%; filter: drop-shadow(0 2px 3px rgba(0,0,0,.6)); }
section { margin-top: 56px; }
section h2 { font-size: 1.6rem; font-weight: 400; margin: 0 0 12px; }
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; gap: 28px; } }
section p { margin: 0 0 12px; color: var(--bone-dim); }
section p b { color: var(--bone); font-weight: 500; }
ul.how { margin: 0; padding-left: 1.2em; color: var(--bone-dim); }
ul.how li { margin-bottom: 8px; }
ul.how b { color: var(--bone); font-weight: 500; }
.keeper { display: flex; gap: 20px; align-items: flex-start; }
.keeper img { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; border: 2px solid var(--line); }
@media (max-width: 480px) { .keeper { flex-direction: column; } }
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-dim); font-size: 0.9rem; }
footer p { margin: 0 0 8px; }
/* The hall-wide notice: its own colors, the same in every game of the hall. */
.hall-announcement { position: sticky; top: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; gap: .8rem; padding: .45rem 2.4rem .45rem 1rem; background: #f2c45a; color: #1d1a12; font: 500 .95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: center; box-shadow: 0 1px 4px #0005; }
.hall-announcement button { position: absolute; right: .5rem; background: none; border: 0; color: inherit; font-size: 1.3rem; line-height: 1; padding: .2rem .5rem; cursor: pointer; }
</style>
</head>
<body>
<div class="wrap">
<header class="mast">
<h1>Kestrel's Hall</h1>
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
</header>
<div class="games">
<a class="game first" href="https://wizwar.kestrelsnest.social">
<div class="art">
<div class="maze">
<div class="w h" style="left:12.5%;top:19%;width:25%"></div>
<div class="w v" style="left:36.2%;top:19%;height:42%"></div>
<div class="w h" style="left:50%;top:39%;width:37.5%"></div>
<div class="w v" style="left:61.2%;top:59%;height:41%"></div>
<div class="w h" style="left:0;top:59%;width:25%"></div>
<div class="w v" style="left:86.2%;top:0;height:40%"></div>
<div class="w h" style="left:75%;top:80%;width:25%"></div>
<img class="t" src="wizard-0.svg" alt="" style="left:0.5%;top:21%">
<img class="t" src="wizard-1.svg" style="left:75.5%;top:42%" alt="">
<img class="t" src="wizard-2.svg" style="left:38%;top:62%" alt="">
<img class="t" src="treasure-0.svg" style="left:88%;top:1%" alt="">
<img class="t" src="skeleton.svg" style="left:25.5%;top:1%" alt="">
<img class="t" src="troll.svg" style="left:50.5%;top:21%" alt="">
<img class="t" src="rock.svg" style="left:13%;top:82%" alt="">
</div>
</div>
<div class="body">
<h2>Wiz-War</h2>
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
<p class="players">2 to 6 players, or solo against the automaton</p>
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
</div>
</a>
<a class="game" href="https://hands.kestrelsnest.social">
<div class="art"><img class="fill" src="hands.png" alt="Four turns of a Waving Hands duel: each row a turn, each hand drawn in its gesture, and a spell named under the gesture that finished it."></div>
<div class="body">
<h2>Waving Hands</h2>
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
<p class="players">2 to 12 players, or solo against a rival wizard</p>
<p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
</div>
</a>
<a class="game" href="https://tafl.kestrelsnest.social">
<div class="art"><img class="fill tafl" src="tafl.svg" alt="A hnefatafl board at the start of a game: the king on the throne, defenders around him, attackers on the four edges."></div>
<div class="body">
<h2>Hnefatafl</h2>
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
<p class="players">2 players, or solo against the housecarl</p>
<p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
</div>
</a>
<a class="game" href="https://dip.kestrelsnest.social">
<div class="art"><img class="fill dip" src="dip.jpg" alt="Dip's map of Europe at the opening: the seven powers' armies and fleets on their home centers, each power's lands in its color."></div>
<div class="body">
<h2>Dip</h2>
<p class="pitch">Seven powers share one map, and none of them has to keep its word. Other maps seat five or ten.</p>
<p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
<p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
</div>
</a>
<a class="game" href="https://wyrm.kestrelsnest.social">
<div class="art"><img class="fill" src="wyrm.jpg" alt="Wyrm's glass board at the opening, seen from a corner: three tinted sheets, the air, the land and the underworld, with Gold's figures on the near ranks and Scarlet's on the far ones."></div>
<div class="body">
<h2>Wyrm</h2>
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
<p class="players">2 players, or solo against the drake</p>
<p class="origin">An unofficial rebuild of Gary Gygax's 1985 Dragonchess, from Dragon Magazine No. 100</p>
</div>
</a>
</div>
<section class="cols">
<div>
<h2>How a table works</h2>
<ul class="how">
<li><b>Start a table</b> and you get a four-letter code and a link. Send it to a friend, and begin once they sit down.</li>
<li><b>No one home?</b> Every game can seat the house, whether an automaton, a housecarl, a rival wizard or an envoy, so you can play right away on your own.</li>
<li><b>Nothing to sign up for.</b> Your browser holds your seat. Come back in the same browser and the game is where you left it. On another device, use Transfer seat in any game to carry your seat over with four words.</li>
<li><b>Watchers welcome.</b> Anyone with the link can sit in the Peanut Gallery and, if the host allows, join the table talk.</li>
<li><b>Something went wrong?</b> Every table has a report button that reaches the keeper with the exact moment attached.</li>
</ul>
</div>
<div>
<h2>The keeper</h2>
<div class="keeper">
<img src="face.jpg" alt="Eric Wagoner">
<div>
<p>I am Eric, and I keep these tables from Athens, Georgia. These are games I have loved at a real table, rebuilt so friends can play them from wherever they are, with the door left open for anyone who wanders in.</p>
<p>Send word to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric">@eric@toots.kestrelsnest.social</a> on Mastodon. I roost at <a href="https://blog.kestrelsnest.social">blog.kestrelsnest.social</a>.</p>
</div>
</div>
<h2 style="margin-top:28px">The hosting</h2>
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.</p>
</div>
</section>
<footer>
<p>Wiz-War is Tom Jolly's design, and the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Hnefatafl belongs to everyone, and is more than a thousand years old. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro; the designers of Dip's Chromatic and Youngstown Redux maps are credited in Dip's How to play. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro.</p>
</footer>
</div>
<script>
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
// host): checked on load, every few minutes, and when the page is looked at
// again; a reader may dismiss one, and the next still shows.
(() => {
const KEY = 'hall:dismissed';
let bar = null;
let current = null;
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
function when(at) {
const t = at ? Date.parse(at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - Date.now()) / 60000);
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
return minutes >= -15 ? ' (now)' : '';
}
function show() {
const a = current;
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
if (!live) { bar?.remove(); bar = null; return; }
if (!bar) {
bar = document.createElement('div');
bar.className = 'hall-announcement';
bar.setAttribute('role', 'status');
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
document.body.prepend(bar);
}
bar.querySelector('span').textContent = a.text + when(a.at);
}
async function check() {
try {
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
const a = res.ok ? await res.json() : null;
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
} catch {}
show();
}
check();
setInterval(check, 5 * 60000);
setInterval(show, 30000);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rock token">
<title>Rock</title><desc>Rock illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#7b8272" stop-opacity=".2"/><stop offset="1" stop-color="#7b8272" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M22 103L29 68 50 42 83 31 111 57 125 97 99 119 50 122Z" fill="#777b75" stroke="#302a29" stroke-width="2.0" /><path d="M29 68L64 74 83 31 50 42Z" fill="#b4b4a0" stroke="#302a29" stroke-width="2.0" /><path d="M64 74L99 119 50 122 22 103Z" fill="#929486" stroke="#302a29" stroke-width="2.0" /><path d="M64 74L108 62 125 97 99 119Z" fill="#535d59" stroke="#302a29" stroke-width="2.0" /><path d="M83 31L108 62 64 74Z" fill="#969b8d" stroke="#302a29" stroke-width="2.0" /><path d="M35 71L47 53 60 46M29 100L44 113M76 78L97 110" fill="none" stroke="#d0c9ac" stroke-width="2" /><path d="M99 72L109 90M104 70L117 96M103 104L115 99M52 117L47 104M57 117L52 103" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#7b8272" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Rock</text>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Skeleton token">
<title>Skeleton</title><desc>Skeleton illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#657275" stop-opacity=".2"/><stop offset="1" stop-color="#657275" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 119L43 88 57 83 55 70 88 69 87 84 107 94 118 119Z" fill="#464b58" stroke="#302a29" stroke-width="2.0" /><path d="M46 91L26 102 21 78M99 93L114 82 117 59" fill="none" stroke="#302a29" stroke-width="9" /><path d="M46 91L26 102 21 78M99 93L114 82 117 59" fill="none" stroke="#e9dfc4" stroke-width="5" /><path d="M72 79V107M56 85Q70 93 87 84M54 94Q72 102 89 92M58 103Q72 110 85 101" fill="none" stroke="#302a29" stroke-width="8" /><path d="M72 79V107M56 85Q70 93 87 84M54 94Q72 102 89 92M58 103Q72 110 85 101" fill="none" stroke="#e9dfc4" stroke-width="4" /><path d="M61 109L53 123M81 108L91 122" fill="none" stroke="#e9dfc4" stroke-width="5" /><path d="M49 52Q45 27 70 24Q100 23 100 50L91 64 88 77 59 77 56 64Z" fill="#ede3c9" stroke="#302a29" stroke-width="2.0" /><path d="M85 29Q103 37 96 52L85 66 85 76 93 64 100 49Q100 32 85 29Z" fill="#b8ae93" stroke="none" stroke-width="2.0" /><ellipse cx="62" cy="51" rx="9" ry="10" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="50" rx="9" ry="10" fill="#302a29" stroke="none" stroke-width="2"/><path d="M50 41L68 48M78 46L96 38" fill="none" stroke="#ede3c9" stroke-width="5" /><path d="M73 58L67 67 78 67Z" fill="#302a29" stroke="#302a29" stroke-width="2.0" /><path d="M62 70H85M65 70V77M73 70V77M81 70V77" fill="none" stroke="#302a29" stroke-width="2" /><path d="M110 69L124 54M118 57L112 17 119 22 124 54Z" fill="#aeb8b5" stroke="#302a29" stroke-width="2.0" /><path d="M113 65L119 71" fill="none" stroke="#d6ad57" stroke-width="4" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#657275" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Skeleton</text>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="artwork svelte-14xsz9c calm" data-set="copenhagen" viewBox="0 0 452 452" role="img" aria-label="Copenhagen board in its opening position"><style>.artwork.svelte-14xsz9c {width:100%;height:auto;display:block;
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */--surface-light: #bd935c;--surface-dark: #79502d;--rim-light: #e0bf84;--attacker-light: #63564a;--defender-light: #fff4d8;--king-light: #ffe4a0;--wood: #a77945;--wood-line: rgba(30, 16, 6, 0.55);--coord: #302216;--square: rgba(255, 240, 210, 0.08);--square-edge: rgba(255, 250, 235, 0.11);--throne: rgba(240, 165, 58, 0.35);--corner: rgba(109, 52, 29, 0.22);--attacker: #2a2320;--attacker-rim: #211a15;--defender: #ece4d0;--defender-rim: #b9ab8b;--king: #f0d58a;--king-rim: #a67c1a;--crown: #6b4a0f;}
/* Tablut: an excavated-object aesthetic, with limestone and aged counters. */.artwork[data-set='tablut'].svelte-14xsz9c {--surface-light: #aaa18b;--surface-dark: #797564;--rim-light: #c4b99e;--attacker-light: #534f43;--defender-light: #ded0ae;--king-light: #e5cc92;--wood: #8a8578;--wood-line: rgba(36, 32, 27, 0.45);--coord: #37362c;--square: rgba(255, 250, 235, 0.05);--square-edge: rgba(255, 250, 235, 0.09);--throne: rgba(120, 100, 70, 0.4);--corner: transparent;--attacker: #3d3530;--attacker-rim: #29281f;--defender: #cfc4ad;--defender-rim: #8c8069;--king: #c9b27a;--king-rim: #7d6636;--crown: #5a4a2a;}.wood.svelte-14xsz9c {stroke:#79502d;stroke-width:1.4;}.stain.svelte-14xsz9c {fill:#fff;pointer-events:none;}.chip.svelte-14xsz9c {fill:rgba(28, 24, 20, 0.42);stroke:rgba(230, 222, 205, 0.18);stroke-width:0.6;pointer-events:none;}.crack.svelte-14xsz9c {fill:none;stroke:rgba(43, 39, 29, 0.48);stroke-width:0.5;stroke-linecap:round;pointer-events:none;}.coord.svelte-14xsz9c {fill:#302216;font-family:'Iowan Old Style', Georgia, serif;font-size:9px;font-weight:600;pointer-events:none;}.square.svelte-14xsz9c {fill:rgba(255, 240, 210, 0.08);stroke:rgba(30, 16, 6, 0.55);stroke-width:0.65;cursor:default;transition:fill 0.3s ease;}.square.edge.svelte-14xsz9c {fill:rgba(255, 250, 235, 0.11);}.square.throne.svelte-14xsz9c {fill:rgba(240, 165, 58, 0.35);}.square.corner.svelte-14xsz9c {fill:rgba(109, 52, 29, 0.22);}.square.last.svelte-14xsz9c {fill:rgba(140, 200, 224, 0.28);}.square.target.svelte-14xsz9c {cursor:pointer;}.square.fallen.svelte-14xsz9c {fill:rgba(224, 101, 92, 0.45);}.square.svelte-14xsz9c:focus-visible {outline:none;stroke:#9cd0dc;stroke-width:2.5;}.hint.svelte-14xsz9c {fill:#9cd0dc;opacity:0.85;}.board-rim.svelte-14xsz9c {fill:none;stroke:#e0bf84;stroke-width:0.7;opacity:0.65;pointer-events:none;}.join.svelte-14xsz9c {fill:none;stroke:#50351f;stroke-width:0.6;opacity:0.18;pointer-events:none;}.surface-wear.svelte-14xsz9c {pointer-events:none;}.crack-light.svelte-14xsz9c {fill:none;stroke:#e1d6bd;stroke-width:0.55;opacity:0.45;}.inlay.svelte-14xsz9c {fill:none;stroke:#e0bf84;stroke-width:0.8;opacity:0.75;pointer-events:none;}.artwork[data-set='tablut'].svelte-14xsz9c .inlay.svelte-14xsz9c {stroke:#494638;opacity:0.45;}.piece.svelte-14xsz9c {--piece-rim: #b9ab8b;transition:transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
opacity 0.3s ease;}.piece.attacker.svelte-14xsz9c {--piece-rim: #211a15;}.piece.king.svelte-14xsz9c {--piece-rim: #a67c1a;}.piece.dying.svelte-14xsz9c {opacity:0;}.piece.svelte-14xsz9c .shadow.svelte-14xsz9c {fill:#16130e;opacity:0.28;}.piece.svelte-14xsz9c .base.svelte-14xsz9c {fill:#b9ab8b;}.piece.svelte-14xsz9c .body.svelte-14xsz9c {stroke:#b9ab8b;stroke-width:0.65;}.bevel-light.svelte-14xsz9c {fill:none;stroke:#fff1cc;stroke-width:0.9;opacity:0.48;stroke-linecap:round;}.bevel-dark.svelte-14xsz9c {fill:none;stroke:#211d16;stroke-width:0.8;opacity:0.3;stroke-linecap:round;}.turned.svelte-14xsz9c {fill:none;stroke:#b9ab8b;stroke-width:0.45;opacity:0.35;}.counter-wear.svelte-14xsz9c {fill:none;stroke:#534b37;stroke-width:0.5;opacity:0.3;stroke-linecap:round;}.attacker.svelte-14xsz9c .counter-wear.svelte-14xsz9c {stroke:#d6c6a3;opacity:0.23;}.artwork[data-set='tablut'].svelte-14xsz9c .bevel-light.svelte-14xsz9c {opacity:0.25;stroke-width:1.1;}.artwork[data-set='tablut'].svelte-14xsz9c .turned.svelte-14xsz9c {display:none;}.pit.svelte-14xsz9c {fill:#544b38;opacity:0.3;}.attacker.svelte-14xsz9c .pit.svelte-14xsz9c {fill:#d6c6a3;opacity:0.2;}.piece.svelte-14xsz9c .crown.svelte-14xsz9c {fill:#6b4a0f;stroke:#6b4a0f;stroke-width:0.7;stroke-linejoin:round;}.piece.svelte-14xsz9c .crown-gleam.svelte-14xsz9c {fill:#f8e5b2;stroke:#f8e5b2;stroke-width:0.9;opacity:0.7;}.piece.selected.svelte-14xsz9c .body.svelte-14xsz9c {stroke:#9cd0dc;stroke-width:2.5;}.calm.svelte-14xsz9c .piece.svelte-14xsz9c,
.calm.svelte-14xsz9c .square.svelte-14xsz9c {transition:none;}.piece.attacker .base{fill:#211a15}.piece.attacker .body,.piece.attacker .turned{stroke:#211a15}.piece.defender .base{fill:#b9ab8b}.piece.defender .body,.piece.defender .turned{stroke:#b9ab8b}.piece.king .base{fill:#a67c1a}.piece.king .body,.piece.king .turned{stroke:#a67c1a}</style><defs><linearGradient id="s1-surface" x2="0.85" y2="1"><stop stop-color="#bd935c"></stop><stop offset="0.48" stop-color="#a77945"></stop><stop offset="1" stop-color="#79502d"></stop></linearGradient><filter id="s1-grain" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.025 0.5" numOctaves="3" seed="7" result="noise"></feTurbulence><feColorMatrix in="noise" type="saturate" values="0"></feColorMatrix><feComponentTransfer result="soft"><feFuncA type="linear" slope="0.13"></feFuncA></feComponentTransfer><feBlend in="SourceGraphic" in2="soft" mode="multiply"></feBlend></filter><filter id="s1-worn" x="-5%" y="-5%" width="110%" height="110%"><feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp"></feTurbulence><feDisplacementMap in="SourceGraphic" in2="warp" scale="1.15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap></filter><filter id="s1-mottle" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11"></feTurbulence><feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0"></feColorMatrix><feComposite in2="SourceGraphic" operator="in"></feComposite></filter><radialGradient id="s1-attacker" cx="32%" cy="24%" r="80%"><stop stop-color="#63564a"></stop><stop offset="0.65" stop-color="#2a2320"></stop><stop offset="1" stop-color="#211a15"></stop></radialGradient><radialGradient id="s1-defender" cx="32%" cy="24%" r="80%"><stop stop-color="#fff4d8"></stop><stop offset="0.65" stop-color="#ece4d0"></stop><stop offset="1" stop-color="#b9ab8b"></stop></radialGradient><radialGradient id="s1-king" cx="32%" cy="24%" r="80%"><stop stop-color="#ffe4a0"></stop><stop offset="0.65" stop-color="#f0d58a"></stop><stop offset="1" stop-color="#a67c1a"></stop></radialGradient></defs><rect class="wood svelte-14xsz9c" x="0.7" y="0.7" width="450.6" height="450.6" rx="6" fill="url(#s1-surface)" filter="url(#s1-grain)"></rect><rect class="board-rim svelte-14xsz9c" x="2" y="2" width="448" height="448" rx="5"></rect><path class="join svelte-14xsz9c" d="M102.8 3 v446"></path><path class="join svelte-14xsz9c" d="M230.4 3 v446"></path><path class="join svelte-14xsz9c" d="M353.6 3 v446"></path><g class="surface-wear svelte-14xsz9c" aria-hidden="true"></g><g><rect class="square svelte-14xsz9c corner" data-i="0" x="6" y="6" width="40" height="40" aria-label="a11"></rect><rect class="square svelte-14xsz9c" data-i="1" x="46" y="6" width="40" height="40" aria-label="b11"></rect><rect class="square svelte-14xsz9c" data-i="2" x="86" y="6" width="40" height="40" aria-label="c11"></rect><rect class="square svelte-14xsz9c" data-i="3" x="126" y="6" width="40" height="40" aria-label="d11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="4" x="166" y="6" width="40" height="40" aria-label="e11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="5" x="206" y="6" width="40" height="40" aria-label="f11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="6" x="246" y="6" width="40" height="40" aria-label="g11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="7" x="286" y="6" width="40" height="40" aria-label="h11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="8" x="326" y="6" width="40" height="40" aria-label="i11"></rect><rect class="square svelte-14xsz9c" data-i="9" x="366" y="6" width="40" height="40" aria-label="j11"></rect><rect class="square svelte-14xsz9c corner" data-i="10" x="406" y="6" width="40" height="40" aria-label="k11"></rect><rect class="square svelte-14xsz9c" data-i="11" x="6" y="46" width="40" height="40" aria-label="a10"></rect><rect class="square svelte-14xsz9c" data-i="12" x="46" y="46" width="40" height="40" aria-label="b10"></rect><rect class="square svelte-14xsz9c" data-i="13" x="86" y="46" width="40" height="40" aria-label="c10"></rect><rect class="square svelte-14xsz9c" data-i="14" x="126" y="46" width="40" height="40" aria-label="d10"></rect><rect class="square svelte-14xsz9c" data-i="15" x="166" y="46" width="40" height="40" aria-label="e10"></rect><rect class="square svelte-14xsz9c" data-i="16" x="206" y="46" width="40" height="40" aria-label="f10, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="17" x="246" y="46" width="40" height="40" aria-label="g10"></rect><rect class="square svelte-14xsz9c" data-i="18" x="286" y="46" width="40" height="40" aria-label="h10"></rect><rect class="square svelte-14xsz9c" data-i="19" x="326" y="46" width="40" height="40" aria-label="i10"></rect><rect class="square svelte-14xsz9c" data-iLine truncated

After

Width:  |  Height:  |  Size: 42 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
<title>Treasure</title><desc>Open treasure chest with gold coins and a matching wizard color and crest.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#28724d" stop-opacity=".2"/><stop offset="1" stop-color="#28724d" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M22 68L99 67 124 82 119 117 42 123 23 108Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><path d="M25 75L43 88 42 123 23 108Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M43 88L123 80 119 117 42 123Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M29 67L23 42Q57 19 103 34L112 59 93 78Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><path d="M30 44Q62 27 99 39L104 55 35 64Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M36 48Q68 35 94 42" fill="none" stroke="#7bbf7d" stroke-width="4" /><path d="M29 66L93 60 112 62 100 76 42 85Z" fill="#48362e" stroke="#302a29" stroke-width="2.0" /><ellipse cx="43" cy="70" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M39 69h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="58" cy="70" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M54 69h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="72" cy="67" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M68 66h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="88" cy="69" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M84 68h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="99" cy="64" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M95 63h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="54" cy="77" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M50 76h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="79" cy="76" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M75 75h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><path d="M21 68L43 84 125 77 124 86 43 94 22 77Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M48 90L55 90 53 121 46 122ZM107 85L114 85 111 118 104 119Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M35 39L42 37 50 60 43 62ZM88 33L95 34 103 57 96 58Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M45 120L119 114" fill="none" stroke="#7bbf7d" stroke-width="2" /><g transform="translate(80 104) scale(.86)"><path d="M-9 3L-13-5-5-2 0-8 5-2 13-5 9 3 0 8Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g><path d="M25.0,21.0 26.7,27.3 33.0,29.0 26.7,30.7 25.0,37.0 23.3,30.7 17.0,29.0 23.3,27.3Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M119.0,42.0 120.5,47.5 126.0,49.0 120.5,50.5 119.0,56.0 117.5,50.5 112.0,49.0 117.5,47.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#28724d" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Treasure</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Troll token">
<title>Troll</title><desc>Troll illustrated in shaded ink on parchment.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#788653" stop-opacity=".2"/><stop offset="1" stop-color="#788653" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M23 120L27 96 43 82 41 61 29 46 47 49Q48 29 73 28Q98 29 100 48L117 44 103 63 103 86 123 102 125 120Z" fill="#788653" stroke="#302a29" stroke-width="2.0" /><path d="M26 109L49 101 53 119M101 90L91 112 114 119Z" fill="#4a5a3e" stroke="none" stroke-width="2.0" /><path d="M44 44L44 27Q69 18 97 28L97 43Z" fill="#60527e" stroke="#302a29" stroke-width="2.0" /><path d="M43 37L98 37" fill="none" stroke="#a294c3" stroke-width="4" /><path d="M46 57Q55 48 66 57M80 55Q89 47 100 54" fill="none" stroke="#302a29" stroke-width="5" /><ellipse cx="59" cy="61" rx="6" ry="4" fill="#f8edcf" stroke="none" stroke-width="2"/><ellipse cx="88" cy="59" rx="5" ry="3" fill="#f8edcf" stroke="none" stroke-width="2"/><ellipse cx="61" cy="61" rx="2" ry="3" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="87" cy="59" rx="1.6" ry="2.5" fill="#302a29" stroke="none" stroke-width="2"/><path d="M70 58Q66 67 60 72Q56 81 69 83Q86 85 84 76L77 61Z" fill="#a4ab6b" stroke="#302a29" stroke-width="2.0" /><path d="M48 82Q72 99 99 80L94 98Q73 114 51 97Z" fill="#443c34" stroke="#302a29" stroke-width="2.0" /><path d="M54 88L57 77 63 92M84 93L91 78 93 88Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M61 106Q72 112 84 104" fill="none" stroke="#adbb7c" stroke-width="3" /><path d="M35 100L31 109M41 103L37 113M102 102L106 110" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#788653" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Troll</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<title>Wizard</title><desc>Dark-skinned woman with silver curls and a green winged helmet.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#28724d" stop-opacity=".2"/><stop offset="1" stop-color="#28724d" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#143f35" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#7bbf7d" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M47 48Q38 52 43 63Q35 67 43 76Q38 85 49 90Q45 101 59 104L66 93 84 95Q98 102 101 90Q110 86 103 76Q110 66 101 61L94 44Z" fill="#d5d1bd" stroke="#302a29" stroke-width="2.0" /><path d="M44 70Q51 64 53 72M46 83Q51 76 56 83M94 76Q99 70 102 77M91 91Q98 86 100 92" fill="none" stroke="#8e9287" stroke-width="1.8" /><path d="M53 49Q55 33 74 32Q93 35 94 52L89 75Q82 91 72 94Q59 89 53 73Z" fill="#815039" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#60392f" stroke="none" stroke-width="2.0" /><path d="M57 54Q64 50 69 54M79 54Q86 49 91 53" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M73 58L70 68Q65 75 73 75L79 72" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M64 81Q74 77 83 79Q75 88 67 84Z" fill="#a75e57" stroke="#302a29" stroke-width="1.3" /><path d="M51 50L44 36 54 24 74 19 93 28 98 45 87 49 74 39Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M51 39L29 33 18 17 46 25 57 37M91 37L111 31 125 15 97 24 86 36Z" fill="#7bbf7d" stroke="#302a29" stroke-width="2.0" /><path d="M26 24L44 30M32 31L47 35M101 29L116 22M95 35L110 30" fill="none" stroke="#f8edcf" stroke-width="2" /><path d="M53 45L65 43 73 49 82 42 95 45 90 53 80 50 73 58 66 51 56 54Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><circle cx="52" cy="76" r="3" fill="#d6ad57" stroke="#302a29" stroke-width="2"/><circle cx="93" cy="75" r="3" fill="#d6ad57" stroke="#302a29" stroke-width="2"/><g transform="translate(72 117) scale(.58)"><path d="M-9 3L-13-5-5-2 0-8 5-2 13-5 9 3 0 8Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#28724d" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<title>Wizard</title><desc>Brown-skinned man with a short dark beard and red tiger hood.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#b83f35" stop-opacity=".2"/><stop offset="1" stop-color="#b83f35" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#b83f35" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#622a2c" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#ee9160" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M46 43L47 76 59 91 89 84 101 58 93 33Z" fill="#34292a" stroke="#302a29" stroke-width="2.0" /><path d="M51 49Q50 32 73 30Q95 31 97 52L93 78 80 94 63 91 52 76Z" fill="#ad714b" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#814b37" stroke="none" stroke-width="2.0" /><path d="M56 53L68 55M80 53L91 51" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M74 57L71 67Q63 73 70 76Q76 78 81 73" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M63 82Q74 87 85 79" fill="none" stroke="#302a29" stroke-width="1.3" /><path d="M48 74L39 46 42 25 53 29Q70 16 87 28L101 24 103 44 95 77 89 54 80 42 61 42 53 55Z" fill="#b83f35" stroke="#302a29" stroke-width="2.0" /><path d="M44 29L53 34 45 42M91 33L99 29 98 41Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M54 34L66 39 56 44M84 33L75 39 88 44M41 48L50 51 45 58M98 48L91 53 95 60" fill="none" stroke="#622a2c" stroke-width="4" /><path d="M61 33L71 29 81 33 73 37Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M54 74L62 83Q73 89 86 80L93 73 91 88 83 98 69 99 59 91Z" fill="#34292a" stroke="#302a29" stroke-width="2.0" /><path d="M64 86Q74 91 84 84" fill="none" stroke="#ad714b" stroke-width="2" /><path d="M62 91L66 95M71 92V97M82 90L79 95" fill="none" stroke="#917468" stroke-width="1.1" opacity=".45"/><g transform="translate(72 117) scale(.58)"><path d="M0-10Q9-2 6 5Q0 12-6 5Q-10 0-4-4L-3 2Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#b83f35" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
<title>Wizard</title><desc>Androgynous tan-skinned wizard with dark hair and a magenta horned crown.</desc>
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#ac3876" stop-opacity=".2"/><stop offset="1" stop-color="#ac3876" stop-opacity=".03"/></radialGradient></defs>
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#ac3876" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#62264f" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#e891b6" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M50 37Q72 22 94 39L98 73 91 95 80 88 57 93 48 76Z" fill="#332932" stroke="#302a29" stroke-width="2.0" /><path d="M53 49Q53 31 74 31Q93 33 95 52L90 76 75 93 62 86 53 71Z" fill="#c18b66" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#945b48" stroke="none" stroke-width="2.0" /><path d="M57 53L69 56M80 55L91 51" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M74 59L71 70 77 72" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M65 81Q75 84 83 79" fill="none" stroke="#302a29" stroke-width="1.3" /><path d="M50 54Q46 39 52 28Q44 27 35 13Q30 41 48 50L53 59 63 48 75 53 84 46 94 57 97 49Q114 35 109 13Q101 28 92 28Q99 42 93 47L80 38 74 46 64 38Z" fill="#ac3876" stroke="#302a29" stroke-width="2.0" /><path d="M49 40Q35 31 35 13M96 40Q108 29 109 13" fill="none" stroke="#e891b6" stroke-width="4" /><path d="M70 32L75 27 80 32 75 40Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M49 63L51 79 59 84M96 61L94 80 86 88" fill="none" stroke="#e891b6" stroke-width="3" /><path d="M58 43L61 47 66 42M55 76L56 88 62 94" fill="none" stroke="#332932" stroke-width="3" /><g transform="translate(72 117) scale(.58)"><path d="M-10-8Q-9 8 0 8Q9 8 10-8L4-3 0-7-4-3Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#ac3876" stroke-width="2"/>
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+10 -7
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
# Stamp out a new game from the template.
# ./new-game.sh <slug> "<Name>" [port] [domain]
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8789 tumbleweed.kestrelsnest.social
# Ports so far: wizwar 8787, waving-hands 8788. Each game is its own droplet,
# so the port only has to be unique on your machine for running two locally.
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
# games share one host (deploy/README.md), so a new game takes the next free port.
set -euo pipefail
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
NAME="${2:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
@@ -20,7 +20,7 @@ find "$DEST" -depth -name '*__SLUG__*' | while read -r f; do
mv "$f" "$(dirname "$f")/$(basename "$f" | sed "s/__SLUG__/$SLUG/g")"
done
# Then the placeholders inside. __IP__ and the Sentry ids stay until they exist.
find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \
find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.py' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \
| xargs -0 sed -i '' -e "s/__SLUG__/$SLUG/g" -e "s/__NAME__/$NAME/g" -e "s/__PORT__/$PORT/g" -e "s/__DOMAIN__/$DOMAIN/g"
cp "$KIT/CONVENTIONS.md" "$DEST/docs-conventions.md" 2>/dev/null || true
mkdir -p "$DEST/docs" && mv "$DEST/docs-conventions.md" "$DEST/docs/conventions.md"
@@ -37,18 +37,21 @@ Next:
and src/lib/components/Board.svelte with its board. Keep the tests green.
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte),
the rules page, the guide, src/app.html's description, and the palette in src/app.css.
Write every line a player reads in the game's own voice: an image with each
fact (docs/conventions.md, "The voice"). Then read every page against
"Writing for players" in the same file, the template's own lines included.
Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180):
app.html already points at them, and the static fallback serves the hall's
HTML for a missing file, so a share card without the image breaks quietly.
3. Create the droplet (deploy/README.md), then replace __IP__ everywhere:
grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/<ip>/g'
3. Add the game to the shared host (deploy/README.md), after replacing __IP__ with its address:
grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/159.203.115.252/g'
4. Create a Sentry project (the Sentry MCP can) and put its DSN and project id
in deploy/$SLUG.service and deploy/sentry-slack-alert.sh.
5. deploy/deploy.sh <ip>
6. Put the game in the family: add it to the kit's family.json (every game's
about panel lists the others from that file at its next deploy), then give
it a card on the family's landing page, Kestrel's Hall, hand-written HTML in
the blog repo at ~/Sites/blog/static/gamehall/index.html, and deploy the blog.
the kit's gamehall/site/index.html, and publish it: gamehall/deploy.sh <host>.
Card art is the game's own drawing; never a publisher's logo or scan.
CONVENTIONS.md in the kit is the house style every game follows.
MSG
+13 -3
View File
@@ -1,4 +1,10 @@
__HOST__
__HOST__ {
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
handle_path /hall/* {
root * /var/lib/hall
header Cache-Control "no-store"
file_server
}
root * /opt/__SLUG__/build
encode gzip zstd
@@ -11,7 +17,7 @@ header {
}
log {
output file /var/lib/caddy/access.log {
output file /var/lib/caddy/__SLUG__-access.log {
roll_size 10MiB
roll_keep 30
}
@@ -37,6 +43,10 @@ handle {
header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/*
header @mutable Cache-Control "no-cache"
try_files {path} {path}.html /index.html
# try_files passes the root over as a directory and would fall to the shell; the hall's
# own page is index.html.
rewrite / /index.html
try_files {path} {path}.html /shell.html
file_server
}
}
+52 -36
View File
@@ -1,22 +1,28 @@
# Deploying __NAME__
Every game, against a housecarl or a friend, is a room on a small Node
process that keeps it as an append-only ledger of moves in
/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy
terminates TLS with automatic certificates, serves the static build from
/opt/__SLUG__/build, and proxies /api and /ws to the game server on port
__PORT__, which runs as the `__SLUG__` user under systemd from
/opt/__SLUG__/app.
Every game is a room on a small Node process that keeps it as an
append-only ledger of moves in /var/lib/__SLUG__/rooms. Production is the
shared Kestrel's Hall host: Caddy terminates TLS with automatic
certificates, serves the static build from /opt/__SLUG__/build, and proxies
/api and /ws to the game server on port __PORT__, which runs as the
`__SLUG__` user under systemd from /opt/__SLUG__/app.
## Sizing
## The shared host
The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
and the game server comfortably: the server is one small Node process capped
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
The games of Kestrel's Hall share one droplet, `hall` (nyc3, s-1vcpu-1gb,
$6 a month, tag `kestrels-hall`, IP 159.203.115.252). Each game keeps its
own Caddy site file in /etc/caddy/sites/<slug>.caddy, its own port,
service user, /opt/<slug> and /var/lib/<slug> trees, access log
(/var/lib/caddy/<slug>-access.log), cron file and ops scripts;
/etc/caddy/Caddyfile only says `import sites/*.caddy`. A game server is one
small Node process capped at 300 MB by its unit file, and a room is a few
kilobytes of ledger, so the host carries several. Each game needs a port
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
dip 8790, wyrm 8791.
## Current production
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__
- Host: __IP__
- URLs: https://__DOMAIN__ (A record at Hover, where kestrelsnest.social's
DNS lives) and https://__SLUG__.__IP__.sslip.io (always works, zero DNS).
- Everyday deploy: `deploy/deploy.sh __IP__`
@@ -24,44 +30,52 @@ at 300 MB by its unit file, and a room is a few kilobytes of ledger.
and the screenshots to ~/Desktop/__SLUG__-reports with a digest;
`deploy/report-reply.sh __IP__ <id> <status> "text"` answers one.
## New droplet from scratch
## Adding this game to the host
1. `doctl compute droplet create __SLUG__ --region nyc3 \
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
--ssh-keys <your-key-ids> --tag-name __SLUG__ --wait`
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
1. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
"bash /root/setup-droplet.sh '__DOMAIN__, __SLUG__.<ip>.sslip.io'"`
(point the A record at the new IP first, or leave the real name out until it is).
3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
4. `deploy/deploy.sh <ip>`
It installs Caddy and Node if the host lacks them, writes this game's
site file, and touches nothing of another game's. Point the A record at
the host first, or leave the real name out until it is; running it again
with the new line is how a name is added later.
2. `deploy/deploy.sh <ip>`
The sslip.io hostname works with no DNS at all. To add a real name, point an
A record at the droplet and add the name to the first line of
/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy
fetches the certificate on first request.
A fresh host, when one is needed: `doctl compute droplet create hall --region nyc3 \
--size s-1vcpu-1gb --image ubuntu-24-04-x64 --ssh-keys <your-key-ids> \
--tag-name kestrels-hall --wait`, then step 1.
## Operations scripts on the droplet
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every
deploy, so the live copies are the repo copies:
`deploy.sh` installs these to /usr/local/bin and the `traffic.py` they share
to /usr/local/lib/__SLUG__ on every deploy, so the live copies are the repo
copies. The cron file goes to /etc/cron.d/__SLUG__ only on a box of the game's
own: on the hall's shared host, `hall-nightly.sh` (in the kit's `gamehall/ops`)
runs every game's rollup and backup from /etc/cron.d/gamehall.
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
- `__SLUG__-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
token for a player locked out of it, as a link to send them.
- `__SLUG__-say.sh ROOM "text"`: a word from the keeper in a table's talk,
signed as the keeper, through the same door; it is heard whether or not
the gallery may talk, so a call that came too late can be answered.
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box).
- `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run
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".
Errors from the game server go to Sentry, project `__SLUG__` in the
locallygrownnet organisation; the DSN is in the unit file. The nightly rollup
and backup check in with Sentry Crons when /root/.__SLUG__-sentry-cron-rollup
and /root/.__SLUG__-sentry-cron hold their check-in URLs (the ingest
URL with the project's cron path and public key), so a missed night is noticed.
locallygrownnet organisation; the DSN is in the unit file. On the shared host
the hall's nightly job checks in with Sentry Crons once for every game's
rollup and once for every backup (monitors `hall-rollup` and `hall-backup` in
the `gamehall` project), and each game's script reports by its exit status.
A game on its own box checks in for itself when /root/.__SLUG__-sentry-cron-rollup
and /root/.__SLUG__-sentry-cron hold check-in URLs (the ingest URL with the
project's cron path and public key); absent, it sends nothing.
To have every new issue, regression and reappearance posted to Slack the way
wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
@@ -80,14 +94,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.
+14 -5
View File
@@ -1,38 +1,46 @@
#!/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__.
# On a host the hall shares, hall-nightly.sh runs this and checks in once
# for every game, so the file is absent and the exit status is the word.
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"
STAMP=$(date +%Y-%m-%d)
mkdir -p "$(dirname "$LOG")"
CRON_URL=$(cat /root/.__SLUG__-sentry-cron 2>/dev/null || true)
# One check-in, when the run ends, carrying its duration: a start and a
# finish sent seconds apart can reach Sentry in either order, and a start
# that lands second is never closed. A run that never ends sends nothing,
# and Sentry marks the night missed.
START=$SECONDS
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
if [ ! -s /root/.config/rclone/rclone.conf ]; then
echo "$(date -Is) skipped: rclone is not configured" >> "$LOG"
exit 0
exit 1
fi
status=0
{
echo "=== $(date -Is) backup start"
checkin in_progress
if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 &&
rclone copy "$SRC" "spaces:$BUCKET/$PREFIX/snapshots/$STAMP" 2>&1; then
checkin ok
else
checkin error
status=1
fi
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \
@@ -45,3 +53,4 @@ fi
done
echo "=== $(date -Is) backup done"
} >> "$LOG" 2>&1
exit $status
+1 -1
View File
@@ -33,4 +33,4 @@ else
fi
echo "--- box"
echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)"
echo "caddy access log: $(du -sh /var/lib/caddy/access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/access*.log* 2>/dev/null | wc -l) files"
echo "caddy access log: $(du -sh /var/lib/caddy/__SLUG__-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/__SLUG__-access*.log* 2>/dev/null | wc -l) files"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# A way back for a player whose browser lost their seat: the seat gets a new
# token, the old one stops working, and the link printed carries the new one.
# Whether the one asking holds that seat is the keeper's call; send the link
# only to them. Runs ON the droplet, through the game server's keeper door.
# __SLUG__-reseat.sh ROOM who sits at the table, and when each person last moved or spoke
# __SLUG__-reseat.sh ROOM SEAT re-seat SEAT and print the link
set -euo pipefail
ROOM="${1:?usage: __SLUG__-reseat.sh ROOM [SEAT]}"
DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}"
[ -s /etc/__SLUG__/keeper.key ] || { echo "No keeper's key at /etc/__SLUG__/keeper.key; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/__SLUG__/keeper.key)")
if [ $# -lt 2 ]; then
curl -s -H "$KEY" "$DOOR" | python3 -c '
import datetime, json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("room %s, %s" % (r["roomId"], "in play" if r["started"] else "not begun"))
for s in r["seats"]:
when = "bot" if s["bot"] else ("a bot stands in; " if s.get("standIn") else "") + ("never moved or spoke" if s["lastActed"] is None else "last acted " + datetime.datetime.fromtimestamp(s["lastActed"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
print(" %-3s %-24s %s" % (s["id"], s["name"], when))
'
else
curl -s -X POST -H "$KEY" -H 'content-type: application/json' -d "{\"seat\":\"$2\"}" "$DOOR/reseat" | python3 -c '
import json, sys
r = json.load(sys.stdin)
if "error" in r: sys.exit(r["error"])
print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"])
print(r["link"])
'
fi
+13 -6
View File
@@ -8,21 +8,27 @@
# Cron: /etc/cron.d/__SLUG__, installed by deploy.sh; SCHEDULE below
# must match it, because the Sentry monitor is told the same shape.
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron-rollup holds the
# check-in URL (absent = no check-ins).
# check-in URL (absent = no check-ins). On a host the hall shares,
# hall-nightly.sh runs this and checks in once for every game, so the
# file is absent and the exit status is the word.
set -u
OUT="/var/lib/__SLUG__/rollup.jsonl"
LOG="/var/log/__SLUG__/rollup.log"
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
SCHEDULE="12 0 * * *"
CRON_URL=$(cat /root/.__SLUG__-sentry-cron-rollup 2>/dev/null || true)
# The check-in carries the schedule: Sentry keeps a monitor only once told its shape.
# One check-in, when the run ends, carrying its duration and the schedule
# (Sentry keeps a monitor only once told its shape). A start and a finish
# sent seconds apart can reach Sentry in either order, and a start that lands
# second is never closed. A run that never ends sends nothing, and Sentry
# marks the night missed.
START=$SECONDS
checkin() {
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
"$CRON_URL" || true
}
mkdir -p "$(dirname "$LOG")"
checkin in_progress
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
import datetime, glob, json, os, shutil, sys
sys.path.insert(0, "/usr/local/lib/__SLUG__")
@@ -58,7 +64,8 @@ for line in open("/proc/meminfo"):
k, v = line.split(":", 1); mem[k] = int(v.split()[0])
load = os.getloadavg()[1]
row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), roomsOpened=t["opened"], sharedOn=dict(t["shared"]),
scanners=t["scanners"], monitors=t["monitors"], referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots),
turns=turns, ledgers=len(glob.glob("/var/lib/__SLUG__/rooms/*.jsonl")),
vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)),
@@ -73,4 +80,4 @@ with open(out + ".tmp", "w") as fh:
os.replace(out + ".tmp", out)
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
PY
then checkin ok; else checkin error; fi
then checkin ok; else checkin error; exit 1; fi
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# A word from the keeper in a table's talk, signed as the keeper, whether or
# not the host lets the gallery talk: the answer to a call that came too late,
# or anything else the table should hear from the keeper. Runs ON the droplet,
# through the game server's keeper door.
# __SLUG__-say.sh ROOM "text"
set -euo pipefail
ROOM="${1:?usage: __SLUG__-say.sh ROOM \"text\"}"
TEXT="${2:?usage: __SLUG__-say.sh ROOM \"text\"}"
DOOR="http://127.0.0.1:__PORT__/keeper/rooms/${ROOM^^}"
[ -s /etc/__SLUG__/keeper.key ] || { echo "No keeper's key at /etc/__SLUG__/keeper.key; a deploy makes it." >&2; exit 1; }
# The key goes as a header read from a file descriptor, never on a command line another user could see.
KEY="@/dev/fd/3"
exec 3< <(printf 'x-keeper-key: %s\n' "$(cat /etc/__SLUG__/keeper.key)")
BODY=$(TEXT="$TEXT" python3 -c 'import json, os; print(json.dumps({"text": os.environ["TEXT"]}))')
CODE=$(curl -s -o /tmp/__SLUG__-say.$$ -w '%{http_code}' -X POST -H "$KEY" -H 'content-type: application/json' -d "$BODY" "$DOOR/say")
if [ "$CODE" = 204 ]; then echo "said at ${ROOM^^}"; else cat /tmp/__SLUG__-say.$$ >&2; echo >&2; rm -f /tmp/__SLUG__-say.$$; exit 1; fi
rm -f /tmp/__SLUG__-say.$$
+11 -5
View File
@@ -26,9 +26,9 @@ print("live sockets: %s | rooms touched in the last hour: %s" % (
# --- traffic: Caddy's access log, people and bots apart ---
t = traffic.scan(day)
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % (
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
print("traffic: %d addresses of people, %d page requests (%d bot addresses, of them %d scanners, %d monitors); hall %d, rules %d, guide %d; rooms opened here %d, room links %s; shared on %s; referrers %s; campaigns %s" % (
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["scanners"], t["monitors"], t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
t["opened"], dict(t["joins"]) or "none", dict(t["shared"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
# --- rooms: every ledger, today's in detail ---
first_seen = {}
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {}
names, seat_changes = {}, []
for x in lines[1:]:
if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"])
@@ -56,12 +56,16 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat":
talk += 1
elif x["t"] == "standIn":
seat_changes.append("%s %s's seat to a bot" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "reseat":
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans:
if h not in first_seen or created < first_seen[h]:
first_seen[h] = created
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
names = sorted({h for r in today for h in r["humans"]})
@@ -78,4 +82,6 @@ for r in today:
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
for change in r["seat_changes"]:
print(" %s" % change)
PY
+31 -3
View File
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
# it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/traffic.py' \
--exclude='*' \
./ "root@$HOST:/opt/__SLUG__/app/"
@@ -42,14 +42,42 @@ ssh "root@$HOST" '
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-reseat.sh /usr/local/bin/__SLUG__-reseat.sh
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-say.sh /usr/local/bin/__SLUG__-say.sh
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
if [ ! -s /etc/__SLUG__/keeper.key ]; then
install -d -m 750 -o root -g __SLUG__ /etc/__SLUG__
(umask 037; openssl rand -hex 32 > /etc/__SLUG__/keeper.key)
chown root:__SLUG__ /etc/__SLUG__/keeper.key
fi
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py
install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
# of every game and checks in with Sentry once for all; a game on a box of
# its own schedules its own.
if [ -f /etc/cron.d/gamehall ]; then rm -f /etc/cron.d/__SLUG__
else install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__; fi
mkdir -p /var/log/__SLUG__
systemctl daemon-reload
systemctl enable --now __SLUG__
systemctl restart __SLUG__
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
ADDR=$(head -1 /etc/caddy/sites/__SLUG__.caddy | sed "s/ {\$//")
sed "s|__HOST__|$ADDR|" /opt/__SLUG__/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/__SLUG__.caddy
caddy validate --config /etc/caddy/Caddyfile >/dev/null
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
chown -R caddy:caddy /var/lib/caddy
systemctl reload caddy
sleep 1
# "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 -1
View File
@@ -19,7 +19,7 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
# The Slack action takes the channel's ID as well as its name; the
# project's error detector is looked up by project id; a workflow is bound
# to its detectors by PUT after creation.
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}"
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
CHANNEL_ID="$SLACK_CHANNEL_ID"
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
+51 -18
View File
@@ -1,36 +1,69 @@
#!/usr/bin/env bash
# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet
# and run ON the droplet as root:
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io'
# The argument is the Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this droplet.
# Caddy serves the built files and terminates TLS; setup-server.sh adds the
# game server behind it.
# Prepare a host for __NAME__. The host may be shared with other games of
# Kestrel's Hall: each keeps its own Caddy site file in /etc/caddy/sites, its
# own port, service user, data directory and access log, and this script
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
# to the host and run ON it as root; safe to run again.
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<host-ip>.sslip.io'
# The argument is this game's Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this host.
set -euo pipefail
HOST="${1:?usage: setup-droplet.sh <hostname>}"
HOST="${1:?usage: setup-droplet.sh <site address line>}"
apt-get update -q
apt-get install -qy curl rsync
# Caddy (auto-HTTPS)
# Caddy (auto-HTTPS), once per host.
if ! command -v caddy >/dev/null; then
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list
apt-get update -q && apt-get install -qy caddy
fi
# Site directory, readable by Caddy.
mkdir -p /opt/__SLUG__/build
chown -R root:caddy /opt/__SLUG__
chmod -R g+rX /opt/__SLUG__
# Node 22, once per host; every game runs its server on it.
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -qy nodejs
fi
# The host's Caddyfile only gathers the games' site files. A Caddyfile that
# is neither Caddy's stock one nor this is a single-game host from before the
# shared layout: stop rather than overwrite it.
ROOT_CADDY='# Kestrel'"'"'s Hall: every game on this host brings its own site file.
import sites/*.caddy'
mkdir -p /etc/caddy/sites
if ! grep -q '^import sites/\*.caddy' /etc/caddy/Caddyfile 2>/dev/null; then
if [ -s /etc/caddy/Caddyfile ] && ! grep -q 'root \* /usr/share/caddy' /etc/caddy/Caddyfile; then
echo "/etc/caddy/Caddyfile holds another site; move it into /etc/caddy/sites/ first" >&2
exit 1
fi
printf '%s\n' "$ROOT_CADDY" > /etc/caddy/Caddyfile
fi
# This game: its user, its directories, its site.
# The service user shares the slug. A slug that is already a system group
# (Ubuntu ships "dip", "games", "audio" and others) cannot also be a user:
# stop and say so, and choose another user name in this script and the unit.
if ! id -u __SLUG__ &>/dev/null; then
if getent group __SLUG__ >/dev/null; then
echo "a system group is already called __SLUG__; pick another service user name" >&2
exit 1
fi
useradd -r -m -d /opt/__SLUG__-home __SLUG__
fi
mkdir -p /opt/__SLUG__/build /opt/__SLUG__/app /var/lib/__SLUG__/rooms /var/log/__SLUG__
chown -R root:caddy /opt/__SLUG__/build
chmod -R g+rX /opt/__SLUG__/build
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/__SLUG__.caddy
# Caddy: the site and the game server's paths, from the template beside this script.
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
# Validation as root touches the access log; Caddy runs as caddy and must own it.
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
chown -R caddy:caddy /var/lib/caddy
systemctl restart caddy
systemctl reload-or-restart caddy
# Firewall: ssh + web only.
ufw allow OpenSSH
@@ -38,4 +71,4 @@ ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "droplet ready: now run deploy/deploy.sh <ip> from your machine"
echo "host ready for __SLUG__: now run deploy/deploy.sh <ip> from your machine"
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bash
# Adds the game server to a droplet that setup-droplet.sh already prepared.
# Copy this script and Caddyfile.tmpl to the droplet and run ON the droplet
# as root; safe to run again. Installs Node 22, creates the service user and
# data directory, and teaches Caddy to hand /api and /ws to the server while
# it keeps serving the static site itself.
set -euo pipefail
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -qy nodejs
fi
id -u __SLUG__ &>/dev/null || useradd -r -m -d /opt/__SLUG__-home __SLUG__
mkdir -p /opt/__SLUG__/app /var/lib/__SLUG__/rooms
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
# Caddy: rewrite the site's configuration from the template, keeping its
# address line, so the game server's paths are routed before the files.
HOST_LINE=$(head -1 /etc/caddy/Caddyfile)
sed "s|__HOST__|$HOST_LINE|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
echo "server prerequisites ready: now run deploy/deploy.sh <ip> from your machine"
+125 -18
View File
@@ -7,22 +7,84 @@ import gzip
import json
import os
import re
from collections import Counter
from collections import Counter, defaultdict
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
# Link previews: the app that fetched the page to show it in a conversation. The people
# who click such a link arrive with no referrer, so this is the best sign of where it went.
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
UNFURL = [
("Slack", re.compile(r"slack", re.I)),
("Discord", re.compile(r"discord", re.I)),
("Google Messages", re.compile(r"googlemessages", re.I)),
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
("WhatsApp", re.compile(r"whatsapp", re.I)),
("Telegram", re.compile(r"telegram", re.I)),
("Reddit", re.compile(r"redditbot", re.I)),
("LinkedIn", re.compile(r"linkedinbot", re.I)),
("Mastodon", re.compile(r"mastodon", re.I)),
("Bluesky", re.compile(r"bluesky|cardyb", re.I)),
("X", re.compile(r"twitterbot", re.I)),
]
# Paths nobody asks a game for but a vulnerability scanner: its address is a bot's all day.
# Matched against whole path segments, never the site's own hashed assets under /_app/.
SCANNER = re.compile(r"\.php$|(^|/)(\.env|\.git|\.aws|wp-admin|wp-login|wp-content|wp-includes|wordpress|phpmyadmin|cgi-bin|vendor|actuator|boaform|owa|admin)(/|$)", re.I)
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
SITE = "__DOMAIN__"
LOGS = "/var/lib/caddy/access*.log*"
# Each game on a shared host keeps its own access log.
LOGS = "/var/lib/caddy/__SLUG__-access*.log*"
# The site's own addresses, for telling a referrer from a move between its pages: its domain, and its sslip.io name.
OWN = re.compile(re.escape(SITE) + r"|\.sslip\.io\b", re.I)
# Requests from one address more than this far apart are separate visits.
VISIT_GAP = 30 * 60
def unfurled_by(ua):
"""The app a link preview came from, or None."""
for name, pattern in UNFURL:
if pattern.search(ua):
return name
return None
def parse(r):
"""The parts of one log line the counts read."""
q = r.get("request", {})
h = q.get("headers", {})
uri = q.get("uri", "")
return dict(
ts=r.get("ts", 0), status=r.get("status", 0), method=q.get("method"), uri=uri, path=uri.split("?")[0],
ip=q.get("client_ip") or q.get("remote_ip") or "?",
ua=" ".join(h.get("User-Agent", [""])), referer=" ".join(h.get("Referer", [""])),
)
def visit_starts(times):
"""When each visit began: requests more than VISIT_GAP apart are separate visits."""
starts, last = [], None
for ts in sorted(times):
if last is None or ts - last > VISIT_GAP:
starts.append(ts)
last = ts
return starts
def scan(day, logs=LOGS):
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
people, the addresses of people and of bots, requests by page, room links
opened, referrers, and campaign tags (each counted once per address)."""
opened, rooms opened from the site itself, where links were shared,
referrers, and campaign tags (each counted once per address).
A request is a bot's when its browser says so. An address is a bot's for
the whole day when it asks for a scanner's paths, or comes back three
visits or more without ever opening or joining a game: a monitor,
not a person. A monitor that takes a new address every visit gives itself
away by keeping time: one browser, page loads at a steady interval, four
visits or more, never a game."""
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter())
seen_campaign = set()
requests = []
for f in sorted(glob.glob(logs)):
if os.path.getmtime(f) < t0:
continue
@@ -34,19 +96,66 @@ def scan(day, logs=LOGS):
r = json.loads(line)
except ValueError:
continue
if not (t0 <= r.get("ts", 0) < t1):
if t0 <= r.get("ts", 0) < t1:
requests.append(parse(r))
except OSError:
continue
q = r.get("request", {})
h = q.get("headers", {})
ua = " ".join(h.get("User-Agent", [""]))
ip = q.get("client_ip") or q.get("remote_ip") or "?"
uri = q.get("uri", "")
path = uri.split("?")[0]
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
for r in requests:
if BOT.search(r["ua"]) or not r["ua"]:
continue
path = r["path"]
a = seen[r["ip"]]
a["times"].append(r["ts"])
if not path.startswith("/_app/") and SCANNER.search(path):
a["scanner"] = True
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
a["played"] = True
def keeps_time(times):
starts = visit_starts(times)
if len(starts) < 4:
return False
gaps = [b - a for a, b in zip(starts, starts[1:])]
typical = sorted(gaps)[len(gaps) // 2]
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
scanners = {ip for ip, a in seen.items() if a["scanner"]}
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and len(visit_starts(a["times"])) >= 3}
by_browser = defaultdict(list)
for r in requests:
if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
by_browser[r["ua"]].append((r["ts"], r["ip"]))
for hits in by_browser.values():
if keeps_time([ts for ts, _ in hits]):
monitors |= {ip for _, ip in hits}
posing = scanners | monitors
t = dict(
requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(),
opened=0, shared=Counter(), scanners=len(scanners), monitors=len(monitors), referrers=Counter(), campaigns=Counter(),
)
seen_campaign = set()
seen_share = set()
for r in requests:
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
t["requests"] += 1
if BOT.search(ua) or not ua:
if ip in posing or BOT.search(ua) or not ua:
t["bot"] += 1
t["botAddresses"].add(ip)
service = unfurled_by(ua)
# One share per app and page: a preview is often fetched twice. Only a
# page counts: an app that already holds a preview fetches its card
# image again to show it, and robots.txt and scripts are not shared.
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
seen_share.add((service, path))
t["shared"][service] += 1
continue
# A room opened from the site itself never passes through /join: count the API's answer.
if r["method"] == "POST" and path == "/api/rooms" and 200 <= r["status"] < 300:
t["opened"] += 1
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
continue
t["human"] += 1
@@ -55,13 +164,11 @@ def scan(day, logs=LOGS):
t["pages"][PAGES[path]] += 1
elif path.startswith("/join/"):
t["joins"][path.split("/")[2].upper()] += 1
ref = " ".join(h.get("Referer", [""]))
if ref and SITE not in ref:
ref = r["referer"]
if ref and not OWN.search(ref):
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
if m and (ip, m.group(1)) not in seen_campaign:
seen_campaign.add((ip, m.group(1)))
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
except OSError:
continue
return t
+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));
+93 -5
View File
@@ -7,13 +7,17 @@
// 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
// POST /api/rooms/:id/rematch {token} a finished table opens the next one
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed
// POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game
// POST /api/transfer {phrase} claim one
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
@@ -23,10 +27,18 @@
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
// a seat in the Peanut Gallery, counted for the table
//
// Caddy serves the static site and proxies /api and /ws here.
// Caddy serves the static site and proxies /api and /ws here. The keeper's
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
// (deploy/__SLUG__-reseat.sh sends it):
//
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
import * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { WebSocketServer, WebSocket } from 'ws';
import { game } from '../../src/lib/game';
@@ -43,6 +55,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
const KEEPER = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/__SLUG__/keeper.key';
const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -72,6 +86,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30;
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
const PING_MS = Number(process.env.PING_MS ?? 30_000);
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS);
@@ -184,9 +200,44 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
throw new RoomError('Not here.', 404);
}
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
function keeperKeyGiven(req: IncomingMessage): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
/**
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
* forwarded is turned away besides, and so is one without the keeper's key.
*/
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
const room = rooms.get(parts[2] ?? '');
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
const { seat, token } = rooms.reseat(room, body.seat);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
}
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
const body = await readBody(req);
rooms.keeperSays(room, body.text);
return send(res, 204, {});
}
throw new RoomError('Not here.', 404);
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
@@ -230,8 +281,16 @@ 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);
rooms.submit(room, token, body.input, body.resolved);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'say') {
@@ -241,7 +300,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') {
@@ -268,6 +327,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, token));
}
if (action === 'phrase') {
return send(res, 200, rooms.hostPhrase(room, token, body.seat));
}
if (action === 'standin') {
rooms.standIn(room, token, body.seat);
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
}
if (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
const seat = token ? rooms.seatOf(room, token) : null;
@@ -292,7 +358,8 @@ const server = createServer((req, res) => {
});
});
const wss = new WebSocketServer({ noServer: true });
// The socket only ever speaks to the browser; anything the browser sends is ignored, so it may send almost nothing.
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy();
@@ -312,9 +379,30 @@ server.on('upgrade', (req, socket, head) => {
};
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe);
answered.set(ws, true);
ws.on('pong', () => answered.set(ws, true));
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate());
});
});
/**
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
* answers by itself, finds it within two rounds.
*/
const answered = new WeakMap<WebSocket, boolean>();
setInterval(() => {
for (const ws of wss.clients) {
if (!answered.get(ws)) {
ws.terminate();
continue;
}
answered.set(ws, false);
ws.ping();
}
}, PING_MS).unref();
server.listen(PORT, HOST, () => {
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+329
View File
@@ -0,0 +1,329 @@
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';
import { SPECTATOR } from '../../src/lib/game/spec';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
/** The bots move once the request that made their move due has been answered: after this tick. */
const answered = () => new Promise<void>((r) => setImmediate(r));
/** 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("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
const { rooms, room } = table();
expect(room.galleryTalk).toBe(false);
rooms.keeperSays(room, 'I came too late for the seat; call me again.');
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
expect(room.chat.at(-1)).toMatchObject(said);
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
});
it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => {
const { rooms, room, bo } = table();
const phrase = rooms.transfer(room, bo).phrase;
const { seat, token } = rooms.reseat(room, 'B');
expect(seat.name).toBe('Bo');
expect(rooms.seatOf(room, token).id).toBe('B');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
// A phrase minted with the old token carries nothing now.
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(rooms.seatOf(reopened, token).id).toBe('B');
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
});
it('re-seats only a person, and says when each person last acted', () => {
const { rooms, room, ann } = table();
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
rooms.submit(room, ann, { pick: 5 }, 0);
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
expect(seats[0].lastActed).toBeGreaterThan(0);
expect(seats[1].lastActed).toBeNull();
});
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', async () => {
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);
await answered();
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);
await answered();
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;
vi.runOnlyPendingTimers();
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);
});
});
});
describe('the host and the other seats', () => {
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0);
rooms.standIn(room, ann, 'B');
// The round waited on Bo alone; the bot plays it as soon as the host has been answered.
await answered();
expect(room.resolved).toBe(1);
expect(rooms.waitingOn(room)).toEqual(['A']);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
rooms.submit(room, ann, { pick: 3 }, 1);
await answered();
expect(room.resolved).toBe(2);
});
it('keeps a move the person made before the bot stood in', () => {
const { rooms, room, ann, bo } = table();
rooms.submit(room, bo, { pick: 2 }, 0);
rooms.standIn(room, ann, 'B');
rooms.submit(room, ann, { pick: 5 }, 0);
expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 });
});
it('is the host\'s call alone, in a game under way, for a person other than the host', () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/);
const lobby = rooms.create('Ann', 2);
rooms.join(lobby.room, 'Bo');
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
});
it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => {
const { rooms, room, ann, bo } = table();
rooms.standIn(room, ann, 'B');
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
expect(claimed.seat).toBe('B');
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(rooms.waitingOn(room)).toContain('B');
const reopened = new Rooms(game, new Store(dir)).get(room.id);
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
});
it("re-seats a person whose browser lost the seat, by the host's phrase, once", () => {
const { rooms, room, ann, bo } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
const claimed = rooms.claim(phrase);
expect(claimed.token).not.toBe(bo);
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
});
it("makes a phrase only for the host, and only for another person's seat", () => {
const { rooms, room, ann, bo } = table();
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/);
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
const withBot = rooms.create('Ann', 2);
const bot = rooms.addBot(withBot.room, withBot.token);
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
});
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => {
const { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
while (!game.over(room.state!)) {
rooms.submit(room, ann, { pick: 5 }, room.resolved);
await answered();
}
const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
});
});
+318 -49
View File
@@ -5,21 +5,31 @@
// 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 {
id: SeatId;
name: string;
/** SHA-256 of the seat's token, hex; empty for a bot. */
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the person's, which opens nothing while it does. */
tokenHash: string;
/** A bot plays this seat: one seated as a bot, or one standing in for a person. */
bot: boolean;
/** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */
standIn?: true;
}
function hashToken(token: string): string {
@@ -48,7 +58,13 @@ 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;
/** Bumped on every change a client might want to see. */
seq: number;
updatedAt: number;
@@ -106,10 +122,14 @@ export class Rooms<State, Input> {
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
/** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>();
/** Phrases waiting to be claimed, with the seat and the token they carry. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string | null; 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>,
@@ -118,7 +138,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.
@@ -166,33 +193,25 @@ export class Rooms<State, Input> {
seatOf(room: Room<State>, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which plays on in your place. Ask them for a transfer phrase to take it back.', 403);
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat;
}
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: [], 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. */
@@ -223,6 +242,11 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
}
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
keeperSays(room: Room<State>, raw: unknown): void {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
}
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
@@ -255,9 +279,10 @@ export class Rooms<State, Input> {
const caller = this.seatOf(room, token);
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
// A person a bot stood in for is still one of the company; the stand-in does not follow.
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
}
@@ -265,6 +290,22 @@ export class Rooms<State, Input> {
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token);
return this.mint(room.id, seat.id, token!);
}
/**
* The host mints a phrase for another person's seat: for a player whose browser lost it, or to take it back
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
*/
hostPhrase(room: Room<State>, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may make a phrase for another seat.', 403);
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No other person sits in that seat.', 404);
return this.mint(room.id, seat.id, null);
}
private mint(roomId: string, seatId: SeatId, token: string | null): { phrase: string; expiresAt: number } {
const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
@@ -273,7 +314,7 @@ export class Rooms<State, Input> {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS;
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
return { phrase, expiresAt };
}
@@ -294,6 +335,10 @@ export class Rooms<State, Input> {
}
this.transfers.delete(phrase);
const room = this.get(t.roomId);
if (t.token === null) {
const { seat, token } = this.reseat(room, t.seatId);
return { roomId: room.id, seat: seat.id, token };
}
const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token };
}
@@ -304,23 +349,89 @@ 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.botsAfterAnswering(room);
}
/** 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 {
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
const inputs: Record<SeatId, unknown> = {};
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
this.commit(room, { t: 'turn', at: Date.now(), inputs });
/**
* The bots' moves, once the request that made them due has been answered.
* A bot that thinks for a second or two would otherwise hold up the reply
* to the person whose move it is answering, so their own move would seem
* to hang and the bot's to come at once. The table is told as each move
* lands, the same as any other.
*/
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
private isTable(room: Room<State>): boolean {
return this.rooms.get(room.id) === room;
}
private botsAfterAnswering(room: Room<State>): void {
setImmediate(() => {
if (!this.isTable(room)) return;
try {
this.driveBots(room);
this.botsSpeak(room, 'phase');
} catch (e) {
// Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands.
console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
}
});
}
/**
* 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, 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);
if (!this.isTable(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);
@@ -348,6 +459,52 @@ export class Rooms<State, Input> {
return { seat, token };
}
/**
* A person's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
* they made; this carries it out. The old token opens nothing after.
*/
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
if (!seat) throw new RoomError('No person sits in that seat.', 404);
const token = newId(18);
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
this.voidPhrases(room, seat.id);
return { seat, token };
}
/**
* The host gives a person's seat to a bot, for a player who cannot go on: the bot plays it from the next move
* the seat owes, and the person's token opens nothing until a phrase from the host gives it back.
*/
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a bot.', 403);
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409);
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
if (!seat) throw new RoomError('No person sits in that seat.', 404);
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
this.voidPhrases(room, seat.id);
// The round may have been waiting on this seat alone. Bots already playing on take the new one in their stride.
if (!this.pacing.has(room.id) && this.waitingOn(room).length === 0) this.resolve(room);
}
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
private voidPhrases(room: Room<State>, seatId: SeatId): void {
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
}
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
const last = new Map<SeatId, number>();
for (const line of this.store.read(room.id)) {
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
}
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
}
/** The host sends a bot away before the game begins. People leave by not coming back. */
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token);
@@ -358,13 +515,37 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'unseat', id: seat.id });
}
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
/** 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.
*/
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved: unknown): void {
const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
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.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 });
@@ -373,15 +554,64 @@ 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);
// A move a person settled before a bot stood in for them stands.
for (const seat of room.seats) {
if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs && this.settled(inputs[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.botsAfterAnswering(room);
}
/** 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) {
// A stand-in plays a person's seat; it does not speak in their name.
if (!seat.bot || seat.standIn || (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.');
// A private line to a seat a bot stands in for waits for the person; the stand-in reads none.
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id && s.bot && !s.standIn));
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 && !s.standIn).map((s) => s.id));
}
private cleanTalk(raw: unknown): string {
@@ -394,28 +624,37 @@ 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,
size: room.size,
me: seatId,
host: room.seats[0]?.id ?? seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
waitingOn: this.waitingOn(room),
turn: room.state ? this.game.turn(room.state) : null,
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,
@@ -475,6 +714,21 @@ export class Rooms<State, Input> {
case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
break;
case 'reseat': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.tokenHash = line.tokenHash;
seat.bot = false;
delete seat.standIn;
break;
}
case 'standIn': {
const seat = room.seats.find((s) => s.id === line.id);
if (!seat) break;
seat.bot = true;
seat.standIn = true;
break;
}
case 'galleryTalk':
room.galleryTalk = line.on;
break;
@@ -488,19 +742,34 @@ 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.keeper ? { keeper: true as const } : {}),
...(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;
}
@@ -509,7 +778,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;
+20 -4
View File
@@ -11,17 +11,33 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
/**
* A person's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
* old one; the old one opens nothing after. A seat a bot was standing in for is the person's again.
*/
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
/** The host gave a person's seat to a bot, which plays it from here on; a reseat gives it back. */
| { t: 'standIn'; at: number; id: SeatId; by: SeatId }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { 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; keeper?: true; to?: SeatId[]; note?: unknown }
/** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
/** A seat is held for the keeper of the site, called to the table. */
+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;
}
+3 -4
View File
@@ -7,21 +7,20 @@
<meta name="theme-color" content="#121a20" />
<title>__NAME__</title>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="description" content="__NAME__: a game between friends, free in the browser." />
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
<link rel="canonical" href="https://__DOMAIN__/" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="__NAME__" />
<meta property="og:title" content="__NAME__" />
<meta property="og:description" content="__NAME__: a game between friends, free in the browser." />
<meta property="og:description" content="__NAME__, a game between friends, free in the browser." />
<meta property="og:url" content="https://__DOMAIN__/" />
<meta property="og:image" content="https://__DOMAIN__/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="__NAME__" />
<meta name="twitter:description" content="__NAME__: a game between friends, free in the browser." />
<meta name="twitter:description" content="__NAME__, a game between friends, free in the browser." />
<meta name="twitter:image" content="https://__DOMAIN__/og.png" />
%sveltekit.head%
</head>
@@ -0,0 +1,126 @@
<script lang="ts">
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
// game's site serves it at /hall/announcement.json. It is checked when the page
// loads, every few minutes, and whenever the page is looked at again. A reader
// may dismiss one announcement; the next one still shows.
import { onMount } from 'svelte';
interface Announcement {
/** Names this announcement, so dismissing it does not hide the next. */
id: string;
text: string;
/** When what it announces happens, told in each reader's own time. */
at?: string;
/** After this it is no longer shown. */
until?: string;
}
/** An empty file, `{}`, is how the keeper takes an announcement down. */
function isAnnouncement(a: unknown): a is Announcement {
if (!a || typeof a !== 'object') return false;
const { id, text } = a as Partial<Announcement>;
return typeof id === 'string' && typeof text === 'string' && text !== '';
}
const SOURCE = '/hall/announcement.json';
const CHECK_MS = 5 * 60_000;
const DISMISSED = 'hall:dismissed';
let current = $state<Announcement | null>(null);
let dismissed = $state<string | null>(null);
let now = $state(Date.now());
async function check() {
try {
const res = await fetch(SOURCE, { cache: 'no-store' });
const a: unknown = res.ok ? await res.json() : null;
current = isAnnouncement(a) ? a : null;
} catch {
// No announcement reachable: none shown.
}
now = Date.now();
}
onMount(() => {
try {
dismissed = localStorage.getItem(DISMISSED);
} catch {
// Without storage a dismissal lasts this visit.
}
void check();
const poll = setInterval(check, CHECK_MS);
const tick = setInterval(() => (now = Date.now()), 30_000);
const looked = () => {
if (document.visibilityState === 'visible') void check();
};
document.addEventListener('visibilitychange', looked);
return () => {
clearInterval(poll);
clearInterval(tick);
document.removeEventListener('visibilitychange', looked);
};
});
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
const when = $derived.by(() => {
const t = shown?.at ? Date.parse(shown.at) : NaN;
if (!Number.isFinite(t)) return '';
const minutes = Math.round((t - now) / 60_000);
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
return minutes >= -15 ? ' (now)' : '';
});
function dismiss() {
// Read before dismissing: once dismissed, nothing is shown.
const id = shown?.id;
if (!id) return;
dismissed = id;
try {
localStorage.setItem(DISMISSED, id);
} catch {
// The dismissal still holds for this visit.
}
}
</script>
{#if shown}
<div class="hall-announcement" role="status">
<span>{shown.text}{when}</span>
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
</div>
{/if}
<style>
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
.hall-announcement {
position: sticky;
top: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0.45rem 2.4rem 0.45rem 1rem;
background: #f2c45a;
color: #1d1a12;
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
text-align: center;
box-shadow: 0 1px 4px #0005;
}
button {
position: absolute;
right: 0.5rem;
background: none;
border: 0;
color: inherit;
font-size: 1.3rem;
line-height: 1;
padding: 0.2rem 0.5rem;
cursor: pointer;
}
</style>
+10 -3
View File
@@ -51,17 +51,24 @@
<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>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
</section>
{:else}
<section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2>
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p>
<h2>Name a number for round {g.rounds.length + 1}</h2>
<p class="muted">Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.</p>
<div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
+122 -83
View File
@@ -4,6 +4,8 @@
// 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 TabbedDialog from './TabbedDialog.svelte';
import TallyBoard from './Tally.svelte';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
@@ -12,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('');
@@ -31,6 +34,28 @@
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
});
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' },
{ n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' }
];
const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
});
/** The other games of Kestrel's Hall, for the about panel. */
let family = $state<FamilyGame[]>([]);
let hall = $state('');
@@ -48,12 +73,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);
@@ -115,16 +142,27 @@
if (c) void goto(`/join/${c}`);
}
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
let forgetting = $state<string | null>(null);
function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId);
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';
if (g === null) return 'unreachable';
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) {
if (g.over.winner === held.seat) return 'you won';
@@ -161,12 +199,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="pitch">One sentence that says what the game is, in its own world.</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> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div>
@@ -193,14 +231,14 @@
{/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" />
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
<p class="muted small">With a code you can take a seat if one is free, or watch from the Peanut Gallery.</p>
<form class="joinform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span>
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
@@ -214,18 +252,34 @@
<p class="caption">A picture of the game goes here.</p>
</div>
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#each seats as held (held.roomId)}
{#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>
{#if forgetting === held.roomId}
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
{:else}
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div>
{/each}
{/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 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)}
@@ -260,32 +314,23 @@
</div>
{/if}
<details class="about" id="about" bind:open={aboutOpen}>
<summary>about this game</summary>
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
{#snippet aboutGame()}
<div class="about-body">
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
<h3>Send word</h3>
<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>
{#if tally}
<h3>The tally</h3>
<dl class="tally">
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'game' : 'games'} played to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd>
<dt>{tally.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
<dt>{tally.turnsPlayed}</dt><dd>turns recorded; {tally.longestGameTurns} in the longest game</dd>
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between moves</dd>
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'game' : 'games'} with a bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk</dd>
{#each Object.entries(tally.byGame) as [label, n] (label)}
<dt>{n}</dt><dd>{label}</dd>
{/each}
</dl>
<p class="muted small">
{#if tally.firstGameAt}The first game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.
</p>
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</p>
</div>
{/snippet}
{#snippet aboutTally()}
{#if told}
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
{:else}
<p class="muted">Counting…</p>
{/if}
{#if family.length}
{/snippet}
{#snippet aboutFamily()}
<div class="about-body">{#if family.length}
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
<ul class="family">
{#each family as g (g.slug)}
@@ -294,12 +339,29 @@
</ul>
{/if}
<p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
</p>
<p class="made">__NAME__ is its designer's. This page was made by Eric and a very enthusiastic AI, 2026.</p>
<p class="made">__NAME__ is its designer's.</p>
</div>
</details>
{/snippet}
{#snippet aboutWord()}
<div class="about-body">
<p>If a rule reads wrong, something breaks, or you have a game 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}
<TabbedDialog
address="about"
bind:open={aboutOpen}
title="About __NAME__"
label="About this game"
tabs={[
{ id: 'game', label: 'Origins', body: aboutGame },
{ id: 'tally', label: 'Games so far', body: aboutTally },
{ id: 'family', label: 'More games', body: aboutFamily },
{ id: 'word', label: 'Send word', body: aboutWord }
]}
/>
</section>
<style>
@@ -312,14 +374,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' 'about';
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' 'about about';
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
}
.lid.returning {
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
}
}
@@ -439,6 +508,10 @@
padding-top: 0.5rem;
}
.ledger.past {
grid-area: past;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
@@ -457,6 +530,11 @@
background: rgba(240, 165, 58, 0.08);
}
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
@@ -544,19 +622,7 @@
min-width: 0;
}
/* about */
.about {
grid-area: about;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--bone-dim);
}
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
.about-body {
max-width: 44em;
color: var(--bone-dim);
@@ -567,17 +633,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);
@@ -610,20 +665,4 @@
margin: 0.2rem 0 0;
max-width: 22em;
}
.tally {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.25rem 0.9rem;
margin: 0.4rem 0 0.6rem;
}
.tally dt {
text-align: right;
font-weight: 500;
}
.tally dd {
margin: 0;
}
</style>
+100 -7
View File
@@ -1,12 +1,40 @@
<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();
const v = $derived(room.view);
/** The host asked to begin while a seat is still held for someone on their way. */
let beginAsked = $state(false);
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 server with no KEEPER set calls its keeper "the keeper", which reads badly beside the title. */
const named = $derived(v.keeper.toLowerCase() !== 'the keeper');
// 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);
@@ -35,7 +63,7 @@
{#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
<p>The table is full, so you watch from the Peanut Gallery. The game begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {v.roomId}</p>
@@ -60,15 +88,41 @@
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
</ul>
{#if v.rematchOf}
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
<p class="muted">This is a rematch of table {v.rematchOf}, with 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, and 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}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
{#if room.isHost && beginAsked && v.expected.length > 0}
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
<button type="button" class="commit small" onclick={() => room.begin()}>Begin without them</button>
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
{:else if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else}
<span class="muted">{hostName} will begin when the table is ready.</span>
@@ -82,11 +136,11 @@
{/if}
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper">
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {named ? `${v.keeper}, the keeper` : 'the keeper'}</button>
<p class="muted small note">{named ? v.keeper : 'The keeper'} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {named ? v.keeper : 'the keeper'} lives.{/if} The seat is held either way.</p>
</div>
{:else if v.challenge}
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a bot, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.</p>
<p class="muted small note">{named ? v.keeper : 'The keeper'} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
{/if}
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
@@ -170,6 +224,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,12 +54,14 @@
}
</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}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the round. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you. The keeper will look into it.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
@@ -74,7 +76,7 @@
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
<p class="muted small note">The room code and the turn are sent with it, so the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -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,154 @@
<script lang="ts">
// The host's hand on the other seats during a game: a transfer phrase for a
// player whose browser lost their seat, and a bot to play the seat of one
// who cannot go on, until a phrase gives it back.
import type { SeatId } from '$lib/game/spec';
import type { Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
let asked = $state<SeatId | null>(null);
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
let error = $state('');
function close() {
open = false;
asked = null;
error = '';
}
async function makePhrase(seat: SeatId, name: string) {
error = '';
try {
const made = await room.hostPhrase(seat);
phrase = { name, ...made };
setTimeout(() => {
if (phrase?.phrase === made.phrase) phrase = null;
}, Math.max(0, made.expiresAt - Date.now()));
} catch (e) {
error = e instanceof Error ? e.message : 'The phrase could not be made.';
}
}
async function standIn(seat: SeatId) {
asked = null;
if (!(await room.standIn(seat))) error = room.error;
}
function phraseTitle(name: string, standIn: boolean): string {
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
}
</script>
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<div class="slip" role="dialog" aria-labelledby="seats-heading">
<h2 id="seats-heading">The other seats</h2>
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p>
{#if people.length === 0}
<p class="muted">Nobody else at this table is a person.</p>
{/if}
<ul>
{#each people as s (s.id)}
<li>
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span>
<span class="ways">
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
{#if !s.standIn && !room.view.over}
{#if asked === s.id}
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
{:else}
<button type="button" class="quiet" title="A bot plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
{/if}
{/if}
</span>
</li>
{/each}
</ul>
{#if phrase}
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this table: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
{/if}
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
</div>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
grid-template-rows: minmax(0, 1fr);
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 34rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
ul {
list-style: none;
padding: 0;
margin: 0.8rem 0 0;
}
li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.4rem 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--rule);
}
.note {
margin-left: 0.5em;
font-style: italic;
font-size: 0.9em;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.made {
margin-top: 0.8rem;
}
.phrase {
letter-spacing: 0.06em;
user-select: all;
}
.warning {
margin-top: 0.5rem;
}
.actions {
margin-top: 0.9rem;
}
</style>
@@ -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>
+9 -2
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.keeper ? `${line.name}, the keeper` : 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}
@@ -57,7 +60,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form>
{:else if room.spectating}
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
<p class="muted small note">The host has not let the gallery talk at this table.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
@@ -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>
+94
View File
@@ -0,0 +1,94 @@
<script lang="ts" module>
/** One count told: the number, and what it counts. */
export interface TallyLine {
n: number | string;
label: string;
}
</script>
<script lang="ts">
// 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 }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title: string } =
$props();
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
</script>
<h3>{title}</h3>
<div class="stories">
{#each headline as s (s.label)}
<div class="story"><span class="big">{shown(s.n)}</span><span>{s.label}</span></div>
{/each}
</div>
{#if details.length}
<h3>The ledgers in detail</h3>
<dl class="ledger">
{#each details as d (d.label)}
<dt>{shown(d.n)}</dt><dd>{d.label}</dd>
{/each}
</dl>
{/if}
{#if note}<p class="note">{note}</p>{/if}
<style>
h3 {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bone);
border-bottom: 1px solid var(--rule-strong);
padding-bottom: 0.25rem;
margin: 0.2rem 0 0.7rem;
}
.stories {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
gap: 0.7rem;
margin: 0 0 1.2rem;
}
.story {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.2rem;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.7rem 0.5rem 0.6rem;
font-size: 0.9rem;
color: var(--bone-dim);
}
.big {
font-size: 1.9rem;
font-weight: 700;
line-height: 1.05;
color: var(--ember);
font-variant-numeric: tabular-nums;
}
.ledger {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem 0.8rem;
margin: 0 0 1rem;
}
.ledger dt {
font-weight: 700;
color: var(--ember);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.ledger dd {
margin: 0;
color: var(--bone-dim);
}
.note {
font-size: 0.85rem;
font-style: italic;
color: var(--bone-faint);
}
</style>
+7 -5
View File
@@ -1,7 +1,7 @@
// The other games of Kestrel's Hall, from the family list the kit keeps
// (family.json, one copy shipped with every game), so a player who liked one
// table can find the next, and the hall's front door. This game is left out
// of its own list.
// (family.json, one copy shipped with every game): the hall's front door, and
// the next table for a player who liked this one. This game is left out of its
// own list.
export interface FamilyGame {
slug: string;
@@ -18,13 +18,15 @@ export interface Family {
games: FamilyGame[];
}
const NONE: Family = { hall: '', games: [] };
export async function loadFamily(self: string): Promise<Family> {
try {
const res = await fetch('/family.json', { cache: 'no-cache' });
if (!res.ok) return { hall: '', games: [] };
if (!res.ok) return NONE;
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
} catch {
return { hall: '', games: [] };
return NONE;
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
if (scorer) state.players[scorer].points += 1;
state.rounds.push({ picks, scorer });
if (scorer && state.players[scorer].points >= TARGET) {
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` };
state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
}
return state;
}
+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);
}
+13 -2
View File
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
}
}
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
export function seatInLink(hash: string): HeldSeat | null {
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
return m ? { seat: m[1], token: m[2] } : null;
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
@@ -131,8 +137,10 @@ export function makeApi<State, Input>() {
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
/** 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) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
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 }),
@@ -140,6 +148,9 @@ export function makeApi<State, Input>() {
rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
/** The host's phrase for another person's seat; claimed, it re-seats them. */
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
+34 -6
View File
@@ -5,7 +5,7 @@
import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import type { RoomView } from './view';
@@ -44,6 +44,8 @@ export class Room {
error = $state('');
/** A move is on its way to the server. */
sending = $state(false);
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
lost = $state('');
constructor(view: RoomView<State>, token: string) {
this.view = view;
@@ -83,7 +85,10 @@ export class Room {
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (this.error = e.message));
.catch((e: Error) => {
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
else this.error = e.message;
});
};
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
}
@@ -112,9 +117,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. */
@@ -145,10 +161,20 @@ export class Room {
return api.transfer(this.roomId, this.token);
}
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
return api.hostPhrase(this.roomId, this.token, seat);
}
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
standIn(seat: SeatId): Promise<boolean> {
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.');
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
return await this.act(() => api.turn(this.roomId, this.token, input), 'The move did not reach the server.');
return await this.act(() => api.turn(this.roomId, this.token, input, this.view.resolved), 'The move did not reach the server.');
} finally {
this.sending = false;
}
@@ -165,7 +191,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);
}
+24 -1
View File
@@ -9,6 +9,14 @@ export interface ChatLine {
text: string;
at: number;
name?: string;
/** Said by the keeper of the site, through the keeper's door. */
keeper?: true;
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
to?: SeatId[];
/** The round the line was said in, counted from one; absent before the game begins. */
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> {
@@ -20,14 +28,29 @@ export interface RoomView<State> {
me: SeatId;
/** The player who opened the table and may begin the game. */
host: SeatId;
seats: { id: SeatId; name: string; bot: boolean }[];
/** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
/** Seats that still have to move before the round resolves. */
waitingOn: SeatId[];
/** Rounds played so far; a move is sent with the count it was made against. */
resolved: number;
/** The round being written, from one; null before the game begins. */
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. */
+2
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import '../app.css';
import Announcement from '$lib/components/Announcement.svelte';
let { children } = $props();
</script>
<Announcement />
{@render children()}
+3 -2
View File
@@ -1,4 +1,5 @@
// Everything happens in the browser against the game server, so nothing is rendered on the
// server. Each route is prerendered as an empty shell that the client fills in.
// Everything happens in the browser against the game server, so by default a route is
// prerendered as an empty shell that the client fills in. The hall, the rules and the guide
// turn rendering on in their own +page.ts, since their text is the same for everyone.
export const ssr = false;
export const prerender = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+7 -7
View File
@@ -16,9 +16,9 @@
<main class="reading">
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
<h1>How to play</h1>
<p class="lede">Two sentences that set the scene and say what this page walks through.</p>
<p class="lede">Two sentences that set the scene: who the player is, and what they are trying to do.</p>
<nav aria-label="Sections">
<a href="#hall">The hall</a>
<a href="#table">The table</a>
@@ -30,12 +30,12 @@
<section id="hall">
<h2>The hall</h2>
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the house's construct. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the bot and begins. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
</section>
<section id="table">
<h2>The table</h2>
<p>A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
<p>A table fills as players open the link. Whoever laid it is the host. The host may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
</section>
<section id="board">
@@ -45,14 +45,14 @@
<section id="gallery">
<h2>The Peanut Gallery and table talk</h2>
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
<p>A seat can follow you to another device: <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch: a new table with the same company, seats held until everyone is back. And while a table is laid, you may challenge the keeper of this site to a seat; the note beside the button says what hour it is where they live.</p>
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a bot there, and the same words bring them back to it. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p>
</section>
<section id="first">
<h2>A first game</h2>
<p>The advice you would give a friend across the table for their first few turns.</p>
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or the page misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>; the hall's <em>about this game</em> has the other ways to reach the keeper.</p>
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
</section>
</main>
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+92 -8
View File
@@ -4,9 +4,10 @@
import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte';
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -21,20 +22,53 @@
/** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false);
let seatsOpen = $state(false);
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
let seatLost = $state('');
let claimPhrase = $state('');
let claiming = $state(false);
const link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false);
// Return to a held seat, or take a place in the gallery and offer one.
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
// still opens, so a link someone else sends cannot take a working seat away.
$effect(() => {
const id = roomId;
room = null;
error = '';
const sent = seatInLink(location.hash);
// The browser's own call: this runs as the page first loads, before the router could take one.
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
const held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id);
joining = !held;
const take = (s: HeldSeat) =>
Room.resume(id, s).then((r) => {
rememberSeat(id, s);
trustSeat(id);
return r;
});
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
const watchRefused = (e: Error) => {
seatLost = e.message;
return Room.watch(id);
};
seatLost = '';
const arrive = held
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
: sent
? take(sent).catch(() => Room.watch(id))
: Room.watch(id);
joining = !held && !sent;
if (!held) name = playerName();
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
arrive
.then((r) => {
room = r;
if (r.spectating) joining = true;
})
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
@@ -44,6 +78,36 @@
return r.connect();
});
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery.
$effect(() => {
const r = room;
if (!r?.lost) return;
seatLost = r.lost;
Room.watch(r.roomId)
.then((w) => (room = w))
.catch((e: Error) => (error = e.message));
});
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
async function claim(e: SubmitEvent) {
e.preventDefault();
claiming = true;
error = '';
try {
const got = await api.claim(claimPhrase.trim());
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
trustSeat(got.roomId);
claimPhrase = '';
seatLost = '';
if (got.roomId === roomId) room = await Room.resume(roomId, { seat: got.seat, token: got.token });
else await goto(`/join/${got.roomId}`);
} catch (err) {
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
} finally {
claiming = false;
}
}
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
@@ -119,6 +183,9 @@
{#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if}
{#if room.isHost && room.started}
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
{/if}
{/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
@@ -133,6 +200,7 @@
<Preferences bind:open={prefsOpen} />
{#if room}
<ReportSlip {room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
{/if}
{#if error}
@@ -150,7 +218,7 @@
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
{:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
<span>the same company, at a new table; seats are held for everyone here.</span>
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
{/if}
</p>
{/if}
@@ -159,7 +227,7 @@
<section class="seat">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
<p>A player has opened this table and is gathering company. Choose a name and sit down. The game begins when the host says.</p>
<form class="sit" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
@@ -171,6 +239,22 @@
</section>
{/if}
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
<p class="notice muted">A bot plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
{/if}
{#if watching && room?.started && !room.view.over}
<div class="notice muted">
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
{:else}{#if room.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
<p>Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
<form class="sit" onsubmit={claim}>
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
</form>
</div>
{/if}
{#if room && !room.started}
<Lobby {room} {link} {copied} {copyLink} />
{:else if room}
+2 -2
View File
@@ -15,7 +15,7 @@
<main class="reading">
<header>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
<h1>The rules</h1>
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p>
</header>
@@ -35,7 +35,7 @@
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
</section>
<p class="word">If this page reads a rule differently from the original text, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
<p class="word">If this page reads a rule differently from the original text, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
</main>
<style>
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+4 -2
View File
@@ -10,7 +10,9 @@ export default defineConfig({
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
// A static build: Caddy serves files and proxies /api and /ws to the game server.
adapter: adapter({ fallback: 'index.html' })
// The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
// hall's own prerendered page is not overwritten by it.
adapter: adapter({ fallback: 'shell.html' })
})
],
server: {
@@ -21,6 +23,6 @@ export default defineConfig({
}
},
test: {
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
}
});