Compare commits
29
Commits
294b1d5460
...
main
No files matched your search
@@ -23,7 +23,7 @@ name (the sslip test address is left out) and prints:
|
||||
own digest (/wizwar-visitors) lists gamehall.kestrelsnest.social among
|
||||
its referrers.
|
||||
- **last 7 days** from `/var/lib/gamehall/rollup.jsonl`, written nightly
|
||||
at 00:05 UTC by `gamehall-rollup.sh` for the day before (counts only,
|
||||
at 00:12 UTC by `gamehall-rollup.sh` (run by `hall-nightly.sh`) for the day before (counts only,
|
||||
no addresses).
|
||||
|
||||
## Read it
|
||||
|
||||
+119
-7
@@ -11,10 +11,10 @@ README.
|
||||
*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; for someone coming back it comes first, and finished
|
||||
games wait further down), *your reports to the keeper*, and *about this game —
|
||||
a labor of love*, which opens the about panel: a dialog of tabs (The
|
||||
game, The tally, More games, Send word; a game may add its own, as Dip's
|
||||
Playing here), `TabbedDialog.svelte`, with each game's words in its hall.
|
||||
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`.
|
||||
@@ -47,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
|
||||
@@ -86,7 +107,7 @@ README.
|
||||
- **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.
|
||||
|
||||
@@ -194,12 +215,17 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
||||
- `<slug>-visitors.sh` (skill `<slug>-visitors`): who is here now, who came
|
||||
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
|
||||
@@ -236,11 +262,92 @@ thing will happen), and the image carries it.
|
||||
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
|
||||
@@ -248,6 +355,11 @@ thing will happen), and the image carries it.
|
||||
- `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,11 +3,13 @@
|
||||
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
|
||||
@@ -45,7 +47,7 @@ Run the demo locally from a new project:
|
||||
src/lib/game/spec.ts the contract a game implements
|
||||
src/lib/game/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), tally, ratelimit, and their tests
|
||||
deploy/ deploy, setup, Caddy, systemd, cron, gate + replay, backup, rollup, pulse, visitors, Slack alert, reports tools
|
||||
@@ -78,6 +80,21 @@ then the round resolves; turn-at-a-time games fit as the case where one
|
||||
seat needs input at a time. A game of single commands with out-of-turn
|
||||
interruptions, Wiz-War's shape, does not fit and keeps its own server.
|
||||
|
||||
## The hall-wide notice
|
||||
|
||||
Every game on the shared host shows the same notice at the top of its
|
||||
pages when there is one: maintenance, a new game, anything the whole
|
||||
family should hear. The keeper sets it on the host,
|
||||
|
||||
hall-announce "Kestrel's Hall is down for five minutes of maintenance" --at "2026-09-30 11:00 UTC"
|
||||
hall-announce --clear
|
||||
|
||||
which writes /var/lib/hall/announcement.json; each game's site serves it at
|
||||
`/hall/announcement.json` (a `handle_path` in its Caddy site file), and
|
||||
`Announcement.svelte`, in the template's layout, shows it with the time in
|
||||
each reader's own clock. A reader may dismiss one notice; the next still
|
||||
shows. `hall-announce` is installed by `gamehall/deploy.sh`.
|
||||
|
||||
## The family
|
||||
|
||||
`family.json` lists every game of Kestrel's Hall: slug, name, address,
|
||||
@@ -100,3 +117,11 @@ nightly rollup line in /var/lib/gamehall/rollup.jsonl. The skill
|
||||
`gamehall-visitors` reads them. A new game on the shared host is added to
|
||||
`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.
|
||||
@@ -11,11 +11,12 @@ 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 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/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"
|
||||
|
||||
+12
-4
@@ -14,7 +14,7 @@
|
||||
"slug": "waving-hands",
|
||||
"name": "Waving Hands",
|
||||
"url": "https://hands.kestrelsnest.social",
|
||||
"pitch": "Duelling wizards. Two hands each. The right run of gestures is a spell.",
|
||||
"pitch": "Wizards duel with their hands, and the right run of gestures is a spell.",
|
||||
"players": "2 to 12",
|
||||
"origin": "Richard Bartle's pencil-and-paper duel, 1977"
|
||||
},
|
||||
@@ -22,7 +22,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -30,9 +30,17 @@
|
||||
"slug": "dip",
|
||||
"name": "Dip",
|
||||
"url": "https://dip.kestrelsnest.social",
|
||||
"pitch": "Seven powers. One map. Nobody has to keep their word.",
|
||||
"players": "2 to 7",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,8 +21,13 @@ ssh "root@$HOST" '
|
||||
# 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
|
||||
'
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# 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)
|
||||
# Cron: /etc/cron.d/gamehall, installed by gamehall/deploy.sh.
|
||||
# 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"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# Kestrel's Hall: nightly rollup of yesterday's counts. UTC.
|
||||
5 0 * * * root /usr/local/bin/gamehall-rollup.sh
|
||||
# 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"
|
||||
Executable
+52
@@ -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
|
||||
@@ -36,7 +36,7 @@ LOGS = "/var/lib/caddy/gamehall-access*.log*"
|
||||
# The games that share this host, by the name the hall links to. A visitor
|
||||
# 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"}
|
||||
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")
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 128 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 33 KiB |
+102
-34
@@ -4,7 +4,7 @@
|
||||
<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, and Dip. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
||||
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, Dip and Wyrm. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
|
||||
<link rel="canonical" href="https://gamehall.kestrelsnest.social/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="Kestrel's Hall">
|
||||
@@ -22,7 +22,6 @@
|
||||
--line: #2c3236;
|
||||
--bone: #e6dfcf;
|
||||
--bone-dim: #a9a494;
|
||||
--bone-faint: #7c7868;
|
||||
--gold: #d2a94a;
|
||||
--gutter: 16px;
|
||||
}
|
||||
@@ -56,31 +55,42 @@
|
||||
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; }
|
||||
@media (max-width: 860px) { .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; } }
|
||||
.game {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
.game:hover { transform: translateY(-3px); border-color: var(--gold); text-decoration: none; }
|
||||
.game:hover { border-color: var(--gold); text-decoration: none; }
|
||||
.game:hover h2 { color: var(--gold); }
|
||||
/* The first game is the one newcomers are pointed at, so it has the whole width: art beside the words. */
|
||||
.game.first { grid-column: 1 / -1; flex-direction: row; }
|
||||
.game.first .art { flex: 0 0 55%; aspect-ratio: 2 / 1; }
|
||||
.game.first .body { justify-content: center; padding: 22px 26px; }
|
||||
.game.first h2 { font-size: 1.9rem; }
|
||||
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
|
||||
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.game .art img.tafl { object-position: 50% 50%; }
|
||||
.game .art img.hands { object-position: 0% 50%; }
|
||||
.game .art img.dip { object-position: 50% 55%; }
|
||||
.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 .facts { margin: 0; color: var(--bone-faint); font-size: 0.92rem; }
|
||||
.game .play { margin-top: auto; padding-top: 10px; color: var(--gold); font-size: 1rem; }
|
||||
.game:hover .play { text-decoration: underline; }
|
||||
.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 {
|
||||
@@ -111,8 +121,11 @@
|
||||
.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-faint); font-size: 0.9rem; }
|
||||
footer { margin-top: 64px; padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--bone-dim); font-size: 0.9rem; }
|
||||
footer p { margin: 0 0 8px; }
|
||||
/* 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>
|
||||
@@ -120,12 +133,12 @@
|
||||
|
||||
<header class="mast">
|
||||
<h1>Kestrel's Hall</h1>
|
||||
<p class="lede"><b>Free board games in the browser.</b> Play with friends by sending a link, or alone against the house. No accounts, no adverts, no timers, and nothing sold.</p>
|
||||
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
|
||||
<p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
|
||||
</header>
|
||||
|
||||
<div class="games">
|
||||
|
||||
<a class="game" href="https://wizwar.kestrelsnest.social">
|
||||
<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>
|
||||
@@ -147,18 +160,18 @@
|
||||
<div class="body">
|
||||
<h2>Wiz-War</h2>
|
||||
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
|
||||
<p class="facts">2 to 6 players · an unofficial rebuild of Tom Jolly's 1993 board game</p>
|
||||
<span class="play">Play at wizwar.kestrelsnest.social →</span>
|
||||
<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 hands" src="hands.png" alt="The Waving Hands share card: a column of gesture letters beside the title."></div>
|
||||
<div class="art"><img class="fill" src="hands.png" alt="Four turns of a Waving Hands duel: each row a turn, each hand drawn in its gesture, and a spell named under the gesture that finished it."></div>
|
||||
<div class="body">
|
||||
<h2>Waving Hands</h2>
|
||||
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
|
||||
<p class="facts">2 to 12 players · Richard Bartle's pencil-and-paper duel, 1977</p>
|
||||
<span class="play">Play at hands.kestrelsnest.social →</span>
|
||||
<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>
|
||||
|
||||
@@ -166,19 +179,29 @@
|
||||
<div class="art"><img class="fill tafl" src="tafl.svg" alt="A hnefatafl board at the start of a game: the king on the throne, defenders around him, attackers on the four edges."></div>
|
||||
<div class="body">
|
||||
<h2>Hnefatafl</h2>
|
||||
<p class="pitch">The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.</p>
|
||||
<p class="facts">2 players · the Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
|
||||
<span class="play">Play at tafl.kestrelsnest.social →</span>
|
||||
<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 chart of Europe at the opening: the seven powers' armies and fleets on their home centers, joined by the lines they may move along."></div>
|
||||
<div class="art"><img class="fill dip" src="dip.jpg" alt="Dip's map of Europe at the opening: the seven powers' armies and fleets on their home centers, each power's lands in its color."></div>
|
||||
<div class="body">
|
||||
<h2>Dip</h2>
|
||||
<p class="pitch">Seven powers. One map. Nobody has to keep their word.</p>
|
||||
<p class="facts">2 to 7 players · an unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
|
||||
<span class="play">Play at dip.kestrelsnest.social →</span>
|
||||
<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>
|
||||
|
||||
@@ -188,11 +211,11 @@
|
||||
<div>
|
||||
<h2>How a table works</h2>
|
||||
<ul class="how">
|
||||
<li><b>Open a table</b> and you get a four-letter code and a link. Send it to a friend; they sit down and you begin.</li>
|
||||
<li><b>No one home?</b> Every game seats the house: an automaton, a housecarl, a rival wizard, an envoy. Play now, alone.</li>
|
||||
<li><b>Nothing to sign up for.</b> Your browser holds your seat. Close the tab and come back; the game is where you left it.</li>
|
||||
<li><b>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, pinned to the exact moment.</li>
|
||||
<li><b>Something went wrong?</b> Every table has a report button that reaches the keeper with the exact moment attached.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -205,14 +228,59 @@
|
||||
</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; anyone who wants to chip in toward the hosting can at <a href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.</p>
|
||||
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>Wiz-War is Tom Jolly's design; the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro. Hnefatafl belongs to everyone; it is a thousand years old.</p>
|
||||
<p>Wiz-War is Tom Jolly's design, and the rights rest with Steve Jackson Games. Waving Hands is Richard Bartle's. Hnefatafl belongs to everyone, and is more than a thousand years old. Diplomacy is Allan B. Calhamer's design, and its name is a trademark of Hasbro; the designers of Dip's Chromatic and Youngstown Redux maps are credited in Dip's How to play. Nothing here is official or endorsed by Steve Jackson Games, Richard Bartle or Hasbro.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
// The hall-wide notice every game of Kestrel's Hall shows (hall-announce on the
|
||||
// host): checked on load, every few minutes, and when the page is looked at
|
||||
// again; a reader may dismiss one, and the next still shows.
|
||||
(() => {
|
||||
const KEY = 'hall:dismissed';
|
||||
let bar = null;
|
||||
let current = null;
|
||||
const read = () => { try { return localStorage.getItem(KEY); } catch { return null; } };
|
||||
function when(at) {
|
||||
const t = at ? Date.parse(at) : NaN;
|
||||
if (!Number.isFinite(t)) return '';
|
||||
const minutes = Math.round((t - Date.now()) / 60000);
|
||||
if (minutes > 90) return ` (${new Date(t).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })}, your time)`;
|
||||
if (minutes > 1) return ` (in ${minutes} minutes, at ${new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })})`;
|
||||
return minutes >= -15 ? ' (now)' : '';
|
||||
}
|
||||
function show() {
|
||||
const a = current;
|
||||
const live = a && a.id !== read() && !(a.until && Date.parse(a.until) < Date.now());
|
||||
if (!live) { bar?.remove(); bar = null; return; }
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.className = 'hall-announcement';
|
||||
bar.setAttribute('role', 'status');
|
||||
bar.innerHTML = '<span></span><button type="button" aria-label="Dismiss this announcement">×</button>';
|
||||
bar.querySelector('button').onclick = () => { try { localStorage.setItem(KEY, current.id); } catch {} show(); };
|
||||
document.body.prepend(bar);
|
||||
}
|
||||
bar.querySelector('span').textContent = a.text + when(a.at);
|
||||
}
|
||||
async function check() {
|
||||
try {
|
||||
const res = await fetch('/hall/announcement.json', { cache: 'no-store' });
|
||||
const a = res.ok ? await res.json() : null;
|
||||
current = a && typeof a.text === 'string' && a.text && typeof a.id === 'string' ? a : null;
|
||||
} catch {}
|
||||
show();
|
||||
}
|
||||
check();
|
||||
setInterval(check, 5 * 60000);
|
||||
setInterval(show, 30000);
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') check(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
+3
-2
@@ -2,7 +2,7 @@
|
||||
# Stamp out a new game from the template.
|
||||
# ./new-game.sh <slug> "<Name>" [port] [domain]
|
||||
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
|
||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The
|
||||
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
|
||||
# games share one host (deploy/README.md), so a new game takes the next free port.
|
||||
set -euo pipefail
|
||||
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
|
||||
@@ -38,7 +38,8 @@ Next:
|
||||
2. Fill in the hall's pitch, tag and about panel (src/lib/components/Hall.svelte),
|
||||
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").
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
__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
|
||||
|
||||
@@ -36,7 +43,10 @@ __HOST__ {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
|
||||
small Node process capped at 300 MB by its unit file, and a room is a few
|
||||
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.
|
||||
dip 8790, wyrm 8791.
|
||||
|
||||
## Current production
|
||||
|
||||
@@ -46,11 +46,18 @@ A fresh host, when one is needed: `doctl compute droplet create hall --region ny
|
||||
|
||||
## Operations scripts on the droplet
|
||||
|
||||
`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
|
||||
@@ -62,10 +69,13 @@ deploy, so the live copies are the repo copies:
|
||||
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
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
# 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" # the hall's shared Space, named for its first tenant
|
||||
@@ -14,25 +16,31 @@ 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
|
||||
Executable
+33
@@ -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__")
|
||||
@@ -74,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
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# A word from the keeper in a table's talk, signed as the keeper, whether or
|
||||
# not the host lets the gallery talk: the answer to a call that came too late,
|
||||
# or anything else the table should hear from the keeper. Runs ON the droplet,
|
||||
# through the game server's keeper door.
|
||||
# __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.$$
|
||||
@@ -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,12 +42,30 @@ ssh "root@$HOST" '
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-pulse.sh /usr/local/bin/__SLUG__-pulse.sh
|
||||
install -m 755 /opt/__SLUG__/app/deploy/__SLUG__-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
|
||||
# "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.
|
||||
|
||||
+44
-41
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
|
||||
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
|
||||
# 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|meta-externalagent", 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)),
|
||||
@@ -46,6 +48,28 @@ def unfurled_by(ua):
|
||||
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
|
||||
@@ -53,8 +77,8 @@ def scan(day, logs=LOGS):
|
||||
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 for pages
|
||||
three visits or more without ever opening or joining a game: a monitor,
|
||||
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."""
|
||||
@@ -73,55 +97,37 @@ def scan(day, logs=LOGS):
|
||||
except ValueError:
|
||||
continue
|
||||
if t0 <= r.get("ts", 0) < t1:
|
||||
requests.append(r)
|
||||
requests.append(parse(r))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 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:
|
||||
q = r.get("request", {})
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
||||
if BOT.search(ua) or not ua:
|
||||
if BOT.search(r["ua"]) or not r["ua"]:
|
||||
continue
|
||||
path = q.get("uri", "").split("?")[0]
|
||||
a = seen[ip]
|
||||
a["times"].append(r.get("ts", 0))
|
||||
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 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 keeps_time(times):
|
||||
starts, last = [], None
|
||||
for ts in sorted(times):
|
||||
if last is None or ts - last > VISIT_GAP:
|
||||
starts.append(ts)
|
||||
last = ts
|
||||
gaps = [b - a for a, b in zip(starts, starts[1:])]
|
||||
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 visits(a["times"]) >= 3}
|
||||
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:
|
||||
q = r.get("request", {})
|
||||
ip = q.get("client_ip") or q.get("remote_ip") or "?"
|
||||
ua = " ".join(q.get("headers", {}).get("User-Agent", [""]))
|
||||
if ua and not BOT.search(ua) and q.get("uri", "").split("?")[0] in PAGES and not seen[ip]["played"]:
|
||||
by_browser[ua].append((r.get("ts", 0), ip))
|
||||
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}
|
||||
@@ -134,24 +140,21 @@ def scan(day, logs=LOGS):
|
||||
seen_campaign = set()
|
||||
seen_share = set()
|
||||
for r in requests:
|
||||
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]
|
||||
ip, ua, uri, path = r["ip"], r["ua"], r["uri"], r["path"]
|
||||
t["requests"] += 1
|
||||
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.
|
||||
if service and (service, path) not in seen_share:
|
||||
# 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 q.get("method") == "POST" and path == "/api/rooms" and 200 <= r.get("status", 0) < 300:
|
||||
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
|
||||
@@ -161,7 +164,7 @@ 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", [""]))
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// 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
|
||||
@@ -25,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';
|
||||
@@ -45,6 +55,8 @@ const PUBLIC_URL = (process.env.PUBLIC_URL ?? 'https://__DOMAIN__').replace(/\/$
|
||||
/** The keeper of the site, whom a table may call to a seat, and where they sleep. */
|
||||
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.
|
||||
@@ -74,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);
|
||||
@@ -186,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') {
|
||||
@@ -278,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;
|
||||
@@ -323,11 +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}`);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ 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(() => {
|
||||
@@ -15,6 +16,9 @@ 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));
|
||||
@@ -35,6 +39,39 @@ describe('rooms', () => {
|
||||
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');
|
||||
@@ -127,7 +164,7 @@ describe('rooms', () => {
|
||||
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
|
||||
});
|
||||
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => {
|
||||
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => {
|
||||
const heard: string[][] = [];
|
||||
const talking: GameSpec<State, Input> = {
|
||||
...game,
|
||||
@@ -145,6 +182,7 @@ describe('rooms', () => {
|
||||
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;
|
||||
@@ -154,6 +192,7 @@ describe('rooms', () => {
|
||||
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([]);
|
||||
@@ -195,6 +234,7 @@ describe('rooms', () => {
|
||||
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);
|
||||
@@ -207,3 +247,83 @@ describe('rooms', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the host and the other seats', () => {
|
||||
it("gives a person's seat to a bot, which plays it at once, and the person's token opens nothing", async () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
rooms.standIn(room, ann, 'B');
|
||||
// The round waited on Bo alone; the bot plays it as soon as the host has been answered.
|
||||
await answered();
|
||||
expect(room.resolved).toBe(1);
|
||||
expect(rooms.waitingOn(room)).toEqual(['A']);
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/);
|
||||
expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
|
||||
rooms.submit(room, ann, { pick: 3 }, 1);
|
||||
await answered();
|
||||
expect(room.resolved).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps a move the person made before the bot stood in', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.submit(room, bo, { pick: 2 }, 0);
|
||||
rooms.standIn(room, ann, 'B');
|
||||
rooms.submit(room, ann, { pick: 5 }, 0);
|
||||
expect(room.state!.rounds[0].picks).toEqual({ A: 5, B: 2 });
|
||||
});
|
||||
|
||||
it('is the host\'s call alone, in a game under way, for a person other than the host', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
expect(() => rooms.standIn(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||
expect(() => rooms.standIn(room, ann, 'A')).toThrow(/keeps their own seat/);
|
||||
expect(() => rooms.standIn(room, ann, 'Z')).toThrow(/No person sits/);
|
||||
const lobby = rooms.create('Ann', 2);
|
||||
rooms.join(lobby.room, 'Bo');
|
||||
expect(() => rooms.standIn(lobby.room, lobby.token, 'B')).toThrow(/under way/);
|
||||
});
|
||||
|
||||
it('gives the seat back by the host\'s phrase, with a new token, and a restart keeps both changes', () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
rooms.standIn(room, ann, 'B');
|
||||
expect(new Rooms(game, new Store(dir)).get(room.id).seats[1]).toMatchObject({ bot: true, standIn: true });
|
||||
const claimed = rooms.claim(rooms.hostPhrase(room, ann, 'B').phrase);
|
||||
expect(claimed.seat).toBe('B');
|
||||
expect(rooms.seatOf(room, claimed.token).name).toBe('Bo');
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
expect(rooms.waitingOn(room)).toContain('B');
|
||||
const reopened = new Rooms(game, new Store(dir)).get(room.id);
|
||||
expect(reopened.seats[1]).toEqual({ id: 'B', name: 'Bo', tokenHash: room.seats[1].tokenHash, bot: false });
|
||||
});
|
||||
|
||||
it("re-seats a person whose browser lost the seat, by the host's phrase, once", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
const claimed = rooms.claim(phrase);
|
||||
expect(claimed.token).not.toBe(bo);
|
||||
expect(() => rooms.seatOf(room, bo)).toThrow(/opens no seat/);
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
});
|
||||
|
||||
it("makes a phrase only for the host, and only for another person's seat", () => {
|
||||
const { rooms, room, ann, bo } = table();
|
||||
expect(() => rooms.hostPhrase(room, bo, 'A')).toThrow(/Only the player who opened/);
|
||||
expect(() => rooms.hostPhrase(room, ann, 'A')).toThrow(/No other person/);
|
||||
const withBot = rooms.create('Ann', 2);
|
||||
const bot = rooms.addBot(withBot.room, withBot.token);
|
||||
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/);
|
||||
});
|
||||
|
||||
it('voids a phrase for the seat once a bot stands in, and holds the person\'s seat at a rematch without the stand-in', async () => {
|
||||
const { rooms, room, ann } = table();
|
||||
const { phrase } = rooms.hostPhrase(room, ann, 'B');
|
||||
rooms.standIn(room, ann, 'B');
|
||||
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
|
||||
while (!game.over(room.state!)) {
|
||||
rooms.submit(room, ann, { pick: 5 }, room.resolved);
|
||||
await answered();
|
||||
}
|
||||
const next = rooms.get(rooms.rematch(room, ann).roomId);
|
||||
expect(next.expected).toEqual(['Bo']);
|
||||
expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
|
||||
});
|
||||
});
|
||||
+138
-17
@@ -24,9 +24,12 @@ 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 {
|
||||
@@ -119,8 +122,8 @@ 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. */
|
||||
@@ -190,7 +193,8 @@ 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;
|
||||
}
|
||||
@@ -238,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);
|
||||
@@ -270,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 };
|
||||
}
|
||||
@@ -280,6 +290,22 @@ export class Rooms<State, Input> {
|
||||
/** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */
|
||||
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);
|
||||
@@ -288,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 };
|
||||
}
|
||||
|
||||
@@ -309,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 };
|
||||
}
|
||||
@@ -321,8 +351,32 @@ export class Rooms<State, Input> {
|
||||
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
|
||||
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.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bots' moves, once the request that made them due has been answered.
|
||||
* A bot that thinks for a second or two would otherwise hold up the reply
|
||||
* to the person whose move it is answering, so their own move would seem
|
||||
* to hang and the bot's to come at once. The table is told as each move
|
||||
* lands, the same as any other.
|
||||
*/
|
||||
/** A room forgotten and loaded again drives itself; a timer or tick that wakes with the old one must do nothing. */
|
||||
private isTable(room: Room<State>): boolean {
|
||||
return this.rooms.get(room.id) === room;
|
||||
}
|
||||
|
||||
private botsAfterAnswering(room: Room<State>): void {
|
||||
setImmediate(() => {
|
||||
if (!this.isTable(room)) return;
|
||||
try {
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
} catch (e) {
|
||||
// Nothing is waiting on this callback: an error here would take the process down, so it is logged and the table left as it stands.
|
||||
console.error(`${room.id}: the bots could not move: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -353,8 +407,7 @@ export class Rooms<State, Input> {
|
||||
const hurried = this.hurried.has(room.id);
|
||||
const next = setTimeout(() => {
|
||||
this.pacing.delete(room.id);
|
||||
// A room forgotten and loaded again drives itself; this one is no longer the table.
|
||||
if (this.rooms.get(room.id) !== room) return;
|
||||
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');
|
||||
@@ -406,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);
|
||||
@@ -436,7 +535,7 @@ export class Rooms<State, Input> {
|
||||
* 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 {
|
||||
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);
|
||||
@@ -455,10 +554,14 @@ 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, this.heardBy(room, seat.id));
|
||||
// A move a person settled before a bot stood in for them stands.
|
||||
for (const seat of room.seats) {
|
||||
if (seat.bot && this.game.needsInput(room.state!, seat.id) && !(seat.id in inputs && this.settled(inputs[seat.id]))) {
|
||||
inputs[seat.id] = this.game.botInput(room.state!, seat.id, this.heardBy(room, seat.id));
|
||||
}
|
||||
}
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
this.driveBots(room);
|
||||
this.botsSpeak(room, 'phase');
|
||||
this.botsAfterAnswering(room);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,7 +573,8 @@ export class Rooms<State, Input> {
|
||||
if (!this.game.botPress || !room.state) return;
|
||||
const people = room.seats.filter((s) => !s.bot).map((s) => s.id);
|
||||
for (const seat of room.seats) {
|
||||
if (!seat.bot || (only && !only.includes(seat.id))) continue;
|
||||
// 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;
|
||||
@@ -500,13 +604,14 @@ export class Rooms<State, Input> {
|
||||
? [...new Set(rawTo.filter((id): id is SeatId => typeof id === 'string' && id !== seat.id && room.seats.some((s) => s.id === id)))]
|
||||
: [];
|
||||
if (Array.isArray(rawTo) && rawTo.length && !to.length) throw new RoomError('Nobody at this table answers to that.');
|
||||
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id)?.bot);
|
||||
// A private line to a seat a bot stands in for waits for the person; the stand-in reads none.
|
||||
const botsTo = to.filter((id) => room.seats.find((s) => s.id === id && s.bot && !s.standIn));
|
||||
if (botsTo.length && !this.game.botPress) throw new RoomError('The bots at this table read no private talk.');
|
||||
// A person's first word of the game, said to everyone, is heard by the bots too: the game chooses who answers.
|
||||
const first = !to.length && !room.chat.some((l) => l.id === seat.id);
|
||||
this.commit(room, { t: 'chat', at: Date.now(), id: seat.id, text: this.cleanTalk(raw), ...(to.length ? { to } : {}) });
|
||||
if (botsTo.length) this.botsSpeak(room, 'spoken', botsTo);
|
||||
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot).map((s) => s.id));
|
||||
else if (first) this.botsSpeak(room, 'spoken', room.seats.filter((s) => s.bot && !s.standIn).map((s) => s.id));
|
||||
}
|
||||
|
||||
private cleanTalk(raw: unknown): string {
|
||||
@@ -536,7 +641,7 @@ export class Rooms<State, Input> {
|
||||
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,
|
||||
@@ -609,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;
|
||||
@@ -646,6 +766,7 @@ export class Rooms<State, Input> {
|
||||
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 } : {})
|
||||
});
|
||||
|
||||
@@ -11,6 +11,13 @@ 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. */
|
||||
@@ -30,7 +37,7 @@ export type LedgerLine =
|
||||
* A line of table talk: from a seat, or, with `name`, from the Peanut Gallery (id SPECTATOR).
|
||||
* `to` names the seats a private line was said to; `note` is a bot's memory of what it said, never shown.
|
||||
*/
|
||||
| { t: 'chat'; at: number; id: SeatId; text: string; name?: string; to?: SeatId[]; note?: unknown }
|
||||
| { 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. */
|
||||
|
||||
@@ -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>
|
||||
@@ -67,8 +67,8 @@
|
||||
</section>
|
||||
{:else}
|
||||
<section class="move">
|
||||
<h2>Round {g.rounds.length + 1}: name a number</h2>
|
||||
<p class="muted">Every player lays a card face down; the highest card nobody else laid takes the point. 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>
|
||||
|
||||
@@ -39,21 +39,19 @@
|
||||
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 told = new Set<string>([]);
|
||||
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: `games with a bot seated` },
|
||||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
||||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||||
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
|
||||
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||||
];
|
||||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||||
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.` };
|
||||
@@ -144,7 +142,14 @@
|
||||
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();
|
||||
}
|
||||
@@ -157,7 +162,7 @@
|
||||
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';
|
||||
@@ -197,9 +202,9 @@
|
||||
<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">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>
|
||||
|
||||
@@ -233,7 +238,7 @@
|
||||
<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" />
|
||||
@@ -253,7 +258,11 @@
|
||||
<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>
|
||||
{/snippet}
|
||||
|
||||
@@ -310,12 +319,12 @@
|
||||
<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>
|
||||
<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="How much love the table is getting" headline={told.headline} details={told.details} note={told.note} />
|
||||
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
|
||||
{:else}
|
||||
<p class="muted">Counting…</p>
|
||||
{/if}
|
||||
@@ -330,15 +339,15 @@
|
||||
</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.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet aboutWord()}
|
||||
<div class="about-body">
|
||||
<p>A rule read wrong, a bug, a game you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
<p>If a rule reads wrong, something breaks, or you have a game to tell of, write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
<TabbedDialog
|
||||
@@ -347,8 +356,8 @@
|
||||
title="About __NAME__"
|
||||
label="About this game"
|
||||
tabs={[
|
||||
{ id: 'game', label: 'The game', body: aboutGame },
|
||||
{ id: 'tally', label: 'The tally', body: aboutTally },
|
||||
{ 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 }
|
||||
]}
|
||||
@@ -521,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);
|
||||
}
|
||||
@@ -651,5 +665,4 @@
|
||||
margin: 0.2rem 0 0;
|
||||
max-width: 22em;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -7,8 +7,12 @@
|
||||
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);
|
||||
@@ -59,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>
|
||||
@@ -84,13 +88,13 @@
|
||||
<li class="empty muted">{v.size - v.seats.length - v.expected.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{v.galleryTalk ? ', allowed to talk' : ''}{/if}</li>
|
||||
</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; nobody sees another player's.
|
||||
Best first. When the game begins, the deal reads everyone's ranking, and nobody sees another player's.
|
||||
{#if !v.yourRanking}Until you move one, you have no preference and take what is left.{:else}Your ranking is kept.{/if}
|
||||
</p>
|
||||
<ol>
|
||||
@@ -113,8 +117,12 @@
|
||||
{#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>
|
||||
@@ -128,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}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<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>
|
||||
@@ -76,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -48,7 +48,7 @@
|
||||
{/if}
|
||||
{#each lines as line, i (line.at + ':' + i)}
|
||||
<li class:you={!line.name && line.id === room.me} class:private={!!line.to}>
|
||||
<em>{line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
|
||||
<em>{line.keeper ? `${line.name}, the keeper` : line.name ? `${line.name} (gallery)` : line.id === room.me ? 'You' : room.nameOf(line.id)}{#if line.to}<span class="to">, to {line.to.map((id) => (id === room.me ? 'you' : room.nameOf(id))).join(', ')} alone</span>{/if}</em>
|
||||
{line.text}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -60,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" />
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// What the ledgers add up to: a few counts that are the game's own story,
|
||||
// large, on cards; then the rest in a column of figures; then the fine
|
||||
// print. Each game chooses its counts and words.
|
||||
let { headline, details, note, title = 'How much love the table is getting' }: { headline: TallyLine[]; details: TallyLine[]; note?: string; title?: string } =
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The other games of Kestrel's Hall, from the family list the kit keeps
|
||||
// (family.json, one copy shipped with every game), so a player who liked one
|
||||
// table can find the next, and the hall's front door. This game is left out
|
||||
// of its own list.
|
||||
// (family.json, one copy shipped with every game): the hall's front door, and
|
||||
// the next table for a player who liked this one. This game is left out of its
|
||||
// own list.
|
||||
|
||||
export interface FamilyGame {
|
||||
slug: string;
|
||||
@@ -18,13 +18,15 @@ export interface Family {
|
||||
games: FamilyGame[];
|
||||
}
|
||||
|
||||
const NONE: Family = { hall: '', games: [] };
|
||||
|
||||
export async function loadFamily(self: string): Promise<Family> {
|
||||
try {
|
||||
const res = await fetch('/family.json', { cache: 'no-cache' });
|
||||
if (!res.ok) return { hall: '', games: [] };
|
||||
if (!res.ok) return NONE;
|
||||
const data = (await res.json()) as { hall?: string; games?: FamilyGame[] };
|
||||
return { hall: data.hall ?? '', games: (data.games ?? []).filter((g) => g.slug !== self) };
|
||||
} catch {
|
||||
return { hall: '', games: [] };
|
||||
return NONE;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -142,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));
|
||||
}
|
||||
@@ -156,6 +161,16 @@ 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 {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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. */
|
||||
@@ -26,7 +28,8 @@ 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. */
|
||||
|
||||
@@ -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;
|
||||
@@ -18,7 +18,7 @@
|
||||
<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>
|
||||
<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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user