Compare commits
12
Commits
c439c15e36
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7b1d68ec | ||
|
|
c6366853cd | ||
|
|
647e8cb2bf | ||
|
|
40a52dfe37 | ||
|
|
75428b5a9b | ||
|
|
62cb00be33 | ||
|
|
dcd2293b42 | ||
|
|
cbe85d2d31 | ||
|
|
5e7ac999db | ||
|
|
e91d209cbe | ||
|
|
8c37cc5210 | ||
|
|
09a8b675ad |
No files matched your search
+24
-1
@@ -55,7 +55,19 @@ README.
|
||||
`/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 both ways back, and the hall asks before forgetting 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
|
||||
@@ -205,6 +217,8 @@ Every game gets the same tools, installed by `deploy.sh` on every deploy:
|
||||
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,
|
||||
@@ -280,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
|
||||
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").
|
||||
@@ -337,6 +355,11 @@ opened its mouth.") as long as the fact survives.
|
||||
- `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`.
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 and come back by the keeper's
|
||||
link when a browser loses them, the keeper's bell, append-only ledgers
|
||||
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
|
||||
@@ -46,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
|
||||
|
||||
@@ -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/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__-reseat.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"
|
||||
|
||||
+9
-1
@@ -31,8 +31,16 @@
|
||||
"name": "Dip",
|
||||
"url": "https://dip.kestrelsnest.social",
|
||||
"pitch": "Seven powers share one map, and none of them has to keep its word.",
|
||||
"players": "2 to 7",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
+28
-35
@@ -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">
|
||||
@@ -58,18 +58,22 @@
|
||||
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%; }
|
||||
@@ -77,13 +81,15 @@
|
||||
.game .body { padding: 16px 18px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
|
||||
.game h2 { margin: 0; font-size: 1.45rem; font-weight: 500; }
|
||||
.game .pitch { margin: 0; color: var(--bone); }
|
||||
.game .players, .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 .length:empty { display: none; }
|
||||
.game .go { margin-top: auto; padding-top: 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.game .play { color: var(--gold); font-size: 1rem; }
|
||||
.game .site { color: var(--bone-dim); font-size: 0.85rem; }
|
||||
.game:hover .play { text-decoration: underline; }
|
||||
@media (max-width: 860px) {
|
||||
.games { grid-template-columns: 1fr; max-width: 520px; margin-left: auto; margin-right: auto; }
|
||||
.game.first { flex-direction: column; }
|
||||
.game.first .art { flex: none; aspect-ratio: 5 / 3; }
|
||||
.game.first .body { padding: 16px 18px 18px; }
|
||||
.game.first h2 { font-size: 1.45rem; }
|
||||
}
|
||||
.nowrap { white-space: nowrap; }
|
||||
|
||||
/* The maze card: a stone floor in CSS, mortar-coloured walls, and the hall's own token drawings. */
|
||||
@@ -128,14 +134,11 @@
|
||||
<header class="mast">
|
||||
<h1>Kestrel's Hall</h1>
|
||||
<p class="lede"><b>Free board games in the browser.</b> Play alone against the house right away, or start a table and send friends the link. No accounts, no adverts, no timers, and nothing sold.</p>
|
||||
<!-- 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>
|
||||
</header>
|
||||
|
||||
<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" 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>
|
||||
@@ -159,11 +162,6 @@
|
||||
<p class="pitch">Steal treasure. Blast through walls. Outwit your friends.</p>
|
||||
<p class="players">2 to 6 players, or solo against the automaton</p>
|
||||
<p class="origin">An unofficial rebuild of Tom Jolly's 1993 board game</p>
|
||||
<p class="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Wiz-War →</span>
|
||||
<span class="site">wizwar.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Waving Hands →</span>
|
||||
<span class="site">hands.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Hnefatafl →</span>
|
||||
<span class="site">tafl.kestrelsnest.social</span>
|
||||
</div>
|
||||
</div>
|
||||
</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="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="length"></p>
|
||||
<div class="go">
|
||||
<span class="play">Play Dip →</span>
|
||||
<span class="site">dip.kestrelsnest.social</span>
|
||||
</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>
|
||||
|
||||
@@ -235,7 +228,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<h2 style="margin-top:28px">The hosting</h2>
|
||||
<p>The games are free and will stay free. They run on small servers that cost a few dollars a month, and anyone who wants to chip in toward the hosting can do so at <a class="nowrap" href="https://ko-fi.com/kestrelsnest">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and 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>
|
||||
</section>
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
+1
-1
@@ -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]}"
|
||||
|
||||
@@ -43,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
|
||||
|
||||
@@ -55,6 +55,9 @@ 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
|
||||
|
||||
@@ -19,7 +19,7 @@ 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 "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")
|
||||
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
|
||||
|
||||
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/"
|
||||
|
||||
@@ -43,6 +43,7 @@ ssh "root@$HOST" '
|
||||
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__
|
||||
@@ -59,6 +60,12 @@ ssh "root@$HOST" '
|
||||
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.
|
||||
|
||||
@@ -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)),
|
||||
@@ -144,8 +146,10 @@ def scan(day, logs=LOGS):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +33,7 @@
|
||||
//
|
||||
// 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';
|
||||
@@ -83,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);
|
||||
@@ -221,6 +226,11 @@ async function handleKeeper(req: IncomingMessage, res: ServerResponse, parts: st
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -317,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;
|
||||
@@ -362,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,15 @@ 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;
|
||||
@@ -151,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,
|
||||
@@ -169,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;
|
||||
@@ -178,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([]);
|
||||
@@ -219,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);
|
||||
@@ -231,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']);
|
||||
});
|
||||
});
|
||||
+116
-24
@@ -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');
|
||||
@@ -407,27 +460,49 @@ export class Rooms<State, Input> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The keeper gives a person's seat a new token, for a player whose browser lost the old one. Whether the one
|
||||
* asking holds the seat is the keeper's judgment; this carries it out. The old token opens nothing after.
|
||||
* 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);
|
||||
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) });
|
||||
// A phrase minted with the old token would carry a seat that no longer answers to it.
|
||||
for (const [phrase, t] of this.transfers) if (t.roomId === room.id && t.seatId === seat.id) this.transfers.delete(phrase);
|
||||
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; lastActed: number | null }[] {
|
||||
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, lastActed: s.bot ? null : (last.get(s.id) ?? null) }));
|
||||
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. */
|
||||
@@ -479,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -494,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;
|
||||
@@ -524,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 {
|
||||
@@ -560,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,
|
||||
@@ -635,7 +716,17 @@ export class Rooms<State, Input> {
|
||||
break;
|
||||
case 'reseat': {
|
||||
const seat = room.seats.find((s) => s.id === line.id);
|
||||
if (seat) seat.tokenHash = line.tokenHash;
|
||||
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':
|
||||
@@ -675,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,8 +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 }
|
||||
/** The keeper gave a person's seat a new token, for a player whose browser lost the old one; the old one opens nothing after. */
|
||||
/**
|
||||
* 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. */
|
||||
@@ -32,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. */
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<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__" />
|
||||
|
||||
@@ -45,14 +45,13 @@
|
||||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||||
];
|
||||
const onCards = 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: 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]) => !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' })}. ` : '';
|
||||
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.` };
|
||||
@@ -163,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';
|
||||
@@ -325,7 +324,7 @@
|
||||
{/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}
|
||||
@@ -341,7 +340,7 @@
|
||||
{/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, 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 class="made">__NAME__ is its designer's.</p>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
@@ -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 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} 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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -148,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,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;
|
||||
@@ -46,7 +46,7 @@
|
||||
<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 gives a name, and their lines are marked as the gallery's. A line goes to everyone at the table and everyone in the gallery, and is kept with the game.</p>
|
||||
<p>A seat can follow you to another device. <em>Transfer seat</em> in the masthead gives four words, good for ten minutes, and the hall on the other device claims them. When a game ends, anyone at the table may call for a rematch, 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>
|
||||
<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">
|
||||
|
||||
@@ -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 { rememberSeat, seatFor, seatInLink, ServerError, trustSeat, type HeldSeat } from '$lib/net/client';
|
||||
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||||
|
||||
const roomId = $derived((page.params.code ?? '').toUpperCase());
|
||||
|
||||
@@ -21,6 +22,11 @@
|
||||
/** 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);
|
||||
@@ -44,7 +50,17 @@
|
||||
});
|
||||
// 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;
|
||||
const arrive = held ? Room.resume(id, held).catch((e: Error) => (sent && refused(e) ? take(sent) : Promise.reject(e))) : sent ? take(sent).catch(() => Room.watch(id)) : Room.watch(id);
|
||||
// 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
|
||||
@@ -62,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;
|
||||
@@ -137,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>
|
||||
@@ -151,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}
|
||||
@@ -189,8 +239,20 @@
|
||||
</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}
|
||||
<p class="notice muted">Had a seat at this table on another device? Transfer it from there and claim the phrase in the hall, 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>
|
||||
<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}
|
||||
|
||||
@@ -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