Compare commits

..
24 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
46 changed files with 1064 additions and 154 deletions

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ name (the sslip test address is left out) and prints:
own digest (/wizwar-visitors) lists gamehall.kestrelsnest.social among own digest (/wizwar-visitors) lists gamehall.kestrelsnest.social among
its referrers. its referrers.
- **last 7 days** from `/var/lib/gamehall/rollup.jsonl`, written nightly - **last 7 days** from `/var/lib/gamehall/rollup.jsonl`, written nightly
at 00:05 UTC by `gamehall-rollup.sh` for the day before (counts only, at 00:12 UTC by `gamehall-rollup.sh` (run by `hall-nightly.sh`) for the day before (counts only,
no addresses). no addresses).
## Read it ## Read it
+105 -12
View File
@@ -11,10 +11,10 @@ README.
*Open a table*, *or join one* with a four-letter code, then *your games* *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 (the ledger of tables this browser holds a seat at, with whose move it
is and unread talk; for someone coming back it comes first, and finished 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 — games wait further down), *your reports to the keeper*, and *about this game*,
a labor of love*, which opens the about panel: a dialog of tabs (The which opens the about panel: a dialog of tabs (Origins, Games so far,
game, The tally, More games, Send word; a game may add its own, as Dip's More games, Send word; a game may rename the first two in its own world,
Playing here), `TabbedDialog.svelte`, with each game's words in its hall. 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 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 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`. Dip does. Its strip of tabs is `Tabs.svelte`.
@@ -47,6 +47,27 @@ README.
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four - **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 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. 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 - **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 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 stale answer or the wrong backend answer with ignorance, and ignorance
@@ -86,7 +107,7 @@ README.
- **The hosting line** follows the family list in the about panel: the - **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 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 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 never names a game's rights holder: a tip is for the hosting, not the
game. game.
@@ -194,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 - `<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 today, every room opened and how far it got. Names seated for the first
time are marked NEW. 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, - `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
the rollup's last week, the backup's last word, the box's vitals. the rollup's last week, the backup's last word, the box's vitals.
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts, - `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
no addresses. `<slug>-backup.sh` nightly at 07:23 UTC: rclone to the 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 shared Space, mirror plus dated snapshots pruned after 90 days. On the
in with Sentry Crons so a missed night is noticed. 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 - The reports desk (skill `<slug>-reports`): `pull-reports.sh` mirrors the
desk to the Desktop with a digest; `report-reply.sh <ip> <id> <status> 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 "text"` answers with `resolved`, `by-design` or `open`. A fix goes live
@@ -237,16 +263,78 @@ thing will happen), and the image carries it.
- **Our own words.** An older implementation's prose is a guide to the - **Our own words.** An older implementation's prose is a guide to the
tone, never a source to copy. tone, never a source to copy.
- **Plain chrome.** Headings, tabs, captions and notes say what is there - **Plain chrome.** Headings, tabs, captions and notes say what is there
in ordinary words. Avoid the construction a label, a colon, then an in ordinary words. The images live in what happens, not in the labels
explanation ("The ledger: every gesture..."), and headings made of "The" around it. The patterns readers take as generated text are listed under
and an abstract noun ("The game", "The tally"); readers take both as the "Writing for players" below.
mark of generated text. The images live in what happens, not in the
labels around it.
- **Changing text is not changing rules.** Chronicle wording is not part - **Changing text is not changing rules.** Chronicle wording is not part
of the ledger, so it ships without a rules revision; the deploy gate 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 compares outcomes, not sentences. A test that pins a phrase should pin
the fact inside it. 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 ## The move
- **Every history reads top to bottom**, oldest first, and scrolls to the - **Every history reads top to bottom**, oldest first, and scrolls to the
@@ -267,6 +355,11 @@ thing will happen), and the image carries it.
- `npm install --omit=optional` on the droplet breaks Vite; the server - `npm install --omit=optional` on the droplet breaks Vite; the server
carries its own small `package.json` and installs only that. carries its own small `package.json` and installs only that.
- A dynamic `/join/[code]` route must set `prerender = false`. - 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 - Two tabs on one origin share a seat; test a second player from
`127.0.0.1` against `localhost` (Vite needs `--host`). `127.0.0.1` against `localhost` (Vite needs `--host`).
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`. - Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
+16 -6
View File
@@ -3,11 +3,13 @@
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next 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 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, 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 seats that travel between devices by phrase and come back by the keeper's
ledgers replayed through a link or the host's phrase when a browser loses them, a bot to stand in
deterministic engine, the rules revision, the reports desk, the deploy for a player who cannot go on, the keeper's bell, append-only ledgers
script with its determinism gate, nightly rollup and backup, Sentry with replayed through a deterministic engine, the rules revision, the reports
Slack alerts, and the visitors, pulse and reports skills for Claude. 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` 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 (see `src/lib/game/spec.ts`); the server, the client store, the hall and
@@ -45,7 +47,7 @@ Run the demo locally from a new project:
src/lib/game/spec.ts the contract a game implements src/lib/game/spec.ts the contract a game implements
src/lib/game/index.ts the demo game; replace it 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/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 src/routes/ the hall, /join/[code], /rules, /guide
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests 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 deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
@@ -115,3 +117,11 @@ 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 `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 `GAMES` in `gamehall/ops/traffic.py` so the digest counts the people the
hall sends it. 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.
+2 -1
View File
@@ -11,11 +11,12 @@ SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
KIT="$(cd "$(dirname "$0")" && pwd)" KIT="$(cd "$(dirname "$0")" && pwd)"
MAP="$KIT/drift-map/$SLUG.txt" 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 server/src/tally.ts 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/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 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/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/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 total=0
for t in $SHARED; do for t in $SHARED; do
g="$t" g="$t"
+12 -4
View File
@@ -14,7 +14,7 @@
"slug": "waving-hands", "slug": "waving-hands",
"name": "Waving Hands", "name": "Waving Hands",
"url": "https://hands.kestrelsnest.social", "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", "players": "2 to 12",
"origin": "Richard Bartle's pencil-and-paper duel, 1977" "origin": "Richard Bartle's pencil-and-paper duel, 1977"
}, },
@@ -22,7 +22,7 @@
"slug": "hnefatafl", "slug": "hnefatafl",
"name": "Hnefatafl", "name": "Hnefatafl",
"url": "https://tafl.kestrelsnest.social", "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", "players": "2",
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut" "origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
}, },
@@ -30,9 +30,17 @@
"slug": "dip", "slug": "dip",
"name": "Dip", "name": "Dip",
"url": "https://dip.kestrelsnest.social", "url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers. One map. Nobody has to keep their word.", "pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 7", "players": "2 to 10, depending on the map",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild" "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"
} }
] ]
} }
+2
View File
@@ -21,6 +21,8 @@ ssh "root@$HOST" '
# The counting tools: installed on every publish so the live copies are the repo copies. # 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-visitors.sh /usr/local/bin/gamehall-visitors.sh
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.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 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
# The hall-wide notice every game serves at /hall/announcement.json. # The hall-wide notice every game serves at /hall/announcement.json.
+1 -1
View File
@@ -4,7 +4,7 @@
# and the people sent on to each game here). Counts only: no addresses are # and the people sent on to each game here). Counts only: no addresses are
# kept, so the trend outlives the self-rotating access log. # kept, so the trend outlives the self-rotating access log.
# gamehall-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC) # gamehall-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC)
# Cron: /etc/cron.d/gamehall, installed by gamehall/deploy.sh. # Run nightly by hall-nightly.sh rollup (/etc/cron.d/gamehall).
set -u set -u
OUT="/var/lib/gamehall/rollup.jsonl" OUT="/var/lib/gamehall/rollup.jsonl"
LOG="/var/log/gamehall/rollup.log" LOG="/var/log/gamehall/rollup.log"
+4 -2
View File
@@ -1,2 +1,4 @@
# Kestrel's Hall: nightly rollup of yesterday's counts. UTC. # Kestrel's Hall: the nightly work of every game on this host, and the hall's own count. UTC.
5 0 * * * root /usr/local/bin/gamehall-rollup.sh # 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
+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
+1 -1
View File
@@ -36,7 +36,7 @@ LOGS = "/var/lib/caddy/gamehall-access*.log*"
# The games that share this host, by the name the hall links to. A visitor # 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 # 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. # 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"} 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") ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 33 KiB

+54 -34
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kestrel's Hall</title> <title>Kestrel's Hall</title>
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, and Dip. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold."> <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/"> <link rel="canonical" href="https://gamehall.kestrelsnest.social/">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Kestrel's Hall"> <meta property="og:title" content="Kestrel's Hall">
@@ -22,7 +22,6 @@
--line: #2c3236; --line: #2c3236;
--bone: #e6dfcf; --bone: #e6dfcf;
--bone-dim: #a9a494; --bone-dim: #a9a494;
--bone-faint: #7c7868;
--gold: #d2a94a; --gold: #d2a94a;
--gutter: 16px; --gutter: 16px;
} }
@@ -56,31 +55,42 @@
color: var(--bone-dim); color: var(--bone-dim);
} }
header.mast p.lede b { color: var(--bone); font-weight: 500; } 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; } .games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
@media (max-width: 860px) { .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; } }
.game { .game {
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 6px;
overflow: hidden; overflow: hidden;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: transform .15s ease, border-color .15s ease;
} }
.game:hover { transform: translateY(-3px); border-color: var(--gold); 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 { 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.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
.game .art img.tafl { object-position: 50% 50%; } .game .art img.tafl { object-position: 50% 50%; }
.game .art img.hands { object-position: 0% 50%; } .game .art img.dip { object-position: 50% 80%; }
.game .art img.dip { object-position: 50% 55%; }
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; } .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 h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
.game .pitch { margin: 0; color: var(--bone); } .game .pitch { margin: 0; color: var(--bone); }
.game .facts { margin: 0; color: var(--bone-faint); font-size: 0.92rem; } .game .players { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
.game .play { margin-top: auto; padding-top: 10px; color: var(--gold); font-size: 1rem; } .game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
.game:hover .play { text-decoration: underline; } @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. */ /* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
.maze { .maze {
@@ -111,7 +121,7 @@
.keeper img { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; border: 2px solid var(--line); } .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; } } @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-faint); font-size: 0.9rem; } 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; } footer p { margin: 0 0 8px; }
/* The hall-wide notice: its own colors, the same in every game of the hall. */ /* 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 { 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; }
@@ -123,12 +133,12 @@
<header class="mast"> <header class="mast">
<h1>Kestrel's Hall</h1> <h1>Kestrel's Hall</h1>
<p class="lede"><b>Free board games in the browser.</b> Play with friends by sending a link, or alone against the house. No accounts, no adverts, no timers, and nothing sold.</p> <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> </header>
<div class="games"> <div class="games">
<a class="game first" href="https://wizwar.kestrelsnest.social">
<a class="game" href="https://wizwar.kestrelsnest.social">
<div class="art"> <div class="art">
<div class="maze"> <div class="maze">
<div class="w h" style="left:12.5%;top:19%;width:25%"></div> <div class="w h" style="left:12.5%;top:19%;width:25%"></div>
@@ -150,18 +160,18 @@
<div class="body"> <div class="body">
<h2>Wiz-War</h2> <h2>Wiz-War</h2>
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p> <p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
<p class="facts">2 to 6 players · an unofficial rebuild of Tom Jolly's 1993 board game</p> <p class="players">2 to 6 players, or solo against the automaton</p>
<span class="play">Play at wizwar.kestrelsnest.social →</span> <p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
</div> </div>
</a> </a>
<a class="game" href="https://hands.kestrelsnest.social"> <a class="game" href="https://hands.kestrelsnest.social">
<div class="art"><img class="fill hands" src="hands.png" alt="The Waving Hands share card: a column of gesture letters beside the title."></div> <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"> <div class="body">
<h2>Waving Hands</h2> <h2>Waving Hands</h2>
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p> <p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
<p class="facts">2 to 12 players · Richard Bartle's pencil-and-paper duel, 1977</p> <p class="players">2 to 12 players, or solo against a rival wizard</p>
<span class="play">Play at hands.kestrelsnest.social →</span> <p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
</div> </div>
</a> </a>
@@ -169,19 +179,29 @@
<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="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"> <div class="body">
<h2>Hnefatafl</h2> <h2>Hnefatafl</h2>
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p> <p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
<p class="facts">2 players · the Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p> <p class="players">2 players, or solo against the housecarl</p>
<span class="play">Play at tafl.kestrelsnest.social →</span> <p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
</div> </div>
</a> </a>
<a class="game" href="https://dip.kestrelsnest.social"> <a class="game" href="https://dip.kestrelsnest.social">
<div class="art"><img class="fill dip" src="dip.jpg" alt="Dip's chart of Europe at the opening: the seven powers' armies and fleets on their home centers, joined by the lines they may move along."></div> <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"> <div class="body">
<h2>Dip</h2> <h2>Dip</h2>
<p class="pitch">Seven powers. One map. Nobody has to keep their word.</p> <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="facts">2 to 7 players · an unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p> <p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
<span class="play">Play at dip.kestrelsnest.social →</span> <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> </div>
</a> </a>
@@ -191,11 +211,11 @@
<div> <div>
<h2>How a table works</h2> <h2>How a table works</h2>
<ul class="how"> <ul class="how">
<li><b>Open a table</b> and you get a four-letter code and a link. Send it to a friend; they sit down and you begin.</li> <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 seats the house: an automaton, a housecarl, a rival wizard, an envoy. Play now, alone.</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. Close the tab and come back; the game is where you left it.</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>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, pinned to the exact moment.</li> <li><b>Something went wrong?</b> Every table has a report button that reaches the keeper with the exact moment attached.</li>
</ul> </ul>
</div> </div>
<div> <div>
@@ -208,12 +228,12 @@
</div> </div>
</div> </div>
<h2 style="margin-top:28px">The hosting</h2> <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; anyone who wants to chip in toward the hosting can at <a href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.</p> <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> </div>
</section> </section>
<footer> <footer>
<p>Wiz-War is Tom Jolly's design; the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro. Hnefatafl belongs to everyone; it is a thousand years old.</p> <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> </footer>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+3 -2
View File
@@ -2,7 +2,7 @@
# Stamp out a new game from the template. # Stamp out a new game from the template.
# ./new-game.sh <slug> "<Name>" [port] [domain] # ./new-game.sh <slug> "<Name>" [port] [domain]
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social # e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The # 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. # games share one host (deploy/README.md), so a new game takes the next free port.
set -euo pipefail set -euo pipefail
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}" SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
@@ -38,7 +38,8 @@ Next:
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte), 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. 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 Write every line a player reads in the game's own voice: an image with each
fact (docs/conventions.md, "The voice"). 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): 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 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. HTML for a missing file, so a share card without the image breaks quietly.
+4 -1
View File
@@ -43,7 +43,10 @@ __HOST__ {
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/* @mutable not path /_app/immutable/*
header @mutable Cache-Control "no-cache" 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 file_server
} }
} }
+18 -8
View File
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
small Node process capped at 300 MB by its unit file, and a room is a few 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 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, of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
dip 8790. dip 8790, wyrm 8791.
## Current production ## Current production
@@ -46,11 +46,18 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
## Operations scripts on the droplet ## Operations scripts on the droplet
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to `deploy.sh` installs these to /usr/local/bin and the `traffic.py` they share
/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every to /usr/local/lib/__SLUG__ on every deploy, so the live copies are the repo
deploy, so the live copies are the repo copies: 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__-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 - `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
trend, backup, box). trend, backup, box).
- `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run - `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run
@@ -62,10 +69,13 @@ deploy, so the live copies are the repo copies:
wizwar droplet; until then it logs "skipped". wizwar droplet; until then it logs "skipped".
Errors from the game server go to Sentry, project `__SLUG__` in the Errors from the game server go to Sentry, project `__SLUG__` in the
locallygrownnet organisation; the DSN is in the unit file. The nightly rollup locallygrownnet organisation; the DSN is in the unit file. On the shared host
and backup check in with Sentry Crons when /root/.__SLUG__-sentry-cron-rollup the hall's nightly job checks in with Sentry Crons once for every game's
and /root/.__SLUG__-sentry-cron hold their check-in URLs (the ingest rollup and once for every backup (monitors `hall-rollup` and `hall-backup` in
URL with the project's cron path and public key), so a missed night is noticed. 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 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 wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
+6 -1
View File
@@ -5,6 +5,8 @@
# Ledgers are append-only JSONL; the game server never needs stopping. # Ledgers are append-only JSONL; the game server never needs stopping.
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron holds the check-in # 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__. # 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 set -u
SCHEDULE="23 7 * * *" SCHEDULE="23 7 * * *"
BUCKET="kestrel-wizwar-backups" # the hall's shared Space, named for its first tenant BUCKET="kestrel-wizwar-backups" # the hall's shared Space, named for its first tenant
@@ -27,9 +29,10 @@ checkin() {
if [ ! -s /root/.config/rclone/rclone.conf ]; then if [ ! -s /root/.config/rclone/rclone.conf ]; then
echo "$(date -Is) skipped: rclone is not configured" >> "$LOG" echo "$(date -Is) skipped: rclone is not configured" >> "$LOG"
exit 0 exit 1
fi fi
status=0
{ {
echo "=== $(date -Is) backup start" echo "=== $(date -Is) backup start"
if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 && if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 &&
@@ -37,6 +40,7 @@ fi
checkin ok checkin ok
else else
checkin error checkin error
status=1
fi fi
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d) CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \ rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \
@@ -49,3 +53,4 @@ fi
done done
echo "=== $(date -Is) backup done" echo "=== $(date -Is) backup done"
} >> "$LOG" 2>&1 } >> "$LOG" 2>&1
exit $status
+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
+4 -2
View File
@@ -8,7 +8,9 @@
# Cron: /etc/cron.d/__SLUG__, installed by deploy.sh; SCHEDULE below # Cron: /etc/cron.d/__SLUG__, installed by deploy.sh; SCHEDULE below
# must match it, because the Sentry monitor is told the same shape. # must match it, because the Sentry monitor is told the same shape.
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron-rollup holds the # 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 set -u
OUT="/var/lib/__SLUG__/rollup.jsonl" OUT="/var/lib/__SLUG__/rollup.jsonl"
LOG="/var/log/__SLUG__/rollup.log" LOG="/var/log/__SLUG__/rollup.log"
@@ -78,4 +80,4 @@ with open(out + ".tmp", "w") as fh:
os.replace(out + ".tmp", out) os.replace(out + ".tmp", out)
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created)) print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
PY 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.$$
+8 -2
View File
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
continue continue
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc) created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0 humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
names = {} names, seat_changes = {}, []
for x in lines[1:]: for x in lines[1:]:
if x["t"] == "seat": if x["t"] == "seat":
(bots_seated if x["bot"] else humans).append(x["name"]) (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) last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
elif x["t"] == "chat": elif x["t"] == "chat":
talk += 1 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": elif x["t"] == "over":
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"]) over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
for h in humans: for h in humans:
if h not in first_seen or created < first_seen[h]: if h not in first_seen or created < first_seen[h]:
first_seen[h] = created 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"]) 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"]}) 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 "", 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", 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 "")) (" %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 PY
+20 -2
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. # it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \ 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='/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='*' \ --exclude='*' \
./ "root@$HOST:/opt/__SLUG__/app/" ./ "root@$HOST:/opt/__SLUG__/app/"
@@ -42,12 +42,30 @@ 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__-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__-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__-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 -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__ mkdir -p /var/log/__SLUG__
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now __SLUG__ systemctl enable --now __SLUG__
systemctl restart __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 systemctl reload caddy
# "active" means only that the process started: wait for it to answer HTTP, # "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. # so a server that dies loading its code fails the deploy instead of looping.
+7 -3
View File
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
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) 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 # 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. # 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 = [ UNFURL = [
("Slack", re.compile(r"slack", re.I)), ("Slack", re.compile(r"slack", re.I)),
("Discord", re.compile(r"discord", 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)), ("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)), ("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
("WhatsApp", re.compile(r"whatsapp", re.I)), ("WhatsApp", re.compile(r"whatsapp", re.I)),
("Telegram", re.compile(r"telegram", re.I)), ("Telegram", re.compile(r"telegram", re.I)),
("Reddit", re.compile(r"redditbot", re.I)), ("Reddit", re.compile(r"redditbot", re.I)),
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
t["bot"] += 1 t["bot"] += 1
t["botAddresses"].add(ip) t["botAddresses"].add(ip)
service = unfurled_by(ua) service = unfurled_by(ua)
# One share per app and page: a preview is often fetched twice. # One share per app and page: a preview is often fetched twice. Only a
if service and (service, path) not in seen_share: # 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)) seen_share.add((service, path))
t["shared"][service] += 1 t["shared"][service] += 1
continue continue
+76 -1
View File
@@ -16,6 +16,8 @@
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings // 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/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/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/transfer {phrase} claim one
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round // 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 // POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
@@ -25,10 +27,18 @@
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // 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 // 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 * as Sentry from '@sentry/node';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; 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 { dirname } from 'node:path';
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { game } from '../../src/lib/game'; import { game } from '../../src/lib/game';
@@ -45,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. */ /** 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 = process.env.KEEPER ?? 'the keeper';
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York'; 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; const BODY_LIMIT = 16 * 1024;
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise. // Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
@@ -74,6 +86,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */ /** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
const claims = new RateLimit(10, 10 * 60 * 1000); const claims = new RateLimit(10, 10 * 60 * 1000);
const MAX_AUDIENCE = 30; 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; const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
setInterval(() => { setInterval(() => {
const n = rooms.evictIdle(IDLE_ROOM_MS); const n = rooms.evictIdle(IDLE_ROOM_MS);
@@ -186,9 +200,44 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
throw new RoomError('Not here.', 404); 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> { async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost'); const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean); 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] === '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] === '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') { if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
@@ -278,6 +327,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (action === 'transfer') { if (action === 'transfer') {
return send(res, 200, rooms.transfer(room, token)); 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 (action === 'report') {
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429); if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
const seat = token ? rooms.seatOf(room, token) : null; const seat = token ? rooms.seatOf(room, token) : null;
@@ -323,11 +379,30 @@ server.on('upgrade', (req, socket, head) => {
}; };
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
ws.on('close', unsubscribe); 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. // A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
ws.on('error', () => ws.terminate()); 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, () => { server.listen(PORT, HOST, () => {
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
}); });
+121 -1
View File
@@ -6,6 +6,7 @@ import { game, type Input, type State } from '../../src/lib/game';
import type { GameSpec } from '../../src/lib/game/spec'; import type { GameSpec } from '../../src/lib/game/spec';
import { Rooms } from './rooms'; import { Rooms } from './rooms';
import { Store } from './store'; import { Store } from './store';
import { SPECTATOR } from '../../src/lib/game/spec';
let dir: string; let dir: string;
beforeEach(() => { beforeEach(() => {
@@ -15,6 +16,9 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true }); 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. */ /** 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) { function table(spec: GameSpec<State, Input> = game, withCy = false) {
const rooms = new Rooms(spec, new Store(dir)); const rooms = new Rooms(spec, new Store(dir));
@@ -35,6 +39,39 @@ describe('rooms', () => {
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/); 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', () => { it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
const { room } = table(); const { room } = table();
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn'); appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
@@ -127,7 +164,7 @@ describe('rooms', () => {
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/); 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', () => { 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 heard: string[][] = [];
const talking: GameSpec<State, Input> = { const talking: GameSpec<State, Input> = {
...game, ...game,
@@ -145,6 +182,7 @@ describe('rooms', () => {
const cy = rooms.join(host.room, 'Cy'); const cy = rooms.join(host.room, 'Cy');
const room = host.room; const room = host.room;
rooms.begin(room, host.token); rooms.begin(room, host.token);
await answered();
rooms.say(room, host.token, 'Psst.', ['B']); rooms.say(room, host.token, 'Psst.', ['B']);
rooms.say(room, cy.token, 'Hello, all.'); rooms.say(room, cy.token, 'Hello, all.');
const annSees = rooms.view(room, 'A').chat; const annSees = rooms.view(room, 'A').chat;
@@ -154,6 +192,7 @@ describe('rooms', () => {
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']); expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
rooms.submit(room, host.token, { pick: 1 }, 0); rooms.submit(room, host.token, { pick: 1 }, 0);
rooms.submit(room, cy.token, { pick: 2 }, 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(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(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
expect(rooms.view(room, 'A').parting).toEqual([]); expect(rooms.view(room, 'A').parting).toEqual([]);
@@ -195,6 +234,7 @@ describe('rooms', () => {
rooms.addBot(host.room, host.token); rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token); rooms.begin(host.room, host.token);
const room = host.room; const room = host.room;
vi.runOnlyPendingTimers();
expect(room.state!.n).toBe(4); expect(room.state!.n).toBe(4);
expect(rooms.view(room, 'A').playingOn).toBe(true); expect(rooms.view(room, 'A').playingOn).toBe(true);
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
@@ -207,3 +247,83 @@ describe('rooms', () => {
}); });
}); });
}); });
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']);
});
});
+137 -16
View File
@@ -24,9 +24,12 @@ export { SPECTATOR };
export interface Seat { export interface Seat {
id: SeatId; id: SeatId;
name: string; 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; tokenHash: string;
/** A bot plays this seat: one seated as a bot, or one standing in for a person. */
bot: boolean; 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 { function hashToken(token: string): string {
@@ -119,8 +122,8 @@ export class Rooms<State, Input> {
private listeners = new Map<string, Set<(room: Room<State>) => void>>(); private listeners = new Map<string, Set<(room: Room<State>) => void>>();
/** Sockets watching from the gallery, by room. */ /** Sockets watching from the gallery, by room. */
private gallery = new Map<string, number>(); private gallery = new Map<string, number>();
/** Phrases waiting to be claimed, with the seat and the token they carry. */ /** 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; expiresAt: number }>(); private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string | null; expiresAt: number }>();
private failedClaims = 0; private failedClaims = 0;
private failWindowStart = 0; private failWindowStart = 0;
/** Tables whose bots are playing on, paced: the next round's timer. */ /** Tables whose bots are playing on, paced: the next round's timer. */
@@ -190,7 +193,8 @@ export class Rooms<State, Input> {
seatOf(room: Room<State>, token: string | undefined): Seat { seatOf(room: Room<State>, token: string | undefined): Seat {
if (!token) throw new RoomError('That token opens no seat at this game.', 403); if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex'); 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); if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat; return seat;
} }
@@ -238,6 +242,11 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id }); this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
} }
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
keeperSays(room: Room<State>, raw: unknown): void {
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
}
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */ /** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void { sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403); if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
@@ -270,9 +279,10 @@ export class Rooms<State, Input> {
const caller = this.seatOf(room, token); 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.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 }; 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 }); 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 }); 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 }; return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
} }
@@ -280,6 +290,22 @@ export class Rooms<State, Input> {
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ /** 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 } { transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
const seat = this.seatOf(room, token); 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(); const now = Date.now();
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase); 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); if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
@@ -288,7 +314,7 @@ export class Rooms<State, Input> {
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-'); phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
} while (this.transfers.has(phrase)); } while (this.transfers.has(phrase));
const expiresAt = now + TRANSFER_TTL_MS; 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 }; return { phrase, expiresAt };
} }
@@ -309,6 +335,10 @@ export class Rooms<State, Input> {
} }
this.transfers.delete(phrase); this.transfers.delete(phrase);
const room = this.get(t.roomId); 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); const seat = this.seatOf(room, t.token);
return { roomId: room.id, seat: seat.id, token: t.token }; return { roomId: room.id, seat: seat.id, token: t.token };
} }
@@ -321,8 +351,32 @@ export class Rooms<State, Input> {
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 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, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) }); 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. // A game whose first move belongs to a bot must not wait for a human who is not owed a move.
this.botsAfterAnswering(room);
}
/**
* 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.driveBots(room);
this.botsSpeak(room, 'phase'); 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)}`);
}
});
} }
/** /**
@@ -353,8 +407,7 @@ export class Rooms<State, Input> {
const hurried = this.hurried.has(room.id); const hurried = this.hurried.has(room.id);
const next = setTimeout(() => { const next = setTimeout(() => {
this.pacing.delete(room.id); this.pacing.delete(room.id);
// A room forgotten and loaded again drives itself; this one is no longer the table. if (!this.isTable(room)) return;
if (this.rooms.get(room.id) !== room) return;
this.driveBots(room, hurried ? HURRIED_STEP : 1); 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. // 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'); if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
@@ -406,6 +459,52 @@ export class Rooms<State, Input> {
return { seat, token }; 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. */ /** 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 { unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
const host = this.seatOf(room, token); const host = this.seatOf(room, token);
@@ -455,10 +554,14 @@ export class Rooms<State, Input> {
private resolve(room: Room<State>): void { private resolve(room: Room<State>): void {
const inputs: Record<SeatId, unknown> = { ...room.pending }; 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, this.heardBy(room, 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.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room); this.botsAfterAnswering(room);
this.botsSpeak(room, 'phase');
} }
/** /**
@@ -470,7 +573,8 @@ export class Rooms<State, Input> {
if (!this.game.botPress || !room.state) return; if (!this.game.botPress || !room.state) return;
const people = room.seats.filter((s) => !s.bot).map((s) => s.id); const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
for (const seat of room.seats) { for (const seat of room.seats) {
if (!seat.bot || (only && !only.includes(seat.id))) continue; // 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)) { 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)); const to = line.to.filter((id) => people.includes(id));
if (!to.length || !line.text.trim()) continue; if (!to.length || !line.text.trim()) continue;
@@ -500,13 +604,14 @@ export class Rooms<State, Input> {
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))] ? [...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.'); if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot); // 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.'); 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. // 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); 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 } : {}) }); 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); if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id)); else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot && !s.standIn).map((s) => s.id));
} }
private cleanTalk(raw: unknown): string { private cleanTalk(raw: unknown): string {
@@ -536,7 +641,7 @@ export class Rooms<State, Input> {
size: room.size, size: room.size,
me: seatId, me: seatId,
host: room.seats[0]?.id ?? 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), waitingOn: this.waitingOn(room),
turn: room.state ? this.game.turn(room.state) : null, turn: room.state ? this.game.turn(room.state) : null,
resolved: room.resolved, resolved: room.resolved,
@@ -609,6 +714,21 @@ export class Rooms<State, Input> {
case 'seat': case 'seat':
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
break; 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': case 'galleryTalk':
room.galleryTalk = line.on; room.galleryTalk = line.on;
break; break;
@@ -646,6 +766,7 @@ export class Rooms<State, Input> {
at: line.at, at: line.at,
...(room.state ? { turn: this.game.turn(room.state) } : {}), ...(room.state ? { turn: this.game.turn(room.state) } : {}),
...(line.name ? { name: line.name } : {}), ...(line.name ? { name: line.name } : {}),
...(line.keeper ? { keeper: true as const } : {}),
...(line.to ? { to: line.to } : {}), ...(line.to ? { to: line.to } : {}),
...(line.note !== undefined ? { note: line.note } : {}) ...(line.note !== undefined ? { note: line.note } : {})
}); });
+8 -1
View File
@@ -11,6 +11,13 @@ export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions } | { 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. */ /** 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 } | { 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. */ /** 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 } | { 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. */ /** A bot sent away by the host before the game began; its seat id is free again. */
@@ -30,7 +37,7 @@ export type LedgerLine =
* A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). * A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR).
* `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown. * `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown.
*/ */
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; to?: SeatId[]; note?: unknown } | { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true; to?: SeatId[]; note?: unknown }
/** The host let the gallery talk, or hushed it. */ /** The host let the gallery talk, or hushed it. */
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId } | { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
/** A seat is held for the keeper of the site, called to the table. */ /** A seat is held for the keeper of the site, called to the table. */
+3 -4
View File
@@ -7,21 +7,20 @@
<meta name="theme-color" content="#121a20" /> <meta name="theme-color" content="#121a20" />
<title>__NAME__</title> <title>__NAME__</title>
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <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="canonical" href="https://__DOMAIN__/" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="__NAME__" /> <meta property="og:site_name" content="__NAME__" />
<meta property="og:title" 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:url" content="https://__DOMAIN__/" />
<meta property="og:image" content="https://__DOMAIN__/og.png" /> <meta property="og:image" content="https://__DOMAIN__/og.png" />
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="__NAME__" /> <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" /> <meta name="twitter:image" content="https://__DOMAIN__/og.png" />
%sveltekit.head% %sveltekit.head%
</head> </head>
+2 -2
View File
@@ -67,8 +67,8 @@
</section> </section>
{:else} {:else}
<section class="move"> <section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2> <h2>Name a number for round {g.rounds.length + 1}</h2>
<p class="muted">Every player lays a card face down; the highest card nobody else laid takes the point. First to {TARGET}.</p> <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"> <div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)} {#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> <button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
+28 -13
View File
@@ -45,14 +45,13 @@
{ n: t.turnsPlayed, label: 'turns recorded' }, { n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' } { n: hours(t.minutesAtTable), label: 'at the table' }
]; ];
const onCards = new Set<string>([]);
const details = [ const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` }, { 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.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.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' }, { 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' }] : []), ...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label })) ...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' })}. ` : ''; 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.` }; 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.` };
@@ -143,7 +142,14 @@
if (c) void goto(`/join/${c}`); 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) { function forget(roomId: string) {
if (!games[roomId]?.over && forgetting !== roomId) {
forgetting = roomId;
return;
}
forgetting = null;
forgetSeat(roomId); forgetSeat(roomId);
seats = allSeats(); seats = allSeats();
} }
@@ -156,7 +162,7 @@
const g = games[held.roomId]; const g = games[held.roomId];
if (g === undefined) return 'looking'; if (g === undefined) return 'looking';
if (g === null) return 'unreachable'; 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.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.over) { if (g.over) {
if (g.over.winner === held.seat) return 'you won'; if (g.over.winner === held.seat) return 'you won';
@@ -196,9 +202,9 @@
<section class="lid" class:returning={active.length > 0}> <section class="lid" class:returning={active.length > 0}>
<div class="lid-head"> <div class="lid-head">
<h1>__NAME__</h1> <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="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">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} /> <Preferences bind:open={prefsOpen} />
</div> </div>
@@ -232,7 +238,7 @@
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button> <button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form> </form>
</div> </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}> <form class="joinform" onsubmit={claim}>
<span class="or">or bring a seat from another device</span> <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" /> <input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
@@ -252,7 +258,11 @@
<span class="ledger-code">{held.roomId}</span> <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> <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> </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> <button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
{/if}
</div> </div>
{/snippet} {/snippet}
@@ -309,12 +319,12 @@
<div class="about-body"> <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>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>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> <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> </div>
{/snippet} {/snippet}
{#snippet aboutTally()} {#snippet aboutTally()}
{#if told} {#if told}
<TallyBoard title="How much love the table is getting" headline={told.headline} details={told.details} note={told.note} /> <TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
{:else} {:else}
<p class="muted">Counting…</p> <p class="muted">Counting…</p>
{/if} {/if}
@@ -329,15 +339,15 @@
</ul> </ul>
{/if} {/if}
<p class="hosting"> <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 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>. Nothing is unlocked by it and nothing is withheld without it. <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>
<p class="made">__NAME__ is its designer's.</p> <p class="made">__NAME__ is its designer's.</p>
</div> </div>
{/snippet} {/snippet}
{#snippet aboutWord()} {#snippet aboutWord()}
<div class="about-body"> <div class="about-body">
<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> <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> </div>
{/snippet} {/snippet}
<TabbedDialog <TabbedDialog
@@ -346,8 +356,8 @@
title="About __NAME__" title="About __NAME__"
label="About this game" label="About this game"
tabs={[ tabs={[
{ id: 'game', label: 'The game', body: aboutGame }, { id: 'game', label: 'Origins', body: aboutGame },
{ id: 'tally', label: 'The tally', body: aboutTally }, { id: 'tally', label: 'Games so far', body: aboutTally },
{ id: 'family', label: 'More games', body: aboutFamily }, { id: 'family', label: 'More games', body: aboutFamily },
{ id: 'word', label: 'Send word', body: aboutWord } { id: 'word', label: 'Send word', body: aboutWord }
]} ]}
@@ -520,6 +530,11 @@
background: rgba(240, 165, 58, 0.08); background: rgba(240, 165, 58, 0.08);
} }
.forget-ask {
font-size: 0.85em;
flex: none;
}
.ledger-row.your-move .ledger-info { .ledger-row.your-move .ledger-info {
color: var(--ember); color: var(--ember);
} }
+16 -8
View File
@@ -7,8 +7,12 @@
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props(); let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
const v = $derived(room.view); 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 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())); 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. // 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 choices = $derived(game.rankable?.(v.options) ?? null);
@@ -59,7 +63,7 @@
{#if room.seatEmpty} {#if room.seatEmpty}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p> <p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else} {: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} {/if}
{:else} {:else}
<p class="eyebrow">room {v.roomId}</p> <p class="eyebrow">room {v.roomId}</p>
@@ -84,13 +88,13 @@
<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> <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> </ul>
{#if v.rematchOf} {#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}
{#if choices && !room.spectating} {#if choices && !room.spectating}
<section class="ranking" aria-label="Your ranking"> <section class="ranking" aria-label="Your ranking">
<h3>Your ranking</h3> <h3>Your ranking</h3>
<p class="muted small"> <p class="muted small">
Best first. When the game begins, the deal reads everyone's ranking; nobody sees another player's. 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} {#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
</p> </p>
<ol> <ol>
@@ -113,8 +117,12 @@
{#if room.seatEmpty && room.isHost} {#if room.seatEmpty && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button> <button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if} {/if}
{#if room.isHost} {#if room.isHost && beginAsked && v.expected.length > 0}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button> <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> <span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else} {:else}
<span class="muted">{hostName} will begin when the table is ready.</span> <span class="muted">{hostName} will begin when the table is ready.</span>
@@ -128,11 +136,11 @@
{/if} {/if}
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld} {#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper"> <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> <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">{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> <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> </div>
{:else if v.challenge} {: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} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
@@ -61,7 +61,7 @@
<form class="slip" aria-labelledby="report-heading" onsubmit={send}> <form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2> <h2 id="report-heading">Something amiss?</h2>
{#if sent} {#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} {:else}
<label class="field"> <label class="field">
<span>What happened?</span> <span>What happened?</span>
@@ -76,7 +76,7 @@
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} /> <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} {#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label> </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} {#if error}<p class="warning">{error}</p>{/if}
<div class="actions"> <div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button> <button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
@@ -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>
+2 -2
View File
@@ -48,7 +48,7 @@
{/if} {/if}
{#each lines as line, i (line.at + ':' + i)} {#each lines as line, i (line.at + ':' + i)}
<li class:you={!line.name && line.id === room.me} class:private={!!line.to}> <li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
<em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em> <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} {line.text}
</li> </li>
{/each} {/each}
@@ -60,7 +60,7 @@
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button> <button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
</form> </form>
{:else if room.spectating} {: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} {:else}
<form onsubmit={say}> <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" /> <input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
+9
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 { export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null; return heldSeats()[roomId] ?? null;
} }
@@ -142,6 +148,9 @@ export function makeApi<State, Input>() {
rematch: (roomId: string, token: string) => rematch: (roomId: string, token: string) =>
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }), 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 }), 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 }), claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
report: (roomId: string, token: string, happened: string, expected: string) => report: (roomId: string, token: string, happened: string, expected: string) =>
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }), call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
+17 -2
View File
@@ -5,7 +5,7 @@
import { game } from '$lib/game'; import { game } from '$lib/game';
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec'; import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
import type { Input, State } from '$lib/game'; 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 { markTalkSeen } from './talk';
import type { RoomView } from './view'; import type { RoomView } from './view';
@@ -44,6 +44,8 @@ export class Room {
error = $state(''); error = $state('');
/** A move is on its way to the server. */ /** A move is on its way to the server. */
sending = $state(false); 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) { constructor(view: RoomView<State>, token: string) {
this.view = view; this.view = view;
@@ -83,7 +85,10 @@ export class Room {
const refresh = () => { const refresh = () => {
fetchView() fetchView()
.then((v) => this.apply(v)) .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)); return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
} }
@@ -156,6 +161,16 @@ export class Room {
return api.transfer(this.roomId, this.token); 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> { async submit(input: Input): Promise<boolean> {
this.sending = true; this.sending = true;
try { try {
+4 -1
View File
@@ -9,6 +9,8 @@ export interface ChatLine {
text: string; text: string;
at: number; at: number;
name?: string; name?: string;
/** Said by the keeper of the site, through the keeper's door. */
keeper?: true;
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */ /** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
to?: SeatId[]; to?: SeatId[];
/** The round the line was said in, counted from one; absent before the game begins. */ /** The round the line was said in, counted from one; absent before the game begins. */
@@ -26,7 +28,8 @@ export interface RoomView<State> {
me: SeatId; me: SeatId;
/** The player who opened the table and may begin the game. */ /** The player who opened the table and may begin the game. */
host: SeatId; 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. */ /** Seats that still have to move before the round resolves. */
waitingOn: SeatId[]; waitingOn: SeatId[];
/** Rounds played so far; a move is sent with the count it was made against. */ /** Rounds played so far; a move is sent with the count it was made against. */
+3 -2
View File
@@ -1,4 +1,5 @@
// Everything happens in the browser against the game server, so nothing is rendered on the // Everything happens in the browser against the game server, so by default a route is
// server. Each route is prerendered as an empty shell that the client fills in. // 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 ssr = false;
export const prerender = true; 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;
+6 -6
View File
@@ -18,7 +18,7 @@
<header> <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> <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"> <nav aria-label="Sections">
<a href="#hall">The hall</a> <a href="#hall">The hall</a>
<a href="#table">The table</a> <a href="#table">The table</a>
@@ -30,12 +30,12 @@
<section id="hall"> <section id="hall">
<h2>The hall</h2> <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>
<section id="table"> <section id="table">
<h2>The table</h2> <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>
<section id="board"> <section id="board">
@@ -45,14 +45,14 @@
<section id="gallery"> <section id="gallery">
<h2>The Peanut Gallery and table talk</h2> <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 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. 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 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>
<section id="first"> <section id="first">
<h2>A first game</h2> <h2>A first game</h2>
<p>The advice you would give a friend across the table for their first few turns.</p> <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> </section>
</main> </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 Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte'; import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte';
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte'; import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client'; import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase()); const roomId = $derived((page.params.code ?? '').toUpperCase());
@@ -21,20 +22,53 @@
/** A phrase minted to carry this seat to another device, while it lasts. */ /** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false); 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 link = $derived(`${location.origin}/join/${roomId}`);
const watching = $derived(room?.spectating ?? false); 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(() => { $effect(() => {
const id = roomId; const id = roomId;
room = null; room = null;
error = ''; 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 held = seatFor(id);
const arrive = held ? Room.resume(id, held) : Room.watch(id); const take = (s: HeldSeat) =>
joining = !held; 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(); 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. // Keep the view fresh for as long as the room is on screen.
@@ -44,6 +78,36 @@
return r.connect(); 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) { async function takeSeat(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
busy = true; busy = true;
@@ -119,6 +183,9 @@
{#if !watching} {#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button> <button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if} {/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} {/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button> <button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a> <a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
@@ -133,6 +200,7 @@
<Preferences bind:open={prefsOpen} /> <Preferences bind:open={prefsOpen} />
{#if room} {#if room}
<ReportSlip {room} bind:open={reporting} /> <ReportSlip {room} bind:open={reporting} />
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
{/if} {/if}
{#if error} {#if error}
@@ -150,7 +218,7 @@
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a> <a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
{:else} {:else}
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button> <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} {/if}
</p> </p>
{/if} {/if}
@@ -159,7 +227,7 @@
<section class="seat"> <section class="seat">
<p class="eyebrow">room {roomId}</p> <p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2> <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}> <form class="sit" onsubmit={takeSeat}>
<label class="field"> <label class="field">
<span>Your name</span> <span>Your name</span>
@@ -171,6 +239,22 @@
</section> </section>
{/if} {/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} {#if room && !room.started}
<Lobby {room} {link} {copied} {copyLink} /> <Lobby {room} {link} {copied} {copyLink} />
{:else if room} {:else if room}
+1 -1
View File
@@ -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> <p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
</section> </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> </main>
<style> <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;
+3 -1
View File
@@ -10,7 +10,9 @@ export default defineConfig({
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
}, },
// A static build: Caddy serves files and proxies /api and /ws to the game server. // 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: { server: {