Compare commits
No files matched your search
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: gamehall-visitors
|
||||
description: Who is visiting Kestrel's Hall, the landing page at gamehall.kestrelsnest.social - the last hour, today, where they came from, how many went on into each game, and the week's trend. Use when Eric asks about hall traffic or visitors.
|
||||
---
|
||||
|
||||
# Kestrel's Hall visitors
|
||||
|
||||
One command on the shared host prints the digest:
|
||||
|
||||
ssh root@159.203.115.252 /usr/local/bin/gamehall-visitors.sh [YYYY-MM-DD]
|
||||
|
||||
Default is today, UTC. It reads Caddy's access log for the hall's own
|
||||
name (the sslip test address is left out) and prints:
|
||||
|
||||
- **last hour** (today only): people and page views, the "right now".
|
||||
- **today**: people (distinct addresses), page views, bot addresses.
|
||||
- **referrers** and **campaigns**: the sites that sent people, and the
|
||||
tags links carry (`?ref=`, utm_source, Facebook's fbclid). Reddit and
|
||||
some apps send no referrer.
|
||||
- **went on to**: people each game on this host (Dip, Waving Hands,
|
||||
Hnefatafl) received with the hall as referrer. The hall sends its
|
||||
origin as referrer, nothing more. Wiz-War is on its own droplet; its
|
||||
own digest (/wizwar-visitors) lists gamehall.kestrelsnest.social among
|
||||
its referrers.
|
||||
- **last 7 days** from `/var/lib/gamehall/rollup.jsonl`, written nightly
|
||||
at 00:12 UTC by `gamehall-rollup.sh` (run by `hall-nightly.sh`) for the day before (counts only,
|
||||
no addresses).
|
||||
|
||||
## Read it
|
||||
|
||||
Lead with whether anyone is here now and today's people, then where they
|
||||
came from, then how many went on into a game (the hall's job is to send
|
||||
people to tables). Give the week's direction from the rollup in one
|
||||
sentence. Eric's own devices count as people; a handful on a quiet day
|
||||
may be him.
|
||||
|
||||
The source is `gamehall/ops/` in game-kit; `gamehall/deploy.sh` installs
|
||||
it on every publish.
|
||||
@@ -10,8 +10,14 @@ README.
|
||||
- **The hall** is the landing page (`/`). Name, *Play now against the bot*,
|
||||
*Open a table*, *or join one* with a four-letter code, then *your games*
|
||||
(the ledger of tables this browser holds a seat at, with whose move it
|
||||
is and unread talk), *your reports to the keeper*, and *about this game —
|
||||
a labor of love*.
|
||||
is and unread talk; for someone coming back it comes first, and finished
|
||||
games wait further down), *your reports to the keeper*, and *about this game*,
|
||||
which opens the about panel: a dialog of tabs (Origins, Games so far,
|
||||
More games, Send word; a game may rename the first two in its own world,
|
||||
as Waving Hands' Duels so far, or add its own, as Dip's Playing here), `TabbedDialog.svelte`, with each game's words in its hall.
|
||||
The address names the dialog (`#about`, `#about/tally`), so a plain link
|
||||
opens it; a game may put its guide and rules in dialogs the same way, as
|
||||
Dip does. Its strip of tabs is `Tabs.svelte`.
|
||||
- **A table** is a room. Its code is four letters or digits from an alphabet
|
||||
with no look-alikes, and its link is `/join/CODE`. The masthead button
|
||||
reads *invite friends · room CODE* and copies the link. Whoever opened
|
||||
@@ -25,6 +31,10 @@ README.
|
||||
*lets the Peanut Gallery talk*; then a watcher signs a name no seat holds
|
||||
and their lines read *Name (gallery)*. Each line is a ledger line, so it
|
||||
replays with the game, and the hall counts lines said while you were away.
|
||||
A line said while a game is running carries its round (`turn`). A seat
|
||||
may also speak privately, naming seats (`room.say(text, to)`): the line
|
||||
is shown to its sender and those seats alone, never to the gallery, and
|
||||
a game whose talk is its heart puts the choice of whom in its talk box.
|
||||
- **A rematch**: a finished table may call for one. A new table of the same
|
||||
size opens with the same bots, seats held for the same people, and the
|
||||
caller as host; a second call finds it already open. Held seats refuse
|
||||
@@ -37,6 +47,27 @@ README.
|
||||
- **A seat travels by phrase**: *Transfer seat* in the masthead mints four
|
||||
words good for ten minutes and one use; the hall's *bring a seat from
|
||||
another device* claims them. The browser that claims holds the seat too.
|
||||
- **The keeper can bring a seat back**: a player whose browser lost the
|
||||
seat (Safari's seven days, an app's own browser, cleared data) writes to
|
||||
the keeper, and `<slug>-reseat.sh ROOM SEAT` on the host gives the seat
|
||||
a new token and prints a link carrying it in the fragment. The old token
|
||||
opens nothing after. The script answers through the game server's
|
||||
`/keeper` door, which Caddy does not proxy and which asks for the key in
|
||||
`/etc/<slug>/keeper.key`, made by the deploy and readable by root and the
|
||||
game's user alone. A spectator at a game in play
|
||||
is told the ways back, and the hall asks before forgetting a game in play.
|
||||
- **The host has a hand on the other seats**: once the game begins, *Seats*
|
||||
in the host's masthead makes a transfer phrase for another person's seat
|
||||
(claimed, it gives the seat a new token, as the keeper's link does), and
|
||||
gives the seat of a player who cannot go on to a bot, which plays it
|
||||
from the move the seat owes and does not speak in the person's name.
|
||||
The person's token opens nothing while it stands in, with the reason;
|
||||
their page goes to the gallery, where a claim box takes the host's
|
||||
phrase. A rematch holds the person's seat and leaves the stand-in out.
|
||||
- **A dead browser's socket is closed**: the server pings every socket on
|
||||
the protocol's own ping, which browsers answer by themselves, and closes
|
||||
one silent for a whole round, so a crashed tab does not linger in the
|
||||
gallery's count.
|
||||
- **Held seats are doubted before they are dropped**: the hall forgets a
|
||||
seat only on the second refusal in a row from the server. A restart, a
|
||||
stale answer or the wrong backend answer with ignorance, and ignorance
|
||||
@@ -63,10 +94,20 @@ README.
|
||||
- **Send word** is the contact paragraph: mail, Bluesky and Mastodon, and
|
||||
where the keeper roosts. It lives in the about panel; the colophons, the
|
||||
rules page and the guide each carry a one-line mail link.
|
||||
- **The family list** in the about panel, *More games in Kestrel's Hall*,
|
||||
comes from `family.json` and its heading links to the family's landing
|
||||
page, gamehall.kestrelsnest.social. A new game is added to the kit's
|
||||
`family.json` and given a card on that page (`gamehall/` in the kit);
|
||||
both are hand-kept.
|
||||
- **A move names the round it was made for.** The room counts rounds
|
||||
played (`resolved` in its view), and a page sends that count with each
|
||||
move; the server refuses a move whose count is behind, so a slow
|
||||
connection, a second click or a stale tab can never be taken as the
|
||||
move for the next round. A page without the count is told to reload.
|
||||
- **The hosting line** follows the family list in the about panel: the
|
||||
games are free and will stay free, the servers cost a few dollars a
|
||||
month, and ko-fi.com/kestrelsnest is where anyone may chip in. It says
|
||||
nothing is unlocked by it and nothing withheld without it, and it
|
||||
chipping in unlocks nothing and nothing is held back without it, and it
|
||||
never names a game's rights holder: a tip is for the hosting, not the
|
||||
game.
|
||||
|
||||
@@ -80,22 +121,32 @@ README.
|
||||
- **The tally** sits in the about panel: what the ledgers add up to
|
||||
(tables, games begun and finished, names seated, turns, time at the
|
||||
table, games with a bot, talk), counted on demand from the files and
|
||||
cached a minute. A game adds its own lines through `GameSpec.tally`.
|
||||
cached a minute. A game adds its own lines through `GameSpec.tally` and
|
||||
`GameSpec.tallyBegun`.
|
||||
- **Table options** are the host's choices when opening a table (a
|
||||
variant, a side, a length): the game declares them in `GameSpec.options`,
|
||||
the hall renders them as selects, the room line records them, and
|
||||
`create` receives them. A game with one way to play declares none.
|
||||
`create` receives them. A game with one way to play declares none. An
|
||||
option that changes how many can sit (a smaller map) says so through
|
||||
`GameSpec.seatsFor`. The server, the tally and the ledger replay all read
|
||||
a room line through `cleanOptions` and size a table with `tableSize`,
|
||||
both in `spec.ts`, so each creates the same game from the same ledger.
|
||||
|
||||
## The engine
|
||||
|
||||
- Pure and deterministic: `create(names, seed, rules)` and
|
||||
- Pure and deterministic: `create(names, seed, rules, options)` and
|
||||
`resolve(state, inputs)` are functions of their arguments with a seeded
|
||||
generator inside the state. Bots are functions of the state too. This is
|
||||
what lets a ledger be the whole record.
|
||||
- Simultaneous by default: every seat that `needsInput` submits, then the
|
||||
round resolves at once, bots included. A seat that owes an extra turn
|
||||
alone (a time stop, a bonus move) is just a state where only it needs
|
||||
input; the server resolves rounds owed only to bots straight away.
|
||||
input; the server resolves rounds owed only to bots straight away. A few
|
||||
such rounds are played at once; past that (a table every person is out
|
||||
of), the bots play on a round every two seconds so the table can be
|
||||
watched and the server answers everyone else between rounds. The view
|
||||
says so (`playingOn`), and a seat may ask for the rest at once
|
||||
(`room.hurry()`).
|
||||
- Hidden information is the engine's business: `view(state, viewer)`
|
||||
returns what one seat may see, and the SPECTATOR view is the
|
||||
intersection of everyone's. The server sends nothing else.
|
||||
@@ -104,14 +155,49 @@ README.
|
||||
round resolves; keep the old path behind `state.rules < N` and pin both
|
||||
with tests. A fix that diverges from no ledger ships without a bump.
|
||||
|
||||
## Optional hooks
|
||||
|
||||
A game implements only the hooks it needs; without one, the kit behaves as
|
||||
if it were never there.
|
||||
|
||||
- `validate(state, seat, input)`: a reason to refuse a move, shown to the player.
|
||||
- `seatsFor(options)`: fewer seats for some table options.
|
||||
- `settled(input)`: whether an input is final. A game with drafts answers
|
||||
false until the player says it is done; a draft may be replaced (and a
|
||||
settled move taken back as a draft) until the round resolves, and the
|
||||
round waits for every seat's settled input. A seat's view carries its
|
||||
own input in waiting (`yourInput`), so a page reopened shows the draft,
|
||||
and its input in the last round it played (`yourLastInput`), so the page
|
||||
can tell it what became of its own choice.
|
||||
- `rankable(options)`: the choices a table asks each seat to rank before
|
||||
the deal (sides, roles, colours). The lobby shows a seat its own ranking
|
||||
and how many have ranked, nothing more; the start line records every
|
||||
ranking and `create` receives them. A solo table asking for one waits in
|
||||
the lobby for the player's.
|
||||
- `botPress(state, seat, press, people, trigger)`: bots that talk. A bot
|
||||
speaks when a round opens and when a person writes to it (or says a first
|
||||
word to the table); its lines go privately to the people it names, as
|
||||
ledger lines, with a `note` for its own memory that no view ever carries.
|
||||
`botInput` receives the same press, so a bot can keep what was agreed.
|
||||
`parting(press, viewer)` gives the bots' last words once the game is
|
||||
over. Without `botPress`, private talk to a bot is refused.
|
||||
- `tally(state)`: the game's own lines for the tally, from a finished game;
|
||||
`tallyBegun(options)`: lines from every game begun, finished or not, by
|
||||
its table's options (games begun on each variant).
|
||||
- `preferences`, `options`: the preferences panel's settings and the
|
||||
host's table choices (above).
|
||||
|
||||
## The server
|
||||
|
||||
One Node process per game, on its own port, run from source with `tsx` as
|
||||
a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
|
||||
and `/ws` before the static files. Rooms in memory are evicted after a week
|
||||
idle and come back from disk on the next visit. Rate limits sit on the doors
|
||||
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
|
||||
never nears them. A seat token is minted once, sent once, and compared by
|
||||
a stranger can knock on (rooms, seats, talk, the desk), and a looser one
|
||||
on a seat's own actions, each a ledger line; a table of friends never nears
|
||||
them. An address holds at most twenty sockets, a socket that stops
|
||||
answering pings is closed, and a request has seconds, not minutes, to
|
||||
arrive. A seat token is minted once, sent once, and compared by
|
||||
hash in constant time ever after; a shared link carries none. Room codes
|
||||
are checked against the disk as well as memory, so an evicted room's code
|
||||
is never reissued over its ledger. A game may refuse a move with a reason
|
||||
@@ -129,12 +215,17 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
||||
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
|
||||
today, every room opened and how far it got. Names seated for the first
|
||||
time are marked NEW.
|
||||
- `<slug>-reseat.sh ROOM [SEAT]`: who sits at a table and when each last
|
||||
acted; with a seat, a new token for a player locked out, as a link.
|
||||
- `<slug>-say.sh ROOM "text"`: a word from the keeper in a table's talk,
|
||||
signed as the keeper, whether or not the gallery may talk.
|
||||
- `<slug>-pulse.sh` (skill `<slug>-pulse`): service health, journal errors,
|
||||
the rollup's last week, the backup's last word, the box's vitals.
|
||||
- `<slug>-rollup.sh` nightly at 00:12 UTC: one JSON line a day of counts,
|
||||
no addresses. `<slug>-backup.sh` nightly at 07:23 UTC: rclone to the
|
||||
shared Space, mirror plus dated snapshots pruned after 90 days. Both check
|
||||
in with Sentry Crons so a missed night is noticed.
|
||||
shared Space, mirror plus dated snapshots pruned after 90 days. On the
|
||||
shared host the hall's `hall-nightly.sh` runs both for every game and
|
||||
checks in with Sentry Crons once for all, so a missed night is noticed.
|
||||
- The reports desk (skill `<slug>-reports`): `pull-reports.sh` mirrors the
|
||||
desk to the Desktop with a digest; `report-reply.sh <ip> <id> <status>
|
||||
"text"` answers with `resolved`, `by-design` or `open`. A fix goes live
|
||||
@@ -153,6 +244,110 @@ emoji in the interface; the words do the work. Phone width first: a sticky
|
||||
bar for the move if the board runs long, lists that shorten, gutters of
|
||||
16px, no horizontal scroll.
|
||||
|
||||
## The voice
|
||||
|
||||
Every game speaks in its own world. Wherever the page tells a player what
|
||||
happened, it gives an image as well as the fact: not "Corwin takes 5 from
|
||||
a fireball" but "A fireball bursts over Corwin, for 5". The fact stays
|
||||
exact and whole (the number, the duration, who is affected, the turn a
|
||||
thing will happen), and the image carries it.
|
||||
|
||||
- **The chronicle** is where the voice matters most. Each move, blow,
|
||||
enchantment, arrival and death gets its own picture. Something that
|
||||
unfolds over turns (an illness, a siege, a fuse) is told a turn at a
|
||||
time, names the turn it ends on when it begins, and warns on the last
|
||||
turn anything can be done about it.
|
||||
- **The rest of the surround** speaks the same way: the lobby, the guide,
|
||||
the status lines, the verdict. Plain words for controls and buttons;
|
||||
the images belong to what happens, not to what you press.
|
||||
- **Our own words.** An older implementation's prose is a guide to the
|
||||
tone, never a source to copy.
|
||||
- **Plain chrome.** Headings, tabs, captions and notes say what is there
|
||||
in ordinary words. The images live in what happens, not in the labels
|
||||
around it. The patterns readers take as generated text are listed under
|
||||
"Writing for players" below.
|
||||
- **Changing text is not changing rules.** Chronicle wording is not part
|
||||
of the ledger, so it ships without a rules revision; the deploy gate
|
||||
compares outcomes, not sentences. A test that pins a phrase should pin
|
||||
the fact inside it.
|
||||
|
||||
## Writing for players
|
||||
|
||||
Players have learned the habits of generated text, and once they spot one
|
||||
they trust the rest of the page less. Every game built from this kit has
|
||||
needed an editing pass to remove them. Write without them from the start,
|
||||
and read each new page against this list before it ships.
|
||||
|
||||
- **One semicolon is fine. A semicolon in every paragraph is a tell.** Use
|
||||
two sentences, or join with "and" or "so". Lists of rules may keep their
|
||||
semicolons.
|
||||
- **No statement, colon, explanation.** "The table is yours: seat a bot,
|
||||
invite a friend" becomes "You can seat a bot or invite a friend". The
|
||||
same goes for a label and a colon at the start of a line ("Shieldwall:
|
||||
two or more pieces..."), and for a heading that ends in a colon.
|
||||
- **No headings made of "The" and an abstract noun** ("The game", "The
|
||||
tally"). Name what is there ("Origins", "Games so far").
|
||||
- **A button may name its action and its cost** ("Absorb: take 2
|
||||
instead"). The colon rule is for sentences, not for controls.
|
||||
- **No clipped fragments in threes** as a habit. "Four towers. One crown.
|
||||
No second chances." Write one sentence instead. A game may keep one
|
||||
deliberate triplet as its tagline when the energy is the point, as
|
||||
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
|
||||
does. Once is a choice, and a pattern is a tell.
|
||||
- **No one sentence shape for a whole list.** Thirty chronicle lines that
|
||||
each run image, colon, consequence read as a template however good the
|
||||
images are. Let the fact come first in some, in its own sentence in others,
|
||||
and joined by "and" or "which" in the rest.
|
||||
- **No closing aphorisms.** A paragraph ends on its last fact, not on a line
|
||||
meant to sound wise ("This is the whole art", "Nothing is unlocked by it
|
||||
and nothing is withheld without it").
|
||||
- **No "X, not Y" flourishes** ("drawn, not borrowed"). Say what it is.
|
||||
- **No page that talks about itself.** Avoid "This page walks the room", "the
|
||||
page keeps watch" and "a walk through the page". Describe the game and
|
||||
what the player does.
|
||||
- **No spaced em dashes** in the interface, and few anywhere.
|
||||
- **Nothing copied from another game that is untrue here.** Text carried over
|
||||
from the kit or a sibling game is the most common source of errors. Look
|
||||
for hidden information in a game with none, points in a game without
|
||||
them, a bot's name from another game, and "both" at a table that seats
|
||||
more than two. Read every inherited line as if it were new.
|
||||
- **Every claim checked.** A date, a count, "only" or "never" is checked
|
||||
against the source and the engine. "A thousand years before chess" was
|
||||
wrong, and so was "a shield is the only defence".
|
||||
- **Words for any table size.** Use "every player" for what each does on a
|
||||
turn, and plurals for opponents ("your rivals"), unless the game seats
|
||||
exactly two.
|
||||
- **Some words are load-bearing.** Before rewording, grep for the string.
|
||||
An end-of-game reason written to a ledger line is matched later by exact
|
||||
text, and rewording one needs a migration. The client may match a server
|
||||
error by its words. Replay screenshots kept as goldens include captions,
|
||||
and stored shares carry event text. A bot's lines may be told apart by
|
||||
their shape, and the examples it gives players must still parse.
|
||||
- **Bots should not share scaffolding.** When each personality recites the
|
||||
same clause ("I keep out of X, you keep out of Y, through 1903"), they
|
||||
stop sounding like people. Give each its own way of saying the terms, and
|
||||
avoid assistant phrasing ("Certainly", "Happy to help", "I hope you
|
||||
understand").
|
||||
|
||||
The game's own voice (above) is not on this list. The chronicle's images,
|
||||
the in-world names, the keeper and a game's whimsy are what the editing
|
||||
passes protect. The template's plain lines are a default. A game may say
|
||||
the same thing in its own voice ("The gallery listens. The host has not
|
||||
opened its mouth.") as long as the fact survives.
|
||||
|
||||
## The move
|
||||
|
||||
- **Every history reads top to bottom**, oldest first, and scrolls to the
|
||||
newest line: the ledger, the chronicle, table talk. That is the order
|
||||
people already know from chat.
|
||||
- **The move and its button stay together and in view.** Whatever the
|
||||
player has chosen this turn, what it will do, any warning, and the
|
||||
button that commits it sit in one row that does not scroll away.
|
||||
- **Warn before, not after.** A move that is legal but wasted is pointed
|
||||
out while it can still be changed, and left legal.
|
||||
- **A move can be typed** when the game's moves have short names, with
|
||||
Enter to commit and Backspace to take one back.
|
||||
|
||||
## Things learned the hard way
|
||||
|
||||
- Caddy's `try_files` rewrites `/api` before `reverse_proxy` unless the
|
||||
@@ -160,6 +355,11 @@ bar for the move if the board runs long, lists that shorten, gutters of
|
||||
- `npm install --omit=optional` on the droplet breaks Vite; the server
|
||||
carries its own small `package.json` and installs only that.
|
||||
- A dynamic `/join/[code]` route must set `prerender = false`.
|
||||
- The hall, the rules and the guide set `ssr = true` in their own `+page.ts`,
|
||||
so view-source, a crawler and a reader without scripts get the text; the
|
||||
rest stays an empty shell. The adapter's fallback is `shell.html`, not
|
||||
`index.html`, because a fallback named `index.html` overwrites the
|
||||
prerendered hall, and Caddy's `try_files` ends in `/shell.html`.
|
||||
- Two tabs on one origin share a seat; test a second player from
|
||||
`127.0.0.1` against `localhost` (Vite needs `--host`).
|
||||
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
|
||||
|
||||
@@ -3,17 +3,28 @@
|
||||
The shared infrastructure of Wiz-War and Waving Hands, extracted so the next
|
||||
game starts with all of it in place: the hall, room codes and tables, the
|
||||
Peanut Gallery (and the host's leave for it to talk), table talk, rematches,
|
||||
seats that travel between devices by phrase, the keeper's bell, append-only
|
||||
ledgers replayed through a
|
||||
deterministic engine, the rules revision, the reports desk, the deploy
|
||||
script with its determinism gate, nightly rollup and backup, Sentry with
|
||||
Slack alerts, and the visitors, pulse and reports skills for Claude.
|
||||
seats that travel between devices by phrase and come back by the keeper's
|
||||
link or the host's phrase when a browser loses them, a bot to stand in
|
||||
for a player who cannot go on, the keeper's bell, append-only ledgers
|
||||
replayed through a deterministic engine, the rules revision, the reports
|
||||
desk, the deploy script with its determinism gate, nightly rollup and
|
||||
backup, Sentry with Slack alerts, and the visitors, pulse and reports
|
||||
skills for Claude.
|
||||
|
||||
The game itself is one file. `src/lib/game/index.ts` implements `GameSpec`
|
||||
(see `src/lib/game/spec.ts`); the server, the client store, the hall and
|
||||
the ops tools never look past it. The template ships with a demo game,
|
||||
High Card, so everything runs before you have written a line.
|
||||
|
||||
Beyond the required methods, a game opts into what it needs through
|
||||
optional hooks, and without one the kit behaves as if it were not there:
|
||||
`validate` (refuse a move with a reason), `seatsFor` (fewer seats for some
|
||||
options), `settled` (drafts a player may replace until final), `rankable`
|
||||
(a secret ranking before the deal, read by `create`), `botPress` and
|
||||
`parting` (bots that talk, remember by notes and read the talk in
|
||||
`botInput`), and `tally` / `tallyBegun` (the game's own lines in the tally).
|
||||
`CONVENTIONS.md` says what each does.
|
||||
|
||||
## Start a game
|
||||
|
||||
./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
@@ -26,6 +37,7 @@ the new project as `docs/conventions.md`; it is the house style.
|
||||
|
||||
Run the demo locally from a new project:
|
||||
|
||||
npm run dev:all # server and site together; Ctrl-C stops both
|
||||
npm run server # the game server on its port
|
||||
npm run dev -- --host
|
||||
|
||||
@@ -35,9 +47,9 @@ Run the demo locally from a new project:
|
||||
src/lib/game/spec.ts the contract a game implements
|
||||
src/lib/game/index.ts the demo game; replace it
|
||||
src/lib/net/ client.ts (calls, held seats, socket), room.svelte.ts (the room store), talk.ts, view.ts
|
||||
src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, Board (demo; replace it)
|
||||
src/lib/components/ Hall, Lobby, TableTalk, ReportSlip, SeatsSlip, Board (demo; replace it)
|
||||
src/routes/ the hall, /join/[code], /rules, /guide
|
||||
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), ratelimit
|
||||
server/src/ index (routes), rooms (game-agnostic), store (ledger), reports (the desk), tally, ratelimit, and their tests
|
||||
deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
|
||||
.claude/skills/ <slug>-visitors, <slug>-pulse, <slug>-reports
|
||||
|
||||
@@ -68,11 +80,48 @@ then the round resolves; turn-at-a-time games fit as the case where one
|
||||
seat needs input at a time. A game of single commands with out-of-turn
|
||||
interruptions, Wiz-War's shape, does not fit and keeps its own server.
|
||||
|
||||
## The hall-wide notice
|
||||
|
||||
Every game on the shared host shows the same notice at the top of its
|
||||
pages when there is one: maintenance, a new game, anything the whole
|
||||
family should hear. The keeper sets it on the host,
|
||||
|
||||
hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC"
|
||||
hall-announce --clear
|
||||
|
||||
which writes /var/lib/hall/announcement.json; each game's site serves it at
|
||||
`/hall/announcement.json` (a `handle_path` in its Caddy site file), and
|
||||
`Announcement.svelte`, in the template's layout, shows it with the time in
|
||||
each reader's own clock. A reader may dismiss one notice; the next still
|
||||
shows. `hall-announce` is installed by `gamehall/deploy.sh`.
|
||||
|
||||
## The family
|
||||
|
||||
`family.json` lists every game of Kestrel's Hall: slug, name, address,
|
||||
one-line pitch, player count and origin. Each game ships a copy as
|
||||
`static/family.json` (the deploy script takes the kit's latest when the kit
|
||||
is beside the game) and its about panel lists the others under *More games
|
||||
in Kestrel's Hall*. Add a game here, then deploy the games; nothing
|
||||
else needs to know.
|
||||
one-line pitch, player count and origin, plus `hall`, the address of the
|
||||
family's landing page. Each game ships a copy as `static/family.json` (the
|
||||
deploy script takes the kit's latest when the kit is beside the game) and
|
||||
its about panel lists the others under *More games in Kestrel's Hall*, the
|
||||
heading linking to the family's landing page. Add a game here, then
|
||||
deploy the games.
|
||||
|
||||
That landing page, https://gamehall.kestrelsnest.social, is one static page
|
||||
in `gamehall/site/` with a card per game, written by hand. It is served by
|
||||
Caddy on the shared host beside the games (`gamehall/gamehall.caddy`) and
|
||||
published with `gamehall/deploy.sh 159.203.115.252`. A new game is not in
|
||||
the family until it has its card there too.
|
||||
|
||||
Its visitors are counted from Caddy's log by `gamehall/ops/` (installed by
|
||||
the same deploy): `gamehall-visitors.sh` on the host for today, and a
|
||||
nightly rollup line in /var/lib/gamehall/rollup.jsonl. The skill
|
||||
`gamehall-visitors` reads them. A new game on the shared host is added to
|
||||
`GAMES` in `gamehall/ops/traffic.py` so the digest counts the people the
|
||||
hall sends it.
|
||||
|
||||
The hall also keeps the night on the shared host: `hall-nightly.sh rollup`
|
||||
at 00:12 UTC and `hall-nightly.sh backup` at 07:23 UTC (`gamehall.cron`)
|
||||
run the hall's own rollup and every game's rollup and backup found in
|
||||
/usr/local/bin, and check in with Sentry Crons once for each job, monitors
|
||||
`hall-rollup` and `hall-backup` in the `gamehall` project, from the URLs in
|
||||
/root/.hall-sentry-cron-rollup and /root/.hall-sentry-cron-backup. A game's
|
||||
own cron file and check-ins are for a box of its own.
|
||||
@@ -10,11 +10,13 @@ GAME="${1:?usage: drift.sh <game-dir> [slug]}"
|
||||
SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
|
||||
KIT="$(cd "$(dirname "$0")" && pwd)"
|
||||
MAP="$KIT/drift-map/$SLUG.txt"
|
||||
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts
|
||||
src/lib/net/client.ts src/lib/net/talk.ts src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte
|
||||
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
|
||||
src/routes/+layout.ts src/routes/+page.ts src/routes/rules/+page.ts src/routes/guide/+page.ts vite.config.ts
|
||||
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
|
||||
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte
|
||||
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
|
||||
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
|
||||
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
|
||||
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh deploy/__SLUG__-reseat.sh deploy/__SLUG__-say.sh"
|
||||
total=0
|
||||
for t in $SHARED; do
|
||||
g="$t"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"family": "Kestrel's Hall",
|
||||
"hall": "https://gamehall.kestrelsnest.social",
|
||||
"games": [
|
||||
{
|
||||
"slug": "wizwar",
|
||||
@@ -13,7 +14,7 @@
|
||||
"slug": "waving-hands",
|
||||
"name": "Waving Hands",
|
||||
"url": "https://hands.kestrelsnest.social",
|
||||
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
|
||||
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
|
||||
"players": "2 to 12",
|
||||
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
||||
},
|
||||
@@ -21,9 +22,25 @@
|
||||
"slug": "hnefatafl",
|
||||
"name": "Hnefatafl",
|
||||
"url": "https://tafl.kestrelsnest.social",
|
||||
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
|
||||
"pitch": "The Viking board game of a king breaking for safety and a ring of attackers closing in.",
|
||||
"players": "2",
|
||||
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
|
||||
},
|
||||
{
|
||||
"slug": "dip",
|
||||
"name": "Dip",
|
||||
"url": "https://dip.kestrelsnest.social",
|
||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||
"players": "2 to 10, depending on the map",
|
||||
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
|
||||
},
|
||||
{
|
||||
"slug": "wyrm",
|
||||
"name": "Wyrm",
|
||||
"url": "https://wyrm.kestrelsnest.social",
|
||||
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
|
||||
"players": "2",
|
||||
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish Kestrel's Hall to the shared host: the page to /opt/gamehall and
|
||||
# its Caddy site file beside the games'. Usage: gamehall/deploy.sh <host>
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: gamehall/deploy.sh <host>}"
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
rsync -az --delete "$HERE/site/" "root@$HOST:/opt/gamehall/"
|
||||
scp -q "$HERE/gamehall.caddy" "root@$HOST:/etc/caddy/sites/gamehall.caddy"
|
||||
rsync -az "$HERE/ops/" "root@$HOST:/tmp/gamehall-ops/"
|
||||
ssh "root@$HOST" '
|
||||
set -e
|
||||
chown -R root:caddy /opt/gamehall
|
||||
chmod -R g+rX /opt/gamehall
|
||||
# Validating as root would create the access log as root; Caddy runs as caddy and must own it.
|
||||
touch /var/lib/caddy/gamehall-access.log
|
||||
chown caddy:caddy /var/lib/caddy/gamehall-access.log
|
||||
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1
|
||||
chown caddy:caddy /var/lib/caddy/gamehall-access.log
|
||||
systemctl reload caddy
|
||||
# The counting tools: installed on every publish so the live copies are the repo copies.
|
||||
install -m 755 /tmp/gamehall-ops/gamehall-visitors.sh /usr/local/bin/gamehall-visitors.sh
|
||||
install -m 755 /tmp/gamehall-ops/gamehall-rollup.sh /usr/local/bin/gamehall-rollup.sh
|
||||
# The nightly job that runs the rollup and backup of every game here and checks in once for all.
|
||||
install -m 755 /tmp/gamehall-ops/hall-nightly.sh /usr/local/bin/hall-nightly.sh
|
||||
install -m 644 -D /tmp/gamehall-ops/traffic.py /usr/local/lib/gamehall/traffic.py
|
||||
install -m 644 /tmp/gamehall-ops/gamehall.cron /etc/cron.d/gamehall
|
||||
# The hall-wide notice every game serves at /hall/announcement.json.
|
||||
install -m 755 /tmp/gamehall-ops/hall-announce.sh /usr/local/bin/hall-announce
|
||||
install -d -m 755 /var/lib/hall
|
||||
mkdir -p /var/lib/gamehall /var/log/gamehall
|
||||
rm -r /tmp/gamehall-ops
|
||||
'
|
||||
echo "published."
|
||||
@@ -0,0 +1,31 @@
|
||||
# Kestrel's Hall, the front door of the family: a static page, no server.
|
||||
gamehall.kestrelsnest.social, gamehall.159.203.115.252.sslip.io {
|
||||
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
|
||||
handle_path /hall/* {
|
||||
root * /var/lib/hall
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
root * /opt/gamehall
|
||||
encode gzip zstd
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
# A card followed into a game says it came from the hall, and nothing more.
|
||||
Referrer-Policy "strict-origin"
|
||||
# Revalidated on every load, so a deploy shows up at once.
|
||||
Cache-Control "no-cache"
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/lib/caddy/gamehall-access.log {
|
||||
roll_size 10MiB
|
||||
roll_keep 30
|
||||
}
|
||||
}
|
||||
|
||||
file_server
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nightly rollup: one JSON line per day in /var/lib/gamehall/rollup.jsonl,
|
||||
# the hall's counts for that day (people, views, bots, referrers, campaigns,
|
||||
# and the people sent on to each game here). Counts only: no addresses are
|
||||
# kept, so the trend outlives the self-rotating access log.
|
||||
# gamehall-rollup.sh [YYYY-MM-DD] (default: yesterday, UTC)
|
||||
# Run nightly by hall-nightly.sh rollup (/etc/cron.d/gamehall).
|
||||
set -u
|
||||
OUT="/var/lib/gamehall/rollup.jsonl"
|
||||
LOG="/var/log/gamehall/rollup.log"
|
||||
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
|
||||
mkdir -p "$(dirname "$OUT")" "$(dirname "$LOG")"
|
||||
python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import datetime, json, os, sys
|
||||
sys.path.insert(0, "/usr/local/lib/gamehall")
|
||||
import traffic
|
||||
day, out = sys.argv[1], sys.argv[2]
|
||||
t = traffic.scan(day)
|
||||
row = dict(day=day, people=len(t["addresses"]), views=t["views"], botAddresses=len(t["botAddresses"]),
|
||||
scanners=t["scanners"], monitors=t["monitors"], sharedOn=dict(t["shared"]),
|
||||
referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]), sent=t["sent"],
|
||||
at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"))
|
||||
rows = [json.loads(l) for l in open(out) if l.strip()] if os.path.exists(out) else []
|
||||
rows = sorted([r for r in rows if r.get("day") != day] + [row], key=lambda r: r["day"])
|
||||
with open(out + ".tmp", "w") as fh:
|
||||
for r in rows:
|
||||
fh.write(json.dumps(r) + "\n")
|
||||
os.replace(out + ".tmp", out)
|
||||
print("%s rolled: %d people, %d views" % (day, row["people"], row["views"]))
|
||||
PY
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# The hall's visitors: who came in the last hour and today, where from, how
|
||||
# many went on into each game here, and the last week from the rollup.
|
||||
# Runs ON the shared host: gamehall-visitors.sh [YYYY-MM-DD] (default: today, UTC)
|
||||
set -u
|
||||
DAY="${1:-$(date -u +%F)}"
|
||||
python3 - "$DAY" <<'PY'
|
||||
import datetime, json, os, sys
|
||||
sys.path.insert(0, "/usr/local/lib/gamehall")
|
||||
import traffic
|
||||
|
||||
day = sys.argv[1]
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
print("as of %s UTC, day %s" % (now.strftime("%H:%M"), day))
|
||||
|
||||
if day == now.strftime("%F"):
|
||||
h = traffic.scan(day, since=now.timestamp() - 3600)
|
||||
print("last hour: %d people, %d page views" % (len(h["addresses"]), h["views"]))
|
||||
|
||||
t = traffic.scan(day)
|
||||
print("today: %d people, %d page views, %d bot addresses (of them %d scanners, %d monitors)" % (len(t["addresses"]), t["views"], len(t["botAddresses"]), t["scanners"], t["monitors"]))
|
||||
print("shared on: %s" % (dict(t["shared"].most_common()) or "none"))
|
||||
print("referrers: %s" % (dict(t["referrers"].most_common()) or "none"))
|
||||
print("campaigns: %s" % (dict(t["campaigns"].most_common()) or "none"))
|
||||
print("went on to: %s (Wiz-War is on its own host; its digest shows the hall as a referrer)" % (", ".join("%s %d" % kv for kv in t["sent"].items())))
|
||||
|
||||
out = "/var/lib/gamehall/rollup.jsonl"
|
||||
if os.path.exists(out):
|
||||
rows = [json.loads(l) for l in open(out) if l.strip()][-7:]
|
||||
if rows:
|
||||
print("last %d days:" % len(rows))
|
||||
for r in rows:
|
||||
sent = ", ".join("%s %d" % kv for kv in r.get("sent", {}).items())
|
||||
print(" %s %3d people %3d views refs %s on to %s" % (r["day"], r["people"], r["views"], r.get("referrers") or "-", sent or "-"))
|
||||
PY
|
||||
@@ -0,0 +1,4 @@
|
||||
# Kestrel's Hall: the nightly work of every game on this host, and the hall's own count. UTC.
|
||||
# The times are also SCHEDULE in hall-nightly.sh, which tells Sentry to expect them; change both.
|
||||
12 0 * * * root /usr/local/bin/hall-nightly.sh rollup
|
||||
23 7 * * * root /usr/local/bin/hall-nightly.sh backup
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
# The notice every game of Kestrel's Hall shows at the top of its pages.
|
||||
# Each site serves /var/lib/hall/announcement.json at /hall/announcement.json;
|
||||
# a page checks it on load and every few minutes. Run on the host as root:
|
||||
# hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC" [--until "2026-09-30 11:15 UTC"]
|
||||
# hall-announce --show
|
||||
# hall-announce --clear
|
||||
# --at is when it happens, told in each reader's own time; --until is when the
|
||||
# notice stops showing (an hour after --at when not given).
|
||||
set -euo pipefail
|
||||
FILE=/var/lib/hall/announcement.json
|
||||
|
||||
# No notice is an empty one, {}, never a missing file: a page's check for a missing file
|
||||
# is a failed request, and the browser reports it in every visitor's console.
|
||||
case "${1:-}" in
|
||||
--clear) mkdir -p "$(dirname "$FILE")"; echo '{}' > "$FILE.tmp"; chmod 644 "$FILE.tmp"; mv "$FILE.tmp" "$FILE"; echo "cleared."; exit 0 ;;
|
||||
--show) if grep -q '"text"' "$FILE" 2>/dev/null; then cat "$FILE"; else echo "no announcement."; fi; exit 0 ;;
|
||||
''|-*) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
||||
esac
|
||||
|
||||
TEXT="$1"; shift
|
||||
AT=""; UNTIL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--at) AT="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
|
||||
--until) UNTIL="$(date -u -d "$2" +%Y-%m-%dT%H:%M:%SZ)"; shift 2 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$AT" ] && [ -z "$UNTIL" ] && UNTIL="$(date -u -d "$AT + 1 hour" +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
mkdir -p "$(dirname "$FILE")"
|
||||
# A new id each time, so a reader who dismissed the last notice still sees this one.
|
||||
TEXT="$TEXT" AT="$AT" UNTIL="$UNTIL" ID="$(date -u +%Y%m%dT%H%M%S)" python3 -c '
|
||||
import json, os
|
||||
a = {"id": os.environ["ID"], "text": os.environ["TEXT"]}
|
||||
if os.environ["AT"]: a["at"] = os.environ["AT"]
|
||||
if os.environ["UNTIL"]: a["until"] = os.environ["UNTIL"]
|
||||
print(json.dumps(a))' > "$FILE.tmp"
|
||||
chmod 644 "$FILE.tmp"
|
||||
mv "$FILE.tmp" "$FILE"
|
||||
cat "$FILE"
|
||||
@@ -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
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Caddy's access logs for one UTC day: the hall's visitors, people and
|
||||
bots apart, and the people each game on this host received from the hall.
|
||||
The digest and the nightly rollup both count through here, so they agree."""
|
||||
|
||||
import datetime
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
# The bot, link-preview and scanner patterns are kept in step with template/deploy/traffic.py.
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
# Link previews: the app that fetched the page to show it in a conversation. The people
|
||||
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
||||
UNFURL = [
|
||||
("Slack", re.compile(r"slack", re.I)),
|
||||
("Discord", re.compile(r"discord", re.I)),
|
||||
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
||||
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)),
|
||||
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
||||
("Telegram", re.compile(r"telegram", re.I)),
|
||||
("Reddit", re.compile(r"redditbot", re.I)),
|
||||
("LinkedIn", re.compile(r"linkedinbot", re.I)),
|
||||
("Mastodon", re.compile(r"mastodon", re.I)),
|
||||
("Bluesky", re.compile(r"bluesky|cardyb", re.I)),
|
||||
("X", re.compile(r"twitterbot", re.I)),
|
||||
]
|
||||
# Paths nobody asks the hall for but a vulnerability scanner: its address is a bot's all day.
|
||||
SCANNER = re.compile(r"\.php$|(^|/)(\.env|\.git|\.aws|wp-admin|wp-login|wp-content|wp-includes|wordpress|phpmyadmin|cgi-bin|vendor|actuator|boaform|owa|admin)(/|$)", re.I)
|
||||
# Requests from one address more than this far apart are separate visits.
|
||||
VISIT_GAP = 30 * 60
|
||||
SITE = "gamehall.kestrelsnest.social"
|
||||
LOGS = "/var/lib/caddy/gamehall-access*.log*"
|
||||
# The games that share this host, by the name the hall links to. A visitor
|
||||
# who follows a card arrives with the hall as referrer (the hall sends its
|
||||
# origin and nothing more). Wiz-War keeps its own host and its own count.
|
||||
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social", "wyrm": "wyrm.kestrelsnest.social"}
|
||||
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
|
||||
|
||||
|
||||
def unfurled_by(ua):
|
||||
"""The app a link preview came from, or None."""
|
||||
for name, pattern in UNFURL:
|
||||
if pattern.search(ua):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _visits(times):
|
||||
n, last = 0, None
|
||||
for ts in sorted(times):
|
||||
if last is None or ts - last > VISIT_GAP:
|
||||
n += 1
|
||||
last = ts
|
||||
return n
|
||||
|
||||
|
||||
def _lines(pattern, t0):
|
||||
for f in sorted(glob.glob(pattern)):
|
||||
if os.path.getmtime(f) < t0:
|
||||
continue
|
||||
opener = gzip.open if f.endswith(".gz") else open
|
||||
try:
|
||||
with opener(f, "rt", errors="ignore") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def _bounds(day):
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
return d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
|
||||
|
||||
def scan(day, since=None):
|
||||
"""Counts for one UTC day (YYYY-MM-DD), or from `since` (a timestamp)
|
||||
to the end of it: page views and addresses of people and of bots, where
|
||||
links to the hall were shared, referrers, campaign tags (once per
|
||||
address), and the people each game here received from the hall.
|
||||
|
||||
A request is a bot's when its browser says so. An address is a bot's for
|
||||
the day when it asks for a scanner's paths, or loads the hall three
|
||||
visits or more without ever following a card to a game: a monitor."""
|
||||
t0, t1 = _bounds(day)
|
||||
if since is not None:
|
||||
t0 = max(t0, since)
|
||||
t = dict(views=0, addresses=set(), botAddresses=set(), shared=Counter(), scanners=0, monitors=0, referrers=Counter(), campaigns=Counter(), sent={})
|
||||
followed = set()
|
||||
for slug, host in GAMES.items():
|
||||
people = set()
|
||||
for r in _lines("/var/lib/caddy/%s-access*.log*" % slug, t0):
|
||||
if not (t0 <= r.get("ts", 0) < t1):
|
||||
continue
|
||||
q = r.get("request", {})
|
||||
h = q.get("headers", {})
|
||||
if SITE not in " ".join(h.get("Referer", [""])) or BOT.search(" ".join(h.get("User-Agent", [""]))):
|
||||
continue
|
||||
if q.get("uri", "").split("?")[0].endswith(ASSET):
|
||||
continue
|
||||
people.add(q.get("client_ip") or q.get("remote_ip") or "?")
|
||||
t["sent"][slug] = len(people)
|
||||
followed |= people
|
||||
|
||||
hall = [r for r in _lines(LOGS, t0) if t0 <= r.get("ts", 0) < t1 and r.get("request", {}).get("host", "").split(":")[0] == SITE]
|
||||
times, scanning = {}, set()
|
||||
for r in hall:
|
||||
q = r.get("request", {})
|
||||
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
||||
if BOT.search(ua) or not ua:
|
||||
continue
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
times.setdefault(ip, []).append(r.get("ts", 0))
|
||||
if SCANNER.search(q.get("uri", "").split("?")[0]):
|
||||
scanning.add(ip)
|
||||
monitors = {ip for ip, ts in times.items() if ip not in scanning and ip not in followed and _visits(ts) >= 3}
|
||||
posing = scanning | monitors
|
||||
t["scanners"], t["monitors"] = len(scanning), len(monitors)
|
||||
|
||||
seen_campaign = set()
|
||||
seen_share = set()
|
||||
for r in hall:
|
||||
q = r.get("request", {})
|
||||
h = q.get("headers", {})
|
||||
ua = " ".join(h.get("User-Agent", [""]))
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
uri = q.get("uri", "")
|
||||
path = uri.split("?")[0]
|
||||
if ip in posing or BOT.search(ua) or not ua:
|
||||
t["botAddresses"].add(ip)
|
||||
service = unfurled_by(ua)
|
||||
# One share per app and page: a preview is often fetched twice.
|
||||
if service and (service, path) not in seen_share:
|
||||
seen_share.add((service, path))
|
||||
t["shared"][service] += 1
|
||||
continue
|
||||
if path not in ("/", "/index.html"):
|
||||
continue
|
||||
t["views"] += 1
|
||||
t["addresses"].add(ip)
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and SITE not in ref:
|
||||
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m and (ip, m.group(1)) not in seen_campaign:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
return t
|
||||
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#14181a" stroke="#d2a94a" stroke-width="3"/><text x="32" y="44" text-anchor="middle" font-family="Iowan Old Style, Georgia, serif" font-size="36" fill="#e6dfcf">K</text></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,286 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kestrel's Hall</title>
|
||||
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, Dip and Wyrm. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
||||
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Kestrel's Hall">
|
||||
<meta property="og:description" content="Free board games in the browser. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
||||
<meta property="og:url" content="https://gamehall.kestrelsnest.social/">
|
||||
<meta property="og:image" content="https://gamehall.kestrelsnest.social/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="icon" href="favicon.svg">
|
||||
<style>
|
||||
:root {
|
||||
--ink: #14181a;
|
||||
--panel: #1c2124;
|
||||
--line: #2c3236;
|
||||
--bone: #e6dfcf;
|
||||
--bone-dim: #a9a494;
|
||||
--gold: #d2a94a;
|
||||
--gutter: 16px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--ink); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ink);
|
||||
color: var(--bone);
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--gold); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 var(--gutter); }
|
||||
|
||||
header.mast { padding: 56px 0 28px; text-align: center; }
|
||||
header.mast h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 6vw, 4rem);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.05;
|
||||
}
|
||||
header.mast p.lede {
|
||||
margin: 14px auto 0;
|
||||
max-width: 38em;
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.3rem);
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
header.mast p.lede b { color: var(--bone); font-weight: 500; }
|
||||
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
|
||||
|
||||
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
|
||||
.game {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.game:hover { border-color: var(--gold); text-decoration: none; }
|
||||
.game:hover h2 { color: var(--gold); }
|
||||
/* The first game is the one newcomers are pointed at, so it has the whole width: art beside the words. */
|
||||
.game.first { grid-column: 1 / -1; flex-direction: row; }
|
||||
.game.first .art { flex: 0 0 55%; aspect-ratio: 2 / 1; }
|
||||
.game.first .body { justify-content: center; padding: 22px 26px; }
|
||||
.game.first h2 { font-size: 1.9rem; }
|
||||
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
|
||||
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.game .art img.tafl { object-position: 50% 50%; }
|
||||
.game .art img.dip { object-position: 50% 80%; }
|
||||
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
|
||||
.game .pitch { margin: 0; color: var(--bone); }
|
||||
.game .players { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
|
||||
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
|
||||
@media (max-width: 860px) {
|
||||
.games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
|
||||
.game.first { flex-direction: column; }
|
||||
.game.first .art { flex: none; aspect-ratio: 5 / 3; }
|
||||
.game.first .body { padding: 16px 18px 18px; }
|
||||
.game.first h2 { font-size: 1.45rem; }
|
||||
}
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
|
||||
.maze {
|
||||
width: 100%; height: 100%;
|
||||
background: #2a2e33;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.06) 1px, transparent 1px),
|
||||
radial-gradient(circle at 30% 40%, rgba(255,240,200,.05), transparent 60%);
|
||||
background-size: 12.5% 20%, 12.5% 20%, 100% 100%;
|
||||
}
|
||||
.maze .w { position: absolute; background: #b8a77d; box-shadow: 0 0 0 1px #5c5238, 0 2px 4px rgba(0,0,0,.5); border-radius: 2px; }
|
||||
.maze .h { height: 3%; }
|
||||
.maze .v { width: 1.8%; }
|
||||
.maze .t { position: absolute; width: 12.5%; filter: drop-shadow(0 2px 3px rgba(0,0,0,.6)); }
|
||||
|
||||
section { margin-top: 56px; }
|
||||
section h2 { font-size: 1.6rem; font-weight: 400; margin: 0 0 12px; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .cols { grid-template-columns: 1fr; gap: 28px; } }
|
||||
section p { margin: 0 0 12px; color: var(--bone-dim); }
|
||||
section p b { color: var(--bone); font-weight: 500; }
|
||||
ul.how { margin: 0; padding-left: 1.2em; color: var(--bone-dim); }
|
||||
ul.how li { margin-bottom: 8px; }
|
||||
ul.how b { color: var(--bone); font-weight: 500; }
|
||||
|
||||
.keeper { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.keeper img { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; border: 2px solid var(--line); }
|
||||
@media (max-width: 480px) { .keeper { flex-direction: column; } }
|
||||
|
||||
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-dim); font-size: 0.9rem; }
|
||||
footer p { margin: 0 0 8px; }
|
||||
/* The hall-wide notice: its own colors, the same in every game of the hall. */
|
||||
.hall-announcement { position: sticky; top: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; gap: .8rem; padding: .45rem 2.4rem .45rem 1rem; background: #f2c45a; color: #1d1a12; font: 500 .95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: center; box-shadow: 0 1px 4px #0005; }
|
||||
.hall-announcement button { position: absolute; right: .5rem; background: none; border: 0; color: inherit; font-size: 1.3rem; line-height: 1; padding: .2rem .5rem; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="mast">
|
||||
<h1>Kestrel's Hall</h1>
|
||||
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
|
||||
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
|
||||
</header>
|
||||
|
||||
<div class="games">
|
||||
<a class="game first" href="https://wizwar.kestrelsnest.social">
|
||||
<div class="art">
|
||||
<div class="maze">
|
||||
<div class="w h" style="left:12.5%;top:19%;width:25%"></div>
|
||||
<div class="w v" style="left:36.2%;top:19%;height:42%"></div>
|
||||
<div class="w h" style="left:50%;top:39%;width:37.5%"></div>
|
||||
<div class="w v" style="left:61.2%;top:59%;height:41%"></div>
|
||||
<div class="w h" style="left:0;top:59%;width:25%"></div>
|
||||
<div class="w v" style="left:86.2%;top:0;height:40%"></div>
|
||||
<div class="w h" style="left:75%;top:80%;width:25%"></div>
|
||||
<img class="t" src="wizard-0.svg" alt="" style="left:0.5%;top:21%">
|
||||
<img class="t" src="wizard-1.svg" style="left:75.5%;top:42%" alt="">
|
||||
<img class="t" src="wizard-2.svg" style="left:38%;top:62%" alt="">
|
||||
<img class="t" src="treasure-0.svg" style="left:88%;top:1%" alt="">
|
||||
<img class="t" src="skeleton.svg" style="left:25.5%;top:1%" alt="">
|
||||
<img class="t" src="troll.svg" style="left:50.5%;top:21%" alt="">
|
||||
<img class="t" src="rock.svg" style="left:13%;top:82%" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>Wiz-War</h2>
|
||||
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
|
||||
<p class="players">2 to 6 players, or solo against the automaton</p>
|
||||
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="game" href="https://hands.kestrelsnest.social">
|
||||
<div class="art"><img class="fill" src="hands.png" alt="Four turns of a Waving Hands duel: each row a turn, each hand drawn in its gesture, and a spell named under the gesture that finished it."></div>
|
||||
<div class="body">
|
||||
<h2>Waving Hands</h2>
|
||||
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
|
||||
<p class="players">2 to 12 players, or solo against a rival wizard</p>
|
||||
<p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="game" href="https://tafl.kestrelsnest.social">
|
||||
<div class="art"><img class="fill tafl" src="tafl.svg" alt="A hnefatafl board at the start of a game: the king on the throne, defenders around him, attackers on the four edges."></div>
|
||||
<div class="body">
|
||||
<h2>Hnefatafl</h2>
|
||||
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
|
||||
<p class="players">2 players, or solo against the housecarl</p>
|
||||
<p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="game" href="https://dip.kestrelsnest.social">
|
||||
<div class="art"><img class="fill dip" src="dip.jpg" alt="Dip's map of Europe at the opening: the seven powers' armies and fleets on their home centers, each power's lands in its color."></div>
|
||||
<div class="body">
|
||||
<h2>Dip</h2>
|
||||
<p class="pitch">Seven powers share one map, and none of them has to keep its word. Other maps seat five or ten.</p>
|
||||
<p class="players">2 to 10 players, depending on the map, or solo against the house's envoys</p>
|
||||
<p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="game" href="https://wyrm.kestrelsnest.social">
|
||||
<div class="art"><img class="fill" src="wyrm.jpg" alt="Wyrm's glass board at the opening, seen from a corner: three tinted sheets, the air, the land and the underworld, with Gold's figures on the near ranks and Scarlet's on the far ones."></div>
|
||||
<div class="body">
|
||||
<h2>Wyrm</h2>
|
||||
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
|
||||
<p class="players">2 players, or solo against the drake</p>
|
||||
<p class="origin">An unofficial rebuild of Gary Gygax's 1985 Dragonchess, from Dragon Magazine No. 100</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="cols">
|
||||
<div>
|
||||
<h2>How a table works</h2>
|
||||
<ul class="how">
|
||||
<li><b>Start a table</b> and you get a four-letter code and a link. Send it to a friend, and begin once they sit down.</li>
|
||||
<li><b>No one home?</b> Every game can seat the house, whether an automaton, a housecarl, a rival wizard or an envoy, so you can play right away on your own.</li>
|
||||
<li><b>Nothing to sign up for.</b> Your browser holds your seat. Come back in the same browser and the game is where you left it. On another device, use Transfer seat in any game to carry your seat over with four words.</li>
|
||||
<li><b>Watchers welcome.</b> Anyone with the link can sit in the Peanut Gallery and, if the host allows, join the table talk.</li>
|
||||
<li><b>Something went wrong?</b> Every table has a report button that reaches the keeper with the exact moment attached.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>The keeper</h2>
|
||||
<div class="keeper">
|
||||
<img src="face.jpg" alt="Eric Wagoner">
|
||||
<div>
|
||||
<p>I am Eric, and I keep these tables from Athens, Georgia. These are games I have loved at a real table, rebuilt so friends can play them from wherever they are, with the door left open for anyone who wanders in.</p>
|
||||
<p>Send word to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric">@eric@toots.kestrelsnest.social</a> on Mastodon. I roost at <a href="https://blog.kestrelsnest.social">blog.kestrelsnest.social</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<h2 style="margin-top:28px">The hosting</h2>
|
||||
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>Wiz-War is Tom Jolly's design, and the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Hnefatafl belongs to everyone, and is more than a thousand years old. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro; the designers of Dip's Chromatic and Youngstown Redux maps are credited in Dip's How to play. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
|
||||
// host): checked on load, every few minutes, and when the page is looked at
|
||||
// again; a reader may dismiss one, and the next still shows.
|
||||
(() => {
|
||||
const KEY = 'hall:dismissed';
|
||||
let bar = null;
|
||||
let current = null;
|
||||
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
||||
function when(at) {
|
||||
const t = at ? Date.parse(at) : NaN;
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const minutes = Math.round((t - Date.now()) / 60000);
|
||||
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
|
||||
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
|
||||
return minutes >= -15 ? ' (now)' : '';
|
||||
}
|
||||
function show() {
|
||||
const a = current;
|
||||
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
|
||||
if (!live) { bar?.remove(); bar = null; return; }
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.className = 'hall-announcement';
|
||||
bar.setAttribute('role', 'status');
|
||||
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
|
||||
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
|
||||
document.body.prepend(bar);
|
||||
}
|
||||
bar.querySelector('span').textContent = a.text + when(a.at);
|
||||
}
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
|
||||
const a = res.ok ? await res.json() : null;
|
||||
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
|
||||
} catch {}
|
||||
show();
|
||||
}
|
||||
check();
|
||||
setInterval(check, 5 * 60000);
|
||||
setInterval(show, 30000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 189 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Rock token">
|
||||
<title>Rock</title><desc>Rock illustrated in shaded ink on parchment.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#7b8272" stop-opacity=".2"/><stop offset="1" stop-color="#7b8272" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M22 103L29 68 50 42 83 31 111 57 125 97 99 119 50 122Z" fill="#777b75" stroke="#302a29" stroke-width="2.0" /><path d="M29 68L64 74 83 31 50 42Z" fill="#b4b4a0" stroke="#302a29" stroke-width="2.0" /><path d="M64 74L99 119 50 122 22 103Z" fill="#929486" stroke="#302a29" stroke-width="2.0" /><path d="M64 74L108 62 125 97 99 119Z" fill="#535d59" stroke="#302a29" stroke-width="2.0" /><path d="M83 31L108 62 64 74Z" fill="#969b8d" stroke="#302a29" stroke-width="2.0" /><path d="M35 71L47 53 60 46M29 100L44 113M76 78L97 110" fill="none" stroke="#d0c9ac" stroke-width="2" /><path d="M99 72L109 90M104 70L117 96M103 104L115 99M52 117L47 104M57 117L52 103" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#7b8272" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Rock</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Skeleton token">
|
||||
<title>Skeleton</title><desc>Skeleton illustrated in shaded ink on parchment.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#657275" stop-opacity=".2"/><stop offset="1" stop-color="#657275" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M30 119L43 88 57 83 55 70 88 69 87 84 107 94 118 119Z" fill="#464b58" stroke="#302a29" stroke-width="2.0" /><path d="M46 91L26 102 21 78M99 93L114 82 117 59" fill="none" stroke="#302a29" stroke-width="9" /><path d="M46 91L26 102 21 78M99 93L114 82 117 59" fill="none" stroke="#e9dfc4" stroke-width="5" /><path d="M72 79V107M56 85Q70 93 87 84M54 94Q72 102 89 92M58 103Q72 110 85 101" fill="none" stroke="#302a29" stroke-width="8" /><path d="M72 79V107M56 85Q70 93 87 84M54 94Q72 102 89 92M58 103Q72 110 85 101" fill="none" stroke="#e9dfc4" stroke-width="4" /><path d="M61 109L53 123M81 108L91 122" fill="none" stroke="#e9dfc4" stroke-width="5" /><path d="M49 52Q45 27 70 24Q100 23 100 50L91 64 88 77 59 77 56 64Z" fill="#ede3c9" stroke="#302a29" stroke-width="2.0" /><path d="M85 29Q103 37 96 52L85 66 85 76 93 64 100 49Q100 32 85 29Z" fill="#b8ae93" stroke="none" stroke-width="2.0" /><ellipse cx="62" cy="51" rx="9" ry="10" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="50" rx="9" ry="10" fill="#302a29" stroke="none" stroke-width="2"/><path d="M50 41L68 48M78 46L96 38" fill="none" stroke="#ede3c9" stroke-width="5" /><path d="M73 58L67 67 78 67Z" fill="#302a29" stroke="#302a29" stroke-width="2.0" /><path d="M62 70H85M65 70V77M73 70V77M81 70V77" fill="none" stroke="#302a29" stroke-width="2" /><path d="M110 69L124 54M118 57L112 17 119 22 124 54Z" fill="#aeb8b5" stroke="#302a29" stroke-width="2.0" /><path d="M113 65L119 71" fill="none" stroke="#d6ad57" stroke-width="4" /></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#657275" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Skeleton</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="artwork svelte-14xsz9c calm" data-set="copenhagen" viewBox="0 0 452 452" role="img" aria-label="Copenhagen board in its opening position"><style>.artwork.svelte-14xsz9c {width:100%;height:auto;display:block;
|
||||
/* Copenhagen: a crafted oak board, polished counters and brass inlay. */--surface-light: #bd935c;--surface-dark: #79502d;--rim-light: #e0bf84;--attacker-light: #63564a;--defender-light: #fff4d8;--king-light: #ffe4a0;--wood: #a77945;--wood-line: rgba(30, 16, 6, 0.55);--coord: #302216;--square: rgba(255, 240, 210, 0.08);--square-edge: rgba(255, 250, 235, 0.11);--throne: rgba(240, 165, 58, 0.35);--corner: rgba(109, 52, 29, 0.22);--attacker: #2a2320;--attacker-rim: #211a15;--defender: #ece4d0;--defender-rim: #b9ab8b;--king: #f0d58a;--king-rim: #a67c1a;--crown: #6b4a0f;}
|
||||
|
||||
/* Tablut: an excavated-object aesthetic, with limestone and aged counters. */.artwork[data-set='tablut'].svelte-14xsz9c {--surface-light: #aaa18b;--surface-dark: #797564;--rim-light: #c4b99e;--attacker-light: #534f43;--defender-light: #ded0ae;--king-light: #e5cc92;--wood: #8a8578;--wood-line: rgba(36, 32, 27, 0.45);--coord: #37362c;--square: rgba(255, 250, 235, 0.05);--square-edge: rgba(255, 250, 235, 0.09);--throne: rgba(120, 100, 70, 0.4);--corner: transparent;--attacker: #3d3530;--attacker-rim: #29281f;--defender: #cfc4ad;--defender-rim: #8c8069;--king: #c9b27a;--king-rim: #7d6636;--crown: #5a4a2a;}.wood.svelte-14xsz9c {stroke:#79502d;stroke-width:1.4;}.stain.svelte-14xsz9c {fill:#fff;pointer-events:none;}.chip.svelte-14xsz9c {fill:rgba(28, 24, 20, 0.42);stroke:rgba(230, 222, 205, 0.18);stroke-width:0.6;pointer-events:none;}.crack.svelte-14xsz9c {fill:none;stroke:rgba(43, 39, 29, 0.48);stroke-width:0.5;stroke-linecap:round;pointer-events:none;}.coord.svelte-14xsz9c {fill:#302216;font-family:'Iowan Old Style', Georgia, serif;font-size:9px;font-weight:600;pointer-events:none;}.square.svelte-14xsz9c {fill:rgba(255, 240, 210, 0.08);stroke:rgba(30, 16, 6, 0.55);stroke-width:0.65;cursor:default;transition:fill 0.3s ease;}.square.edge.svelte-14xsz9c {fill:rgba(255, 250, 235, 0.11);}.square.throne.svelte-14xsz9c {fill:rgba(240, 165, 58, 0.35);}.square.corner.svelte-14xsz9c {fill:rgba(109, 52, 29, 0.22);}.square.last.svelte-14xsz9c {fill:rgba(140, 200, 224, 0.28);}.square.target.svelte-14xsz9c {cursor:pointer;}.square.fallen.svelte-14xsz9c {fill:rgba(224, 101, 92, 0.45);}.square.svelte-14xsz9c:focus-visible {outline:none;stroke:#9cd0dc;stroke-width:2.5;}.hint.svelte-14xsz9c {fill:#9cd0dc;opacity:0.85;}.board-rim.svelte-14xsz9c {fill:none;stroke:#e0bf84;stroke-width:0.7;opacity:0.65;pointer-events:none;}.join.svelte-14xsz9c {fill:none;stroke:#50351f;stroke-width:0.6;opacity:0.18;pointer-events:none;}.surface-wear.svelte-14xsz9c {pointer-events:none;}.crack-light.svelte-14xsz9c {fill:none;stroke:#e1d6bd;stroke-width:0.55;opacity:0.45;}.inlay.svelte-14xsz9c {fill:none;stroke:#e0bf84;stroke-width:0.8;opacity:0.75;pointer-events:none;}.artwork[data-set='tablut'].svelte-14xsz9c .inlay.svelte-14xsz9c {stroke:#494638;opacity:0.45;}.piece.svelte-14xsz9c {--piece-rim: #b9ab8b;transition:transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
opacity 0.3s ease;}.piece.attacker.svelte-14xsz9c {--piece-rim: #211a15;}.piece.king.svelte-14xsz9c {--piece-rim: #a67c1a;}.piece.dying.svelte-14xsz9c {opacity:0;}.piece.svelte-14xsz9c .shadow.svelte-14xsz9c {fill:#16130e;opacity:0.28;}.piece.svelte-14xsz9c .base.svelte-14xsz9c {fill:#b9ab8b;}.piece.svelte-14xsz9c .body.svelte-14xsz9c {stroke:#b9ab8b;stroke-width:0.65;}.bevel-light.svelte-14xsz9c {fill:none;stroke:#fff1cc;stroke-width:0.9;opacity:0.48;stroke-linecap:round;}.bevel-dark.svelte-14xsz9c {fill:none;stroke:#211d16;stroke-width:0.8;opacity:0.3;stroke-linecap:round;}.turned.svelte-14xsz9c {fill:none;stroke:#b9ab8b;stroke-width:0.45;opacity:0.35;}.counter-wear.svelte-14xsz9c {fill:none;stroke:#534b37;stroke-width:0.5;opacity:0.3;stroke-linecap:round;}.attacker.svelte-14xsz9c .counter-wear.svelte-14xsz9c {stroke:#d6c6a3;opacity:0.23;}.artwork[data-set='tablut'].svelte-14xsz9c .bevel-light.svelte-14xsz9c {opacity:0.25;stroke-width:1.1;}.artwork[data-set='tablut'].svelte-14xsz9c .turned.svelte-14xsz9c {display:none;}.pit.svelte-14xsz9c {fill:#544b38;opacity:0.3;}.attacker.svelte-14xsz9c .pit.svelte-14xsz9c {fill:#d6c6a3;opacity:0.2;}.piece.svelte-14xsz9c .crown.svelte-14xsz9c {fill:#6b4a0f;stroke:#6b4a0f;stroke-width:0.7;stroke-linejoin:round;}.piece.svelte-14xsz9c .crown-gleam.svelte-14xsz9c {fill:#f8e5b2;stroke:#f8e5b2;stroke-width:0.9;opacity:0.7;}.piece.selected.svelte-14xsz9c .body.svelte-14xsz9c {stroke:#9cd0dc;stroke-width:2.5;}.calm.svelte-14xsz9c .piece.svelte-14xsz9c,
|
||||
.calm.svelte-14xsz9c .square.svelte-14xsz9c {transition:none;}.piece.attacker .base{fill:#211a15}.piece.attacker .body,.piece.attacker .turned{stroke:#211a15}.piece.defender .base{fill:#b9ab8b}.piece.defender .body,.piece.defender .turned{stroke:#b9ab8b}.piece.king .base{fill:#a67c1a}.piece.king .body,.piece.king .turned{stroke:#a67c1a}</style><defs><linearGradient id="s1-surface" x2="0.85" y2="1"><stop stop-color="#bd935c"></stop><stop offset="0.48" stop-color="#a77945"></stop><stop offset="1" stop-color="#79502d"></stop></linearGradient><filter id="s1-grain" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.025 0.5" numOctaves="3" seed="7" result="noise"></feTurbulence><feColorMatrix in="noise" type="saturate" values="0"></feColorMatrix><feComponentTransfer result="soft"><feFuncA type="linear" slope="0.13"></feFuncA></feComponentTransfer><feBlend in="SourceGraphic" in2="soft" mode="multiply"></feBlend></filter><filter id="s1-worn" x="-5%" y="-5%" width="110%" height="110%"><feTurbulence type="fractalNoise" baseFrequency="0.09" numOctaves="2" seed="3" result="warp"></feTurbulence><feDisplacementMap in="SourceGraphic" in2="warp" scale="1.15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap></filter><filter id="s1-mottle" x="0" y="0" width="100%" height="100%"><feTurbulence type="fractalNoise" baseFrequency="0.025" numOctaves="3" seed="11"></feTurbulence><feColorMatrix type="matrix" values="0 0 0 0 0.20 0 0 0 0 0.17 0 0 0 0 0.12 0.28 0 0 0 0"></feColorMatrix><feComposite in2="SourceGraphic" operator="in"></feComposite></filter><radialGradient id="s1-attacker" cx="32%" cy="24%" r="80%"><stop stop-color="#63564a"></stop><stop offset="0.65" stop-color="#2a2320"></stop><stop offset="1" stop-color="#211a15"></stop></radialGradient><radialGradient id="s1-defender" cx="32%" cy="24%" r="80%"><stop stop-color="#fff4d8"></stop><stop offset="0.65" stop-color="#ece4d0"></stop><stop offset="1" stop-color="#b9ab8b"></stop></radialGradient><radialGradient id="s1-king" cx="32%" cy="24%" r="80%"><stop stop-color="#ffe4a0"></stop><stop offset="0.65" stop-color="#f0d58a"></stop><stop offset="1" stop-color="#a67c1a"></stop></radialGradient></defs><rect class="wood svelte-14xsz9c" x="0.7" y="0.7" width="450.6" height="450.6" rx="6" fill="url(#s1-surface)" filter="url(#s1-grain)"></rect><rect class="board-rim svelte-14xsz9c" x="2" y="2" width="448" height="448" rx="5"></rect><path class="join svelte-14xsz9c" d="M102.8 3 v446"></path><path class="join svelte-14xsz9c" d="M230.4 3 v446"></path><path class="join svelte-14xsz9c" d="M353.6 3 v446"></path><g class="surface-wear svelte-14xsz9c" aria-hidden="true"></g><g><rect class="square svelte-14xsz9c corner" data-i="0" x="6" y="6" width="40" height="40" aria-label="a11"></rect><rect class="square svelte-14xsz9c" data-i="1" x="46" y="6" width="40" height="40" aria-label="b11"></rect><rect class="square svelte-14xsz9c" data-i="2" x="86" y="6" width="40" height="40" aria-label="c11"></rect><rect class="square svelte-14xsz9c" data-i="3" x="126" y="6" width="40" height="40" aria-label="d11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="4" x="166" y="6" width="40" height="40" aria-label="e11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="5" x="206" y="6" width="40" height="40" aria-label="f11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="6" x="246" y="6" width="40" height="40" aria-label="g11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="7" x="286" y="6" width="40" height="40" aria-label="h11, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="8" x="326" y="6" width="40" height="40" aria-label="i11"></rect><rect class="square svelte-14xsz9c" data-i="9" x="366" y="6" width="40" height="40" aria-label="j11"></rect><rect class="square svelte-14xsz9c corner" data-i="10" x="406" y="6" width="40" height="40" aria-label="k11"></rect><rect class="square svelte-14xsz9c" data-i="11" x="6" y="46" width="40" height="40" aria-label="a10"></rect><rect class="square svelte-14xsz9c" data-i="12" x="46" y="46" width="40" height="40" aria-label="b10"></rect><rect class="square svelte-14xsz9c" data-i="13" x="86" y="46" width="40" height="40" aria-label="c10"></rect><rect class="square svelte-14xsz9c" data-i="14" x="126" y="46" width="40" height="40" aria-label="d10"></rect><rect class="square svelte-14xsz9c" data-i="15" x="166" y="46" width="40" height="40" aria-label="e10"></rect><rect class="square svelte-14xsz9c" data-i="16" x="206" y="46" width="40" height="40" aria-label="f10, an attacker"></rect><rect class="square svelte-14xsz9c" data-i="17" x="246" y="46" width="40" height="40" aria-label="g10"></rect><rect class="square svelte-14xsz9c" data-i="18" x="286" y="46" width="40" height="40" aria-label="h10"></rect><rect class="square svelte-14xsz9c" data-i="19" x="326" y="46" width="40" height="40" aria-label="i10"></rect><rect class="square svelte-14xsz9c" data-iLine truncated
|
||||
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Treasure token">
|
||||
<title>Treasure</title><desc>Open treasure chest with gold coins and a matching wizard color and crest.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#28724d" stop-opacity=".2"/><stop offset="1" stop-color="#28724d" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M22 68L99 67 124 82 119 117 42 123 23 108Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><path d="M25 75L43 88 42 123 23 108Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M43 88L123 80 119 117 42 123Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M29 67L23 42Q57 19 103 34L112 59 93 78Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><path d="M30 44Q62 27 99 39L104 55 35 64Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M36 48Q68 35 94 42" fill="none" stroke="#7bbf7d" stroke-width="4" /><path d="M29 66L93 60 112 62 100 76 42 85Z" fill="#48362e" stroke="#302a29" stroke-width="2.0" /><ellipse cx="43" cy="70" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M39 69h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="58" cy="70" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M54 69h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="72" cy="67" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M68 66h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="88" cy="69" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M84 68h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="99" cy="64" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M95 63h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="54" cy="77" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M50 76h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><ellipse cx="79" cy="76" rx="8" ry="4" fill="#d6ad57" stroke="#302a29" stroke-width="1.4"/><path d="M75 75h5" fill="none" stroke="#f8edcf" stroke-width="1.3" /><path d="M21 68L43 84 125 77 124 86 43 94 22 77Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M48 90L55 90 53 121 46 122ZM107 85L114 85 111 118 104 119Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M35 39L42 37 50 60 43 62ZM88 33L95 34 103 57 96 58Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M45 120L119 114" fill="none" stroke="#7bbf7d" stroke-width="2" /><g transform="translate(80 104) scale(.86)"><path d="M-9 3L-13-5-5-2 0-8 5-2 13-5 9 3 0 8Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g><path d="M25.0,21.0 26.7,27.3 33.0,29.0 26.7,30.7 25.0,37.0 23.3,30.7 17.0,29.0 23.3,27.3Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /><path d="M119.0,42.0 120.5,47.5 126.0,49.0 120.5,50.5 119.0,56.0 117.5,50.5 112.0,49.0 117.5,47.5Z" fill="#d6ad57" stroke="none" stroke-width="2.0" /></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#28724d" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Treasure</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Troll token">
|
||||
<title>Troll</title><desc>Troll illustrated in shaded ink on parchment.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#788653" stop-opacity=".2"/><stop offset="1" stop-color="#788653" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M23 120L27 96 43 82 41 61 29 46 47 49Q48 29 73 28Q98 29 100 48L117 44 103 63 103 86 123 102 125 120Z" fill="#788653" stroke="#302a29" stroke-width="2.0" /><path d="M26 109L49 101 53 119M101 90L91 112 114 119Z" fill="#4a5a3e" stroke="none" stroke-width="2.0" /><path d="M44 44L44 27Q69 18 97 28L97 43Z" fill="#60527e" stroke="#302a29" stroke-width="2.0" /><path d="M43 37L98 37" fill="none" stroke="#a294c3" stroke-width="4" /><path d="M46 57Q55 48 66 57M80 55Q89 47 100 54" fill="none" stroke="#302a29" stroke-width="5" /><ellipse cx="59" cy="61" rx="6" ry="4" fill="#f8edcf" stroke="none" stroke-width="2"/><ellipse cx="88" cy="59" rx="5" ry="3" fill="#f8edcf" stroke="none" stroke-width="2"/><ellipse cx="61" cy="61" rx="2" ry="3" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="87" cy="59" rx="1.6" ry="2.5" fill="#302a29" stroke="none" stroke-width="2"/><path d="M70 58Q66 67 60 72Q56 81 69 83Q86 85 84 76L77 61Z" fill="#a4ab6b" stroke="#302a29" stroke-width="2.0" /><path d="M48 82Q72 99 99 80L94 98Q73 114 51 97Z" fill="#443c34" stroke="#302a29" stroke-width="2.0" /><path d="M54 88L57 77 63 92M84 93L91 78 93 88Z" fill="#f8edcf" stroke="#302a29" stroke-width="2.0" /><path d="M61 106Q72 112 84 104" fill="none" stroke="#adbb7c" stroke-width="3" /><path d="M35 100L31 109M41 103L37 113M102 102L106 110" fill="none" stroke="#302a29" stroke-width="1.1" opacity=".45"/></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#788653" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Troll</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<title>Wizard</title><desc>Dark-skinned woman with silver curls and a green winged helmet.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#28724d" stop-opacity=".2"/><stop offset="1" stop-color="#28724d" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#143f35" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#7bbf7d" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M47 48Q38 52 43 63Q35 67 43 76Q38 85 49 90Q45 101 59 104L66 93 84 95Q98 102 101 90Q110 86 103 76Q110 66 101 61L94 44Z" fill="#d5d1bd" stroke="#302a29" stroke-width="2.0" /><path d="M44 70Q51 64 53 72M46 83Q51 76 56 83M94 76Q99 70 102 77M91 91Q98 86 100 92" fill="none" stroke="#8e9287" stroke-width="1.8" /><path d="M53 49Q55 33 74 32Q93 35 94 52L89 75Q82 91 72 94Q59 89 53 73Z" fill="#815039" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#60392f" stroke="none" stroke-width="2.0" /><path d="M57 54Q64 50 69 54M79 54Q86 49 91 53" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M73 58L70 68Q65 75 73 75L79 72" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M64 81Q74 77 83 79Q75 88 67 84Z" fill="#a75e57" stroke="#302a29" stroke-width="1.3" /><path d="M51 50L44 36 54 24 74 19 93 28 98 45 87 49 74 39Z" fill="#28724d" stroke="#302a29" stroke-width="2.0" /><path d="M51 39L29 33 18 17 46 25 57 37M91 37L111 31 125 15 97 24 86 36Z" fill="#7bbf7d" stroke="#302a29" stroke-width="2.0" /><path d="M26 24L44 30M32 31L47 35M101 29L116 22M95 35L110 30" fill="none" stroke="#f8edcf" stroke-width="2" /><path d="M53 45L65 43 73 49 82 42 95 45 90 53 80 50 73 58 66 51 56 54Z" fill="#143f35" stroke="#302a29" stroke-width="2.0" /><circle cx="52" cy="76" r="3" fill="#d6ad57" stroke="#302a29" stroke-width="2"/><circle cx="93" cy="75" r="3" fill="#d6ad57" stroke="#302a29" stroke-width="2"/><g transform="translate(72 117) scale(.58)"><path d="M-9 3L-13-5-5-2 0-8 5-2 13-5 9 3 0 8Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#28724d" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<title>Wizard</title><desc>Brown-skinned man with a short dark beard and red tiger hood.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#b83f35" stop-opacity=".2"/><stop offset="1" stop-color="#b83f35" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#b83f35" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#622a2c" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#ee9160" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M46 43L47 76 59 91 89 84 101 58 93 33Z" fill="#34292a" stroke="#302a29" stroke-width="2.0" /><path d="M51 49Q50 32 73 30Q95 31 97 52L93 78 80 94 63 91 52 76Z" fill="#ad714b" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#814b37" stroke="none" stroke-width="2.0" /><path d="M56 53L68 55M80 53L91 51" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M74 57L71 67Q63 73 70 76Q76 78 81 73" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M63 82Q74 87 85 79" fill="none" stroke="#302a29" stroke-width="1.3" /><path d="M48 74L39 46 42 25 53 29Q70 16 87 28L101 24 103 44 95 77 89 54 80 42 61 42 53 55Z" fill="#b83f35" stroke="#302a29" stroke-width="2.0" /><path d="M44 29L53 34 45 42M91 33L99 29 98 41Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M54 34L66 39 56 44M84 33L75 39 88 44M41 48L50 51 45 58M98 48L91 53 95 60" fill="none" stroke="#622a2c" stroke-width="4" /><path d="M61 33L71 29 81 33 73 37Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M54 74L62 83Q73 89 86 80L93 73 91 88 83 98 69 99 59 91Z" fill="#34292a" stroke="#302a29" stroke-width="2.0" /><path d="M64 86Q74 91 84 84" fill="none" stroke="#ad714b" stroke-width="2" /><path d="M62 91L66 95M71 92V97M82 90L79 95" fill="none" stroke="#917468" stroke-width="1.1" opacity=".45"/><g transform="translate(72 117) scale(.58)"><path d="M0-10Q9-2 6 5Q0 12-6 5Q-10 0-4-4L-3 2Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#b83f35" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 150" role="img" aria-label="Wizard token">
|
||||
<title>Wizard</title><desc>Androgynous tan-skinned wizard with dark hair and a magenta horned crown.</desc>
|
||||
<defs><linearGradient id="paper" x2="0" y2="1"><stop stop-color="#fbf2d9"/><stop offset="1" stop-color="#e8d6ae"/></linearGradient><radialGradient id="halo"><stop stop-color="#ac3876" stop-opacity=".2"/><stop offset="1" stop-color="#ac3876" stop-opacity=".03"/></radialGradient></defs>
|
||||
<rect x="2" y="2" width="140" height="146" rx="9" fill="url(#paper)" stroke="#39302a" stroke-width="2.5"/>
|
||||
<rect x="6" y="6" width="132" height="138" rx="6" fill="none" stroke="#b49b70" stroke-width=".8"/>
|
||||
<circle cx="72" cy="68" r="52" fill="url(#halo)"/>
|
||||
<g stroke-linecap="round" stroke-linejoin="round"><ellipse cx="72" cy="119" rx="43" ry="5" fill="#725b3d" stroke="none" stroke-width="0" opacity=".18"/><path d="M17 121Q21 96 49 85L92 85Q120 98 128 121Z" fill="#ac3876" stroke="#302a29" stroke-width="2.0" /><path d="M17 121L42 102 59 121M98 94L87 121H128Z" fill="#62264f" stroke="none" stroke-width="2.0" /><path d="M48 87L71 108 96 87 89 121H56Z" fill="#e891b6" stroke="#302a29" stroke-width="2.0" /><path d="M49 89L71 106 95 89" fill="none" stroke="#d6ad57" stroke-width="3" /><path d="M50 37Q72 22 94 39L98 73 91 95 80 88 57 93 48 76Z" fill="#332932" stroke="#302a29" stroke-width="2.0" /><path d="M53 49Q53 31 74 31Q93 33 95 52L90 76 75 93 62 86 53 71Z" fill="#c18b66" stroke="#302a29" stroke-width="2.0" /><path d="M83 43Q98 57 83 79L73 90 83 88 92 75 95 53Z" fill="#945b48" stroke="none" stroke-width="2.0" /><path d="M57 53L69 56M80 55L91 51" fill="none" stroke="#302a29" stroke-width="2.2" /><path d="M56 60Q62 54 68 59Q63 64 56 60ZM79 59Q85 53 91 58Q86 64 79 59Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.2" /><ellipse cx="64" cy="59" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><ellipse cx="85" cy="58" rx="1.8" ry="2.6" fill="#302a29" stroke="none" stroke-width="2"/><path d="M74 59L71 70 77 72" fill="none" stroke="#302a29" stroke-width="1.5" /><path d="M65 81Q75 84 83 79" fill="none" stroke="#302a29" stroke-width="1.3" /><path d="M50 54Q46 39 52 28Q44 27 35 13Q30 41 48 50L53 59 63 48 75 53 84 46 94 57 97 49Q114 35 109 13Q101 28 92 28Q99 42 93 47L80 38 74 46 64 38Z" fill="#ac3876" stroke="#302a29" stroke-width="2.0" /><path d="M49 40Q35 31 35 13M96 40Q108 29 109 13" fill="none" stroke="#e891b6" stroke-width="4" /><path d="M70 32L75 27 80 32 75 40Z" fill="#d6ad57" stroke="#302a29" stroke-width="2.0" /><path d="M49 63L51 79 59 84M96 61L94 80 86 88" fill="none" stroke="#e891b6" stroke-width="3" /><path d="M58 43L61 47 66 42M55 76L56 88 62 94" fill="none" stroke="#332932" stroke-width="3" /><g transform="translate(72 117) scale(.58)"><path d="M-10-8Q-9 8 0 8Q9 8 10-8L4-3 0-7-4-3Z" fill="#f8edcf" stroke="#302a29" stroke-width="1.5" /></g></g>
|
||||
<path d="M16 130H128" stroke="#b49b70" stroke-width=".8"/>
|
||||
<path d="M12 12h7m-7 0v7m120-7h-7m7 0v7" fill="none" stroke="#ac3876" stroke-width="2"/>
|
||||
<text x="72" y="141" text-anchor="middle" font-family="Georgia,serif" font-size="10.5" font-weight="700" fill="#39302a">Wizard</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 230 KiB |
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stamp out a new game from the template.
|
||||
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8789 tumbleweed.kestrelsnest.social
|
||||
# Ports so far: wizwar 8787, waving-hands 8788. Each game is its own droplet,
|
||||
# so the port only has to be unique on your machine for running two locally.
|
||||
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
|
||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
|
||||
# games share one host (deploy/README.md), so a new game takes the next free port.
|
||||
set -euo pipefail
|
||||
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||
NAME="${2:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||
@@ -20,7 +20,7 @@ find "$DEST" -depth -name '*__SLUG__*' | while read -r f; do
|
||||
mv "$f" "$(dirname "$f")/$(basename "$f" | sed "s/__SLUG__/$SLUG/g")"
|
||||
done
|
||||
# Then the placeholders inside. __IP__ and the Sentry ids stay until they exist.
|
||||
find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \
|
||||
find "$DEST" -type f \( -name '*.ts' -o -name '*.svelte' -o -name '*.json' -o -name '*.md' -o -name '*.sh' -o -name '*.py' -o -name '*.tmpl' -o -name '*.service' -o -name '*.cron' -o -name '*.html' -o -name '*.css' -o -name '.gitignore' \) -print0 \
|
||||
| xargs -0 sed -i '' -e "s/__SLUG__/$SLUG/g" -e "s/__NAME__/$NAME/g" -e "s/__PORT__/$PORT/g" -e "s/__DOMAIN__/$DOMAIN/g"
|
||||
cp "$KIT/CONVENTIONS.md" "$DEST/docs-conventions.md" 2>/dev/null || true
|
||||
mkdir -p "$DEST/docs" && mv "$DEST/docs-conventions.md" "$DEST/docs/conventions.md"
|
||||
@@ -37,13 +37,21 @@ Next:
|
||||
and src/lib/components/Board.svelte with its board. Keep the tests green.
|
||||
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte),
|
||||
the rules page, the guide, src/app.html's description, and the palette in src/app.css.
|
||||
Write every line a player reads in the game's own voice: an image with each
|
||||
fact (docs/conventions.md, "The voice"). Then read every page against
|
||||
"Writing for players" in the same file, the template's own lines included.
|
||||
Add static/og.png (1200 by 630) and static/apple-touch-icon.png (180 by 180):
|
||||
app.html already points at them, and the static fallback serves the hall's
|
||||
HTML for a missing file, so a share card without the image breaks quietly.
|
||||
3. Create the droplet (deploy/README.md), then replace __IP__ everywhere:
|
||||
grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/<ip>/g'
|
||||
3. Add the game to the shared host (deploy/README.md), after replacing __IP__ with its address:
|
||||
grep -rl __IP__ . --exclude-dir=node_modules | xargs sed -i '' 's/__IP__/159.203.115.252/g'
|
||||
4. Create a Sentry project (the Sentry MCP can) and put its DSN and project id
|
||||
in deploy/$SLUG.service and deploy/sentry-slack-alert.sh.
|
||||
5. deploy/deploy.sh <ip>
|
||||
docs/conventions.md is the house style every game follows.
|
||||
6. Put the game in the family: add it to the kit's family.json (every game's
|
||||
about panel lists the others from that file at its next deploy), then give
|
||||
it a card on the family's landing page, Kestrel's Hall, hand-written HTML in
|
||||
the kit's gamehall/site/index.html, and publish it: gamehall/deploy.sh <host>.
|
||||
Card art is the game's own drawing; never a publisher's logo or scan.
|
||||
CONVENTIONS.md in the kit is the house style every game follows.
|
||||
MSG
|
||||
@@ -1,42 +1,52 @@
|
||||
__HOST__
|
||||
__HOST__ {
|
||||
# The hall-wide notice (hall-announce on the host): one file, served by every game's site.
|
||||
handle_path /hall/* {
|
||||
root * /var/lib/hall
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
root * /opt/__SLUG__/build
|
||||
encode gzip zstd
|
||||
root * /opt/__SLUG__/build
|
||||
encode gzip zstd
|
||||
|
||||
header {
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "no-referrer"
|
||||
}
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/lib/caddy/access.log {
|
||||
log {
|
||||
output file /var/lib/caddy/__SLUG__-access.log {
|
||||
roll_size 10MiB
|
||||
roll_keep 30
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The game server answers the API and the websocket; a deploy restarts it for
|
||||
# a few seconds, and the proxy holds requests that land in that gap.
|
||||
@game path /api/* /ws
|
||||
handle @game {
|
||||
# The game server answers the API and the websocket; a deploy restarts it for
|
||||
# a few seconds, and the proxy holds requests that land in that gap.
|
||||
@game path /api/* /ws
|
||||
handle @game {
|
||||
reverse_proxy localhost:__PORT__ {
|
||||
lb_try_duration 30s
|
||||
lb_try_interval 250ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else is the static site. SvelteKit writes each route as
|
||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load.
|
||||
handle {
|
||||
# Everything else is the static site. SvelteKit writes each route as
|
||||
# <route>.html, so /rules is tried as /rules.html before falling back to the
|
||||
# app shell, which serves the rooms. Hashed assets under _app/immutable are
|
||||
# cached for a year; every other response is revalidated so a deploy shows
|
||||
# up on the next load.
|
||||
handle {
|
||||
@immutable path /_app/immutable/*
|
||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||
@mutable not path /_app/immutable/*
|
||||
header @mutable Cache-Control "no-cache"
|
||||
try_files {path} {path}.html /index.html
|
||||
# try_files passes the root over as a directory and would fall to the shell; the hall's
|
||||
# own page is index.html.
|
||||
rewrite / /index.html
|
||||
try_files {path} {path}.html /shell.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
# Deploying __NAME__
|
||||
|
||||
Every game, against a housecarl or a friend, is a room on a small Node
|
||||
process that keeps it as an append-only ledger of moves in
|
||||
/var/lib/__SLUG__/rooms. Production is one DigitalOcean droplet: Caddy
|
||||
terminates TLS with automatic certificates, serves the static build from
|
||||
/opt/__SLUG__/build, and proxies /api and /ws to the game server on port
|
||||
__PORT__, which runs as the `__SLUG__` user under systemd from
|
||||
/opt/__SLUG__/app.
|
||||
Every game is a room on a small Node process that keeps it as an
|
||||
append-only ledger of moves in /var/lib/__SLUG__/rooms. Production is the
|
||||
shared Kestrel's Hall host: Caddy terminates TLS with automatic
|
||||
certificates, serves the static build from /opt/__SLUG__/build, and proxies
|
||||
/api and /ws to the game server on port __PORT__, which runs as the
|
||||
`__SLUG__` user under systemd from /opt/__SLUG__/app.
|
||||
|
||||
## Sizing
|
||||
## The shared host
|
||||
|
||||
The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||
and the game server comfortably: the server is one small Node process capped
|
||||
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
The games of Kestrel's Hall share one droplet, `hall` (nyc3, s-1vcpu-1gb,
|
||||
$6 a month, tag `kestrels-hall`, IP 159.203.115.252). Each game keeps its
|
||||
own Caddy site file in /etc/caddy/sites/<slug>.caddy, its own port,
|
||||
service user, /opt/<slug> and /var/lib/<slug> trees, access log
|
||||
(/var/lib/caddy/<slug>-access.log), cron file and ops scripts;
|
||||
/etc/caddy/Caddyfile only says `import sites/*.caddy`. A game server is one
|
||||
small Node process capped at 300 MB by its unit file, and a room is a few
|
||||
kilobytes of ledger, so the host carries several. Each game needs a port
|
||||
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
|
||||
dip 8790, wyrm 8791.
|
||||
|
||||
## Current production
|
||||
|
||||
- Droplet: `__SLUG__` (nyc3, s-1vcpu-512mb-10gb, tag `__SLUG__`), IP __IP__
|
||||
- Host: __IP__
|
||||
- URLs: https://__DOMAIN__ (A record at Hover, where kestrelsnest.social's
|
||||
DNS lives) and https://__SLUG__.__IP__.sslip.io (always works, zero DNS).
|
||||
- Everyday deploy: `deploy/deploy.sh __IP__`
|
||||
@@ -24,44 +30,52 @@ at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
and the screenshots to ~/Desktop/__SLUG__-reports with a digest;
|
||||
`deploy/report-reply.sh __IP__ <id> <status> "text"` answers one.
|
||||
|
||||
## New droplet from scratch
|
||||
## Adding this game to the host
|
||||
|
||||
1. `doctl compute droplet create __SLUG__ --region nyc3 \
|
||||
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
|
||||
--ssh-keys <your-key-ids> --tag-name __SLUG__ --wait`
|
||||
2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
|
||||
1. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
|
||||
"bash /root/setup-droplet.sh '__DOMAIN__, __SLUG__.<ip>.sslip.io'"`
|
||||
(point the A record at the new IP first, or leave the real name out until it is).
|
||||
3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
|
||||
4. `deploy/deploy.sh <ip>`
|
||||
It installs Caddy and Node if the host lacks them, writes this game's
|
||||
site file, and touches nothing of another game's. Point the A record at
|
||||
the host first, or leave the real name out until it is; running it again
|
||||
with the new line is how a name is added later.
|
||||
2. `deploy/deploy.sh <ip>`
|
||||
|
||||
The sslip.io hostname works with no DNS at all. To add a real name, point an
|
||||
A record at the droplet and add the name to the first line of
|
||||
/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy
|
||||
fetches the certificate on first request.
|
||||
A fresh host, when one is needed: `doctl compute droplet create hall --region nyc3 \
|
||||
--size s-1vcpu-1gb --image ubuntu-24-04-x64 --ssh-keys <your-key-ids> \
|
||||
--tag-name kestrels-hall --wait`, then step 1.
|
||||
|
||||
## Operations scripts on the droplet
|
||||
|
||||
`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to
|
||||
/usr/local/lib/__SLUG__, and the cron file to /etc/cron.d/__SLUG__ on every
|
||||
deploy, so the live copies are the repo copies:
|
||||
`deploy.sh` installs these to /usr/local/bin and the `traffic.py` they share
|
||||
to /usr/local/lib/__SLUG__ on every deploy, so the live copies are the repo
|
||||
copies. The cron file goes to /etc/cron.d/__SLUG__ only on a box of the game's
|
||||
own: on the hall's shared host, `hall-nightly.sh` (in the kit's `gamehall/ops`)
|
||||
runs every game's rollup and backup from /etc/cron.d/gamehall.
|
||||
|
||||
- `__SLUG__-visitors.sh [day]`: who is here now and who came that day.
|
||||
- `__SLUG__-reseat.sh ROOM [SEAT]`: who sits at a table; with a seat, a new
|
||||
token for a player locked out of it, as a link to send them.
|
||||
- `__SLUG__-say.sh ROOM "text"`: a word from the keeper in a table's talk,
|
||||
signed as the keeper, through the same door; it is heard whether or not
|
||||
the gallery may talk, so a call that came too late can be answered.
|
||||
- `__SLUG__-pulse.sh`: the weekly health check (service, errors, rollup
|
||||
trend, backup, box).
|
||||
- `__SLUG__-rollup.sh [day]`: one JSON line per day of counts, run
|
||||
nightly at 00:12 UTC into /var/lib/__SLUG__/rollup.jsonl.
|
||||
- `__SLUG__-backup.sh`: nightly at 07:23 UTC, mirrors /var/lib/__SLUG__
|
||||
to the `kestrel-wizwar-backups` Space under `__SLUG__/` (a current copy
|
||||
and dated snapshots kept 90 days). It needs rclone with the Spaces
|
||||
less the rollup, and dated snapshots kept 90 days). It needs rclone with the Spaces
|
||||
credentials in /root/.config/rclone/rclone.conf, copied by hand from the
|
||||
wizwar droplet; until then it logs "skipped".
|
||||
|
||||
Errors from the game server go to Sentry, project `__SLUG__` in the
|
||||
locallygrownnet organisation; the DSN is in the unit file. The nightly rollup
|
||||
and backup check in with Sentry Crons when /root/.__SLUG__-sentry-cron-rollup
|
||||
and /root/.__SLUG__-sentry-cron hold their check-in URLs (the ingest
|
||||
URL with the project's cron path and public key), so a missed night is noticed.
|
||||
locallygrownnet organisation; the DSN is in the unit file. On the shared host
|
||||
the hall's nightly job checks in with Sentry Crons once for every game's
|
||||
rollup and once for every backup (monitors `hall-rollup` and `hall-backup` in
|
||||
the `gamehall` project), and each game's script reports by its exit status.
|
||||
A game on its own box checks in for itself when /root/.__SLUG__-sentry-cron-rollup
|
||||
and /root/.__SLUG__-sentry-cron hold check-in URLs (the ingest URL with the
|
||||
project's cron path and public key); absent, it sends nothing.
|
||||
|
||||
To have every new issue, regression and reappearance posted to Slack the way
|
||||
wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own
|
||||
@@ -80,14 +94,16 @@ deploy: the server would rewrite that game on restart.
|
||||
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the game server. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
engine sources, installs dependencies, and restarts the game server, then
|
||||
waits up to twenty seconds for it to answer HTTP and fails with the tail of
|
||||
its journal if it does not. A room is replayed from its ledger when the
|
||||
server comes back, which takes a few seconds; Caddy holds requests that
|
||||
land in the gap.
|
||||
|
||||
## Operations
|
||||
|
||||
- Logs: `ssh root@<ip> journalctl -u __SLUG__ -f` for the game server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/__SLUG__-access.log for the web side.
|
||||
- Restart: `ssh root@<ip> systemctl restart __SLUG__`
|
||||
- Rooms: `/var/lib/__SLUG__/rooms/<CODE>.jsonl`, one ledger per game; the
|
||||
nightly backup keeps them in the Space.
|
||||
@@ -1,38 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket.
|
||||
# current/ - exact mirror of /var/lib/__SLUG__
|
||||
# current/ - mirror of /var/lib/__SLUG__, less the rollup (rebuilt from logs)
|
||||
# snapshots/ - one dated copy per day, pruned after 90 days
|
||||
# Ledgers are append-only JSONL; the game server never needs stopping.
|
||||
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron holds the check-in
|
||||
# URL (absent file = no check-ins). SCHEDULE must match /etc/cron.d/__SLUG__.
|
||||
# On a host the hall shares, hall-nightly.sh runs this and checks in once
|
||||
# for every game, so the file is absent and the exit status is the word.
|
||||
set -u
|
||||
SCHEDULE="23 7 * * *"
|
||||
BUCKET="kestrel-wizwar-backups"
|
||||
BUCKET="kestrel-wizwar-backups" # the hall's shared Space, named for its first tenant
|
||||
PREFIX="__SLUG__"
|
||||
SRC="/var/lib/__SLUG__"
|
||||
LOG="/var/log/__SLUG__/backup.log"
|
||||
STAMP=$(date +%Y-%m-%d)
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
CRON_URL=$(cat /root/.__SLUG__-sentry-cron 2>/dev/null || true)
|
||||
# One check-in, when the run ends, carrying its duration: a start and a
|
||||
# finish sent seconds apart can reach Sentry in either order, and a start
|
||||
# that lands second is never closed. A run that never ends sends nothing,
|
||||
# and Sentry marks the night missed.
|
||||
START=$SECONDS
|
||||
checkin() {
|
||||
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
"$CRON_URL" || true
|
||||
}
|
||||
|
||||
if [ ! -s /root/.config/rclone/rclone.conf ]; then
|
||||
echo "$(date -Is) skipped: rclone is not configured" >> "$LOG"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
|
||||
status=0
|
||||
{
|
||||
echo "=== $(date -Is) backup start"
|
||||
checkin in_progress
|
||||
if rclone sync "$SRC" "spaces:$BUCKET/$PREFIX/current" --exclude 'rollup.jsonl' 2>&1 &&
|
||||
rclone copy "$SRC" "spaces:$BUCKET/$PREFIX/snapshots/$STAMP" 2>&1; then
|
||||
checkin ok
|
||||
else
|
||||
checkin error
|
||||
status=1
|
||||
fi
|
||||
CUTOFF=$(date -d "90 days ago" +%Y-%m-%d)
|
||||
rclone lsf "spaces:$BUCKET/$PREFIX/snapshots/" --dirs-only 2>/dev/null | \
|
||||
@@ -45,3 +53,4 @@ fi
|
||||
done
|
||||
echo "=== $(date -Is) backup done"
|
||||
} >> "$LOG" 2>&1
|
||||
exit $status
|
||||
@@ -33,4 +33,4 @@ else
|
||||
fi
|
||||
echo "--- box"
|
||||
echo "disk: $(df -h / | awk 'NR==2{print $5" used of "$2}'); memory: $(free -m | awk 'NR==2{print $7" MB available of "$2}'); load: $(cut -d' ' -f1-3 /proc/loadavg)"
|
||||
echo "caddy access log: $(du -sh /var/lib/caddy/access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/access*.log* 2>/dev/null | wc -l) files"
|
||||
echo "caddy access log: $(du -sh /var/lib/caddy/__SLUG__-access.log 2>/dev/null | cut -f1) live, $(ls /var/lib/caddy/__SLUG__-access*.log* 2>/dev/null | wc -l) files"
|
||||
@@ -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
|
||||
@@ -8,21 +8,27 @@
|
||||
# Cron: /etc/cron.d/__SLUG__, installed by deploy.sh; SCHEDULE below
|
||||
# must match it, because the Sentry monitor is told the same shape.
|
||||
# Sentry Crons check-in: /root/.__SLUG__-sentry-cron-rollup holds the
|
||||
# check-in URL (absent = no check-ins).
|
||||
# check-in URL (absent = no check-ins). On a host the hall shares,
|
||||
# hall-nightly.sh runs this and checks in once for every game, so the
|
||||
# file is absent and the exit status is the word.
|
||||
set -u
|
||||
OUT="/var/lib/__SLUG__/rollup.jsonl"
|
||||
LOG="/var/log/__SLUG__/rollup.log"
|
||||
DAY="${1:-$(date -u -d 'yesterday' +%Y-%m-%d)}"
|
||||
SCHEDULE="12 0 * * *"
|
||||
CRON_URL=$(cat /root/.__SLUG__-sentry-cron-rollup 2>/dev/null || true)
|
||||
# The check-in carries the schedule: Sentry keeps a monitor only once told its shape.
|
||||
# One check-in, when the run ends, carrying its duration and the schedule
|
||||
# (Sentry keeps a monitor only once told its shape). A start and a finish
|
||||
# sent seconds apart can reach Sentry in either order, and a start that lands
|
||||
# second is never closed. A run that never ends sends nothing, and Sentry
|
||||
# marks the night missed.
|
||||
START=$SECONDS
|
||||
checkin() {
|
||||
[ -n "$CRON_URL" ] && curl -sf -o /dev/null -X POST -H "Content-Type: application/json" \
|
||||
-d "{\"status\":\"$1\",\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
-d "{\"status\":\"$1\",\"duration\":$((SECONDS - START)),\"monitor_config\":{\"schedule\":{\"type\":\"crontab\",\"value\":\"$SCHEDULE\"},\"checkin_margin\":30,\"max_runtime\":10,\"timezone\":\"UTC\"}}" \
|
||||
"$CRON_URL" || true
|
||||
}
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
checkin in_progress
|
||||
if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1
|
||||
import datetime, glob, json, os, shutil, sys
|
||||
sys.path.insert(0, "/usr/local/lib/__SLUG__")
|
||||
@@ -58,7 +64,8 @@ for line in open("/proc/meminfo"):
|
||||
k, v = line.split(":", 1); mem[k] = int(v.split()[0])
|
||||
load = os.getloadavg()[1]
|
||||
row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]),
|
||||
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
|
||||
paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), roomsOpened=t["opened"], sharedOn=dict(t["shared"]),
|
||||
scanners=t["scanners"], monitors=t["monitors"], referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]),
|
||||
rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots),
|
||||
turns=turns, ledgers=len(glob.glob("/var/lib/__SLUG__/rooms/*.jsonl")),
|
||||
vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)),
|
||||
@@ -73,4 +80,4 @@ with open(out + ".tmp", "w") as fh:
|
||||
os.replace(out + ".tmp", out)
|
||||
print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created))
|
||||
PY
|
||||
then checkin ok; else checkin error; fi
|
||||
then checkin ok; else checkin error; exit 1; fi
|
||||
@@ -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.$$
|
||||
@@ -26,9 +26,9 @@ print("live sockets: %s | rooms touched in the last hour: %s" % (
|
||||
|
||||
# --- traffic: Caddy's access log, people and bots apart ---
|
||||
t = traffic.scan(day)
|
||||
print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % (
|
||||
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
|
||||
dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
|
||||
print("traffic: %d addresses of people, %d page requests (%d bot addresses, of them %d scanners, %d monitors); hall %d, rules %d, guide %d; rooms opened here %d, room links %s; shared on %s; referrers %s; campaigns %s" % (
|
||||
len(t["addresses"]), t["human"], len(t["botAddresses"]), t["scanners"], t["monitors"], t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"],
|
||||
t["opened"], dict(t["joins"]) or "none", dict(t["shared"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none"))
|
||||
|
||||
# --- rooms: every ledger, today's in detail ---
|
||||
first_seen = {}
|
||||
@@ -42,7 +42,7 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
continue
|
||||
created = datetime.datetime.fromtimestamp(meta["createdAt"] / 1000, datetime.timezone.utc)
|
||||
humans, bots_seated, started, turns, last, over, talk = [], [], False, 0, created, None, 0
|
||||
names = {}
|
||||
names, seat_changes = {}, []
|
||||
for x in lines[1:]:
|
||||
if x["t"] == "seat":
|
||||
(bots_seated if x["bot"] else humans).append(x["name"])
|
||||
@@ -56,12 +56,16 @@ for f in glob.glob("/var/lib/__SLUG__/rooms/*.jsonl"):
|
||||
last = datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc)
|
||||
elif x["t"] == "chat":
|
||||
talk += 1
|
||||
elif x["t"] == "standIn":
|
||||
seat_changes.append("%s %s's seat to a bot" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||
elif x["t"] == "reseat":
|
||||
seat_changes.append("%s %s re-seated" % (datetime.datetime.fromtimestamp(x["at"] / 1000, datetime.timezone.utc).strftime("%H:%M"), names.get(x["id"], x["id"])))
|
||||
elif x["t"] == "over":
|
||||
over = "a draw" if x["winner"] is None else "%s won" % names.get(x["winner"], x["winner"])
|
||||
for h in humans:
|
||||
if h not in first_seen or created < first_seen[h]:
|
||||
first_seen[h] = created
|
||||
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk))
|
||||
rooms.append(dict(id=meta["id"], size=meta["seats"], created=created, humans=humans, bots=bots_seated, started=started, turns=turns, last=last, over=over, talk=talk, seat_changes=seat_changes))
|
||||
|
||||
today = sorted([r for r in rooms if r["created"].strftime("%F") == day], key=lambda r: r["created"])
|
||||
names = sorted({h for r in today for h in r["humans"]})
|
||||
@@ -78,4 +82,6 @@ for r in today:
|
||||
r["created"].strftime("%H:%M"), r["id"], seated, (" (%d seat%s empty)" % (empty, "" if empty == 1 else "s")) if empty else "",
|
||||
state, r["turns"], span, r["last"].strftime("%H:%M") if r["turns"] else "never",
|
||||
(" %d line%s of talk" % (r["talk"], "" if r["talk"] == 1 else "s")) if r["talk"] else ""))
|
||||
for change in r["seat_changes"]:
|
||||
print(" %s" % change)
|
||||
PY
|
||||
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
|
||||
# it needs only its code and the engine. Ledgers live outside this tree.
|
||||
rsync -az --delete --exclude='/server/node_modules' \
|
||||
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \
|
||||
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/traffic.py' \
|
||||
--exclude='*' \
|
||||
./ "root@$HOST:/opt/__SLUG__/app/"
|
||||
|
||||
@@ -42,14 +42,42 @@ ssh "root@$HOST" '
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-rollup.sh /usr/local/bin/__SLUG__-rollup.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-backup.sh /usr/local/bin/__SLUG__-backup.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-reseat.sh /usr/local/bin/__SLUG__-reseat.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-say.sh /usr/local/bin/__SLUG__-say.sh
|
||||
# The key to the keeper door: made once, readable by root (the reseat script) and the game user (the server) alone.
|
||||
if [ ! -s /etc/__SLUG__/keeper.key ]; then
|
||||
install -d -m 750 -o root -g __SLUG__ /etc/__SLUG__
|
||||
(umask 037; openssl rand -hex 32 > /etc/__SLUG__/keeper.key)
|
||||
chown root:__SLUG__ /etc/__SLUG__/keeper.key
|
||||
fi
|
||||
install -m 644 -D /opt/__SLUG__/app/deploy/traffic.py /usr/local/lib/__SLUG__/traffic.py
|
||||
install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__
|
||||
# On a host the hall shares, its hall-nightly.sh runs the rollup and backup
|
||||
# of every game and checks in with Sentry once for all; a game on a box of
|
||||
# its own schedules its own.
|
||||
if [ -f /etc/cron.d/gamehall ]; then rm -f /etc/cron.d/__SLUG__
|
||||
else install -m 644 /opt/__SLUG__/app/deploy/__SLUG__.cron /etc/cron.d/__SLUG__; fi
|
||||
mkdir -p /var/log/__SLUG__
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now __SLUG__
|
||||
systemctl restart __SLUG__
|
||||
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
|
||||
ADDR=$(head -1 /etc/caddy/sites/__SLUG__.caddy | sed "s/ {\$//")
|
||||
sed "s|__HOST__|$ADDR|" /opt/__SLUG__/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/__SLUG__.caddy
|
||||
caddy validate --config /etc/caddy/Caddyfile >/dev/null
|
||||
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
|
||||
chown -R caddy:caddy /var/lib/caddy
|
||||
systemctl reload caddy
|
||||
sleep 1
|
||||
# "active" means only that the process started: wait for it to answer HTTP,
|
||||
# so a server that dies loading its code fails the deploy instead of looping.
|
||||
for i in $(seq 20); do
|
||||
if curl -s -o /dev/null http://127.0.0.1:__PORT__/api/rooms/ZZZZ; then
|
||||
systemctl --no-pager -l status __SLUG__ | head -3
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "the game server is not answering:" >&2
|
||||
journalctl -u __SLUG__ -n 25 --no-pager >&2
|
||||
exit 1
|
||||
'
|
||||
echo "deployed."
|
||||
@@ -8,7 +8,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { game } from '../src/lib/game';
|
||||
import type { SeatId } from '../src/lib/game/spec';
|
||||
import { cleanOptions, type SeatId } from '../src/lib/game/spec';
|
||||
|
||||
interface Seat {
|
||||
id: SeatId;
|
||||
@@ -34,13 +34,16 @@ async function main(): Promise<void> {
|
||||
const seats: Seat[] = [];
|
||||
let state: ReturnType<typeof game.create> | null = null;
|
||||
let turns = 0;
|
||||
// The host's table options ride the room line; a game created without them is a different game.
|
||||
const options = (lines[0]?.t === 'room' ? lines[0].options : undefined) as Record<string, string> | undefined;
|
||||
// The host's table options ride the room line, read exactly as the server reads them.
|
||||
const options = cleanOptions(game, lines[0]?.t === 'room' ? lines[0].options : undefined);
|
||||
try {
|
||||
for (const line of lines) {
|
||||
if (line.t === 'seat') seats.push(line);
|
||||
else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options);
|
||||
else if (line.t === 'unseat') {
|
||||
const i = seats.findIndex((s) => s.id === line.id);
|
||||
if (i >= 0) seats.splice(i, 1);
|
||||
}
|
||||
else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options, line.rankings);
|
||||
else if (line.t === 'turn' && state) {
|
||||
state = game.resolve(state, line.inputs);
|
||||
turns += 1;
|
||||
|
||||
@@ -8,7 +8,7 @@ STATUS="${3:?usage: report-reply.sh <host> <reportId> <status> <text...>}"
|
||||
shift 3
|
||||
TEXT="$*"
|
||||
case "$STATUS" in resolved|by-design|open) ;; *) echo "status must be resolved, by-design, or open" >&2; exit 1;; esac
|
||||
case "$REPORT_ID" in *[!0-9a-f]*|"") echo "a report id is eight hex characters" >&2; exit 1;; esac
|
||||
[[ "$REPORT_ID" =~ ^[0-9a-f]{8}$ ]] || { echo "a report id is eight hex characters" >&2; exit 1; }
|
||||
LINE=$(REPORT_ID="$REPORT_ID" STATUS="$STATUS" TEXT="$TEXT" python3 -c '
|
||||
import json, os, datetime
|
||||
print(json.dumps({"reportId": os.environ["REPORT_ID"], "at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "status": os.environ["STATUS"], "text": os.environ["TEXT"]}))')
|
||||
|
||||
@@ -19,7 +19,7 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN"
|
||||
# The Slack action takes the channel's ID as well as its name; the
|
||||
# project's error detector is looked up by project id; a workflow is bound
|
||||
# to its detectors by PUT after creation.
|
||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-__SENTRY_SLACK_INTEGRATION__}"
|
||||
SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}"
|
||||
: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}"
|
||||
CHANNEL_ID="$SLACK_CHANNEL_ID"
|
||||
PROJECT_ID="${SENTRY_PROJECT_ID:-__SENTRY_PROJECT_ID__}"
|
||||
|
||||
@@ -1,36 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet
|
||||
# and run ON the droplet as root:
|
||||
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<droplet-ip>.sslip.io'
|
||||
# The argument is the Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this droplet.
|
||||
# Caddy serves the built files and terminates TLS; setup-server.sh adds the
|
||||
# game server behind it.
|
||||
# Prepare a host for __NAME__. The host may be shared with other games of
|
||||
# Kestrel's Hall: each keeps its own Caddy site file in /etc/caddy/sites, its
|
||||
# own port, service user, data directory and access log, and this script
|
||||
# touches nothing that belongs to another game. Copy it and Caddyfile.tmpl
|
||||
# to the host and run ON it as root; safe to run again.
|
||||
# bash setup-droplet.sh '__DOMAIN__, __SLUG__.<host-ip>.sslip.io'
|
||||
# The argument is this game's Caddy site address line: one name, or several
|
||||
# separated by commas. Every name must already resolve to this host.
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||
HOST="${1:?usage: setup-droplet.sh <site address line>}"
|
||||
|
||||
apt-get update -q
|
||||
apt-get install -qy curl rsync
|
||||
|
||||
# Caddy (auto-HTTPS)
|
||||
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
# Caddy (auto-HTTPS), once per host.
|
||||
if ! command -v caddy >/dev/null; then
|
||||
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
| tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
apt-get update -q && apt-get install -qy caddy
|
||||
apt-get update -q && apt-get install -qy caddy
|
||||
fi
|
||||
|
||||
# Site directory, readable by Caddy.
|
||||
mkdir -p /opt/__SLUG__/build
|
||||
chown -R root:caddy /opt/__SLUG__
|
||||
chmod -R g+rX /opt/__SLUG__
|
||||
# Node 22, once per host; every game runs its server on it.
|
||||
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -qy nodejs
|
||||
fi
|
||||
|
||||
# The host's Caddyfile only gathers the games' site files. A Caddyfile that
|
||||
# is neither Caddy's stock one nor this is a single-game host from before the
|
||||
# shared layout: stop rather than overwrite it.
|
||||
ROOT_CADDY='# Kestrel'"'"'s Hall: every game on this host brings its own site file.
|
||||
import sites/*.caddy'
|
||||
mkdir -p /etc/caddy/sites
|
||||
if ! grep -q '^import sites/\*.caddy' /etc/caddy/Caddyfile 2>/dev/null; then
|
||||
if [ -s /etc/caddy/Caddyfile ] && ! grep -q 'root \* /usr/share/caddy' /etc/caddy/Caddyfile; then
|
||||
echo "/etc/caddy/Caddyfile holds another site; move it into /etc/caddy/sites/ first" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$ROOT_CADDY" > /etc/caddy/Caddyfile
|
||||
fi
|
||||
|
||||
# This game: its user, its directories, its site.
|
||||
# The service user shares the slug. A slug that is already a system group
|
||||
# (Ubuntu ships "dip", "games", "audio" and others) cannot also be a user:
|
||||
# stop and say so, and choose another user name in this script and the unit.
|
||||
if ! id -u __SLUG__ &>/dev/null; then
|
||||
if getent group __SLUG__ >/dev/null; then
|
||||
echo "a system group is already called __SLUG__; pick another service user name" >&2
|
||||
exit 1
|
||||
fi
|
||||
useradd -r -m -d /opt/__SLUG__-home __SLUG__
|
||||
fi
|
||||
mkdir -p /opt/__SLUG__/build /opt/__SLUG__/app /var/lib/__SLUG__/rooms /var/log/__SLUG__
|
||||
chown -R root:caddy /opt/__SLUG__/build
|
||||
chmod -R g+rX /opt/__SLUG__/build
|
||||
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
|
||||
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/sites/__SLUG__.caddy
|
||||
|
||||
# Caddy: the site and the game server's paths, from the template beside this script.
|
||||
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
# Validation as root touches the access log; Caddy runs as caddy and must own it.
|
||||
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
|
||||
chown -R caddy:caddy /var/lib/caddy
|
||||
systemctl restart caddy
|
||||
systemctl reload-or-restart caddy
|
||||
|
||||
# Firewall: ssh + web only.
|
||||
ufw allow OpenSSH
|
||||
@@ -38,4 +71,4 @@ ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
echo "droplet ready: now run deploy/deploy.sh <ip> from your machine"
|
||||
echo "host ready for __SLUG__: now run deploy/deploy.sh <ip> from your machine"
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Adds the game server to a droplet that setup-droplet.sh already prepared.
|
||||
# Copy this script and Caddyfile.tmpl to the droplet and run ON the droplet
|
||||
# as root; safe to run again. Installs Node 22, creates the service user and
|
||||
# data directory, and teaches Caddy to hand /api and /ws to the server while
|
||||
# it keeps serving the static site itself.
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -qy nodejs
|
||||
fi
|
||||
|
||||
id -u __SLUG__ &>/dev/null || useradd -r -m -d /opt/__SLUG__-home __SLUG__
|
||||
mkdir -p /opt/__SLUG__/app /var/lib/__SLUG__/rooms
|
||||
chown -R __SLUG__:__SLUG__ /var/lib/__SLUG__
|
||||
|
||||
# Caddy: rewrite the site's configuration from the template, keeping its
|
||||
# address line, so the game server's paths are routed before the files.
|
||||
HOST_LINE=$(head -1 /etc/caddy/Caddyfile)
|
||||
sed "s|__HOST__|$HOST_LINE|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
|
||||
echo "server prerequisites ready: now run deploy/deploy.sh <ip> from your machine"
|
||||
@@ -7,22 +7,84 @@ import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
# Link previews: the app that fetched the page to show it in a conversation. The people
|
||||
# who click such a link arrive with no referrer, so this is the best sign of where it went.
|
||||
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
|
||||
UNFURL = [
|
||||
("Slack", re.compile(r"slack", re.I)),
|
||||
("Discord", re.compile(r"discord", re.I)),
|
||||
("Google Messages", re.compile(r"googlemessages", re.I)),
|
||||
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
|
||||
("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
|
||||
("WhatsApp", re.compile(r"whatsapp", re.I)),
|
||||
("Telegram", re.compile(r"telegram", re.I)),
|
||||
("Reddit", re.compile(r"redditbot", re.I)),
|
||||
("LinkedIn", re.compile(r"linkedinbot", re.I)),
|
||||
("Mastodon", re.compile(r"mastodon", re.I)),
|
||||
("Bluesky", re.compile(r"bluesky|cardyb", re.I)),
|
||||
("X", re.compile(r"twitterbot", re.I)),
|
||||
]
|
||||
# Paths nobody asks a game for but a vulnerability scanner: its address is a bot's all day.
|
||||
# Matched against whole path segments, never the site's own hashed assets under /_app/.
|
||||
SCANNER = re.compile(r"\.php$|(^|/)(\.env|\.git|\.aws|wp-admin|wp-login|wp-content|wp-includes|wordpress|phpmyadmin|cgi-bin|vendor|actuator|boaform|owa|admin)(/|$)", re.I)
|
||||
PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"}
|
||||
SITE = "__DOMAIN__"
|
||||
LOGS = "/var/lib/caddy/access*.log*"
|
||||
# Each game on a shared host keeps its own access log.
|
||||
LOGS = "/var/lib/caddy/__SLUG__-access*.log*"
|
||||
# The site's own addresses, for telling a referrer from a move between its pages: its domain, and its sslip.io name.
|
||||
OWN = re.compile(re.escape(SITE) + r"|\.sslip\.io\b", re.I)
|
||||
# Requests from one address more than this far apart are separate visits.
|
||||
VISIT_GAP = 30 * 60
|
||||
|
||||
|
||||
def unfurled_by(ua):
|
||||
"""The app a link preview came from, or None."""
|
||||
for name, pattern in UNFURL:
|
||||
if pattern.search(ua):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def parse(r):
|
||||
"""The parts of one log line the counts read."""
|
||||
q = r.get("request", {})
|
||||
h = q.get("headers", {})
|
||||
uri = q.get("uri", "")
|
||||
return dict(
|
||||
ts=r.get("ts", 0), status=r.get("status", 0), method=q.get("method"), uri=uri, path=uri.split("?")[0],
|
||||
ip=q.get("client_ip") or q.get("remote_ip") or "?",
|
||||
ua=" ".join(h.get("User-Agent", [""])), referer=" ".join(h.get("Referer", [""])),
|
||||
)
|
||||
|
||||
|
||||
def visit_starts(times):
|
||||
"""When each visit began: requests more than VISIT_GAP apart are separate visits."""
|
||||
starts, last = [], None
|
||||
for ts in sorted(times):
|
||||
if last is None or ts - last > VISIT_GAP:
|
||||
starts.append(ts)
|
||||
last = ts
|
||||
return starts
|
||||
|
||||
|
||||
def scan(day, logs=LOGS):
|
||||
"""Counts for one UTC day (YYYY-MM-DD): every request, page requests by
|
||||
people, the addresses of people and of bots, requests by page, room links
|
||||
opened, referrers, and campaign tags (each counted once per address)."""
|
||||
opened, rooms opened from the site itself, where links were shared,
|
||||
referrers, and campaign tags (each counted once per address).
|
||||
|
||||
A request is a bot's when its browser says so. An address is a bot's for
|
||||
the whole day when it asks for a scanner's paths, or comes back three
|
||||
visits or more without ever opening or joining a game: a monitor,
|
||||
not a person. A monitor that takes a new address every visit gives itself
|
||||
away by keeping time: one browser, page loads at a steady interval, four
|
||||
visits or more, never a game."""
|
||||
d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc)
|
||||
t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp()
|
||||
t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter())
|
||||
seen_campaign = set()
|
||||
requests = []
|
||||
for f in sorted(glob.glob(logs)):
|
||||
if os.path.getmtime(f) < t0:
|
||||
continue
|
||||
@@ -34,19 +96,66 @@ def scan(day, logs=LOGS):
|
||||
r = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (t0 <= r.get("ts", 0) < t1):
|
||||
if t0 <= r.get("ts", 0) < t1:
|
||||
requests.append(parse(r))
|
||||
except OSError:
|
||||
continue
|
||||
q = r.get("request", {})
|
||||
h = q.get("headers", {})
|
||||
ua = " ".join(h.get("User-Agent", [""]))
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
uri = q.get("uri", "")
|
||||
path = uri.split("?")[0]
|
||||
|
||||
# First pass: what each address did all day under a browser's name, to find scanners and monitors.
|
||||
seen = defaultdict(lambda: dict(times=[], played=False, scanner=False))
|
||||
for r in requests:
|
||||
if BOT.search(r["ua"]) or not r["ua"]:
|
||||
continue
|
||||
path = r["path"]
|
||||
a = seen[r["ip"]]
|
||||
a["times"].append(r["ts"])
|
||||
if not path.startswith("/_app/") and SCANNER.search(path):
|
||||
a["scanner"] = True
|
||||
if path.startswith(("/join/", "/api/rooms")) or path == "/ws":
|
||||
a["played"] = True
|
||||
|
||||
def keeps_time(times):
|
||||
starts = visit_starts(times)
|
||||
if len(starts) < 4:
|
||||
return False
|
||||
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||
typical = sorted(gaps)[len(gaps) // 2]
|
||||
return all(0.75 * typical <= g <= 1.25 * typical for g in gaps)
|
||||
|
||||
scanners = {ip for ip, a in seen.items() if a["scanner"]}
|
||||
monitors = {ip for ip, a in seen.items() if not (a["scanner"] or a["played"]) and len(visit_starts(a["times"])) >= 3}
|
||||
by_browser = defaultdict(list)
|
||||
for r in requests:
|
||||
if r["ua"] and not BOT.search(r["ua"]) and r["path"] in PAGES and not seen[r["ip"]]["played"]:
|
||||
by_browser[r["ua"]].append((r["ts"], r["ip"]))
|
||||
for hits in by_browser.values():
|
||||
if keeps_time([ts for ts, _ in hits]):
|
||||
monitors |= {ip for _, ip in hits}
|
||||
posing = scanners | monitors
|
||||
|
||||
t = dict(
|
||||
requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(),
|
||||
opened=0, shared=Counter(), scanners=len(scanners), monitors=len(monitors), referrers=Counter(), campaigns=Counter(),
|
||||
)
|
||||
seen_campaign = set()
|
||||
seen_share = set()
|
||||
for r in requests:
|
||||
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
|
||||
t["requests"] += 1
|
||||
if BOT.search(ua) or not ua:
|
||||
if ip in posing or BOT.search(ua) or not ua:
|
||||
t["bot"] += 1
|
||||
t["botAddresses"].add(ip)
|
||||
service = unfurled_by(ua)
|
||||
# One share per app and page: a preview is often fetched twice. Only a
|
||||
# page counts: an app that already holds a preview fetches its card
|
||||
# image again to show it, and robots.txt and scripts are not shared.
|
||||
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
|
||||
seen_share.add((service, path))
|
||||
t["shared"][service] += 1
|
||||
continue
|
||||
# A room opened from the site itself never passes through /join: count the API's answer.
|
||||
if r["method"] == "POST" and path == "/api/rooms" and 200 <= r["status"] < 300:
|
||||
t["opened"] += 1
|
||||
if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")):
|
||||
continue
|
||||
t["human"] += 1
|
||||
@@ -55,13 +164,11 @@ def scan(day, logs=LOGS):
|
||||
t["pages"][PAGES[path]] += 1
|
||||
elif path.startswith("/join/"):
|
||||
t["joins"][path.split("/")[2].upper()] += 1
|
||||
ref = " ".join(h.get("Referer", [""]))
|
||||
if ref and SITE not in ref:
|
||||
ref = r["referer"]
|
||||
if ref and not OWN.search(ref):
|
||||
t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1
|
||||
m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri)
|
||||
if m and (ip, m.group(1)) not in seen_campaign:
|
||||
seen_campaign.add((ip, m.group(1)))
|
||||
t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1
|
||||
except OSError:
|
||||
continue
|
||||
return t
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:all": "node scripts/dev-all.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// The game server and the site together, for playing locally: one terminal,
|
||||
// each line marked with its side, and Ctrl-C (or either one stopping or
|
||||
// failing to get its port) stops both.
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const sides = [
|
||||
['server', ['run', 'server']],
|
||||
['site', ['run', 'dev', '--', ...process.argv.slice(2)]]
|
||||
];
|
||||
const children = sides.map(([name, args]) => {
|
||||
const child = spawn('npm', args, { stdio: ['inherit', 'pipe', 'pipe'] });
|
||||
for (const stream of [child.stdout, child.stderr]) {
|
||||
let rest = '';
|
||||
stream.on('data', (chunk) => {
|
||||
const lines = (rest + chunk).split('\n');
|
||||
rest = lines.pop();
|
||||
for (const line of lines) {
|
||||
console.log(`[${name}] ${line}`);
|
||||
// A watcher outlives the crash it watches, so a port already taken would leave one side running alone.
|
||||
if (line.includes('EADDRINUSE')) {
|
||||
console.log(`[${name}] its port is taken: is another copy already running?`);
|
||||
stop(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
child.on('exit', (code) => stop(code ?? 0));
|
||||
return child;
|
||||
});
|
||||
|
||||
let stopping = false;
|
||||
function stop(code) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const c of children) if (c.exitCode === null) c.kill('SIGTERM');
|
||||
process.exitCode = code;
|
||||
}
|
||||
process.on('SIGINT', () => stop(0));
|
||||
process.on('SIGTERM', () => stop(0));
|
||||
@@ -7,13 +7,17 @@
|
||||
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
|
||||
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
|
||||
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
|
||||
// POST /api/rooms/:id/rank {token, ranking} a seat ranks the table's choices, best first, where the table asks for it
|
||||
// POST /api/rooms/:id/turn {token, input} this seat's move
|
||||
// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room;
|
||||
// POST /api/rooms/:id/hurry {token} a table the bots are playing on plays the rest at once
|
||||
// POST /api/rooms/:id/say {token, text, to?} table talk, from a seat to the whole room, or privately to the seats in `to`;
|
||||
// {name, text} or from the gallery, signed, when the host allows it
|
||||
// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it
|
||||
// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings
|
||||
// POST /api/rooms/:id/rematch {token} a finished table opens the next one
|
||||
// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device
|
||||
// POST /api/rooms/:id/phrase {token, seat} the host mints one for another person's seat, which re-seats it when claimed
|
||||
// POST /api/rooms/:id/standin {token, seat} the host gives a person's seat to a bot for the rest of the game
|
||||
// POST /api/transfer {phrase} claim one
|
||||
// POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the round
|
||||
// POST /api/reports/:id/image <image bytes> a screenshot for a report just filed
|
||||
@@ -23,10 +27,18 @@
|
||||
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token,
|
||||
// a seat in the Peanut Gallery, counted for the table
|
||||
//
|
||||
// Caddy serves the static site and proxies /api and /ws here.
|
||||
// Caddy serves the static site and proxies /api and /ws here. The keeper's
|
||||
// door answers only on the host itself, and only to the key in KEEPER_KEY_FILE
|
||||
// (deploy/__SLUG__-reseat.sh sends it):
|
||||
//
|
||||
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
|
||||
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
|
||||
// POST /keeper/rooms/:id/say {text} a word from the keeper in the table's talk
|
||||
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { game } from '../../src/lib/game';
|
||||
@@ -43,6 +55,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$
|
||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||
const KEEPER = process.env.KEEPER ?? 'the keeper';
|
||||
const KEEPER_TZ = process.env.KEEPER_TZ ?? 'America/New_York';
|
||||
/** The keeper's key, readable by root and this game's user alone, so another service on the host cannot use the keeper's door. Absent, the door stays shut. */
|
||||
const KEEPER_KEY_FILE = process.env.KEEPER_KEY_FILE ?? '/etc/__SLUG__/keeper.key';
|
||||
const BODY_LIMIT = 16 * 1024;
|
||||
|
||||
// Errors go to Sentry when a DSN is set; the SDK drops them otherwise.
|
||||
@@ -72,6 +86,8 @@ const bells = new RateLimit(3, 60 * 60 * 1000);
|
||||
/** Claims on transfer phrases: a phrase is guessed, not brute-forced. */
|
||||
const claims = new RateLimit(10, 10 * 60 * 1000);
|
||||
const MAX_AUDIENCE = 30;
|
||||
/** How often each socket is asked to answer; one silent through a whole round is a browser gone, and is closed. */
|
||||
const PING_MS = Number(process.env.PING_MS ?? 30_000);
|
||||
const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
const n = rooms.evictIdle(IDLE_ROOM_MS);
|
||||
@@ -184,9 +200,44 @@ async function handleReports(req: IncomingMessage, res: ServerResponse, parts: s
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
/** The key the keeper's door asks for, read on each knock so a key written after the server started is honoured. */
|
||||
function keeperKeyGiven(req: IncomingMessage): boolean {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = Buffer.from(readFileSync(KEEPER_KEY_FILE, 'utf8').trim());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const given = Buffer.from(String(req.headers['x-keeper-key'] ?? ''));
|
||||
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The keeper's door. Caddy proxies only /api and /ws, so it is reached from the host alone; a request Caddy
|
||||
* forwarded is turned away besides, and so is one without the keeper's key.
|
||||
*/
|
||||
async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise<void> {
|
||||
if (req.headers['x-forwarded-for'] || parts[1] !== 'rooms' || !keeperKeyGiven(req)) throw new RoomError('Not here.', 404);
|
||||
const room = rooms.get(parts[2] ?? '');
|
||||
if (parts.length === 3 && req.method === 'GET') return send(res, 200, { roomId: room.id, started: !!room.state, seats: rooms.seatsForKeeper(room) });
|
||||
if (parts[3] === 'reseat' && parts.length === 4 && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const { seat, token } = rooms.reseat(room, body.seat);
|
||||
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor this server.
|
||||
return send(res, 200, { name: seat.name, link: `${PUBLIC_URL}/join/${room.id}#seat=${seat.id}.${token}` });
|
||||
}
|
||||
if (parts[3] === 'say' && parts.length === 4 && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
rooms.keeperSays(room, body.text);
|
||||
return send(res, 204, {});
|
||||
}
|
||||
throw new RoomError('Not here.', 404);
|
||||
}
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
if (parts[0] === 'keeper') return handleKeeper(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts);
|
||||
if (parts[0] === 'api' && parts[1] === 'tally' && parts.length === 2 && req.method === 'GET') return send(res, 200, tallies.get());
|
||||
if (parts[0] === 'api' && parts[1] === 'transfer' && parts.length === 2 && req.method === 'POST') {
|
||||
@@ -230,8 +281,16 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
rooms.begin(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'rank') {
|
||||
rooms.rank(room, token, body.ranking);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'hurry') {
|
||||
rooms.hurry(room, token);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'turn') {
|
||||
rooms.submit(room, token, body.input);
|
||||
rooms.submit(room, token, body.input, body.resolved);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'say') {
|
||||
@@ -241,7 +300,7 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
return send(res, 200, rooms.view(room, SPECTATOR));
|
||||
}
|
||||
if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429);
|
||||
rooms.say(room, token, body.text);
|
||||
rooms.say(room, token, body.text, body.to);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'gallery') {
|
||||
@@ -268,6 +327,13 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
if (action === 'transfer') {
|
||||
return send(res, 200, rooms.transfer(room, token));
|
||||
}
|
||||
if (action === 'phrase') {
|
||||
return send(res, 200, rooms.hostPhrase(room, token, body.seat));
|
||||
}
|
||||
if (action === 'standin') {
|
||||
rooms.standIn(room, token, body.seat);
|
||||
return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id));
|
||||
}
|
||||
if (action === 'report') {
|
||||
if (!desk.allow(clientOf(req))) throw new RoomError(DESK_BUSY, 429);
|
||||
const seat = token ? rooms.seatOf(room, token) : null;
|
||||
@@ -292,7 +358,8 @@ const server = createServer((req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
// The socket only ever speaks to the browser; anything the browser sends is ignored, so it may send almost nothing.
|
||||
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 });
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||
if (url.pathname !== '/ws') return socket.destroy();
|
||||
@@ -312,9 +379,30 @@ server.on('upgrade', (req, socket, head) => {
|
||||
};
|
||||
const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange);
|
||||
ws.on('close', unsubscribe);
|
||||
answered.set(ws, true);
|
||||
ws.on('pong', () => answered.set(ws, true));
|
||||
// A socket's own error (a message over the limit, a malformed frame) closes that socket, never the server.
|
||||
ws.on('error', () => ws.terminate());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A browser that died without closing (a crashed tab, a sleeping laptop, a dropped network) leaves its socket
|
||||
* open until TCP gives up, counted in the gallery all the while. The protocol's own ping, which every browser
|
||||
* answers by itself, finds it within two rounds.
|
||||
*/
|
||||
const answered = new WeakMap<WebSocket, boolean>();
|
||||
setInterval(() => {
|
||||
for (const ws of wss.clients) {
|
||||
if (!answered.get(ws)) {
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
answered.set(ws, false);
|
||||
ws.ping();
|
||||
}
|
||||
}, PING_MS).unref();
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`__SLUG__ server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import { appendFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { game, type Input, type State } from '../../src/lib/game';
|
||||
import type { GameSpec } from '../../src/lib/game/spec';
|
||||
import { Rooms } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { SPECTATOR } from '../../src/lib/game/spec';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rooms-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The bots move once the request that made their move due has been answered: after this tick. */
|
||||
const answered = () => new Promise<void>((r) => setImmediate(r));
|
||||
|
||||
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
|
||||
function table(spec: GameSpec<State, Input> = game, withCy = false) {
|
||||
const rooms = new Rooms(spec, new Store(dir));
|
||||
const host = rooms.create('Ann', 3);
|
||||
const guest = rooms.join(host.room, 'Bo');
|
||||
const third = withCy ? rooms.join(host.room, 'Cy') : null;
|
||||
rooms.begin(host.room, host.token);
|
||||
return { rooms, room: host.room, ann: host.token, bo: guest.token, cy: third?.token };
|
||||
}
|
||||
|
||||
describe('rooms', () => {
|
||||
it('resolves a round once every seat has moved, and refuses a move for a round already played', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(rooms.waitingOn(room)).toEqual(['B']);
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(() => rooms.submit(room, ann, { pick: 4 }, 0)).toThrow(/already been played/);
|
||||
});
|
||||
|
||||
it("puts the keeper's word in the table's talk though the gallery may not talk, and a restart keeps it", () => {
|
||||
const { rooms, room } = table();
|
||||
expect(room.galleryTalk).toBe(false);
|
||||
rooms.keeperSays(room, 'I came too late for the seat; call me again.');
|
||||
const said = { id: SPECTATOR, name: 'the keeper', keeper: true, text: 'I came too late for the seat; call me again.' };
|
||||
expect(room.chat.at(-1)).toMatchObject(said);
|
||||
expect(new Rooms(game, new Store(dir)).get(room.id).chat.at(-1)).toMatchObject(said);
|
||||
});
|
||||
|
||||
it('gives a seat a new token at the keeper\'s word: the old one opens nothing, and a restart keeps the change', () => {
|
||||
const { rooms, room, bo } = table();
|
||||
const phrase = rooms.transfer(room, bo).phrase;
|
||||
const { seat, token } = rooms.reseat(room, 'B');
|
||||
expect(seat.name).toBe('Bo');
|
||||
expect(rooms.seatOf(room, token).id).toBe('B');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
// A phrase minted with the old token carries nothing now.
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
||||
expect(rooms.seatOf(reopened, token).id).toBe('B');
|
||||
expect(() => rooms.seatOf(reopened, bo)).toThrow(/opens no seat/);
|
||||
});
|
||||
|
||||
it('re-seats only a person, and says when each person last acted', () => {
|
||||
const { rooms, room, ann } = table();
|
||||
expect(() => rooms.reseat(room, 'Z')).toThrow(/No person sits/);
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
const seats = rooms.seatsForKeeper(room);
|
||||
expect(seats.map((s) => [s.id, s.name])).toEqual([['A', 'Ann'], ['B', 'Bo']]);
|
||||
expect(seats[0].lastActed).toBeGreaterThan(0);
|
||||
expect(seats[1].lastActed).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a room with an unreadable ledger unloaded and opens the rest', () => {
|
||||
const { room } = table();
|
||||
appendFileSync(join(dir, 'ZZZZ.jsonl'), '{"t":"room","id":"ZZZZ","seats":2,"createdAt":1,"options":{}}\n{"t":"seat", torn');
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const reopened = new Rooms(game, new Store(dir));
|
||||
expect(errors).toHaveBeenCalledWith(expect.stringMatching(/ZZZZ: ledger unreadable/));
|
||||
expect(reopened.get(room.id).state).not.toBeNull();
|
||||
errors.mockRestore();
|
||||
});
|
||||
|
||||
it('holds a draft until it is settled, and lets it be replaced until then', () => {
|
||||
type Draft = Input & { final?: boolean };
|
||||
const drafts: GameSpec<State, Input> = {
|
||||
...game,
|
||||
cleanInput: (raw) => ({ ...game.cleanInput(raw), final: (raw as Draft)?.final === true }) as Draft,
|
||||
settled: (input) => (input as Draft).final === true
|
||||
};
|
||||
const { rooms, room, ann, bo } = table(drafts);
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
rooms.submit(room, bo, { pick: 2, final: true }, 0);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||
rooms.submit(room, ann, { pick: 4 }, 0);
|
||||
expect(room.resolved).toBe(0);
|
||||
rooms.submit(room, ann, { pick: 3, final: true }, 0);
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(room.state!.rounds[0].picks).toEqual({ A: 3, B: 2 });
|
||||
rooms.submit(room, bo, { pick: 1, final: true }, 1);
|
||||
expect(() => rooms.submit(room, bo, { pick: 2, final: true }, 1)).toThrow(/already in/);
|
||||
rooms.submit(room, bo, { pick: 2 }, 1);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it("shows a seat its own move in waiting and its last move played, and nobody else's", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(rooms.view(room, 'A').yourInput).toEqual({ pick: 5 });
|
||||
expect(rooms.view(room, 'B').yourInput).toBeNull();
|
||||
expect(rooms.view(room, '').yourInput).toBeNull();
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
expect(rooms.view(room, 'A').yourInput).toBeNull();
|
||||
expect(rooms.view(room, 'A').yourLastInput).toEqual({ pick: 5 });
|
||||
expect(rooms.view(room, 'B').yourLastInput).toEqual({ pick: 2 });
|
||||
expect(rooms.view(room, '').yourLastInput).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a private line to its sender and the seats it names, never to anyone else or the gallery', () => {
|
||||
const { rooms, room, ann, bo } = table(game, true);
|
||||
rooms.say(room, ann, 'Everyone, hello.');
|
||||
rooms.say(room, ann, 'Bo, lay a five.', ['B']);
|
||||
const heard = (seat: string) => rooms.view(room, seat).chat.map((l) => l.text);
|
||||
expect(heard('A')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
|
||||
expect(heard('B')).toEqual(['Everyone, hello.', 'Bo, lay a five.']);
|
||||
expect(heard('C')).toEqual(['Everyone, hello.']);
|
||||
expect(heard('')).toEqual(['Everyone, hello.']);
|
||||
expect(rooms.view(room, 'B').chat[1]).toMatchObject({ id: 'A', to: ['B'], turn: 1 });
|
||||
expect(() => rooms.say(room, bo, 'Anyone?', ['Q'])).toThrow(/Nobody at this table/);
|
||||
});
|
||||
|
||||
it('keeps each ranking secret until the deal, which reads them all from the start line', () => {
|
||||
const dealt: Record<string, string[]>[] = [];
|
||||
const ranked: GameSpec<State, Input> = {
|
||||
...game,
|
||||
rankable: () => [
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'blue', label: 'Blue' }
|
||||
],
|
||||
create: (names, seed, rules, options, rankings) => {
|
||||
dealt.push(rankings ?? {});
|
||||
return game.create(names, seed, rules, options);
|
||||
}
|
||||
};
|
||||
const rooms = new Rooms(ranked, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
const guest = rooms.join(host.room, 'Bo');
|
||||
const room = host.room;
|
||||
expect(() => rooms.rank(room, host.token, ['red'])).toThrow(/every choice once/);
|
||||
rooms.rank(room, host.token, ['blue', 'red']);
|
||||
expect(rooms.view(room, 'A').yourRanking).toEqual(['blue', 'red']);
|
||||
expect(rooms.view(room, 'B')).toMatchObject({ yourRanking: null, ranked: 1 });
|
||||
rooms.begin(room, host.token);
|
||||
expect(dealt).toEqual([{ A: ['blue', 'red'] }]);
|
||||
expect(() => rooms.rank(room, guest.token, ['red', 'blue'])).toThrow(/ranking is closed/);
|
||||
new Rooms(ranked, new Store(dir));
|
||||
expect(dealt[1]).toEqual({ A: ['blue', 'red'] });
|
||||
});
|
||||
|
||||
it('refuses a ranking at a table that asks for none', () => {
|
||||
const rooms = new Rooms(game, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
||||
});
|
||||
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => {
|
||||
const heard: string[][] = [];
|
||||
const talking: GameSpec<State, Input> = {
|
||||
...game,
|
||||
botInput: (state, seat, press) => {
|
||||
heard.push((press ?? []).map((l) => l.text));
|
||||
return game.botInput(state, seat);
|
||||
},
|
||||
botPress: (state, seat, press, people, trigger) =>
|
||||
trigger === 'phase' ? [{ to: people, text: `Round ${game.turn(state)}.`, note: 'secret' }] : [{ to: [press[press.length - 1].id], text: 'I hear you.' }],
|
||||
parting: (_press, viewer) => [{ id: 'B', text: `Farewell, ${viewer}.` }]
|
||||
};
|
||||
const rooms = new Rooms(talking, new Store(dir));
|
||||
const host = rooms.create('Ann', 3);
|
||||
rooms.addBot(host.room, host.token);
|
||||
const cy = rooms.join(host.room, 'Cy');
|
||||
const room = host.room;
|
||||
rooms.begin(room, host.token);
|
||||
await answered();
|
||||
rooms.say(room, host.token, 'Psst.', ['B']);
|
||||
rooms.say(room, cy.token, 'Hello, all.');
|
||||
const annSees = rooms.view(room, 'A').chat;
|
||||
expect(annSees.map((l) => l.text)).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.']);
|
||||
expect(annSees.some((l) => 'note' in l)).toBe(false);
|
||||
expect(rooms.view(room, 'C').chat.map((l) => l.text)).toEqual(['Round 1.', 'Hello, all.', 'I hear you.']);
|
||||
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
|
||||
rooms.submit(room, host.token, { pick: 1 }, 0);
|
||||
rooms.submit(room, cy.token, { pick: 2 }, 0);
|
||||
await answered();
|
||||
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
|
||||
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
|
||||
expect(rooms.view(room, 'A').parting).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses private talk to a bot when the game gives bots no words', () => {
|
||||
const rooms = new Rooms(game, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
expect(() => rooms.say(host.room, host.token, 'Psst.', ['B'])).toThrow(/read no private talk/);
|
||||
});
|
||||
|
||||
describe('a table the bots play on alone', () => {
|
||||
/** A count to ten that only the bots play: the host is never owed a move. */
|
||||
type Count = { n: number; over: { winner: null; reason: string } | null };
|
||||
const count: GameSpec<Count, null> = {
|
||||
seatIds: ['A', 'B'],
|
||||
minSeats: 2,
|
||||
botNames: ['Aldric'],
|
||||
currentRules: 1,
|
||||
create: () => ({ n: 0, over: null }),
|
||||
resolve: (s) => ({ n: s.n + 1, over: s.n + 1 >= 10 ? { winner: null, reason: 'Ten.' } : null }),
|
||||
needsInput: (s, seat) => seat === 'B' && !s.over,
|
||||
over: (s) => s.over,
|
||||
turn: (s) => s.n + 1,
|
||||
view: (s) => s,
|
||||
botInput: () => null,
|
||||
cleanInput: () => null,
|
||||
nameOf: (_s, seat) => seat
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('plays a few rounds at once, then one at a pace to watch, and the rest at once when hurried', () => {
|
||||
const rooms = new Rooms(count, new Store(dir));
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
const room = host.room;
|
||||
vi.runOnlyPendingTimers();
|
||||
expect(room.state!.n).toBe(4);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(true);
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(room.state!.n).toBe(5);
|
||||
rooms.hurry(room, host.token);
|
||||
vi.runAllTimers();
|
||||
expect(room.state!.n).toBe(10);
|
||||
expect(room.over).toBe(true);
|
||||
expect(rooms.view(room, 'A').playingOn).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the host and the other seats', () => {
|
||||
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
rooms.standIn(room, ann, 'B');
|
||||
// The round waited on Bo alone; the bot plays it as soon as the host has been answered.
|
||||
await answered();
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
||||
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||
rooms.submit(room, ann, { pick: 3 }, 1);
|
||||
await answered();
|
||||
expect(room.resolved).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a move the person made before the bot stood in', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
rooms.standIn(room, ann, 'B');
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 });
|
||||
});
|
||||
|
||||
it('is the host\'s call alone, in a game under way, for a person other than the host', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
|
||||
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/);
|
||||
const lobby = rooms.create('Ann', 2);
|
||||
rooms.join(lobby.room, 'Bo');
|
||||
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
|
||||
});
|
||||
|
||||
it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.standIn(room, ann, 'B');
|
||||
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
|
||||
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
|
||||
expect(claimed.seat).toBe('B');
|
||||
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
expect(rooms.waitingOn(room)).toContain('B');
|
||||
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
||||
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
|
||||
});
|
||||
|
||||
it("re-seats a person whose browser lost the seat, by the host's phrase, once", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
const claimed = rooms.claim(phrase);
|
||||
expect(claimed.token).not.toBe(bo);
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
});
|
||||
|
||||
it("makes a phrase only for the host, and only for another person's seat", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
|
||||
const withBot = rooms.create('Ann', 2);
|
||||
const bot = rooms.addBot(withBot.room, withBot.token);
|
||||
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
|
||||
});
|
||||
|
||||
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => {
|
||||
const { rooms, room, ann } = table();
|
||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
while (!game.over(room.state!)) {
|
||||
rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
||||
await answered();
|
||||
}
|
||||
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
||||
expect(next.expected).toEqual(['Bo']);
|
||||
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,31 @@
|
||||
// game beyond the GameSpec it is given.
|
||||
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { SPECTATOR, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||
import { cleanOptions, SPECTATOR, tableSize, type GameSpec, type SeatId, type TableOptions } from '../../src/lib/game/spec';
|
||||
import type { ChatLine, RoomView } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
/** The longest line of talk. A game of long negotiation raises this, and the talk box's maxlength with it. */
|
||||
const CHAT_MAX_LENGTH = 300;
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. */
|
||||
/** Lines of talk kept and sent; the ledger keeps them all. A game that runs for weeks of talk raises this. */
|
||||
const CHAT_KEEP = 200;
|
||||
/** Bots' rounds played in one go, when nobody else owes a move, before the rest are paced. */
|
||||
const PLAYED_AT_ONCE = 4;
|
||||
/** Between paced bots' rounds: long enough to watch the table move. */
|
||||
const PACE_MS = 2000;
|
||||
/** Bots' rounds played between answers to everyone else, once hurried. */
|
||||
const HURRIED_STEP = 3;
|
||||
export { SPECTATOR };
|
||||
|
||||
export interface Seat {
|
||||
id: SeatId;
|
||||
name: string;
|
||||
/** SHA-256 of the seat's token, hex; empty for a bot. */
|
||||
/** SHA-256 of the seat's token, hex; empty for a bot. A seat a bot stands in for keeps the person's, which opens nothing while it does. */
|
||||
tokenHash: string;
|
||||
/** A bot plays this seat: one seated as a bot, or one standing in for a person. */
|
||||
bot: boolean;
|
||||
/** The host gave this person's seat to a bot; a phrase from the host, or the keeper, gives it back. */
|
||||
standIn?: true;
|
||||
}
|
||||
|
||||
function hashToken(token: string): string {
|
||||
@@ -48,7 +58,13 @@ export interface Room<State> {
|
||||
state: State | null;
|
||||
/** Inputs received for the round in progress, by seat. */
|
||||
pending: Record<SeatId, unknown>;
|
||||
/** Each seat's ranking of the table's choices, where the table asks for one; secret before the deal and after. */
|
||||
rankings: Record<SeatId, string[]>;
|
||||
/** Each seat's input in the last round it played, kept through rounds it had no part in; each seat is shown only its own. */
|
||||
played: Record<SeatId, unknown>;
|
||||
chat: ChatLine[];
|
||||
/** Rounds played so far; a move names the count it was made against. */
|
||||
resolved: number;
|
||||
/** Bumped on every change a client might want to see. */
|
||||
seq: number;
|
||||
updatedAt: number;
|
||||
@@ -106,10 +122,14 @@ export class Rooms<State, Input> {
|
||||
private listeners = new Map<string, Set<(room: Room<State>) => void>>();
|
||||
/** Sockets watching from the gallery, by room. */
|
||||
private gallery = new Map<string, number>();
|
||||
/** Phrases waiting to be claimed, with the seat and the token they carry. */
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string; expiresAt: number }>();
|
||||
/** Phrases waiting to be claimed, with the seat and the token they carry; the host's carry none, and re-seat on claim. */
|
||||
private transfers = new Map<string, { roomId: string; seatId: SeatId; token: string | null; expiresAt: number }>();
|
||||
private failedClaims = 0;
|
||||
private failWindowStart = 0;
|
||||
/** Tables whose bots are playing on, paced: the next round's timer. */
|
||||
private pacing = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Tables asked to play the rest at once. */
|
||||
private hurried = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private game: GameSpec<State, Input>,
|
||||
@@ -118,7 +138,14 @@ export class Rooms<State, Input> {
|
||||
private keeper: { name: string; zone: string } = { name: 'the keeper', zone: 'UTC' }
|
||||
) {
|
||||
for (const id of store.roomIds()) {
|
||||
const room = this.replay(id, store.read(id));
|
||||
let room: Room<State> | null;
|
||||
try {
|
||||
room = this.replay(id, store.read(id));
|
||||
} catch (e) {
|
||||
// One unreadable ledger (a torn last line) keeps its room closed, not the server down.
|
||||
console.error(`room ${id}: ledger unreadable, left unloaded: ${e instanceof Error ? e.message : String(e)}`);
|
||||
continue;
|
||||
}
|
||||
if (!room) continue;
|
||||
this.rooms.set(id, room);
|
||||
// A table that stopped with a bot's move owed picks up where it left off.
|
||||
@@ -166,33 +193,25 @@ export class Rooms<State, Input> {
|
||||
seatOf(room: Room<State>, token: string | undefined): Seat {
|
||||
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
const given = Buffer.from(hashToken(token), 'hex');
|
||||
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||
const seat = room.seats.find((s) => s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
|
||||
if (seat?.standIn) throw new RoomError('The host gave your seat to a bot, which plays on in your place. Ask them for a transfer phrase to take it back.', 403);
|
||||
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
|
||||
return seat;
|
||||
}
|
||||
|
||||
create(name: string, size: number, rawOptions?: unknown): { room: Room<State>; seat: Seat; token: string } {
|
||||
const { minSeats } = this.game;
|
||||
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
|
||||
const options = cleanOptions(this.game, rawOptions);
|
||||
const most = tableSize(this.game, options);
|
||||
if (!Number.isInteger(size) || size < minSeats || size > most) {
|
||||
throw new RoomError(`A table seats ${minSeats} to ${most} players.`);
|
||||
}
|
||||
return this.open(name, size, this.cleanOptions(rawOptions));
|
||||
}
|
||||
|
||||
/** The host's choices, kept to the options the game declares and the choices it offers; the rest default. */
|
||||
private cleanOptions(raw: unknown): TableOptions {
|
||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const options: TableOptions = {};
|
||||
for (const o of this.game.options ?? []) {
|
||||
const v = given[o.key];
|
||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||
}
|
||||
return options;
|
||||
return this.open(name, size, options);
|
||||
}
|
||||
|
||||
/** A table with nobody seated yet. */
|
||||
private blank(id: string, size: number, createdAt: number, rematchOf: string | null, expected: string[], options: TableOptions, seq = 0): Room<State> {
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, chat: [], seq, updatedAt: createdAt };
|
||||
return { id, size, createdAt, seats: [], galleryTalk: false, challenge: null, expected, rematch: null, rematchOf, options, over: false, state: null, pending: {}, played: {}, rankings: {}, chat: [], resolved: 0, seq, updatedAt: createdAt };
|
||||
}
|
||||
|
||||
/** Open a table with its first seat taken; a rematch names the table it follows and holds seats for its players. */
|
||||
@@ -223,6 +242,11 @@ export class Rooms<State, Input> {
|
||||
this.commit(room, { t: 'galleryTalk', at: Date.now(), on, by: seat.id });
|
||||
}
|
||||
|
||||
/** The keeper of the site says a word at a table from the keeper's door, whether or not the gallery may talk: the answer to a call they could not take up. */
|
||||
keeperSays(room: Room<State>, raw: unknown): void {
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: SPECTATOR, name: this.keeper.name, text: this.cleanTalk(raw), keeper: true });
|
||||
}
|
||||
|
||||
/** A word from the Peanut Gallery, when the host allows it: signed with a name no seat holds. */
|
||||
sayFromGallery(room: Room<State>, rawName: unknown, raw: unknown): void {
|
||||
if (!room.galleryTalk) throw new RoomError('The gallery listens at this table; the host has not let it talk.', 403);
|
||||
@@ -255,9 +279,10 @@ export class Rooms<State, Input> {
|
||||
const caller = this.seatOf(room, token);
|
||||
if (!room.state || !this.game.over(room.state)) throw new RoomError('The game is not over yet.', 409);
|
||||
if (room.rematch) return { roomId: room.rematch.to, seat: null, token: null, created: false };
|
||||
const others = room.seats.filter((s) => !s.bot && s.id !== caller.id).map((s) => s.name);
|
||||
// A person a bot stood in for is still one of the company; the stand-in does not follow.
|
||||
const others = room.seats.filter((s) => (!s.bot || s.standIn) && s.id !== caller.id).map((s) => s.name);
|
||||
const next = this.open(caller.name, room.size, room.options, { rematchOf: room.id, expected: others });
|
||||
for (const bot of room.seats.filter((s) => s.bot)) this.sit(next.room, bot.name, true);
|
||||
for (const bot of room.seats.filter((s) => s.bot && !s.standIn)) this.sit(next.room, bot.name, true);
|
||||
this.commit(room, { t: 'rematch', at: Date.now(), to: next.room.id, by: caller.id });
|
||||
return { roomId: next.room.id, seat: next.seat, token: next.token, created: true };
|
||||
}
|
||||
@@ -265,6 +290,22 @@ export class Rooms<State, Input> {
|
||||
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
||||
transfer(room: Room<State>, token: string | undefined): { phrase: string; expiresAt: number } {
|
||||
const seat = this.seatOf(room, token);
|
||||
return this.mint(room.id, seat.id, token!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host mints a phrase for another person's seat: for a player whose browser lost it, or to take it back
|
||||
* from a bot standing in. Claimed, it gives the seat a new token, so the old one opens nothing after.
|
||||
*/
|
||||
hostPhrase(room: Room<State>, token: string | undefined, seatId: unknown): { phrase: string; expiresAt: number } {
|
||||
const host = this.seatOf(room, token);
|
||||
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may make a phrase for another seat.', 403);
|
||||
const seat = room.seats.find((s) => s.id === seatId && s.id !== host.id && (!s.bot || s.standIn));
|
||||
if (!seat) throw new RoomError('No other person sits in that seat.', 404);
|
||||
return this.mint(room.id, seat.id, null);
|
||||
}
|
||||
|
||||
private mint(roomId: string, seatId: SeatId, token: string | null): { phrase: string; expiresAt: number } {
|
||||
const now = Date.now();
|
||||
for (const [phrase, t] of this.transfers) if (t.expiresAt < now) this.transfers.delete(phrase);
|
||||
if (this.transfers.size >= 200) throw new RoomError('Too many transfers are pending; try again in a few minutes.', 429);
|
||||
@@ -273,7 +314,7 @@ export class Rooms<State, Input> {
|
||||
phrase = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomBytes(1)[0] % TRANSFER_WORDS.length]).join('-');
|
||||
} while (this.transfers.has(phrase));
|
||||
const expiresAt = now + TRANSFER_TTL_MS;
|
||||
this.transfers.set(phrase, { roomId: room.id, seatId: seat.id, token: token!, expiresAt });
|
||||
this.transfers.set(phrase, { roomId, seatId, token, expiresAt });
|
||||
return { phrase, expiresAt };
|
||||
}
|
||||
|
||||
@@ -294,6 +335,10 @@ export class Rooms<State, Input> {
|
||||
}
|
||||
this.transfers.delete(phrase);
|
||||
const room = this.get(t.roomId);
|
||||
if (t.token === null) {
|
||||
const { seat, token } = this.reseat(room, t.seatId);
|
||||
return { roomId: room.id, seat: seat.id, token };
|
||||
}
|
||||
const seat = this.seatOf(room, t.token);
|
||||
return { roomId: room.id, seat: seat.id, token: t.token };
|
||||
}
|
||||
@@ -304,23 +349,89 @@ export class Rooms<State, Input> {
|
||||
if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may begin.', 403);
|
||||
if (room.state) throw new RoomError('The game has already begun.', 409);
|
||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
|
||||
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
|
||||
// A game whose first move belongs to a bot must not wait for a human who is not owed a move.
|
||||
this.driveBots(room);
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/** Rounds only bots owe (an opening move, an extra turn) resolve at once; a human's waits for them. */
|
||||
private driveBots(room: Room<State>): void {
|
||||
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
|
||||
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
||||
const inputs: Record<SeatId, unknown> = {};
|
||||
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id);
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
/**
|
||||
* The bots' moves, once the request that made them due has been answered.
|
||||
* A bot that thinks for a second or two would otherwise hold up the reply
|
||||
* to the person whose move it is answering, so their own move would seem
|
||||
* to hang and the bot's to come at once. The table is told as each move
|
||||
* lands, the same as any other.
|
||||
*/
|
||||
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
|
||||
private isTable(room: Room<State>): boolean {
|
||||
return this.rooms.get(room.id) === room;
|
||||
}
|
||||
|
||||
private botsAfterAnswering(room: Room<State>): void {
|
||||
setImmediate(() => {
|
||||
if (!this.isTable(room)) return;
|
||||
try {
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
} catch (e) {
|
||||
// Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands.
|
||||
console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds only bots owe (an opening move, an extra turn, a table the
|
||||
* people are out of) resolve without waiting; a human's waits for them.
|
||||
* A few are played at once; past that, the bots play on one round at a
|
||||
* time, so the table can be watched and the server answers everyone else
|
||||
* between rounds. Hurried, they play in larger steps.
|
||||
*/
|
||||
private driveBots(room: Room<State>, burst = PLAYED_AT_ONCE): void {
|
||||
if (this.pacing.has(room.id)) return;
|
||||
const bots = (state: State) => room.seats.filter((s) => s.bot && this.game.needsInput(state, s.id));
|
||||
let played = 0;
|
||||
while (room.state && !this.game.over(room.state) && this.waitingOn(room).length === 0 && bots(room.state).length > 0) {
|
||||
if (played === burst) return this.playOnLater(room);
|
||||
const inputs: Record<SeatId, unknown> = {};
|
||||
for (const seat of bots(room.state)) inputs[seat.id] = this.game.botInput(room.state, seat.id, this.heardBy(room, seat.id));
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
played += 1;
|
||||
}
|
||||
this.hurried.delete(room.id);
|
||||
const outcome = room.state && this.game.over(room.state);
|
||||
if (outcome && !room.over) this.commit(room, { t: 'over', at: Date.now(), winner: outcome.winner, reason: outcome.reason });
|
||||
}
|
||||
|
||||
/** The bots' next round, after a pause to watch it by, or at once when hurried. */
|
||||
private playOnLater(room: Room<State>): void {
|
||||
const hurried = this.hurried.has(room.id);
|
||||
const next = setTimeout(() => {
|
||||
this.pacing.delete(room.id);
|
||||
if (!this.isTable(room)) return;
|
||||
this.driveBots(room, hurried ? HURRIED_STEP : 1);
|
||||
// Played out: the bots have their say once, as after any round, which ends a game with parting words.
|
||||
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
|
||||
}, hurried ? 0 : PACE_MS);
|
||||
next.unref();
|
||||
this.pacing.set(room.id, next);
|
||||
}
|
||||
|
||||
/** A seat at a table the bots are playing on asks for the rest at once. */
|
||||
hurry(room: Room<State>, token: string | undefined): void {
|
||||
this.seatOf(room, token);
|
||||
this.hurried.add(room.id);
|
||||
const waiting = this.pacing.get(room.id);
|
||||
if (!waiting) return;
|
||||
clearTimeout(waiting);
|
||||
this.pacing.delete(room.id);
|
||||
this.playOnLater(room);
|
||||
}
|
||||
|
||||
/** Whether the bots are playing on by themselves at this table, one paced round at a time. */
|
||||
playingOn(room: Room<State>): boolean {
|
||||
return this.pacing.has(room.id);
|
||||
}
|
||||
|
||||
/** The host seats a bot in an empty chair. */
|
||||
addBot(room: Room<State>, token: string | undefined): Seat {
|
||||
const host = this.seatOf(room, token);
|
||||
@@ -348,6 +459,52 @@ export class Rooms<State, Input> {
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* A person's seat gets a new token, for a player whose browser lost the old one, or one taking it back from a
|
||||
* bot standing in. Whether the one asking holds the seat is the keeper's judgment, or the host's in the phrase
|
||||
* they made; this carries it out. The old token opens nothing after.
|
||||
*/
|
||||
reseat(room: Room<State>, seatId: unknown): { seat: Seat; token: string } {
|
||||
const seat = room.seats.find((s) => s.id === seatId && (!s.bot || s.standIn));
|
||||
if (!seat) throw new RoomError('No person sits in that seat.', 404);
|
||||
const token = newId(18);
|
||||
this.commit(room, { t: 'reseat', at: Date.now(), id: seat.id, tokenHash: hashToken(token) });
|
||||
this.voidPhrases(room, seat.id);
|
||||
return { seat, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host gives a person's seat to a bot, for a player who cannot go on: the bot plays it from the next move
|
||||
* the seat owes, and the person's token opens nothing until a phrase from the host gives it back.
|
||||
*/
|
||||
standIn(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||
const host = this.seatOf(room, token);
|
||||
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may give a seat to a bot.', 403);
|
||||
if (!room.state || this.game.over(room.state)) throw new RoomError('Only a game under way needs a stand-in.', 409);
|
||||
if (seatId === host.id) throw new RoomError('The host keeps their own seat.', 409);
|
||||
const seat = room.seats.find((s) => s.id === seatId && !s.bot);
|
||||
if (!seat) throw new RoomError('No person sits in that seat.', 404);
|
||||
this.commit(room, { t: 'standIn', at: Date.now(), id: seat.id, by: host.id });
|
||||
this.voidPhrases(room, seat.id);
|
||||
// The round may have been waiting on this seat alone. Bots already playing on take the new one in their stride.
|
||||
if (!this.pacing.has(room.id) && this.waitingOn(room).length === 0) this.resolve(room);
|
||||
}
|
||||
|
||||
/** A phrase minted for a seat whose token has changed hands would carry a seat that no longer answers to it. */
|
||||
private voidPhrases(room: Room<State>, seatId: SeatId): void {
|
||||
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seatId) this.transfers.delete(phrase);
|
||||
}
|
||||
|
||||
/** The table as the keeper sees it before re-seating: who sits where, and when each person last moved or spoke. */
|
||||
seatsForKeeper(room: Room<State>): { id: SeatId; name: string; bot: boolean; standIn: boolean; lastActed: number | null }[] {
|
||||
const last = new Map<SeatId, number>();
|
||||
for (const line of this.store.read(room.id)) {
|
||||
if (line.t === 'input' || line.t === 'chat') last.set(line.id, line.at);
|
||||
else if (line.t === 'turn') for (const id of Object.keys(line.inputs)) last.set(id, line.at);
|
||||
}
|
||||
return room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot && !s.standIn, standIn: !!s.standIn, lastActed: s.bot && !s.standIn ? null : (last.get(s.id) ?? null) }));
|
||||
}
|
||||
|
||||
/** The host sends a bot away before the game begins. People leave by not coming back. */
|
||||
unseat(room: Room<State>, token: string | undefined, seatId: unknown): void {
|
||||
const host = this.seatOf(room, token);
|
||||
@@ -358,13 +515,37 @@ export class Rooms<State, Input> {
|
||||
this.commit(room, { t: 'unseat', id: seat.id });
|
||||
}
|
||||
|
||||
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
|
||||
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
/** A seat's ranking of the choices the table asks for, best first, before the game begins. */
|
||||
rank(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (room.state) throw new RoomError('The game has begun; the ranking is closed.', 409);
|
||||
const choices = this.game.rankable?.(room.options);
|
||||
if (!choices) throw new RoomError('This table asks for no ranking.', 409);
|
||||
const ranking = Array.isArray(raw) ? raw.map(String) : [];
|
||||
const values = choices.map((c) => c.value);
|
||||
if (ranking.length !== values.length || new Set(ranking).size !== values.length || !ranking.every((r) => values.includes(r))) throw new RoomError('Rank every choice once.');
|
||||
this.commit(room, { t: 'rank', id: seat.id, ranking });
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited.
|
||||
* A draft (an input the game does not call settled) may be replaced, and a settled move taken back
|
||||
* as a draft, until the round resolves.
|
||||
* `resolved` is how many rounds the page had seen played when the move was made: a move that
|
||||
* arrives after its round was played (a slow connection, a second click, a stale tab) is refused
|
||||
* rather than taken as the move for the next round.
|
||||
*/
|
||||
submit(room: Room<State>, token: string | undefined, raw: unknown, resolved: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
|
||||
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
|
||||
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
if (typeof resolved !== 'number') throw new RoomError('This page is out of date. Reload it to see the table as it stands.', 409);
|
||||
if (resolved !== room.resolved) throw new RoomError('That move was made for a turn that has already been played. Your page will catch up.', 409);
|
||||
|
||||
if (!this.game.needsInput(room.state, seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
const input = this.game.cleanInput(raw);
|
||||
// A settled move is final; a draft may replace it while the round still waits on others.
|
||||
if (seat.id in room.pending && this.settled(room.pending[seat.id]) && this.settled(input)) throw new RoomError('Your move is already in.', 409);
|
||||
const why = this.game.validate?.(room.state, seat.id, input);
|
||||
if (why) throw new RoomError(why);
|
||||
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
|
||||
@@ -373,15 +554,64 @@ export class Rooms<State, Input> {
|
||||
|
||||
private resolve(room: Room<State>): void {
|
||||
const inputs: Record<SeatId, unknown> = { ...room.pending };
|
||||
for (const seat of room.seats) if (seat.bot && this.game.needsInput(room.state!, seat.id)) inputs[seat.id] = this.game.botInput(room.state!, seat.id);
|
||||
// A move a person settled before a bot stood in for them stands.
|
||||
for (const seat of room.seats) {
|
||||
if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs && this.settled(inputs[seat.id]))) {
|
||||
inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
|
||||
}
|
||||
}
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
this.driveBots(room);
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/** Table talk, from a seat to everyone at the table and in the gallery. */
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown): void {
|
||||
/**
|
||||
* Bots' talk, for a game that gives them words: when a round opens, and to
|
||||
* a person who has just written to them. Their lines go on the ledger like
|
||||
* anyone's, so a replay repeats them rather than asking again.
|
||||
*/
|
||||
private botsSpeak(room: Room<State>, trigger: 'phase' | 'spoken', only?: SeatId[]): void {
|
||||
if (!this.game.botPress || !room.state) return;
|
||||
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
|
||||
for (const seat of room.seats) {
|
||||
// A stand-in plays a person's seat; it does not speak in their name.
|
||||
if (!seat.bot || seat.standIn || (only && !only.includes(seat.id))) continue;
|
||||
for (const line of this.game.botPress(room.state, seat.id, this.heardBy(room, seat.id), people, trigger)) {
|
||||
const to = line.to.filter((id) => people.includes(id));
|
||||
if (!to.length || !line.text.trim()) continue;
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(line.text), to, ...(line.note !== undefined ? { note: line.note } : {}) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The talk a seat has heard: the table's, and private lines to it or from it. */
|
||||
private heardBy(room: Room<State>, seatId: SeatId): ChatLine[] {
|
||||
return room.chat.filter((line) => !line.to || line.id === seatId || line.to.includes(seatId));
|
||||
}
|
||||
|
||||
/** Whether an input is final; a game without drafts makes every input final. */
|
||||
private settled(input: unknown): boolean {
|
||||
return this.game.settled ? this.game.settled(input as Input) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table talk, from a seat: to everyone at the table and in the gallery,
|
||||
* or, naming seats, privately to them alone. A private line is never
|
||||
* shown to anyone else, the gallery included.
|
||||
*/
|
||||
say(room: Room<State>, token: string | undefined, raw: unknown, rawTo?: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw) });
|
||||
const to = Array.isArray(rawTo)
|
||||
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
|
||||
: [];
|
||||
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
|
||||
// A private line to a seat a bot stands in for waits for the person; the stand-in reads none.
|
||||
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id && s.bot && !s.standIn));
|
||||
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
|
||||
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
|
||||
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
|
||||
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
|
||||
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot && !s.standIn).map((s) => s.id));
|
||||
}
|
||||
|
||||
private cleanTalk(raw: unknown): string {
|
||||
@@ -394,28 +624,37 @@ export class Rooms<State, Input> {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Seats whose input the next resolution needs and has not yet received. */
|
||||
/** Seats whose input the next resolution needs and has not yet received settled; a draft still waits. */
|
||||
waitingOn(room: Room<State>): SeatId[] {
|
||||
if (!room.state || this.game.over(room.state)) return [];
|
||||
return room.seats
|
||||
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending))
|
||||
.filter((s) => !s.bot && this.game.needsInput(room.state!, s.id) && !(s.id in room.pending && this.settled(room.pending[s.id])))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
/** The view for a seat, or for the gallery when the seat is SPECTATOR. */
|
||||
view(room: Room<State>, seatId: SeatId): RoomView<State> {
|
||||
const seated = seatId !== SPECTATOR;
|
||||
return {
|
||||
roomId: room.id,
|
||||
seq: room.seq,
|
||||
size: room.size,
|
||||
me: seatId,
|
||||
host: room.seats[0]?.id ?? seatId,
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot, ...(s.standIn ? { standIn: true as const } : {}) })),
|
||||
waitingOn: this.waitingOn(room),
|
||||
turn: room.state ? this.game.turn(room.state) : null,
|
||||
resolved: room.resolved,
|
||||
over: room.state ? this.game.over(room.state) : null,
|
||||
state: room.state ? this.game.view(room.state, seatId) : null,
|
||||
chat: room.chat,
|
||||
yourRanking: seated ? (room.rankings[seatId] ?? null) : null,
|
||||
ranked: Object.keys(room.rankings).length,
|
||||
yourInput: seated && seatId in room.pending ? room.pending[seatId] : null,
|
||||
yourLastInput: seated && seatId in room.played ? room.played[seatId] : null,
|
||||
// Private lines only to their sender and the seats they name; a note is a bot's own memory, never sent.
|
||||
chat: this.heardBy(room, seatId).map(({ note: _, ...line }) => line),
|
||||
parting: room.state && seated && this.game.parting && this.game.over(room.state) ? this.game.parting(room.chat, seatId) : [],
|
||||
playingOn: this.playingOn(room),
|
||||
audience: this.gallery.get(room.id) ?? 0,
|
||||
galleryTalk: room.galleryTalk,
|
||||
challenge: room.challenge,
|
||||
@@ -475,6 +714,21 @@ export class Rooms<State, Input> {
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot });
|
||||
break;
|
||||
case 'reseat': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (!seat) break;
|
||||
seat.tokenHash = line.tokenHash;
|
||||
seat.bot = false;
|
||||
delete seat.standIn;
|
||||
break;
|
||||
}
|
||||
case 'standIn': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (!seat) break;
|
||||
seat.bot = true;
|
||||
seat.standIn = true;
|
||||
break;
|
||||
}
|
||||
case 'galleryTalk':
|
||||
room.galleryTalk = line.on;
|
||||
break;
|
||||
@@ -488,19 +742,34 @@ export class Rooms<State, Input> {
|
||||
case 'input':
|
||||
room.pending[line.id] = this.game.cleanInput(line.input);
|
||||
break;
|
||||
case 'rank':
|
||||
room.rankings[line.id] = line.ranking;
|
||||
break;
|
||||
case 'unseat':
|
||||
room.seats = room.seats.filter((s) => s.id !== line.id);
|
||||
delete room.rankings[line.id];
|
||||
break;
|
||||
case 'start':
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options);
|
||||
room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options, line.rankings);
|
||||
break;
|
||||
case 'turn':
|
||||
if (!room.state) return;
|
||||
room.state = this.game.resolve(room.state, line.inputs as Record<SeatId, Input>);
|
||||
room.played = { ...room.played, ...(line.inputs as Record<SeatId, unknown>) };
|
||||
room.pending = {};
|
||||
room.resolved += 1;
|
||||
break;
|
||||
case 'chat':
|
||||
room.chat.push({ id: line.id, text: line.text, at: line.at, ...(line.name ? { name: line.name } : {}) });
|
||||
room.chat.push({
|
||||
id: line.id,
|
||||
text: line.text,
|
||||
at: line.at,
|
||||
...(room.state ? { turn: this.game.turn(room.state) } : {}),
|
||||
...(line.name ? { name: line.name } : {}),
|
||||
...(line.keeper ? { keeper: true as const } : {}),
|
||||
...(line.to ? { to: line.to } : {}),
|
||||
...(line.note !== undefined ? { note: line.note } : {})
|
||||
});
|
||||
if (room.chat.length > CHAT_KEEP) room.chat.splice(0, room.chat.length - CHAT_KEEP);
|
||||
break;
|
||||
}
|
||||
@@ -509,7 +778,7 @@ export class Rooms<State, Input> {
|
||||
private replay(id: string, lines: LedgerLine[]): Room<State> | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], this.cleanOptions(first.options), lines.length);
|
||||
const room = this.blank(id, first.seats, first.createdAt, first.rematchOf ?? null, first.expected ?? [], cleanOptions(this.game, first.options), lines.length);
|
||||
for (const line of lines) {
|
||||
this.apply(room, line);
|
||||
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
|
||||
|
||||
@@ -11,17 +11,33 @@ export type LedgerLine =
|
||||
| { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options: TableOptions }
|
||||
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. A bot's hash is empty. */
|
||||
| { t: 'seat'; id: SeatId; name: string; tokenHash: string; bot: boolean }
|
||||
/**
|
||||
* A person's seat got a new token, from the keeper or a phrase the host made, for a player whose browser lost the
|
||||
* old one; the old one opens nothing after. A seat a bot was standing in for is the person's again.
|
||||
*/
|
||||
| { t: 'reseat'; at: number; id: SeatId; tokenHash: string }
|
||||
/** The host gave a person's seat to a bot, which plays it from here on; a reseat gives it back. */
|
||||
| { t: 'standIn'; at: number; id: SeatId; by: SeatId }
|
||||
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
|
||||
| { t: 'input'; at: number; id: SeatId; input: unknown }
|
||||
/** A bot sent away by the host before the game began; its seat id is free again. */
|
||||
| { t: 'unseat'; id: SeatId }
|
||||
/** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */
|
||||
| { t: 'start'; seed: number; rules: number }
|
||||
/** A seat's ranking of the table's choices, best first, before the game begins; the latest for a seat stands. */
|
||||
| { t: 'rank'; id: SeatId; ranking: string[] }
|
||||
/**
|
||||
* The seed and the rules revision the game began under (a later fix keeps
|
||||
* this revision's path for it), and at a table that asks for rankings,
|
||||
* the seats' rankings as the deal read them.
|
||||
*/
|
||||
| { t: 'start'; seed: number; rules: number; rankings?: Record<SeatId, string[]> }
|
||||
| { t: 'turn'; at: number; inputs: Record<SeatId, unknown> }
|
||||
/** Written after the turn that ends the game, for anything that reads ledgers without the engine. */
|
||||
| { t: 'over'; at: number; winner: SeatId | null; reason: string }
|
||||
/** A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR). */
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string }
|
||||
/**
|
||||
* A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR).
|
||||
* `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown.
|
||||
*/
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; keeper?: true; to?: SeatId[]; note?: unknown }
|
||||
/** The host let the gallery talk, or hushed it. */
|
||||
| { t: 'galleryTalk'; at: number; on: boolean; by: SeatId }
|
||||
/** A seat is held for the keeper of the site, called to the table. */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { game, type Input, type State } from '../../src/lib/game';
|
||||
import type { GameSpec } from '../../src/lib/game/spec';
|
||||
import { Rooms } from './rooms';
|
||||
import { Store } from './store';
|
||||
import { Tallies } from './tally';
|
||||
|
||||
let dir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'tally-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('the tally', () => {
|
||||
it("adds a game's own lines from every game begun, finished or not", () => {
|
||||
const counted: GameSpec<State, Input> = { ...game, tallyBegun: () => ({ 'games of High Card begun': 1 }) };
|
||||
const rooms = new Rooms(counted, new Store(dir));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const host = rooms.create('Ann', 2);
|
||||
rooms.addBot(host.room, host.token);
|
||||
rooms.begin(host.room, host.token);
|
||||
}
|
||||
rooms.create('Bo', 2);
|
||||
const t = new Tallies(counted, new Store(dir)).get();
|
||||
expect(t).toMatchObject({ tablesOpened: 3, gamesBegun: 2, gamesFinished: 0 });
|
||||
expect(t.byGame).toEqual({ 'games of High Card begun': 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
// The tally: what the ledgers add up to, for the hall's about panel. Read
|
||||
// from the files on demand and cached for a minute, so a curious visitor
|
||||
// costs nothing and a busy hall costs one scan a minute. The counts are
|
||||
// the kit's; a game may add its own lines through GameSpec.tally.
|
||||
// the kit's; a game may add its own lines through GameSpec.tally (from a
|
||||
// finished game) and GameSpec.tallyBegun (from any game begun).
|
||||
|
||||
import type { GameSpec, SeatId } from '../../src/lib/game/spec';
|
||||
import { cleanOptions, type GameSpec, type SeatId } from '../../src/lib/game/spec';
|
||||
import type { Tally } from '../../src/lib/net/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
@@ -30,7 +31,12 @@ export class Tallies<State, Input> {
|
||||
const names = new Set<string>();
|
||||
let firstStart = Infinity;
|
||||
for (const id of this.store.roomIds()) {
|
||||
const lines = this.store.read(id);
|
||||
let lines: LedgerLine[];
|
||||
try {
|
||||
lines = this.store.read(id);
|
||||
} catch {
|
||||
continue; // an unreadable ledger is left out of the count, as its room is left unloaded
|
||||
}
|
||||
if (lines[0]?.t !== 'room') continue;
|
||||
t.tablesOpened += 1;
|
||||
const seats: { id: SeatId; name: string; bot: boolean }[] = [];
|
||||
@@ -44,13 +50,16 @@ export class Tallies<State, Input> {
|
||||
seats.push(line);
|
||||
if (line.bot) hasBot = true;
|
||||
else names.add(line.name.toLowerCase());
|
||||
} else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1);
|
||||
else if (line.t === 'start') {
|
||||
} else if (line.t === 'unseat') {
|
||||
const i = seats.findIndex((s) => s.id === line.id);
|
||||
if (i >= 0) seats.splice(i, 1);
|
||||
} else if (line.t === 'start') {
|
||||
t.gamesBegun += 1;
|
||||
if (hasBot) t.gamesWithBot += 1;
|
||||
if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt;
|
||||
for (const [k, v] of Object.entries(this.game.tallyBegun?.(cleanOptions(this.game, lines[0].options)) ?? {})) t.byGame[k] = (t.byGame[k] ?? 0) + v;
|
||||
try {
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options);
|
||||
state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, cleanOptions(this.game, lines[0].options), line.rankings);
|
||||
} catch {
|
||||
state = null;
|
||||
}
|
||||
|
||||
@@ -7,21 +7,20 @@
|
||||
<meta name="theme-color" content="#121a20" />
|
||||
<title>__NAME__</title>
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="description" content="__NAME__: a game between friends, free in the browser." />
|
||||
<meta name="description" content="__NAME__, a game between friends, free in the browser." />
|
||||
<link rel="canonical" href="https://__DOMAIN__/" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="__NAME__" />
|
||||
<meta property="og:title" content="__NAME__" />
|
||||
<meta property="og:description" content="__NAME__: a game between friends, free in the browser." />
|
||||
<meta property="og:description" content="__NAME__, a game between friends, free in the browser." />
|
||||
<meta property="og:url" content="https://__DOMAIN__/" />
|
||||
<meta property="og:image" content="https://__DOMAIN__/og.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="__NAME__" />
|
||||
<meta name="twitter:description" content="__NAME__: a game between friends, free in the browser." />
|
||||
<meta name="twitter:description" content="__NAME__, a game between friends, free in the browser." />
|
||||
<meta name="twitter:image" content="https://__DOMAIN__/og.png" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
// The host's announcement, shown by every game of Kestrel's Hall: the keeper
|
||||
// writes /var/lib/hall/announcement.json on the host (hall-announce), and each
|
||||
// game's site serves it at /hall/announcement.json. It is checked when the page
|
||||
// loads, every few minutes, and whenever the page is looked at again. A reader
|
||||
// may dismiss one announcement; the next one still shows.
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Announcement {
|
||||
/** Names this announcement, so dismissing it does not hide the next. */
|
||||
id: string;
|
||||
text: string;
|
||||
/** When what it announces happens, told in each reader's own time. */
|
||||
at?: string;
|
||||
/** After this it is no longer shown. */
|
||||
until?: string;
|
||||
}
|
||||
|
||||
/** An empty file, `{}`, is how the keeper takes an announcement down. */
|
||||
function isAnnouncement(a: unknown): a is Announcement {
|
||||
if (!a || typeof a !== 'object') return false;
|
||||
const { id, text } = a as Partial<Announcement>;
|
||||
return typeof id === 'string' && typeof text === 'string' && text !== '';
|
||||
}
|
||||
|
||||
const SOURCE = '/hall/announcement.json';
|
||||
const CHECK_MS = 5 * 60_000;
|
||||
const DISMISSED = 'hall:dismissed';
|
||||
|
||||
let current = $state<Announcement | null>(null);
|
||||
let dismissed = $state<string | null>(null);
|
||||
let now = $state(Date.now());
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch(SOURCE, { cache: 'no-store' });
|
||||
const a: unknown = res.ok ? await res.json() : null;
|
||||
current = isAnnouncement(a) ? a : null;
|
||||
} catch {
|
||||
// No announcement reachable: none shown.
|
||||
}
|
||||
now = Date.now();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
dismissed = localStorage.getItem(DISMISSED);
|
||||
} catch {
|
||||
// Without storage a dismissal lasts this visit.
|
||||
}
|
||||
void check();
|
||||
const poll = setInterval(check, CHECK_MS);
|
||||
const tick = setInterval(() => (now = Date.now()), 30_000);
|
||||
const looked = () => {
|
||||
if (document.visibilityState === 'visible') void check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', looked);
|
||||
return () => {
|
||||
clearInterval(poll);
|
||||
clearInterval(tick);
|
||||
document.removeEventListener('visibilitychange', looked);
|
||||
};
|
||||
});
|
||||
|
||||
const shown = $derived(current && current.id !== dismissed && !(current.until && Date.parse(current.until) < now) ? current : null);
|
||||
|
||||
/** When it happens, in the reader's own clock: "in 25 minutes, at 7:00 AM", or the day and time further off. */
|
||||
const when = $derived.by(() => {
|
||||
const t = shown?.at ? Date.parse(shown.at) : NaN;
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const minutes = Math.round((t - now) / 60_000);
|
||||
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
|
||||
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
|
||||
return minutes >= -15 ? ' (now)' : '';
|
||||
});
|
||||
|
||||
function dismiss() {
|
||||
// Read before dismissing: once dismissed, nothing is shown.
|
||||
const id = shown?.id;
|
||||
if (!id) return;
|
||||
dismissed = id;
|
||||
try {
|
||||
localStorage.setItem(DISMISSED, id);
|
||||
} catch {
|
||||
// The dismissal still holds for this visit.
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div class="hall-announcement" role="status">
|
||||
<span>{shown.text}{when}</span>
|
||||
<button type="button" aria-label="Dismiss this announcement" onclick={dismiss}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own colors, not the game's: the same notice reads the same in every game of the hall. */
|
||||
.hall-announcement {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.45rem 2.4rem 0.45rem 1rem;
|
||||
background: #f2c45a;
|
||||
color: #1d1a12;
|
||||
font: 500 0.95rem/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
text-align: center;
|
||||
box-shadow: 0 1px 4px #0005;
|
||||
}
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 0.2rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -51,17 +51,24 @@
|
||||
<div class="verdict">
|
||||
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
|
||||
<p>{g.over.reason}</p>
|
||||
{#each room.view.parting as last, i (i)}<p><em>{room.nameOf(last.id)}</em> {last.text}</p>{/each}
|
||||
<a class="commit" href="/">Back to the hall</a>
|
||||
</div>
|
||||
{:else if room.view.playingOn}
|
||||
<section class="move">
|
||||
<h2>The bots play on</h2>
|
||||
<p class="muted">Nobody else owes a move, so the bots play a round every few seconds for you to watch.</p>
|
||||
{#if !room.spectating}<button type="button" class="quiet" onclick={() => room.hurry()}>Play the rest at once</button>{/if}
|
||||
</section>
|
||||
{:else if room.spectating}
|
||||
<section class="move">
|
||||
<h2>You watch from the Peanut Gallery</h2>
|
||||
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p>
|
||||
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'turns a card over and over, still choosing.' : 'turn their cards over and over, still choosing.'}` : 'Cards are being chosen, face down.'}</p>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="move">
|
||||
<h2>Round {g.rounds.length + 1}: name a number</h2>
|
||||
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p>
|
||||
<h2>Name a number for round {g.rounds.length + 1}</h2>
|
||||
<p class="muted">Every player lays a card face down, and the highest card nobody else laid takes the point. The first to {TARGET} points wins.</p>
|
||||
<div class="picks">
|
||||
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
|
||||
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
// bot, open a table, join by code, the ledger of your tables, and the
|
||||
// about panel with the rules, the guide and a way to reach the keeper.
|
||||
import { goto } from '$app/navigation';
|
||||
import { otherGames, type FamilyGame } from '$lib/family';
|
||||
import { loadFamily, type FamilyGame } from '$lib/family';
|
||||
import TabbedDialog from './TabbedDialog.svelte';
|
||||
import TallyBoard from './Tally.svelte';
|
||||
import Preferences from './Preferences.svelte';
|
||||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||||
@@ -12,6 +14,7 @@
|
||||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||||
import type { State } from '$lib/game';
|
||||
import { game } from '$lib/game';
|
||||
import { tableSize } from '$lib/game/spec';
|
||||
|
||||
let name = $state(playerName());
|
||||
let code = $state('');
|
||||
@@ -31,10 +34,36 @@
|
||||
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
|
||||
});
|
||||
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
|
||||
|
||||
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
|
||||
const told = $derived.by(() => {
|
||||
const t = tally;
|
||||
if (!t) return null;
|
||||
const headline = [
|
||||
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
|
||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' },
|
||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||
];
|
||||
const details = [
|
||||
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
|
||||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
|
||||
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
|
||||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||||
];
|
||||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
|
||||
});
|
||||
/** The other games of Kestrel's Hall, for the about panel. */
|
||||
let family = $state<FamilyGame[]>([]);
|
||||
let hall = $state('');
|
||||
$effect(() => {
|
||||
void otherGames('__SLUG__').then((g) => (family = g));
|
||||
void loadFamily('__SLUG__').then((f) => {
|
||||
family = f.games;
|
||||
hall = f.hall;
|
||||
});
|
||||
});
|
||||
/** Reports this browser's seats have filed, with the keeper's replies. */
|
||||
let reports = $state<Report[]>([]);
|
||||
@@ -44,12 +73,14 @@
|
||||
const ready = $derived(name.trim().length > 0);
|
||||
|
||||
// Each held seat is looked up once, so the ledger can say whose move it
|
||||
// is. A seat the server refuses outright is doubted; a second refusal in
|
||||
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
// is; until the answer comes, its game is absent and reads "looking". A
|
||||
// seat the server refuses outright is doubted; a second refusal in a row
|
||||
// forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||||
const asked = new Set<string>();
|
||||
$effect(() => {
|
||||
for (const held of seats) {
|
||||
if (held.roomId in games) continue;
|
||||
games[held.roomId] = null;
|
||||
if (asked.has(held.roomId)) continue;
|
||||
asked.add(held.roomId);
|
||||
api.view(held.roomId, held.token)
|
||||
.then((v) => {
|
||||
trustSeat(held.roomId);
|
||||
@@ -111,16 +142,27 @@
|
||||
if (c) void goto(`/join/${c}`);
|
||||
}
|
||||
|
||||
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
||||
let forgetting = $state<string | null>(null);
|
||||
function forget(roomId: string) {
|
||||
if (!games[roomId]?.over && forgetting !== roomId) {
|
||||
forgetting = roomId;
|
||||
return;
|
||||
}
|
||||
forgetting = null;
|
||||
forgetSeat(roomId);
|
||||
seats = allSeats();
|
||||
}
|
||||
|
||||
/** A game counts as finished once its table says so; until then, even while loading, it is in play. */
|
||||
const finished = $derived(seats.filter((h) => !!games[h.roomId]?.over));
|
||||
const active = $derived(seats.filter((h) => !games[h.roomId]?.over));
|
||||
|
||||
function status(held: { roomId: string; seat: string }): string {
|
||||
const g = games[held.roomId];
|
||||
if (g === undefined) return 'looking';
|
||||
if (g === null) return 'unreachable';
|
||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
|
||||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.standIn ? `${s.name} (a bot stands in)` : s.bot ? `${s.name} (the bot)` : s.name));
|
||||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||||
if (g.over) {
|
||||
if (g.over.winner === held.seat) return 'you won';
|
||||
@@ -157,12 +199,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="lid">
|
||||
<section class="lid" class:returning={active.length > 0}>
|
||||
<div class="lid-head">
|
||||
<h1>__NAME__</h1>
|
||||
<p class="pitch">One line that says what the game is.</p>
|
||||
<p class="pitch">One sentence that says what the game is, in its own world.</p>
|
||||
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
</div>
|
||||
|
||||
@@ -189,14 +231,14 @@
|
||||
{/if}
|
||||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
||||
<div class="ways">
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length, options))}>Open a table</button>
|
||||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>Open a table</button>
|
||||
<form class="joinform" onsubmit={join}>
|
||||
<span class="or">or join one</span>
|
||||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||||
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
|
||||
</form>
|
||||
</div>
|
||||
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||||
<p class="muted small">With a code you can take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||||
<form class="joinform" onsubmit={claim}>
|
||||
<span class="or">or bring a seat from another device</span>
|
||||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
@@ -210,18 +252,34 @@
|
||||
<p class="caption">A picture of the game goes here.</p>
|
||||
</div>
|
||||
|
||||
{#if seats.length}
|
||||
<div class="ledger">
|
||||
<div class="ledger-head">your games</div>
|
||||
{#each seats as held (held.roomId)}
|
||||
{#snippet gameRow(held: (typeof seats)[number])}
|
||||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||||
<span class="ledger-code">{held.roomId}</span>
|
||||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||||
</a>
|
||||
{#if forgetting === held.roomId}
|
||||
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
|
||||
{:else}
|
||||
<button type="button" class="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<!-- The games still in play, first thing for someone coming back; finished ones wait further down. -->
|
||||
{#if active.length}
|
||||
<div class="ledger">
|
||||
<div class="ledger-head">your games</div>
|
||||
{#each active as held (held.roomId)}{@render gameRow(held)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if finished.length || reports.length}
|
||||
<div class="ledger past">
|
||||
{#if finished.length}
|
||||
<div class="ledger-head">finished games</div>
|
||||
{#each finished as held (held.roomId)}{@render gameRow(held)}{/each}
|
||||
{/if}
|
||||
{#if reports.length}
|
||||
<div class="ledger-head reports-head">your reports to the keeper</div>
|
||||
{#each reports as r (r.id)}
|
||||
@@ -256,33 +314,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<details class="about" id="about" bind:open={aboutOpen}>
|
||||
<summary>about this game</summary>
|
||||
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
|
||||
{#snippet aboutGame()}
|
||||
<div class="about-body">
|
||||
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
|
||||
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
|
||||
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move, and each player is shown only what the rules let them see.</p>
|
||||
<h3>Send word</h3>
|
||||
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
{#if tally}
|
||||
<h3>The tally</h3>
|
||||
<dl class="tally">
|
||||
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'game' : 'games'} played to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd>
|
||||
<dt>{tally.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
|
||||
<dt>{tally.turnsPlayed}</dt><dd>turns recorded; {tally.longestGameTurns} in the longest game</dd>
|
||||
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between moves</dd>
|
||||
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'game' : 'games'} with a bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk</dd>
|
||||
{#each Object.entries(tally.byGame) as [label, n] (label)}
|
||||
<dt>{n}</dt><dd>{label}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="muted small">
|
||||
{#if tally.firstGameAt}The first game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
|
||||
Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.
|
||||
</p>
|
||||
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet aboutTally()}
|
||||
{#if told}
|
||||
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
|
||||
{:else}
|
||||
<p class="muted">Counting…</p>
|
||||
{/if}
|
||||
{#if family.length}
|
||||
<h3>More games in Kestrel's Hall</h3>
|
||||
{/snippet}
|
||||
{#snippet aboutFamily()}
|
||||
<div class="about-body">{#if family.length}
|
||||
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
|
||||
<ul class="family">
|
||||
{#each family as g (g.slug)}
|
||||
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
|
||||
@@ -290,12 +339,29 @@
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="hosting">
|
||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
|
||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
|
||||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
|
||||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
|
||||
</p>
|
||||
<p class="made">__NAME__ is its designer's. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||||
<p class="made">__NAME__ is its designer's.</p>
|
||||
</div>
|
||||
</details>
|
||||
{/snippet}
|
||||
{#snippet aboutWord()}
|
||||
<div class="about-body">
|
||||
<p>If a rule reads wrong, something breaks, or you have a game to tell of, write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
<TabbedDialog
|
||||
address="about"
|
||||
bind:open={aboutOpen}
|
||||
title="About __NAME__"
|
||||
label="About this game"
|
||||
tabs={[
|
||||
{ id: 'game', label: 'Origins', body: aboutGame },
|
||||
{ id: 'tally', label: 'Games so far', body: aboutTally },
|
||||
{ id: 'family', label: 'More games', body: aboutFamily },
|
||||
{ id: 'word', label: 'Send word', body: aboutWord }
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -308,14 +374,21 @@
|
||||
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
|
||||
grid-template-areas: 'head' 'play' 'look' 'ledger' 'past';
|
||||
gap: 1.2rem 2.2rem;
|
||||
}
|
||||
/* Someone coming back sees their games first. */
|
||||
.lid.returning {
|
||||
grid-template-areas: 'head' 'ledger' 'play' 'look' 'past';
|
||||
}
|
||||
|
||||
@media (min-width: 901px) {
|
||||
.lid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
|
||||
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
|
||||
}
|
||||
.lid.returning {
|
||||
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +508,10 @@
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ledger.past {
|
||||
grid-area: past;
|
||||
}
|
||||
|
||||
.ledger-head {
|
||||
font-style: italic;
|
||||
color: var(--bone-dim);
|
||||
@@ -453,6 +530,11 @@
|
||||
background: rgba(240, 165, 58, 0.08);
|
||||
}
|
||||
|
||||
.forget-ask {
|
||||
font-size: 0.85em;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ledger-row.your-move .ledger-info {
|
||||
color: var(--ember);
|
||||
}
|
||||
@@ -540,19 +622,7 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* about */
|
||||
.about {
|
||||
grid-area: about;
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.about summary {
|
||||
cursor: pointer;
|
||||
font-style: italic;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
|
||||
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
|
||||
.about-body {
|
||||
max-width: 44em;
|
||||
color: var(--bone-dim);
|
||||
@@ -563,17 +633,6 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.about-body a {
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
.about-body h3 {
|
||||
font-size: 1rem;
|
||||
font-style: italic;
|
||||
margin: 0.9rem 0 0.2rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
|
||||
.about-body .made {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bone-faint);
|
||||
@@ -606,20 +665,4 @@
|
||||
margin: 0.2rem 0 0;
|
||||
max-width: 22em;
|
||||
}
|
||||
|
||||
.tally {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.25rem 0.9rem;
|
||||
margin: 0.4rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.tally dt {
|
||||
text-align: right;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tally dd {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TableTalk from './TableTalk.svelte';
|
||||
import { game } from '$lib/game';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props();
|
||||
|
||||
const v = $derived(room.view);
|
||||
/** The host asked to begin while a seat is still held for someone on their way. */
|
||||
let beginAsked = $state(false);
|
||||
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
|
||||
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
|
||||
/** A server with no KEEPER set calls its keeper "the keeper", which reads badly beside the title. */
|
||||
const named = $derived(v.keeper.toLowerCase() !== 'the keeper');
|
||||
|
||||
// A table that asks for rankings: this seat's, best first, kept on the server as it changes.
|
||||
const choices = $derived(game.rankable?.(v.options) ?? null);
|
||||
const people = $derived(v.seats.filter((s) => !s.bot).length);
|
||||
const labelOf = (value: string) => choices?.find((c) => c.value === value)?.label ?? value;
|
||||
let order = $state<string[]>([]);
|
||||
// Follows the server's copy; the local order is read untracked, so a click is not undone before its reply.
|
||||
$effect(() => {
|
||||
if (!choices) return;
|
||||
const values = choices.map((c) => c.value);
|
||||
const mine = v.yourRanking;
|
||||
if (mine && mine.length === values.length) order = [...mine];
|
||||
else if (untrack(() => order.length) !== values.length) order = values;
|
||||
});
|
||||
/** Move the choice at `i` to place `j`, the others closing up behind it. */
|
||||
async function move(i: number, j: number) {
|
||||
if (j < 0 || j >= order.length || j === i) return;
|
||||
const next = [...order];
|
||||
next.splice(j, 0, ...next.splice(i, 1));
|
||||
order = next;
|
||||
await room.rank(next);
|
||||
}
|
||||
|
||||
/** The hour where the keeper lives, in words, so a caller knows whether to wait. */
|
||||
let minute = $state(0);
|
||||
@@ -35,7 +63,7 @@
|
||||
{#if room.seatEmpty}
|
||||
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
|
||||
{:else}
|
||||
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
|
||||
<p>The table is full, so you watch from the Peanut Gallery. The game begins when {hostName} says.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="eyebrow">room {v.roomId}</p>
|
||||
@@ -60,15 +88,41 @@
|
||||
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||||
</ul>
|
||||
{#if v.rematchOf}
|
||||
<p class="muted">A rematch of table {v.rematchOf}: the same company, seats held for those not yet back.</p>
|
||||
<p class="muted">This is a rematch of table {v.rematchOf}, with seats held for those not yet back.</p>
|
||||
{/if}
|
||||
{#if choices && !room.spectating}
|
||||
<section class="ranking" aria-label="Your ranking">
|
||||
<h3>Your ranking</h3>
|
||||
<p class="muted small">
|
||||
Best first. When the game begins, the deal reads everyone's ranking, and nobody sees another player's.
|
||||
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
|
||||
</p>
|
||||
<ol>
|
||||
{#each order as value, i (value)}
|
||||
<li>
|
||||
<span class="place">{i + 1}.</span>
|
||||
<span class="choice">{labelOf(value)}</span>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} to the top" title="To the top" disabled={i === 0} onclick={() => move(i, 0)}>⤒</button>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} up" disabled={i === 0} onclick={() => move(i, i - 1)}>↑</button>
|
||||
<button type="button" class="quiet" aria-label="Move {labelOf(value)} down" disabled={i === order.length - 1} onclick={() => move(i, i + 1)}>↓</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{#if !v.yourRanking}<button type="button" class="quiet" onclick={() => room.rank(order)}>Keep this order</button>{/if}
|
||||
{#if people > 1}<p class="muted small">{v.ranked} of the {people} players {v.ranked === 1 ? 'has' : 'have'} ranked.</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{#if !room.spectating}
|
||||
<div class="ways">
|
||||
{#if room.seatEmpty && room.isHost}
|
||||
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
|
||||
{/if}
|
||||
{#if room.isHost}
|
||||
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
|
||||
{#if room.isHost && beginAsked && v.expected.length > 0}
|
||||
<span>A seat is held for {v.expected.join(' and ')}, who {v.expected.length === 1 ? 'has' : 'have'} not arrived. The seat goes once the game begins.</span>
|
||||
<button type="button" class="commit small" onclick={() => room.begin()}>Begin without them</button>
|
||||
<button type="button" class="link" onclick={() => (beginAsked = false)}>wait</button>
|
||||
{:else if room.isHost}
|
||||
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => (v.expected.length > 0 ? (beginAsked = true) : room.begin())}>Begin</button>
|
||||
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
|
||||
{:else}
|
||||
<span class="muted">{hostName} will begin when the table is ready.</span>
|
||||
@@ -82,11 +136,11 @@
|
||||
{/if}
|
||||
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
|
||||
<div class="keeper">
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button>
|
||||
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p>
|
||||
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {named ? `${v.keeper}, the keeper` : 'the keeper'}</button>
|
||||
<p class="muted small note">{named ? v.keeper : 'The keeper'} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour} It is {keeperHour} where {named ? v.keeper : 'the keeper'} lives.{/if} The seat is held either way.</p>
|
||||
</div>
|
||||
{:else if v.challenge}
|
||||
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} 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} It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if room.error}<p class="warning">{room.error}</p>{/if}
|
||||
@@ -170,6 +224,45 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ranking {
|
||||
max-width: 26rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.ranking h3 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.ranking ol {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ranking li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
|
||||
.ranking .place {
|
||||
width: 1.6rem;
|
||||
color: var(--bone-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ranking .choice {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ranking .quiet {
|
||||
min-width: 2.6rem;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.keeper {
|
||||
margin-top: 0.8rem;
|
||||
max-width: 38em;
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -54,12 +54,14 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||
|
||||
{#if open}
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
|
||||
<h2 id="report-heading">Something amiss?</h2>
|
||||
{#if sent}
|
||||
<p class="thanks">Recorded beside the room's ledger, with the round. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
|
||||
<p class="thanks">Recorded beside the room's ledger, with the turn. Thank you. The keeper will look into it.{#if error} {error}{/if}</p>
|
||||
{:else}
|
||||
<label class="field">
|
||||
<span>What happened?</span>
|
||||
@@ -74,7 +76,7 @@
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
|
||||
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
|
||||
</label>
|
||||
<p class="muted small note">The room code and the round ride along; the keeper can replay the game to this moment.</p>
|
||||
<p class="muted small note">The room code and the turn are sent with it, so the keeper can replay the game to this moment.</p>
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
|
||||
@@ -92,6 +94,8 @@
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
// The host's hand on the other seats during a game: a transfer phrase for a
|
||||
// player whose browser lost their seat, and a bot to play the seat of one
|
||||
// who cannot go on, until a phrase gives it back.
|
||||
import type { SeatId } from '$lib/game/spec';
|
||||
import type { Room } from '$lib/net/room.svelte';
|
||||
|
||||
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
|
||||
|
||||
const people = $derived(room.view.seats.filter((s) => s.id !== room.me && (!s.bot || s.standIn)));
|
||||
let asked = $state<SeatId | null>(null);
|
||||
let phrase = $state<{ name: string; phrase: string; expiresAt: number } | null>(null);
|
||||
let error = $state('');
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
asked = null;
|
||||
error = '';
|
||||
}
|
||||
|
||||
async function makePhrase(seat: SeatId, name: string) {
|
||||
error = '';
|
||||
try {
|
||||
const made = await room.hostPhrase(seat);
|
||||
phrase = { name, ...made };
|
||||
setTimeout(() => {
|
||||
if (phrase?.phrase === made.phrase) phrase = null;
|
||||
}, Math.max(0, made.expiresAt - Date.now()));
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'The phrase could not be made.';
|
||||
}
|
||||
}
|
||||
|
||||
async function standIn(seat: SeatId) {
|
||||
asked = null;
|
||||
if (!(await room.standIn(seat))) error = room.error;
|
||||
}
|
||||
|
||||
function phraseTitle(name: string, standIn: boolean): string {
|
||||
const why = standIn ? `Brings ${name} back from the bot.` : `For when ${name}'s browser lost the seat, or they need to play from another device.`;
|
||||
return `${why} You get four words to pass to them. Within ten minutes, they enter them in the hall, or in the claim box while watching this table, and sit back down as ${name}. The phrase works once, and the browser they used before stops opening the seat.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => open && e.key === 'Escape' && close()} />
|
||||
|
||||
{#if open}
|
||||
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<div class="slip" role="dialog" aria-labelledby="seats-heading">
|
||||
<h2 id="seats-heading">The other seats</h2>
|
||||
<p class="muted small">As the host, you can bring a player back to a seat their browser lost, or give the seat of one who cannot go on to a bot, which plays it at once.</p>
|
||||
{#if people.length === 0}
|
||||
<p class="muted">Nobody else at this table is a person.</p>
|
||||
{/if}
|
||||
<ul>
|
||||
{#each people as s (s.id)}
|
||||
<li>
|
||||
<span class="name">{s.name}{#if s.standIn}<span class="muted note">a bot plays for them</span>{/if}</span>
|
||||
<span class="ways">
|
||||
<button type="button" class="quiet" title={phraseTitle(s.name, !!s.standIn)} onclick={() => makePhrase(s.id, s.name)}>{s.standIn ? 'Transfer phrase to take it back' : 'Make a transfer phrase'}</button>
|
||||
{#if !s.standIn && !room.view.over}
|
||||
{#if asked === s.id}
|
||||
<button type="button" class="commit small" onclick={() => standIn(s.id)}>Yes, a bot plays for {s.name}</button>
|
||||
<button type="button" class="link" onclick={() => (asked = null)}>no</button>
|
||||
{:else}
|
||||
<button type="button" class="quiet" title="A bot plays {s.name}'s seat from their next move, and their browser stops opening it. A transfer phrase gives it back." onclick={() => (asked = s.id)}>Give to a bot</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if phrase}
|
||||
<p class="made">Give {phrase.name} this phrase to claim within ten minutes, in the hall or while watching this table: <b class="phrase">{phrase.phrase}</b>. It works once, and their old link stops opening the seat.</p>
|
||||
{/if}
|
||||
{#if error}<p class="warning">{error}</p>{/if}
|
||||
<div class="actions"><button type="button" class="link" onclick={close}>close</button></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.slip {
|
||||
width: min(100%, 34rem);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.2rem 1.2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.8rem 0 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-left: 0.5em;
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ways {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.made {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.phrase {
|
||||
letter-spacing: 0.06em;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.warning {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
// A dialog of tabs: the about panel, how to play, the rules. The address
|
||||
// names it, so a plain link opens it: #rules opens the rules, #guide/notation
|
||||
// how to play at its notation. It closes with its button, a click outside
|
||||
// it, or Escape. The tab last read is the one it reopens on. `open` tells
|
||||
// the page whether it is showing.
|
||||
import { untrack, type Snippet } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Tabs, { type Tab } from './Tabs.svelte';
|
||||
|
||||
let {
|
||||
address,
|
||||
title,
|
||||
label = title,
|
||||
tabs,
|
||||
intro,
|
||||
open = $bindable(false)
|
||||
}: { address: string; title: string; label?: string; tabs: Tab[]; intro?: Snippet; open?: boolean } = $props();
|
||||
|
||||
let current = $state('');
|
||||
|
||||
// The dialog is open exactly while the router's address names it. Opening, choosing a tab and
|
||||
// closing all navigate, so the address and the dialog cannot disagree.
|
||||
const [named, tab] = $derived.by(() => {
|
||||
const [name, t] = page.url.hash.slice(1).split('/');
|
||||
return [name === address, t] as const;
|
||||
});
|
||||
$effect(() => {
|
||||
open = named;
|
||||
});
|
||||
$effect(() => {
|
||||
if (named && tabs.some((t) => t.id === tab)) untrack(() => (current = tab));
|
||||
});
|
||||
// The address follows the chosen tab, so what is being read can be linked to.
|
||||
$effect(() => {
|
||||
if (named && current) untrack(() => current !== tab && show(current));
|
||||
});
|
||||
|
||||
const bare = () => `${page.url.pathname}${page.url.search}`;
|
||||
const quietly = { replaceState: true, noScroll: true, keepFocus: true };
|
||||
function show(id: string) {
|
||||
goto(`${bare()}#${address}/${id}`, quietly);
|
||||
}
|
||||
function close() {
|
||||
goto(bare(), quietly);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (named && e.key === 'Escape') close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if named}
|
||||
<div class="scrim anchored" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
|
||||
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="{address}-title">
|
||||
<header>
|
||||
<h2 id="{address}-title">{title}</h2>
|
||||
<button type="button" class="close" aria-label="Close" onclick={close}>×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
{#if intro}<div class="intro">{@render intro()}</div>{/if}
|
||||
<Tabs {tabs} {label} bind:current />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Its own backdrop: not every game has another dialog to share one with. */
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
/* One row the window's height, so the dialog's max-height: 100% is a real limit and it scrolls within. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
/* Held near the top, not centered: a tab of another length must not move the tabs under the pointer. */
|
||||
.anchored {
|
||||
place-items: start center;
|
||||
padding-top: max(16px, 6vh);
|
||||
}
|
||||
/* min-width 0 all the way down, so wide content scrolls in place instead of widening the dialog past a phone's screen. */
|
||||
.panel {
|
||||
width: min(100%, 44rem);
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--slate);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.4rem 1.2rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
.body {
|
||||
overflow: auto;
|
||||
min-height: 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.intro {
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
.body :global(a) {
|
||||
color: var(--frost);
|
||||
}
|
||||
.body :global(h3) {
|
||||
font-size: 1rem;
|
||||
font-style: italic;
|
||||
margin: 0.9rem 0 0.2rem;
|
||||
color: var(--bone);
|
||||
}
|
||||
.body :global(h3:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* The tab strip stays in view while a long tab scrolls beneath it. */
|
||||
.body :global([role='tablist']) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--slate);
|
||||
}
|
||||
</style>
|
||||
@@ -47,7 +47,10 @@
|
||||
<li class="muted">Nobody has said a word.</li>
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={!line.name && line.id === room.me}><em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
|
||||
<li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
|
||||
<em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
|
||||
{line.text}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if room.spectating && room.view.galleryTalk}
|
||||
@@ -57,7 +60,7 @@
|
||||
<button type="submit" class="quiet" disabled={!draft.trim() || !galleryName.trim() || sending}>Say</button>
|
||||
</form>
|
||||
{:else if room.spectating}
|
||||
<p class="muted small note">The gallery listens; the host has not let it talk.</p>
|
||||
<p class="muted small note">The host has not let the gallery talk at this table.</p>
|
||||
{:else}
|
||||
<form onsubmit={say}>
|
||||
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
|
||||
@@ -110,6 +113,10 @@
|
||||
color: var(--frost);
|
||||
}
|
||||
|
||||
li.private {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" module>
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/** One tab and what it shows. */
|
||||
export interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
body: Snippet;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// A strip of tabs over one panel, for a dialog that would otherwise be one
|
||||
// long read. Arrow keys walk the tabs, Home and End go to the ends.
|
||||
import { tick } from 'svelte';
|
||||
|
||||
let { tabs, label, current = $bindable('') }: { tabs: Tab[]; label: string; current?: string } = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
|
||||
let tabButtons: HTMLButtonElement[] = $state([]);
|
||||
|
||||
async function onKey(e: KeyboardEvent, i: number) {
|
||||
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
const next = (to + tabs.length) % tabs.length;
|
||||
current = tabs[next].id;
|
||||
await tick();
|
||||
tabButtons[next]?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs" role="tablist" aria-label={label}>
|
||||
{#each tabs as t, i (t.id)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id="{uid}-{t.id}"
|
||||
aria-controls="{uid}-panel"
|
||||
aria-selected={shown.id === t.id}
|
||||
tabindex={shown.id === t.id ? 0 : -1}
|
||||
class:on={shown.id === t.id}
|
||||
bind:this={tabButtons[i]}
|
||||
onclick={() => (current = t.id)}
|
||||
onkeydown={(e) => onKey(e, i)}>{t.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="panel" id="{uid}-panel" role="tabpanel" aria-labelledby="{uid}-{shown.id}" tabindex="0">
|
||||
{@render shown.body()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Small capitals in a row that may wrap, the open tab boxed: a second row reads as more buttons, not a broken line. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
margin: 0.6rem 0 0.8rem;
|
||||
}
|
||||
.tabs button {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
background: none;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--bone-dim);
|
||||
padding: 0.3rem 0.6rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button:hover {
|
||||
color: var(--bone);
|
||||
}
|
||||
.tabs button.on {
|
||||
border-color: var(--bone);
|
||||
color: var(--bone);
|
||||
background: var(--slate-deep);
|
||||
}
|
||||
.panel :global(p + p) {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" module>
|
||||
/** One count told: the number, and what it counts. */
|
||||
export interface TallyLine {
|
||||
n: number | string;
|
||||
label: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// What the ledgers add up to: a few counts that are the game's own story,
|
||||
// large, on cards; then the rest in a column of figures; then the fine
|
||||
// print. Each game chooses its counts and words.
|
||||
let { headline, details, note, title }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title: string } =
|
||||
$props();
|
||||
|
||||
const shown = (n: number | string) => (typeof n === 'number' ? n.toLocaleString() : n);
|
||||
</script>
|
||||
|
||||
<h3>{title}</h3>
|
||||
<div class="stories">
|
||||
{#each headline as s (s.label)}
|
||||
<div class="story"><span class="big">{shown(s.n)}</span><span>{s.label}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if details.length}
|
||||
<h3>The ledgers in detail</h3>
|
||||
<dl class="ledger">
|
||||
{#each details as d (d.label)}
|
||||
<dt>{shown(d.n)}</dt><dd>{d.label}</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
{#if note}<p class="note">{note}</p>{/if}
|
||||
|
||||
<style>
|
||||
h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bone);
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
padding-bottom: 0.25rem;
|
||||
margin: 0.2rem 0 0.7rem;
|
||||
}
|
||||
.stories {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin: 0 0 1.2rem;
|
||||
}
|
||||
.story {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.2rem;
|
||||
background: var(--slate-deep);
|
||||
border: 1px solid var(--rule-strong);
|
||||
border-radius: 4px;
|
||||
padding: 0.7rem 0.5rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
.big {
|
||||
font-size: 1.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
color: var(--ember);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.35rem 0.8rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.ledger dt {
|
||||
font-weight: 700;
|
||||
color: var(--ember);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger dd {
|
||||
margin: 0;
|
||||
color: var(--bone-dim);
|
||||
}
|
||||
.note {
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
color: var(--bone-faint);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
||||
// (family.json, one copy shipped with every game), so a player who liked one
|
||||
// table can find the next. This game is left out of its own list.
|
||||
// (family.json, one copy shipped with every game): the hall's front door, and
|
||||
// the next table for a player who liked this one. This game is left out of its
|
||||
// own list.
|
||||
|
||||
export interface FamilyGame {
|
||||
slug: string;
|
||||
@@ -11,13 +12,21 @@ export interface FamilyGame {
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export async function otherGames(self: string): Promise<FamilyGame[]> {
|
||||
/** The family list: the hall's front door and every game but this one. */
|
||||
export interface Family {
|
||||
hall: string;
|
||||
games: FamilyGame[];
|
||||
}
|
||||
|
||||
const NONE: Family = { hall: '', games: [] };
|
||||
|
||||
export async function loadFamily(self: string): Promise<Family> {
|
||||
try {
|
||||
const res = await fetch('/family.json', { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { games?: FamilyGame[] };
|
||||
return (data.games ?? []).filter((g) => g.slug !== self);
|
||||
if (!res.ok) return NONE;
|
||||
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
|
||||
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
|
||||
} catch {
|
||||
return [];
|
||||
return NONE;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function resolveRound(previous: State, inputs: Record<SeatId, Input>): St
|
||||
if (scorer) state.players[scorer].points += 1;
|
||||
state.rounds.push({ picks, scorer });
|
||||
if (scorer && state.players[scorer].points >= TARGET) {
|
||||
state.over = { winner: scorer, reason: `${state.players[scorer].name} reached ${TARGET} points.` };
|
||||
state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanOptions, tableSize, type TableOption } from './spec';
|
||||
|
||||
const options: TableOption[] = [
|
||||
{ key: 'length', label: 'Length', choices: [{ value: 'short', label: 'Short' }, { value: 'long', label: 'Long' }], default: 'short' }
|
||||
];
|
||||
|
||||
describe('table options', () => {
|
||||
it('keeps only declared options and offered choices, the rest at their defaults', () => {
|
||||
expect(cleanOptions({ options }, { length: 'long', colour: 'red' })).toEqual({ length: 'long' });
|
||||
expect(cleanOptions({ options }, { length: 'endless' })).toEqual({ length: 'short' });
|
||||
expect(cleanOptions({ options }, null)).toEqual({ length: 'short' });
|
||||
});
|
||||
|
||||
it('seats as many as the options allow, never more than the seats there are', () => {
|
||||
const seatIds = ['A', 'B', 'C', 'D'];
|
||||
expect(tableSize({ seatIds, options })).toBe(4);
|
||||
const seatsFor = (o: Record<string, string>) => (o.length === 'short' ? 2 : 9);
|
||||
expect(tableSize({ seatIds, options, seatsFor }, { length: 'short' })).toBe(2);
|
||||
expect(tableSize({ seatIds, options, seatsFor }, { length: 'long' })).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,29 @@
|
||||
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
||||
export type SeatId = string;
|
||||
|
||||
/**
|
||||
* A line of table talk as a game reads it: who, what, when, the round it was
|
||||
* said in, to whom (absent: everyone), and a bot's note on its own line.
|
||||
*/
|
||||
export interface PressLine {
|
||||
id: SeatId;
|
||||
text: string;
|
||||
at: number;
|
||||
/** The seats a private line was said to. */
|
||||
to?: SeatId[];
|
||||
/** The round the line was said in, from one; absent before the game begins. */
|
||||
turn?: number;
|
||||
/** What a bot noted about its own line; kept in the ledger, never shown. */
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
/** A bot's line of talk: to the people it names, with a note for its own memory. */
|
||||
export interface BotLine {
|
||||
to: SeatId[];
|
||||
text: string;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
|
||||
export const SPECTATOR: SeatId = '';
|
||||
|
||||
@@ -48,6 +71,8 @@ export interface Preference {
|
||||
export interface GameSpec<State, Input> {
|
||||
/** Seats in table order; the table takes at most this many. */
|
||||
seatIds: SeatId[];
|
||||
/** The most seats a table with these options takes, when an option makes it fewer than `seatIds`. */
|
||||
seatsFor?(options: TableOptions): number;
|
||||
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
||||
options?: TableOption[];
|
||||
/** The game's own settings for the preferences panel. */
|
||||
@@ -63,17 +88,49 @@ export interface GameSpec<State, Input> {
|
||||
*/
|
||||
currentRules: number;
|
||||
|
||||
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions): State;
|
||||
/**
|
||||
* Begin the game. `rankings` are the seats' preferences, best first, at a
|
||||
* table that asks for them (`rankable`); the ledger's start line keeps them.
|
||||
*/
|
||||
create(names: Record<SeatId, string>, seed: number, rules: number, options?: TableOptions, rankings?: Record<SeatId, string[]>): State;
|
||||
/**
|
||||
* Whether a table with these options asks each seat to rank something
|
||||
* before the deal (sides, roles, colours): the choices to rank, or null.
|
||||
* Each seat ranks in secret; `create` reads every ranking at once.
|
||||
*/
|
||||
rankable?(options: TableOptions): { value: string; label: string }[] | null;
|
||||
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
|
||||
resolve(state: State, inputs: Record<SeatId, Input>): State;
|
||||
/** Whether this seat's input is needed before the next resolution. */
|
||||
needsInput(state: State, seat: SeatId): boolean;
|
||||
/**
|
||||
* Whether a submitted input is final. A game that lets a player save a
|
||||
* draft and change it answers false until the player says it is done;
|
||||
* the round waits for every seat's settled input. Absent, every input is final.
|
||||
*/
|
||||
settled?(input: Input): boolean;
|
||||
over(state: State): Outcome | null;
|
||||
/** The round being written, counted from one, for the ledger and the report pin. */
|
||||
turn(state: State): number;
|
||||
/** What one viewer may see. The server sends nothing else. */
|
||||
view(state: State, viewer: SeatId): State;
|
||||
botInput(state: State, seat: SeatId): Input;
|
||||
/**
|
||||
* A bot's move. `press` is the talk its seat has heard (the table's, and
|
||||
* private lines to it or from it, notes included), for a game whose bots
|
||||
* keep what was agreed; a function of the state and the press, which the
|
||||
* turn line records, so a replay never asks again.
|
||||
*/
|
||||
botInput(state: State, seat: SeatId, press?: PressLine[]): Input;
|
||||
/**
|
||||
* What a bot says: when a round opens ('phase'), and when a person has
|
||||
* just written to it, or said a first word to the table ('spoken').
|
||||
* `press` is what its seat has heard; `people` the seats played by people.
|
||||
* Each line goes on the ledger, to the people it names and no one else.
|
||||
* Absent, bots say nothing and a private line to one is refused.
|
||||
*/
|
||||
botPress?(state: State, seat: SeatId, press: PressLine[], people: SeatId[], trigger: 'phase' | 'spoken'): BotLine[];
|
||||
/** The bots' last words to `viewer` once the game is over, read from the whole of the talk. */
|
||||
parting?(press: PressLine[], viewer: SeatId): { id: SeatId; text: string }[];
|
||||
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||
cleanInput(raw: unknown): Input;
|
||||
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
||||
@@ -82,4 +139,27 @@ export interface GameSpec<State, Input> {
|
||||
nameOf(state: State, seat: SeatId): string;
|
||||
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
||||
tally?(state: State): Record<string, number>;
|
||||
/** The game's own lines counted from every game begun, finished or not, by its table's options (games begun on each variant). */
|
||||
tallyBegun?(options: TableOptions): Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A table's options as the game will play them: only the options it
|
||||
* declares, only the choices it offers, the rest at their defaults. The
|
||||
* server, the tally and the ledger replay all read a room line through this,
|
||||
* so each creates the same game from the same ledger.
|
||||
*/
|
||||
export function cleanOptions(game: Pick<GameSpec<unknown, unknown>, 'options'>, raw: unknown): TableOptions {
|
||||
const given = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||
const options: TableOptions = {};
|
||||
for (const o of game.options ?? []) {
|
||||
const v = given[o.key];
|
||||
options[o.key] = typeof v === 'string' && o.choices.some((c) => c.value === v) ? v : o.default;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The most seats a table with these options takes. */
|
||||
export function tableSize(game: Pick<GameSpec<unknown, unknown>, 'options' | 'seatIds' | 'seatsFor'>, raw?: unknown): number {
|
||||
return Math.min(game.seatIds.length, game.seatsFor?.(cleanOptions(game, raw)) ?? game.seatIds.length);
|
||||
}
|
||||
@@ -19,6 +19,12 @@ function heldSeats(): Record<string, HeldSeat> {
|
||||
}
|
||||
}
|
||||
|
||||
/** A seat the keeper sent in a link, `#seat=<seat>.<token>`: a fragment stays in the browser, out of every log. */
|
||||
export function seatInLink(hash: string): HeldSeat | null {
|
||||
const m = /^#seat=([^.]+)\.([A-Za-z0-9_-]+)$/.exec(hash);
|
||||
return m ? { seat: m[1], token: m[2] } : null;
|
||||
}
|
||||
|
||||
export function seatFor(roomId: string): HeldSeat | null {
|
||||
return heldSeats()[roomId] ?? null;
|
||||
}
|
||||
@@ -131,8 +137,10 @@ export function makeApi<State, Input>() {
|
||||
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
||||
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
||||
turn: (roomId: string, token: string, input: Input, resolved: number) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input, resolved }),
|
||||
hurry: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/hurry`, { token }),
|
||||
rank: (roomId: string, token: string, ranking: string[]) => call<View>('POST', `/api/rooms/${roomId}/rank`, { token, ranking }),
|
||||
say: (roomId: string, token: string, text: string, to?: SeatId[]) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text, ...(to?.length ? { to } : {}) }),
|
||||
/** A word from the gallery, signed with a name, when the host allows it. */
|
||||
sayFromGallery: (roomId: string, name: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { name, text }),
|
||||
setGalleryTalk: (roomId: string, token: string, on: boolean) => call<View>('POST', `/api/rooms/${roomId}/gallery`, { token, on }),
|
||||
@@ -140,6 +148,9 @@ export function makeApi<State, Input>() {
|
||||
rematch: (roomId: string, token: string) =>
|
||||
call<{ roomId: string; seat: SeatId | null; token: string | null; created: boolean }>('POST', `/api/rooms/${roomId}/rematch`, { token }),
|
||||
transfer: (roomId: string, token: string) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/transfer`, { token }),
|
||||
/** The host's phrase for another person's seat; claimed, it re-seats them. */
|
||||
hostPhrase: (roomId: string, token: string, seat: SeatId) => call<{ phrase: string; expiresAt: number }>('POST', `/api/rooms/${roomId}/phrase`, { token, seat }),
|
||||
standIn: (roomId: string, token: string, seat: SeatId) => call<View>('POST', `/api/rooms/${roomId}/standin`, { token, seat }),
|
||||
claim: (phrase: string) => call<{ roomId: string; seat: SeatId; token: string }>('POST', '/api/transfer', { phrase }),
|
||||
report: (roomId: string, token: string, happened: string, expected: string) =>
|
||||
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { game } from '$lib/game';
|
||||
import { SPECTATOR, type SeatId, type TableOptions } from '$lib/game/spec';
|
||||
import type { Input, State } from '$lib/game';
|
||||
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
|
||||
import { makeApi, rememberSeat, ServerError, watchRoom, type HeldSeat } from './client';
|
||||
import { markTalkSeen } from './talk';
|
||||
import type { RoomView } from './view';
|
||||
|
||||
@@ -44,6 +44,8 @@ export class Room {
|
||||
error = $state('');
|
||||
/** A move is on its way to the server. */
|
||||
sending = $state(false);
|
||||
/** The server stopped taking this seat's token while the page was open, and why; the page goes to the gallery. */
|
||||
lost = $state('');
|
||||
|
||||
constructor(view: RoomView<State>, token: string) {
|
||||
this.view = view;
|
||||
@@ -83,7 +85,10 @@ export class Room {
|
||||
const refresh = () => {
|
||||
fetchView()
|
||||
.then((v) => this.apply(v))
|
||||
.catch((e: Error) => (this.error = e.message));
|
||||
.catch((e: Error) => {
|
||||
if (e instanceof ServerError && e.status === 403 && !this.spectating) this.lost = e.message;
|
||||
else this.error = e.message;
|
||||
});
|
||||
};
|
||||
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
|
||||
}
|
||||
@@ -112,9 +117,20 @@ export class Room {
|
||||
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
||||
}
|
||||
|
||||
say(text: string): Promise<boolean> {
|
||||
/** Out of the game, with the bots playing on: play the rest at once. */
|
||||
hurry(): Promise<boolean> {
|
||||
return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.');
|
||||
}
|
||||
|
||||
/** This seat's ranking of the table's choices, best first, before the game begins. */
|
||||
rank(ranking: string[]): Promise<boolean> {
|
||||
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
|
||||
}
|
||||
|
||||
/** Talk to the whole table, or, naming seats, to them alone. */
|
||||
say(text: string, to?: SeatId[]): Promise<boolean> {
|
||||
if (this.spectating) return Promise.resolve(false);
|
||||
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
|
||||
return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
|
||||
}
|
||||
|
||||
/** A word from the gallery, signed; only when the host has let the gallery talk. */
|
||||
@@ -145,10 +161,20 @@ export class Room {
|
||||
return api.transfer(this.roomId, this.token);
|
||||
}
|
||||
|
||||
/** Host only: a phrase for another person's seat, which gives it a new token when claimed. */
|
||||
hostPhrase(seat: SeatId): Promise<{ phrase: string; expiresAt: number }> {
|
||||
return api.hostPhrase(this.roomId, this.token, seat);
|
||||
}
|
||||
|
||||
/** Host only: a bot plays this person's seat for the rest of the game, until a phrase gives it back. */
|
||||
standIn(seat: SeatId): Promise<boolean> {
|
||||
return this.act(() => api.standIn(this.roomId, this.token, seat), 'The seat could not be given to a bot.');
|
||||
}
|
||||
|
||||
async submit(input: Input): Promise<boolean> {
|
||||
this.sending = true;
|
||||
try {
|
||||
return await this.act(() => api.turn(this.roomId, this.token, input), 'The move did not reach the server.');
|
||||
return await this.act(() => api.turn(this.roomId, this.token, input, this.view.resolved), 'The move did not reach the server.');
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
@@ -165,7 +191,9 @@ export class Room {
|
||||
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
|
||||
const seated = await api.create(name, 2, options);
|
||||
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
||||
await api.addBot(seated.view.roomId, seated.token);
|
||||
const view = await api.addBot(seated.view.roomId, seated.token);
|
||||
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
|
||||
if (game.rankable?.(view.options)) return new Room(view, seated.token);
|
||||
return new Room(await api.begin(seated.view.roomId, seated.token), seated.token);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ export interface ChatLine {
|
||||
text: string;
|
||||
at: number;
|
||||
name?: string;
|
||||
/** Said by the keeper of the site, through the keeper's door. */
|
||||
keeper?: true;
|
||||
/** A private line: the seats it was said to. Absent, the whole table and the gallery heard it. */
|
||||
to?: SeatId[];
|
||||
/** The round the line was said in, counted from one; absent before the game begins. */
|
||||
turn?: number;
|
||||
/** A bot's memory of the line (an offer made, an answer heard). Kept in the ledger; never sent. */
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
export interface RoomView<State> {
|
||||
@@ -20,14 +28,29 @@ export interface RoomView<State> {
|
||||
me: SeatId;
|
||||
/** The player who opened the table and may begin the game. */
|
||||
host: SeatId;
|
||||
seats: { id: SeatId; name: string; bot: boolean }[];
|
||||
/** Who sits where; `standIn` marks a person's seat the host gave to a bot, which plays it until they take it back. */
|
||||
seats: { id: SeatId; name: string; bot: boolean; standIn?: true }[];
|
||||
/** Seats that still have to move before the round resolves. */
|
||||
waitingOn: SeatId[];
|
||||
/** Rounds played so far; a move is sent with the count it was made against. */
|
||||
resolved: number;
|
||||
/** The round being written, from one; null before the game begins. */
|
||||
turn: number | null;
|
||||
over: Outcome | null;
|
||||
state: State | null;
|
||||
/** This seat's ranking of the table's choices, at a table that asks for one; nobody sees another's. */
|
||||
yourRanking: string[] | null;
|
||||
/** How many seats have ranked, and nothing more about them. */
|
||||
ranked: number;
|
||||
/** This seat's input for the round in progress, as the server holds it (a draft, or a settled move); null for none, and for the gallery. */
|
||||
yourInput: unknown;
|
||||
/** This seat's input in the last round it played, for telling it what became of its own choice; null for none, and for the gallery. */
|
||||
yourLastInput: unknown;
|
||||
chat: ChatLine[];
|
||||
/** The bots' last words to this seat once the game is over, for a game that gives them some. */
|
||||
parting: { id: SeatId; text: string }[];
|
||||
/** The bots are playing on by themselves, a round at a time, with no person owing a move. */
|
||||
playingOn: boolean;
|
||||
/** How many watch from the Peanut Gallery. */
|
||||
audience: number;
|
||||
/** The host lets the gallery talk. */
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Announcement from '$lib/components/Announcement.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<Announcement />
|
||||
{@render children()}
|
||||
@@ -1,4 +1,5 @@
|
||||
// Everything happens in the browser against the game server, so nothing is rendered on the
|
||||
// server. Each route is prerendered as an empty shell that the client fills in.
|
||||
// Everything happens in the browser against the game server, so by default a route is
|
||||
// prerendered as an empty shell that the client fills in. The hall, the rules and the guide
|
||||
// turn rendering on in their own +page.ts, since their text is the same for everyone.
|
||||
export const ssr = false;
|
||||
export const prerender = true;
|
||||
@@ -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;
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
<main class="reading">
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/rules')}>The rules</a>
|
||||
<h1>How to play</h1>
|
||||
<p class="lede">Two sentences that set the scene and say what this page walks through.</p>
|
||||
<p class="lede">Two sentences that set the scene: who the player is, and what they are trying to do.</p>
|
||||
<nav aria-label="Sections">
|
||||
<a href="#hall">The hall</a>
|
||||
<a href="#table">The table</a>
|
||||
@@ -30,12 +30,12 @@
|
||||
|
||||
<section id="hall">
|
||||
<h2>The hall</h2>
|
||||
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the house's construct. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
|
||||
<p>Write your name and choose your company. <strong>Play now against the bot</strong> seats you across from the bot and begins. <strong>Open a table</strong> lays a table and hands you a link and a four-letter code to send. <strong>Join one</strong> takes a code someone sent you. Beneath, <em>your games</em> lists every table this browser holds a seat at, and whose move it is.</p>
|
||||
</section>
|
||||
|
||||
<section id="table">
|
||||
<h2>The table</h2>
|
||||
<p>A table fills as players open the link. Whoever laid it is the host: they may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
|
||||
<p>A table fills as players open the link. Whoever laid it is the host. The host may seat a bot in any empty chair or send one away, and they begin the game when the company suits them, however full the table. Once begun, the seats are closed.</p>
|
||||
</section>
|
||||
|
||||
<section id="board">
|
||||
@@ -45,14 +45,14 @@
|
||||
|
||||
<section id="gallery">
|
||||
<h2>The Peanut Gallery and table talk</h2>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk; then a watcher signs a name and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device: <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch: a new table with the same company, seats held until everyone is back. And while a table is laid, you may challenge the keeper of this site to a seat; the note beside the button says what hour it is where they live.</p>
|
||||
<p>A room's link is an invitation to sit while the table is laid and to watch once the game has begun. Anyone who opens it without a seat takes a place in the Peanut Gallery, shown only what every player at the table could see. <em>Table talk</em> is for the players seated, unless the host lets the gallery talk. Then a watcher gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. If your browser loses the seat, the host's <em>Seats</em> makes those words for you. If a player cannot go on, the host can give their seat to a bot there, and the same words bring them back to it. When a game ends, anyone at the table may call for a rematch, which opens a new table for the same company and holds their seats until they are back. While a table is laid, you may challenge the keeper of this site to a seat; the note beside the button gives the hour where they live.</p>
|
||||
</section>
|
||||
|
||||
<section id="first">
|
||||
<h2>A first game</h2>
|
||||
<p>The advice you would give a friend across the table for their first few turns.</p>
|
||||
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or the page misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>; the hall's <em>about this game</em> has the other ways to reach the keeper.</p>
|
||||
<p>The full rules are on the <a href="/rules">rules page</a>. If a rule here reads wrong to you, or something misbehaves, <a href="mailto:eric@ericwagoner.com">send word</a>. The hall's <em>about this game</em> lists the other ways to reach the keeper.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
|
||||
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
|
||||
export const ssr = true;
|
||||
@@ -4,9 +4,10 @@
|
||||
import Board from '$lib/components/Board.svelte';
|
||||
import Lobby from '$lib/components/Lobby.svelte';
|
||||
import ReportSlip from '$lib/components/ReportSlip.svelte';
|
||||
import SeatsSlip from '$lib/components/SeatsSlip.svelte';
|
||||
import Preferences from '$lib/components/Preferences.svelte';
|
||||
import { seatFor } from '$lib/net/client';
|
||||
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
|
||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||
|
||||
@@ -21,20 +22,53 @@
|
||||
/** A phrase minted to carry this seat to another device, while it lasts. */
|
||||
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
|
||||
let rematching = $state(false);
|
||||
let seatsOpen = $state(false);
|
||||
/** Why this browser's seat no longer opens, when the server said so; shown while watching. */
|
||||
let seatLost = $state('');
|
||||
let claimPhrase = $state('');
|
||||
let claiming = $state(false);
|
||||
|
||||
const link = $derived(`${location.origin}/join/${roomId}`);
|
||||
const watching = $derived(room?.spectating ?? false);
|
||||
|
||||
// Return to a held seat, or take a place in the gallery and offer one.
|
||||
// Return to a held seat, or take a place in the gallery and offer one. A link from the keeper carries a seat:
|
||||
// it leaves the address bar at once, is kept only once the server takes it, and never replaces a seat that
|
||||
// still opens, so a link someone else sends cannot take a working seat away.
|
||||
$effect(() => {
|
||||
const id = roomId;
|
||||
room = null;
|
||||
error = '';
|
||||
const sent = seatInLink(location.hash);
|
||||
// The browser's own call: this runs as the page first loads, before the router could take one.
|
||||
if (sent) history.replaceState(history.state, '', location.pathname + location.search);
|
||||
const held = seatFor(id);
|
||||
const arrive = held ? Room.resume(id, held) : Room.watch(id);
|
||||
joining = !held;
|
||||
const take = (s: HeldSeat) =>
|
||||
Room.resume(id, s).then((r) => {
|
||||
rememberSeat(id, s);
|
||||
trustSeat(id);
|
||||
return r;
|
||||
});
|
||||
// Only the server's refusal of the held token lets a link in: a dropped connection is not a lost seat.
|
||||
const refused = (e: unknown) => e instanceof ServerError && e.status === 403;
|
||||
// A held seat the server refuses, with no link to replace it, is watched from the gallery, with the reason.
|
||||
const watchRefused = (e: Error) => {
|
||||
seatLost = e.message;
|
||||
return Room.watch(id);
|
||||
};
|
||||
seatLost = '';
|
||||
const arrive = held
|
||||
? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : refused(e) ? watchRefused(e) : Promise.reject(e)))
|
||||
: sent
|
||||
? take(sent).catch(() => Room.watch(id))
|
||||
: Room.watch(id);
|
||||
joining = !held && !sent;
|
||||
if (!held) name = playerName();
|
||||
arrive.then((r) => (room = r)).catch((e: Error) => (error = e.message));
|
||||
arrive
|
||||
.then((r) => {
|
||||
room = r;
|
||||
if (r.spectating) joining = true;
|
||||
})
|
||||
.catch((e: Error) => (error = e.message));
|
||||
});
|
||||
|
||||
// Keep the view fresh for as long as the room is on screen.
|
||||
@@ -44,6 +78,36 @@
|
||||
return r.connect();
|
||||
});
|
||||
|
||||
// The seat stopped opening while the page was open (the host gave it to a bot): watch on from the gallery.
|
||||
$effect(() => {
|
||||
const r = room;
|
||||
if (!r?.lost) return;
|
||||
seatLost = r.lost;
|
||||
Room.watch(r.roomId)
|
||||
.then((w) => (room = w))
|
||||
.catch((e: Error) => (error = e.message));
|
||||
});
|
||||
|
||||
/** Sit back down by a phrase: the host's, or one this seat minted on another device. */
|
||||
async function claim(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
claiming = true;
|
||||
error = '';
|
||||
try {
|
||||
const got = await api.claim(claimPhrase.trim());
|
||||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||||
trustSeat(got.roomId);
|
||||
claimPhrase = '';
|
||||
seatLost = '';
|
||||
if (got.roomId === roomId) room = await Room.resume(roomId, { seat: got.seat, token: got.token });
|
||||
else await goto(`/join/${got.roomId}`);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'The phrase could not be claimed.';
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function takeSeat(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
busy = true;
|
||||
@@ -119,6 +183,9 @@
|
||||
{#if !watching}
|
||||
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
|
||||
{/if}
|
||||
{#if room.isHost && room.started}
|
||||
<button type="button" class="quiet" title="Bring a player back to a seat their browser lost, or give a seat to a bot" onclick={() => (seatsOpen = true)}>Seats</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
|
||||
<a class="quiet" href={room ? `/guide?room=${roomId}` : '/guide'}>How to play</a>
|
||||
@@ -133,6 +200,7 @@
|
||||
<Preferences bind:open={prefsOpen} />
|
||||
{#if room}
|
||||
<ReportSlip {room} bind:open={reporting} />
|
||||
{#if room.isHost}<SeatsSlip {room} bind:open={seatsOpen} />{/if}
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
@@ -150,7 +218,7 @@
|
||||
<a href={`/join/${room.view.rematch.to}`}>Take your seat</a>
|
||||
{:else}
|
||||
<button type="button" class="quiet" disabled={rematching} onclick={rematch}>Rematch with this table</button>
|
||||
<span>the same company, at a new table; seats are held for everyone here.</span>
|
||||
<span>The same company sits down at a new table, with a seat held for everyone here.</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -159,7 +227,7 @@
|
||||
<section class="seat">
|
||||
<p class="eyebrow">room {roomId}</p>
|
||||
<h2>Take a seat</h2>
|
||||
<p>A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.</p>
|
||||
<p>A player has opened this table and is gathering company. Choose a name and sit down. The game begins when the host says.</p>
|
||||
<form class="sit" onsubmit={takeSeat}>
|
||||
<label class="field">
|
||||
<span>Your name</span>
|
||||
@@ -171,6 +239,22 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if room?.started && !seatLost && room.view.seats.some((s) => s.standIn)}
|
||||
<p class="notice muted">A bot plays for {room.view.seats.filter((s) => s.standIn).map((s) => s.name).join(', ')}, at the host's word, until a transfer phrase from the host brings them back.</p>
|
||||
{/if}
|
||||
|
||||
{#if watching && room?.started && !room.view.over}
|
||||
<div class="notice muted">
|
||||
{#if seatLost}<p>{seatLost} Claim it here, or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a>.</p>
|
||||
{:else}{#if room.view.expected.length > 0}<p>{room.nameOf(room.view.host)} began the game before {room.view.expected.join(' and ')} arrived, and the seat held for {room.view.expected.length === 1 ? 'them' : 'each'} went with it.</p>{/if}
|
||||
<p>Had a seat at this table? Ask the host for a transfer phrase, or make one on the device that still holds the seat, and claim it here. Or <a href="mailto:eric@ericwagoner.com?subject={encodeURIComponent(`My seat in room ${roomId}`)}">write to the keeper</a> for a link that brings it back.</p>{/if}
|
||||
<form class="sit" onsubmit={claim}>
|
||||
<input type="text" bind:value={claimPhrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||||
<button type="submit" class="quiet" disabled={claiming || claimPhrase.trim().split(/[\s-]+/).length < 4}>Claim the seat</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if room && !room.started}
|
||||
<Lobby {room} {link} {copied} {copyLink} />
|
||||
{:else if room}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<main class="reading">
|
||||
<header>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a> · {/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
|
||||
{#if room}<a class="back" href={`/join/${room}`}>Back to your game</a>{' · '}{/if}<a class="back" href="/">Back to the hall</a> · <a class="back" href={withRoom('/guide')}>How to play</a>
|
||||
<h1>The rules</h1>
|
||||
<p class="lede">One paragraph on where these rules come from and which edition or text this page follows.</p>
|
||||
</header>
|
||||
@@ -35,7 +35,7 @@
|
||||
<p>The full source text, in collapsible sections, so a rules question can be settled by the words the engine follows.</p>
|
||||
</section>
|
||||
|
||||
<p class="word">If this page reads a rule differently from the original text, that is a bug: <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||||
<p class="word">If this page reads a rule differently from the original text, that is a bug. Please <a href="mailto:eric@ericwagoner.com">send word</a>.</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"family": "Kestrel's Hall",
|
||||
"hall": "https://gamehall.kestrelsnest.social",
|
||||
"games": [
|
||||
{
|
||||
"slug": "wizwar",
|
||||
@@ -16,6 +17,14 @@
|
||||
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
|
||||
"players": "2 to 12",
|
||||
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
||||
},
|
||||
{
|
||||
"slug": "hnefatafl",
|
||||
"name": "Hnefatafl",
|
||||
"url": "https://tafl.kestrelsnest.social",
|
||||
"pitch": "The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.",
|
||||
"players": "2",
|
||||
"origin": "the Norse tafl game, reconstructed from Linnaeus's 1732 notes; Copenhagen rules or Tablut"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export default defineConfig({
|
||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||
},
|
||||
// A static build: Caddy serves files and proxies /api and /ws to the game server.
|
||||
adapter: adapter({ fallback: 'index.html' })
|
||||
// The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
|
||||
// hall's own prerendered page is not overwritten by it.
|
||||
adapter: adapter({ fallback: 'shell.html' })
|
||||
})
|
||||
],
|
||||
server: {
|
||||
@@ -21,6 +23,6 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'server/src/**/*.test.ts']
|
||||
}
|
||||
});
|
||||