Compare commits

...
9 Commits
Author SHA1 Message Date
Eric WagonerandClaude Opus 5.5 ee7b1d68ec From Hnefatafl: a link counts as shared when a preview app fetches a page, not when Meta's crawlers or robots.txt checks pass by
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
2026-11-06 20:02:52 -05:00
Eric WagonerandClaude Fable 5.1 c6366853cd The root address is rewritten to index.html, since try_files passes the root over as a directory and served the shell in place of the hall
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 11:22:35 -04:00
Eric WagonerandClaude Fable 5.1 647e8cb2bf The hall's game cards no longer lift on hover or end in "Play X →" and a domain line, and the first game, the one newcomers are pointed at, takes the whole width with its art beside the words, so five games fill the grid without an orphan; the template renders the hall, the rules and the guide at build time behind a fallback named shell.html, and its deploy renders the Caddy site file from the template each time; the family list carries Wyrm and Dip's larger maps; the tally heading and the hosting line are said plainly; and the writing checklist names one sentence shape for a whole list as a tell
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-29 10:37:40 -04:00
Eric WagonerandClaude Fable 5.1 40a52dfe37 The hall's Wyrm card is the glass board at the opening, seen from a corner, as a JPEG; its alt text says what the picture shows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-27 17:36:19 -04:00
Eric WagonerandClaude Fable 5.1 75428b5a9b The template's room server guards a stale room once, by name; the test's tick helper is named for what it waits on; the port list and the hall's tally lose a set that was always empty
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 22:38:08 -04:00
Eric WagonerandClaude Fable 5.1 62cb00be33 The template's bots move once the player's request is answered: a slow bot no longer holds up the reply to the move it is answering
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 17:32:23 -04:00
Eric WagonerandClaude Fable 5.1 dcd2293b42 The template's lobby reads well with no KEEPER set: challenge the keeper, not the keeper, the keeper
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 17:20:02 -04:00
Eric WagonerandClaude Fable 5.1 cbe85d2d31 The template no longer links a favicon.ico it does not ship, which failed a new game's first build at prerender
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 16:56:18 -04:00
Eric WagonerandClaude Fable 5.1 5e7ac999db Wyrm joins the family: its card in the hall, its line in family.json, and the hall's visitor count follows the people it sends there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-10-26 16:41:29 -04:00
21 changed files with 136 additions and 67 deletions

No files matched your search

+9
View File
@@ -294,6 +294,10 @@ and read each new page against this list before it ships.
deliberate triplet as its tagline when the energy is the point, as deliberate triplet as its tagline when the energy is the point, as
Wiz-War's "Steal treasure. Blast through walls. Outwit your friends." Wiz-War's "Steal treasure. Blast through walls. Outwit your friends."
does. Once is a choice, and a pattern is a tell. 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 - **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 meant to sound wise ("This is the whole art", "Nothing is unlocked by it
and nothing is withheld without it"). and nothing is withheld without it").
@@ -351,6 +355,11 @@ opened its mouth.") as long as the fact survives.
- `npm install --omit=optional` on the droplet breaks Vite; the server - `npm install --omit=optional` on the droplet breaks Vite; the server
carries its own small `package.json` and installs only that. carries its own small `package.json` and installs only that.
- A dynamic `/join/[code]` route must set `prerender = false`. - A dynamic `/join/[code]` route must set `prerender = false`.
- The hall, the rules and the guide set `ssr = true` in their own `+page.ts`,
so view-source, a crawler and a reader without scripts get the text; the
rest stays an empty shell. The adapter's fallback is `shell.html`, not
`index.html`, because a fallback named `index.html` overwrites the
prerendered hall, and Caddy's `try_files` ends in `/shell.html`.
- Two tabs on one origin share a seat; test a second player from - Two tabs on one origin share a seat; test a second player from
`127.0.0.1` against `localhost` (Vite needs `--host`). `127.0.0.1` against `localhost` (Vite needs `--host`).
- Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`. - Svelte proxies do not `structuredClone`: hand the engine `$state.snapshot`.
+1
View File
@@ -11,6 +11,7 @@ SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
KIT="$(cd "$(dirname "$0")" && pwd)" KIT="$(cd "$(dirname "$0")" && pwd)"
MAP="$KIT/drift-map/$SLUG.txt" MAP="$KIT/drift-map/$SLUG.txt"
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts server/src/tally.ts
src/routes/+layout.ts src/routes/+page.ts src/routes/rules/+page.ts src/routes/guide/+page.ts vite.config.ts
src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts src/lib/game/spec.ts src/lib/net/client.ts src/lib/net/talk.ts src/lib/net/view.ts src/lib/net/room.svelte.ts
src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte src/lib/components/TabbedDialog.svelte src/lib/components/Tabs.svelte src/lib/components/Announcement.svelte
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
+9 -1
View File
@@ -31,8 +31,16 @@
"name": "Dip", "name": "Dip",
"url": "https://dip.kestrelsnest.social", "url": "https://dip.kestrelsnest.social",
"pitch": "Seven powers share one map, and none of them has to keep its word.", "pitch": "Seven powers share one map, and none of them has to keep its word.",
"players": "2 to 7", "players": "2 to 10, depending on the map",
"origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild" "origin": "Allan B. Calhamer's Diplomacy, 1959; an unofficial rebuild"
},
{
"slug": "wyrm",
"name": "Wyrm",
"url": "https://wyrm.kestrelsnest.social",
"pitch": "Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.",
"players": "2",
"origin": "Gary Gygax's Dragonchess, Dragon Magazine No. 100, 1985; an unofficial rebuild"
} }
] ]
} }
+1 -1
View File
@@ -36,7 +36,7 @@ LOGS = "/var/lib/caddy/gamehall-access*.log*"
# The games that share this host, by the name the hall links to. A visitor # The games that share this host, by the name the hall links to. A visitor
# who follows a card arrives with the hall as referrer (the hall sends its # who follows a card arrives with the hall as referrer (the hall sends its
# origin and nothing more). Wiz-War keeps its own host and its own count. # origin and nothing more). Wiz-War keeps its own host and its own count.
GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social"} GAMES = {"dip": "dip.kestrelsnest.social", "waving-hands": "hands.kestrelsnest.social", "hnefatafl": "tafl.kestrelsnest.social", "wyrm": "wyrm.kestrelsnest.social"}
ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest") ASSET = (".png", ".jpg", ".svg", ".ico", ".txt", ".webmanifest")
+29 -36
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kestrel's Hall</title> <title>Kestrel's Hall</title>
<meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, and Dip. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold."> <meta name="description" content="Free board games in the browser: Wiz-War, Waving Hands, Hnefatafl, Dip and Wyrm. Play with friends by link or alone against the house. No accounts, no adverts, nothing sold.">
<link rel="canonical" href="https://gamehall.kestrelsnest.social/"> <link rel="canonical" href="https://gamehall.kestrelsnest.social/">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:title" content="Kestrel's Hall"> <meta property="og:title" content="Kestrel's Hall">
@@ -58,18 +58,22 @@
header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); } header.mast p.newcomer { margin: 10px auto 0; max-width: 38em; font-size: 0.95rem; color: var(--bone-dim); }
.games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; } .games { display: grid; gap: 22px; grid-template-columns: repeat(2, 1fr); margin-top: 22px; }
@media (max-width: 860px) { .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; } }
.game { .game {
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 6px;
overflow: hidden; overflow: hidden;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
transition: transform .15s ease, border-color .15s ease;
} }
.game:hover { transform: translateY(-3px); border-color: var(--gold); text-decoration: none; } .game:hover { border-color: var(--gold); text-decoration: none; }
.game:hover h2 { color: var(--gold); }
/* The first game is the one newcomers are pointed at, so it has the whole width: art beside the words. */
.game.first { grid-column: 1 / -1; flex-direction: row; }
.game.first .art { flex: 0 0 55%; aspect-ratio: 2 / 1; }
.game.first .body { justify-content: center; padding: 22px 26px; }
.game.first h2 { font-size: 1.9rem; }
.game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; } .game .art { position: relative; aspect-ratio: 5 / 3; background: #222; overflow: hidden; }
.game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; } .game .art img.fill { width: 100%; height: 100%; object-fit: cover; display: block; }
.game .art img.tafl { object-position: 50% 50%; } .game .art img.tafl { object-position: 50% 50%; }
@@ -77,13 +81,15 @@
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; } .game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; } .game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
.game .pitch { margin: 0; color: var(--bone); } .game .pitch { margin: 0; color: var(--bone); }
.game .players, .game .length { margin: 0; color: var(--bone-dim); font-size: 0.92rem; } .game .players { margin: 0; color: var(--bone-dim); font-size: 0.92rem; }
.game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; } .game .origin { margin: 0; color: var(--bone-dim); font-size: 0.85rem; }
.game .length:empty { display: none; } @media (max-width: 860px) {
.game .go { margin-top: auto; padding-top: 10px; display: flex; flex-direction: column; gap: 2px; } .games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
.game .play { color: var(--gold); font-size: 1rem; } .game.first { flex-direction: column; }
.game .site { color: var(--bone-dim); font-size: 0.85rem; } .game.first .art { flex: none; aspect-ratio: 5 / 3; }
.game:hover .play { text-decoration: underline; } .game.first .body { padding: 16px 18px 18px; }
.game.first h2 { font-size: 1.45rem; }
}
.nowrap { white-space: nowrap; } .nowrap { white-space: nowrap; }
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */ /* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
@@ -128,14 +134,11 @@
<header class="mast"> <header class="mast">
<h1>Kestrel's Hall</h1> <h1>Kestrel's Hall</h1>
<p class="lede"><b>Free board games in the browser.</b> Play 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="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>
<!-- A word for newcomers, one line to tune or remove. -->
<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> <p class="newcomer">New here? Wiz-War is the friendliest place to start, and a game can be over in minutes. Dip is the long game.</p>
</header> </header>
<div class="games"> <div class="games">
<!-- Each card's empty "length" line is for a typical game's length, once real play has shown it; it is hidden until filled. --> <a class="game first" href="https://wizwar.kestrelsnest.social">
<a class="game" href="https://wizwar.kestrelsnest.social">
<div class="art"> <div class="art">
<div class="maze"> <div class="maze">
<div class="w h" style="left:12.5%;top:19%;width:25%"></div> <div class="w h" style="left:12.5%;top:19%;width:25%"></div>
@@ -159,11 +162,6 @@
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p> <p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
<p class="players">2 to 6 players, or solo against the automaton</p> <p class="players">2 to 6 players, or solo against the automaton</p>
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p> <p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Wiz-War →</span>
<span class="site">wizwar.kestrelsnest.social</span>
</div>
</div> </div>
</a> </a>
@@ -174,11 +172,6 @@
<p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p> <p class="pitch">Wizards duel with their hands, and the right run of gestures is a spell.</p>
<p class="players">2 to 12 players, or solo against a rival wizard</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> <p class="origin">Richard Bartle's pencil-and-paper duel, 1977</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Waving Hands →</span>
<span class="site">hands.kestrelsnest.social</span>
</div>
</div> </div>
</a> </a>
@@ -189,11 +182,6 @@
<p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p> <p class="pitch">The Viking board game of a king breaking for safety and a ring of attackers closing in.</p>
<p class="players">2 players, or solo against the housecarl</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> <p class="origin">The Norse tafl game, by the Copenhagen rules or Linnaeus's 1732 Tablut</p>
<p class="length"></p>
<div class="go">
<span class="play">Play Hnefatafl →</span>
<span class="site">tafl.kestrelsnest.social</span>
</div>
</div> </div>
</a> </a>
@@ -204,11 +192,16 @@
<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="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="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> <p class="origin">An unofficial rebuild of Allan B. Calhamer's 1959 Diplomacy</p>
<p class="length"></p> </div>
<div class="go"> </a>
<span class="play">Play Dip →</span>
<span class="site">dip.kestrelsnest.social</span> <a class="game" href="https://wyrm.kestrelsnest.social">
</div> <div class="art"><img class="fill" src="wyrm.jpg" alt="Wyrm's glass board at the opening, seen from a corner: three tinted sheets, the air, the land and the underworld, with Gold's figures on the near ranks and Scarlet's on the far ones."></div>
<div class="body">
<h2>Wyrm</h2>
<p class="pitch">Chess on three boards, with a Dragon in the air above your King and a Basilisk in the earth below.</p>
<p class="players">2 players, or solo against the drake</p>
<p class="origin">An unofficial rebuild of Gary Gygax's 1985 Dragonchess, from Dragon Magazine No. 100</p>
</div> </div>
</a> </a>
@@ -235,7 +228,7 @@
</div> </div>
</div> </div>
<h2 style="margin-top:28px">The hosting</h2> <h2 style="margin-top:28px">The hosting</h2>
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, 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 nothing is held back without it.</p> <p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.</p>
</div> </div>
</section> </section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
# Stamp out a new game from the template. # Stamp out a new game from the template.
# ./new-game.sh <slug> "<Name>" [port] [domain] # ./new-game.sh <slug> "<Name>" [port] [domain]
# e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social # e.g. ./new-game.sh tumbleweed "Tumbleweed" 8791 tumbleweed.kestrelsnest.social
# Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790. The # Ports so far: wizwar 8787, waving-hands 8788, hnefatafl 8789, dip 8790, wyrm 8791. The
# games share one host (deploy/README.md), so a new game takes the next free port. # games share one host (deploy/README.md), so a new game takes the next free port.
set -euo pipefail set -euo pipefail
SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}" SLUG="${1:?usage: new-game.sh <slug> \"<Name>\" [port] [domain]}"
+4 -1
View File
@@ -43,7 +43,10 @@ __HOST__ {
header @immutable Cache-Control "public, max-age=31536000, immutable" header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/* @mutable not path /_app/immutable/*
header @mutable Cache-Control "no-cache" header @mutable Cache-Control "no-cache"
try_files {path} {path}.html /index.html # try_files passes the root over as a directory and would fall to the shell; the hall's
# own page is index.html.
rewrite / /index.html
try_files {path} {path}.html /shell.html
file_server file_server
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ service user, /opt/<slug> and /var/lib/<slug> trees, access log
small Node process capped at 300 MB by its unit file, and a room is a few small Node process capped at 300 MB by its unit file, and a room is a few
kilobytes of ledger, so the host carries several. Each game needs a port kilobytes of ledger, so the host carries several. Each game needs a port
of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789, of its own on the host: wizwar 8787, waving-hands 8788, hnefatafl 8789,
dip 8790. dip 8790, wyrm 8791.
## Current production ## Current production
+7 -1
View File
@@ -25,7 +25,7 @@ rsync -az --delete --filter='P _app/immutable/*' \
# it needs only its code and the engine. Ledgers live outside this tree. # it needs only its code and the engine. Ledgers live outside this tree.
rsync -az --delete --exclude='/server/node_modules' \ rsync -az --delete --exclude='/server/node_modules' \
--include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \ --include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' --include='/src/lib/net/' --include='/src/lib/net/view.ts' \
--include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/traffic.py' \ --include='/deploy/' --include='/deploy/__SLUG__.service' --include='/deploy/__SLUG__.cron' --include='/deploy/*.sh' --include='/deploy/Caddyfile.tmpl' --include='/deploy/traffic.py' \
--exclude='*' \ --exclude='*' \
./ "root@$HOST:/opt/__SLUG__/app/" ./ "root@$HOST:/opt/__SLUG__/app/"
@@ -60,6 +60,12 @@ ssh "root@$HOST" '
systemctl daemon-reload systemctl daemon-reload
systemctl enable --now __SLUG__ systemctl enable --now __SLUG__
systemctl restart __SLUG__ systemctl restart __SLUG__
# The site file is rendered from the template on every deploy, with the address line the box was set up with.
ADDR=$(head -1 /etc/caddy/sites/__SLUG__.caddy | sed "s/ {\$//")
sed "s|__HOST__|$ADDR|" /opt/__SLUG__/app/deploy/Caddyfile.tmpl > /etc/caddy/sites/__SLUG__.caddy
caddy validate --config /etc/caddy/Caddyfile >/dev/null
# Validation as root touches the access logs; Caddy runs as caddy and must own them.
chown -R caddy:caddy /var/lib/caddy
systemctl reload caddy systemctl reload caddy
# "active" means only that the process started: wait for it to answer HTTP, # "active" means only that the process started: wait for it to answer HTTP,
# so a server that dies loading its code fails the deploy instead of looping. # so a server that dies loading its code fails the deploy instead of looping.
+7 -3
View File
@@ -12,11 +12,13 @@ from collections import Counter, defaultdict
BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|whatsapp|telegram|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I)
# Link previews: the app that fetched the page to show it in a conversation. The people # Link previews: the app that fetched the page to show it in a conversation. The people
# who click such a link arrive with no referrer, so this is the best sign of where it went. # who click such a link arrive with no referrer, so this is the best sign of where it went.
# Meta's crawlers (meta-externalagent, meta-webindexer) name facebook.com too but share nothing.
UNFURL = [ UNFURL = [
("Slack", re.compile(r"slack", re.I)), ("Slack", re.compile(r"slack", re.I)),
("Discord", re.compile(r"discord", re.I)), ("Discord", re.compile(r"discord", re.I)),
("Google Messages", re.compile(r"googlemessages", re.I)),
("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)), ("iMessage", re.compile(r"facebookexternalhit.*twitterbot|applebot.*preview", re.I)),
("Facebook", re.compile(r"facebookexternalhit|facebot|meta-externalagent", re.I)), ("Facebook", re.compile(r"facebookexternalhit|facebot", re.I)),
("WhatsApp", re.compile(r"whatsapp", re.I)), ("WhatsApp", re.compile(r"whatsapp", re.I)),
("Telegram", re.compile(r"telegram", re.I)), ("Telegram", re.compile(r"telegram", re.I)),
("Reddit", re.compile(r"redditbot", re.I)), ("Reddit", re.compile(r"redditbot", re.I)),
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
t["bot"] += 1 t["bot"] += 1
t["botAddresses"].add(ip) t["botAddresses"].add(ip)
service = unfurled_by(ua) service = unfurled_by(ua)
# One share per app and page: a preview is often fetched twice. # One share per app and page: a preview is often fetched twice. Only a
if service and (service, path) not in seen_share: # page counts: an app that already holds a preview fetches its card
# image again to show it, and robots.txt and scripts are not shared.
if service and (path in PAGES or path.startswith("/join/")) and (service, path) not in seen_share:
seen_share.add((service, path)) seen_share.add((service, path))
t["shared"][service] += 1 t["shared"][service] += 1
continue continue
+16 -5
View File
@@ -16,6 +16,9 @@ afterEach(() => {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
/** The bots move once the request that made their move due has been answered: after this tick. */
const answered = () => new Promise<void>((r) => setImmediate(r));
/** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */ /** A table of the demo game: Ann, who opened it, Bo, and Cy when asked for, the game begun. */
function table(spec: GameSpec<State, Input> = game, withCy = false) { function table(spec: GameSpec<State, Input> = game, withCy = false) {
const rooms = new Rooms(spec, new Store(dir)); const rooms = new Rooms(spec, new Store(dir));
@@ -161,7 +164,7 @@ describe('rooms', () => {
expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/); expect(() => rooms.rank(host.room, host.token, ['A'])).toThrow(/asks for no ranking/);
}); });
it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', () => { it('lets a game give its bots words: when a round opens and when spoken to, with notes kept from every view', async () => {
const heard: string[][] = []; const heard: string[][] = [];
const talking: GameSpec<State, Input> = { const talking: GameSpec<State, Input> = {
...game, ...game,
@@ -179,6 +182,7 @@ describe('rooms', () => {
const cy = rooms.join(host.room, 'Cy'); const cy = rooms.join(host.room, 'Cy');
const room = host.room; const room = host.room;
rooms.begin(room, host.token); rooms.begin(room, host.token);
await answered();
rooms.say(room, host.token, 'Psst.', ['B']); rooms.say(room, host.token, 'Psst.', ['B']);
rooms.say(room, cy.token, 'Hello, all.'); rooms.say(room, cy.token, 'Hello, all.');
const annSees = rooms.view(room, 'A').chat; const annSees = rooms.view(room, 'A').chat;
@@ -188,6 +192,7 @@ describe('rooms', () => {
expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']); expect(rooms.view(room, '').chat.map((l) => l.text)).toEqual(['Hello, all.']);
rooms.submit(room, host.token, { pick: 1 }, 0); rooms.submit(room, host.token, { pick: 1 }, 0);
rooms.submit(room, cy.token, { pick: 2 }, 0); rooms.submit(room, cy.token, { pick: 2 }, 0);
await answered();
expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']); expect(heard[0]).toEqual(['Round 1.', 'Psst.', 'I hear you.', 'Hello, all.', 'I hear you.']);
expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']); expect(room.chat.filter((l) => l.note === 'secret').map((l) => l.text)).toEqual(['Round 1.', 'Round 2.']);
expect(rooms.view(room, 'A').parting).toEqual([]); expect(rooms.view(room, 'A').parting).toEqual([]);
@@ -229,6 +234,7 @@ describe('rooms', () => {
rooms.addBot(host.room, host.token); rooms.addBot(host.room, host.token);
rooms.begin(host.room, host.token); rooms.begin(host.room, host.token);
const room = host.room; const room = host.room;
vi.runOnlyPendingTimers();
expect(room.state!.n).toBe(4); expect(room.state!.n).toBe(4);
expect(rooms.view(room, 'A').playingOn).toBe(true); expect(rooms.view(room, 'A').playingOn).toBe(true);
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
@@ -243,16 +249,18 @@ describe('rooms', () => {
}); });
describe('the host and the other seats', () => { 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", () => { 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(); const { rooms, room, ann, bo } = table();
rooms.submit(room, ann, { pick: 5 }, 0); rooms.submit(room, ann, { pick: 5 }, 0);
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
// The round waited on Bo alone; the bot played it. // 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(room.resolved).toBe(1);
expect(rooms.waitingOn(room)).toEqual(['A']); expect(rooms.waitingOn(room)).toEqual(['A']);
expect(() => rooms.seatOf(room, bo)).toThrow(/gave your seat to a bot/); 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 }); expect(rooms.view(room, 'A').seats[1]).toEqual({ id: 'B', name: 'Bo', bot: true, standIn: true });
rooms.submit(room, ann, { pick: 3 }, 1); rooms.submit(room, ann, { pick: 3 }, 1);
await answered();
expect(room.resolved).toBe(2); expect(room.resolved).toBe(2);
}); });
@@ -305,12 +313,15 @@ describe('the host and the other seats', () => {
expect(() => rooms.hostPhrase(withBot.room, withBot.token, bot.id)).toThrow(/No other person/); 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', () => { 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 { rooms, room, ann } = table();
const { phrase } = rooms.hostPhrase(room, ann, 'B'); const { phrase } = rooms.hostPhrase(room, ann, 'B');
rooms.standIn(room, ann, 'B'); rooms.standIn(room, ann, 'B');
expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/); expect(() => rooms.claim(phrase)).toThrow(/unknown or has expired/);
while (!game.over(room.state!)) rooms.submit(room, ann, { pick: 5 }, room.resolved); while (!game.over(room.state!)) {
rooms.submit(room, ann, { pick: 5 }, room.resolved);
await answered();
}
const next = rooms.get(rooms.rematch(room, ann).roomId); const next = rooms.get(rooms.rematch(room, ann).roomId);
expect(next.expected).toEqual(['Bo']); expect(next.expected).toEqual(['Bo']);
expect(next.seats.map((s) => s.name)).toEqual(['Ann']); expect(next.seats.map((s) => s.name)).toEqual(['Ann']);
+28 -6
View File
@@ -351,8 +351,32 @@ export class Rooms<State, Input> {
if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409); if (room.seats.length < this.game.minSeats) throw new RoomError(`The game needs at least ${this.game.minSeats} players.`, 409);
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) }); this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules, ...(Object.keys(room.rankings).length ? { rankings: { ...room.rankings } } : {}) });
// A game whose first move belongs to a bot must not wait for a human who is not owed a move. // A game whose first move belongs to a bot must not wait for a human who is not owed a move.
this.driveBots(room); this.botsAfterAnswering(room);
this.botsSpeak(room, 'phase'); }
/**
* 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)}`);
}
});
} }
/** /**
@@ -383,8 +407,7 @@ export class Rooms<State, Input> {
const hurried = this.hurried.has(room.id); const hurried = this.hurried.has(room.id);
const next = setTimeout(() => { const next = setTimeout(() => {
this.pacing.delete(room.id); this.pacing.delete(room.id);
// A room forgotten and loaded again drives itself; this one is no longer the table. if (!this.isTable(room)) return;
if (this.rooms.get(room.id) !== room) return;
this.driveBots(room, hurried ? HURRIED_STEP : 1); this.driveBots(room, hurried ? HURRIED_STEP : 1);
// Played out: the bots have their say once, as after any round, which ends a game with parting words. // Played out: the bots have their say once, as after any round, which ends a game with parting words.
if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase'); if (!this.pacing.has(room.id)) this.botsSpeak(room, 'phase');
@@ -538,8 +561,7 @@ export class Rooms<State, Input> {
} }
} }
this.commit(room, { t: 'turn', at: Date.now(), inputs }); this.commit(room, { t: 'turn', at: Date.now(), inputs });
this.driveBots(room); this.botsAfterAnswering(room);
this.botsSpeak(room, 'phase');
} }
/** /**
-1
View File
@@ -9,7 +9,6 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="description" content="__NAME__, a game between friends, free in the browser." /> <meta name="description" content="__NAME__, a game between friends, free in the browser." />
<link rel="canonical" href="https://__DOMAIN__/" /> <link rel="canonical" href="https://__DOMAIN__/" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="__NAME__" /> <meta property="og:site_name" content="__NAME__" />
+3 -4
View File
@@ -45,14 +45,13 @@
{ n: t.turnsPlayed, label: 'turns recorded' }, { n: t.turnsPlayed, label: 'turns recorded' },
{ n: hours(t.minutesAtTable), label: 'at the table' } { n: hours(t.minutesAtTable), label: 'at the table' }
]; ];
const onCards = new Set<string>([]);
const details = [ const details = [
{ n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` }, { n: t.gamesBegun, label: `games begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' }, { n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` }, { n: t.turnsPlayed, label: `turns recorded; ${t.longestGameTurns} in the longest game` },
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' }, { n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []), ...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label })) ...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
]; ];
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : ''; const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` }; return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
@@ -325,7 +324,7 @@
{/snippet} {/snippet}
{#snippet aboutTally()} {#snippet aboutTally()}
{#if told} {#if told}
<TallyBoard title="How much love the table is getting" headline={told.headline} details={told.details} note={told.note} /> <TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
{:else} {:else}
<p class="muted">Counting…</p> <p class="muted">Counting…</p>
{/if} {/if}
@@ -341,7 +340,7 @@
{/if} {/if}
<p class="hosting"> <p class="hosting">
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and nothing is held back without it. <a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
</p> </p>
<p class="made">__NAME__ is its designer's.</p> <p class="made">__NAME__ is its designer's.</p>
</div> </div>
+5 -3
View File
@@ -11,6 +11,8 @@
let beginAsked = $state(false); let beginAsked = $state(false);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host'); const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase())); const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase()));
/** A server with no KEEPER set calls its keeper "the keeper", which reads badly beside the title. */
const named = $derived(v.keeper.toLowerCase() !== 'the keeper');
// A table that asks for rankings: this seat's, best first, kept on the server as it changes. // A table that asks for rankings: this seat's, best first, kept on the server as it changes.
const choices = $derived(game.rankable?.(v.options) ?? null); const choices = $derived(game.rankable?.(v.options) ?? null);
@@ -134,11 +136,11 @@
{/if} {/if}
{#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld} {#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
<div class="keeper"> <div class="keeper">
<button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {v.keeper}, the keeper</button> <button type="button" class="quiet" title="A seat is held for {v.keeper}, who keeps this site, and their phone rings" onclick={() => room.callKeeper()}>Challenge {named ? `${v.keeper}, the keeper` : 'the keeper'}</button>
<p class="muted small note">{v.keeper} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {v.keeper} lives.{/if} The seat is held either way.</p> <p class="muted small note">{named ? v.keeper : 'The keeper'} keeps this site and answers every call, sometimes within the minute, sometimes after a night's sleep.{#if keeperHour}&nbsp;It is {keeperHour} where {named ? v.keeper : 'the keeper'} lives.{/if} The seat is held either way.</p>
</div> </div>
{:else if v.challenge} {:else if v.challenge}
<p class="muted small note">{v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held 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> <p class="muted small note">{named ? v.keeper : 'The keeper'} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour}&nbsp;It is {keeperHour} there.{/if} A seat is held for them. If they can come now, they will take it. If they are asleep or away, they will leave a word below when they can. In the meantime you can seat a bot, invite a friend, or play another game in a new tab. This table keeps its place in your hall until you come back.</p>
{/if} {/if}
{/if} {/if}
{#if room.error}<p class="warning">{room.error}</p>{/if} {#if room.error}<p class="warning">{room.error}</p>{/if}
+3 -2
View File
@@ -1,4 +1,5 @@
// Everything happens in the browser against the game server, so nothing is rendered on the // Everything happens in the browser against the game server, so by default a route is
// server. Each route is prerendered as an empty shell that the client fills in. // prerendered as an empty shell that the client fills in. The hall, the rules and the guide
// turn rendering on in their own +page.ts, since their text is the same for everyone.
export const ssr = false; export const ssr = false;
export const prerender = true; export const prerender = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+3
View File
@@ -0,0 +1,3 @@
// The page is text that reads the same for everyone, so it is rendered at build time and served whole;
// the browser-only state it shows (held seats, the saved name) fills in once the script runs.
export const ssr = true;
+3 -1
View File
@@ -10,7 +10,9 @@ export default defineConfig({
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
}, },
// A static build: Caddy serves files and proxies /api and /ws to the game server. // A static build: Caddy serves files and proxies /api and /ws to the game server.
adapter: adapter({ fallback: 'index.html' }) // The fallback is the empty shell Caddy serves for a room address; it is not index.html, so the
// hall's own prerendered page is not overwritten by it.
adapter: adapter({ fallback: 'shell.html' })
}) })
], ],
server: { server: {