From ee5690ebda9ea7a13349669eb4b76743e8bf75b6 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 20:02:45 -0400 Subject: [PATCH] A credibility pass over the whole repo: the board's mechanics in board.ts, one copy of each helper and stylesheet rule, the kit's plumbing this game never used removed, and the ops scripts counting traffic through one parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine and the bot no longer import each other: geometry, movement and captures live in src/lib/game/board.ts and both use it. The escape test, the four directions, the king's neighbours and the enumeration of a side's moves each exist once; the tally counts by the named end sentences rather than by regex over them; exports nobody imports are exports no more. The tests pin the bot's opening move and the three- beside-the-throne capture they named but never exercised. In the client: .small, the word-as-button, the × that dismisses, the visually-hidden rule and the frame of the reading pages are in app.css once; the preferences panel and the report slip share one modal shape; the room store drops the simultaneous-round fields this game never read and gains seatEmpty and seatUnheld, which the lobby and the join page read instead of three spellings of their own. The wire shapes for reports and the tally are declared in view.ts for both sides. The artwork component is re-indented for the top level it lives at. On the server and in deploy: the plaintext-token fallback and its migration script guarded ledgers this game never wrote; the seat line now requires the hash and the start line the rules revision. The route table lists every route. The visitors digest and the nightly rollup count Caddy's log through deploy/traffic.py, and the rollup writes the finished-games count it had been computing behind "and False". The reports digest is one program, deploy/report-digest.ts, that pull-reports.sh and the desk skill both use. The deploy README, the visitors skill and the reports skill no longer describe a browser-only game, a /play route or a rules text in docs/; conventions.md carries the kit's Preferences and tally sections. Kit-shared files touched, to port back: server/src/{index,rooms,store, tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh, traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh}, src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte, ReportSlip.svelte, TableTalk.svelte. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh --- .claude/skills/hnefatafl-reports/SKILL.md | 29 +- .claude/skills/hnefatafl-visitors/SKILL.md | 5 +- .gitignore | 5 +- README.md | 10 +- deploy/Caddyfile.tmpl | 2 +- deploy/README.md | 39 +- deploy/__pycache__/traffic.cpython-314.pyc | Bin 0 -> 5673 bytes deploy/deploy.sh | 11 +- deploy/hash-tokens.ts | 44 -- deploy/hnefatafl-backup.sh | 2 +- deploy/{pulse.sh => hnefatafl-pulse.sh} | 4 +- deploy/hnefatafl-rollup.sh | 58 +-- deploy/{visitors.sh => hnefatafl-visitors.sh} | 57 +-- deploy/hnefatafl.service | 2 +- deploy/pull-reports.sh | 37 +- deploy/replay-ledgers.ts | 2 +- deploy/report-digest.ts | 32 ++ deploy/report-reply.sh | 2 +- deploy/sentry-slack-alert.sh | 11 +- deploy/setup-droplet.sh | 4 +- deploy/traffic.py | 67 +++ deploy/verify-ledgers.sh | 2 +- docs/artwork.md | 11 +- docs/conventions.md | 11 + docs/og-card.svg | 2 +- docs/render-art.mjs | 7 +- server/src/index.ts | 20 +- server/src/reports.ts | 30 +- server/src/rooms.ts | 19 +- server/src/store.ts | 12 +- server/src/tally.ts | 20 +- server/tsconfig.json | 7 +- src/app.css | 66 ++- src/app.html | 2 +- src/lib/components/Board.svelte | 54 +-- src/lib/components/BoardArtwork.svelte | 441 +++++++++++------- src/lib/components/Hall.svelte | 51 +- src/lib/components/Lobby.svelte | 45 +- src/lib/components/Preferences.svelte | 70 ++- src/lib/components/ReportSlip.svelte | 20 +- src/lib/components/TableTalk.svelte | 5 +- src/lib/game/board.ts | 214 +++++++++ src/lib/game/bot.ts | 69 +-- src/lib/game/index.test.ts | 38 +- src/lib/game/index.ts | 256 ++-------- src/lib/game/rules.ts | 4 +- src/lib/game/spec.ts | 15 +- src/lib/net/client.ts | 52 +-- src/lib/net/room.svelte.ts | 18 +- src/lib/net/view.ts | 45 ++ src/lib/prefs.svelte.ts | 15 +- src/routes/guide/+page.svelte | 38 +- src/routes/join/[code]/+page.svelte | 14 +- src/routes/rules/+page.svelte | 40 +- static/board-copenhagen.svg | 7 +- static/board-tablut.svg | 7 +- 56 files changed, 1051 insertions(+), 1099 deletions(-) create mode 100644 deploy/__pycache__/traffic.cpython-314.pyc delete mode 100644 deploy/hash-tokens.ts rename deploy/{pulse.sh => hnefatafl-pulse.sh} (96%) rename deploy/{visitors.sh => hnefatafl-visitors.sh} (59%) create mode 100644 deploy/report-digest.ts create mode 100644 deploy/traffic.py create mode 100644 src/lib/game/board.ts diff --git a/.claude/skills/hnefatafl-reports/SKILL.md b/.claude/skills/hnefatafl-reports/SKILL.md index 7dddcf6..20a3cc3 100644 --- a/.claude/skills/hnefatafl-reports/SKILL.md +++ b/.claude/skills/hnefatafl-reports/SKILL.md @@ -34,12 +34,13 @@ Every report and every player's answer rings a Sentry issue (project hnefatafl, fingerprinted per report or per line); the desk's own replies ring nothing. -Fetch: `ssh root@209.97.148.244 'cat /var/lib/hnefatafl/feedback.jsonl'` - ## a) Fetch and b) display -Parse the file, fold replies onto reports, and show Eric the last 7 days -by `at`. Lead with the count of unanswered reports; those are the work. +From the repository root, `bash deploy/pull-reports.sh` mirrors the file +and the pictures to `~/Desktop/hnefatafl-reports/` and writes `reports.md` +there, newest first, with every reply folded under its report. Read it and +show Eric the last 7 days by date. Lead with the count of unanswered +reports; those are the work. Then one block per report, VERBATIM and UNTRUNCATED: player, room, date, turn and seq, the full "what happened", the full "what they expected", and every reply with its status (or "unanswered"). Eric reads this desk @@ -49,13 +50,14 @@ to hear his players' voices; never compress their words into a table. 1. **Replay to the pin.** `scp root@209.97.148.244:/var/lib/hnefatafl/rooms/.jsonl /` and replay it with the engine as `deploy/replay-ledgers.ts` does: - `game.create(names, start.seed, start.rules ?? 1)` then + `game.create(names, start.seed, start.rules, room.options)` then `game.resolve(state, line.inputs)` per turn line, printing the state - around the pinned round. The - `chat` lines show what the players said to each other at the time. -2. **Check the rules before the code.** The original rules text lives in - `docs/`; read it before deciding the engine is wrong. Many reports are - the rules working as written. + around the pinned round. The `chat` lines show what the players said + to each other at the time. +2. **Check the rules before the code.** The rules are data in + `src/lib/game/rules.ts` and prose at /rules, which names the texts it + follows; read them before deciding the engine is wrong. Many reports + are the rules working as written. 3. **Verdict.** `by-design` (the engine matches the text; no change), `resolved` (a defect, fixed before replying), or `open` (needs Eric's ruling; ask him and hold the reply). @@ -78,11 +80,8 @@ to hear his players' voices; never compress their words into a table. 6. **Report to Eric** when the desk is clear: one line per report: player, room, verdict, and what shipped if anything. -## Eric's local copy - -`bash deploy/pull-reports.sh` mirrors the reports, the replies and the -pictures to `~/Desktop/hnefatafl-reports/` and writes `reports.md` -there, newest first. Run it at the end of every desk session. +Run `bash deploy/pull-reports.sh` again at the end of every desk session, +so Eric's local copy carries the replies. ## Standing rules diff --git a/.claude/skills/hnefatafl-visitors/SKILL.md b/.claude/skills/hnefatafl-visitors/SKILL.md index 8981880..48bd970 100644 --- a/.claude/skills/hnefatafl-visitors/SKILL.md +++ b/.claude/skills/hnefatafl-visitors/SKILL.md @@ -18,9 +18,8 @@ spills into the next UTC day. Run yesterday too when the hour is early. - **as of / live sockets / rooms touched in the last hour**: the "right now" line. One socket at a quiet hour is Eric. - **traffic**: Caddy's access log for the day, bots split out by user - agent, page requests by page (hall, play, rules) and room links opened, - referrers and campaign tags. Single-player games live in browsers, so - a visit to /play is all the log shows of them. + agent, page requests by page (hall, rules, guide) and room links opened, + referrers and campaign tags. - **players today / NEW today**: names seated in rooms created today; NEW means the name's first room ever is today. - **rooms today**: one line each: time, code, humans vs bots, empty diff --git a/.gitignore b/.gitignore index 89e1602..2a3cb0d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,5 @@ Thumbs.db vite.config.js.timestamp-* vite.config.ts.timestamp-* -# Playwright MCP output -.playwright-mcp - -# Duel ledgers written by the server +# Room ledgers written by the server /data diff --git a/README.md b/README.md index d9acb53..5eac445 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ default; Tablut, the nine-by-nine game Linnaeus wrote down in 1732, as an option, on a board that shows its age. Built on the game kit: `src/lib/game/` is the game (rules as data in -`rules.ts`, the engine in `index.ts`, a short alpha-beta bot in `bot.ts`), -`src/lib/components/Board.svelte` is the board, and everything else is the -kit's hall, tables, gallery, ledgers, reports desk and ops tools. -`docs/conventions.md` is the house style. +`rules.ts`, the board's mechanics in `board.ts`, the engine in `index.ts`, +a short alpha-beta bot in `bot.ts`), `src/lib/components/Board.svelte` is +the board, and everything else is the kit's hall, tables, gallery, ledgers, +reports desk and ops tools. `docs/conventions.md` is the house style; the +guide here departs from it in one way, walking the page in words and one +drawn figure rather than screenshots. npm install && (cd server && npm install) npm run server # the game server on port 8789 diff --git a/deploy/Caddyfile.tmpl b/deploy/Caddyfile.tmpl index 7fb62e4..f02d389 100644 --- a/deploy/Caddyfile.tmpl +++ b/deploy/Caddyfile.tmpl @@ -31,7 +31,7 @@ handle @game { # .html, so /rules is tried as /rules.html before falling back to the # app shell, which serves the rooms. Hashed assets under _app/immutable are # cached for a year; every other response is revalidated so a deploy shows -# up on the next load. The two header matchers are disjoint. +# up on the next load. handle { @immutable path /_app/immutable/* header @immutable Cache-Control "public, max-age=31536000, immutable" diff --git a/deploy/README.md b/deploy/README.md index e457111..1d05840 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,12 +1,12 @@ # Deploying Hnefatafl -The single-player game runs entirely in the browser. Duels between people -go through a small Node process that keeps each room as an append-only -ledger of moves in /var/lib/hnefatafl/rooms. Production is one -DigitalOcean droplet: Caddy terminates TLS with automatic certificates, -serves the static build from /opt/hnefatafl/build, and proxies /api and -/ws to the game server on port 8789, which runs as the `hnefatafl` user -under systemd from /opt/hnefatafl/app. +Every game, against a housecarl or a friend, is a room on a small Node +process that keeps it as an append-only ledger of moves in +/var/lib/hnefatafl/rooms. Production is one DigitalOcean droplet: Caddy +terminates TLS with automatic certificates, serves the static build from +/opt/hnefatafl/build, and proxies /api and /ws to the game server on port +8789, which runs as the `hnefatafl` user under systemd from +/opt/hnefatafl/app. ## Sizing @@ -14,11 +14,6 @@ The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy and the game server comfortably: the server is one small Node process capped at 300 MB by its unit file, and a room is a few kilobytes of ledger. -The zero-cost alternative is a second site block in an existing Caddy -server's configuration pointing at a second directory; the deploy script -works unchanged against that host. A droplet of its own keeps this site's -uptime and upgrades independent of anything else. - ## Current production - Droplet: `hnefatafl` (nyc3, s-1vcpu-512mb-10gb, tag `hnefatafl`), IP 209.97.148.244 @@ -47,8 +42,9 @@ fetches the certificate on first request. ## Operations scripts on the droplet -`deploy.sh` installs these to /usr/local/bin and the cron file to -/etc/cron.d/hnefatafl on every deploy, so the live copies are the repo copies: +`deploy.sh` installs these to /usr/local/bin, the `traffic.py` they share to +/usr/local/lib/hnefatafl, and the cron file to /etc/cron.d/hnefatafl on every +deploy, so the live copies are the repo copies: - `hnefatafl-visitors.sh [day]`: who is here now and who came that day. - `hnefatafl-pulse.sh`: the weekly health check (service, errors, rollup @@ -70,7 +66,8 @@ URL with the project's cron path and public key), so a missed night is noticed. To have every new issue, regression and reappearance posted to Slack the way wizwar's are, run `deploy/sentry-slack-alert.sh [#channel]` once from your own shell with `SENTRY_TOKEN` (an org auth token with alerts:write) and -`SLACK_CHANNEL_ID` set; the token never leaves the shell. +`SLACK_CHANNEL_ID` (the channel's Slack ID) set; the token never leaves the +shell. Before every deploy, `deploy/verify-ledgers.sh ` fetches every production ledger and replays it with the local engine, comparing each room with what the @@ -83,8 +80,7 @@ deploy: the server would rewrite that game on restart. Runs the type-checks, the tests and the build locally, rsyncs `build/` to the droplet keeping the previous week's hashed assets, rsyncs the server and -engine sources, installs dependencies, and restarts the game server. A -single-player game is in the player's own browser and loses nothing. A room +engine sources, installs dependencies, and restarts the game server. A room is replayed from its ledger when the server comes back, which takes a few seconds; Caddy holds requests that land in the gap. @@ -93,10 +89,5 @@ seconds; Caddy holds requests that land in the gap. - Logs: `ssh root@ journalctl -u hnefatafl -f` for the game server, `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side. - Restart: `ssh root@ systemctl restart hnefatafl` -- Who has been playing: `ssh root@ hnefatafl-visitors.sh [YYYY-MM-DD]` - prints today's visitors from Caddy's log (people and bots apart, by page), - every room opened today with how far it got, and the names seated, marking - those seen for the first time. -- Rooms: `/var/lib/hnefatafl/rooms/.jsonl`, one ledger per game. - Copy that directory to back them up; single-player games are in players' - browsers. +- Rooms: `/var/lib/hnefatafl/rooms/.jsonl`, one ledger per game; the + nightly backup keeps them in the Space. diff --git a/deploy/__pycache__/traffic.cpython-314.pyc b/deploy/__pycache__/traffic.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ade37522531568cc7d20dc24db0f0593b939768 GIT binary patch literal 5673 zcmbUlTTmO<_3rA85at<{_hOlcY(T*_gAKtB1{&KGtSkzNYXMoLMOrMh>$@uxD@sB; zovG7)gxJX#=TUHzj+N<0m}x%JX*%^xo2fIMPQZeQtlddx`qe+X@g!~f({lwYDKT!G zF7}>t?mhQB?m74FIoea;$V2c{hu)j!^AY+xA@s+*g?#=tAajUADjG)|b&OJ}G^ABJ z4GpRx4UMWX4Na;k4b7?|H?4!8+aulZ29USFEE=TOeK0XjQ*QBt#-!CW%8wTVlbqJ&#_nv zhodrC7GnKUG@`O<2usmmhz$u?Xkirz2844gAH;&tHteD_OCMnqaO}shDDKDnY&fpS zVnD!gB^2@G9>t@wD$WQR<<^WLJ{*pj z-8c$dnkfx{3`W5y&6EZ)M&+l%ZKq)iun<-v0NWJFFY@77mHRA@-C=Rk?I+vp-rVHf zZLrI`{afKm2MtmQ84xml?t%20Lq0vG$dkdchzB^o`HZ6d)aIDS^vyYxD^E|-943!7 zhuA_ns&h-8OpnqY`@J0dw}wXP9BYXm-+1cLIK|Prw3eLN|0Nao`F7~6<8X||k*`Rn zbtZK+3YLR_4pprF;525M-ZwGNVfPG zozqjPV_E2_$>KUTYSE>P8VeQ)?fg3u^RbUoyW6N z$Fgw9d;^hLSFl}GHmXQ z3_0JMzb#K*4i8+Eo>tw0Ec80IU7620+9=(|j6VhU(NmyvbW?gfg4M)_I&_~EJGNPo zj(Hv83gsR5SyjY2berTtZ=uJX^+U(FLS6DWYRSnyxyWncio0RAb4SOi7KgqPp{OBB zlaTrz*2g^ibSw+K8E-Sb!kL2D6=dl4G;QKa9!wutR+?*-rz5LP$Gn*xt3w0S;4#RH zB2TA2n}uA1?tjJ_I6q)r5m$ELDab82n}pi-R5gBcZNNylLEjHSpNV_uf&D4}F8+9n zdYuR6y#2}hc3FBBzuU7x~kp4tJ1Y@ZXy=qb4U}k*`_bWitvuEk^GC z8p8E+ghbSBs6isdqCF@REvOQ!kw_D+y7b?GaS*=FI;XFHTY1JoI7YfWS5s^)U05rb z(*6l=A1UwI=9BQW4h^;T_q)1S;jDn6Y!}W%p@xOhpAQN;rLdFd@-};c7E-mdP|sqh zn+1iHrgEDEph%z4lDjZ12}UGoh7F66(@LHs3lSken5G1b1q}S8!|?MnGA{-rtjY%! zwprl)Tb+XtfR4m3fFi7GcC|snU{+&dWJ&_e2<|8@n`YfIABy!*bcT;;1~5mnWz5y` zf$r6f*G$Ao;GLGlh@#mt_GmWU7A0nA3&u1WtC|^1ldwSag4NLZXtb(mmMx8%g)|f3 zi$<}ryeAX^xAq2wh^iqF78U`U5M`obMp6ZWQinA|6pNU2l$cb`xBy-;gL_uNq~ONn zn~O;q3>^nC(2Wbp2)R-;3rDsN%phfPEN`5&G|J&Fw>#!Mb+~b?89a$c)tL!JiekSI zpPB>{0VRaYxp0e7#$vV(V%OBmt}X%5qbL!yI4iiA}mpc#WZVYWr}wde5zQ2pA=NbF$T9B#gdTiQqLa|?LHjCemJ=gs;jU8609mIQp`C+N2s{kw zK>oLDl&U!tL5NJ~Nu*IznmL{HSfi)Fb~pymUt*0qqfsG^j`A8U%D5Kx0F&>hW`Ou< z)QnOJL}Md+&!cs^NrowI0gL`wJ_!4a-7=P@1;47eLlI$$CtayK0MU@nxmC zaH~DsimfAQ8<{(@;n?w#??vCw#up~vK9F*D+;Dch-@fMTS+DI$)f`=|Ihw3FexAN% z&7bEN&Mfio?fZ4dAC*5u*Sx2a-mx_yoD?E=5qg$7LVr#`KMj`d9KR_#>_y-9oRQt_JI`DuRX zMs3~V^yTS9S*q53qt^XO>7|Mb74Lb^n{U&*Y-OJol`l{W`xDkwQTuvP`!aW{x^}+* zHf<=Yzg5jH9)IollA5e;S)gv4QBB>|hARyTV}gIH?UU+`<-PFwlwmiRibd09(}I{N zPBJa?z4y=+)r-t!W~n~CI)5222a-FxlNFE5AK9p=Tr9g>w(xASVo$2#=t{-W#F=-a z??jhRCEdN71J_1?t+Hyd;c~;0X$dDQchC22RMjpXynJwJ|`t_=o`TmX4via#t zLragZmo{D;x>a6zDfV(~Y3HTqFFgM)2L>%PELxX(-*migB}Cgg<4Q5Dt4wQRa*b)% zz=!gxOE0|q!b($Ds=WI~dH4J3wde=Wt$4gC&)BMGY-Rjx$}@YzGfP-ORq)dBrTSM- zTs%&Q)2|MvN*h;88<+4}>28qpM)Z~Eh?=H1^WN@Al>V|i#kiN-*O~omjC-S^X0h&a zU4ltf?7dO3cZ+Rl|JAN5T?zkNhk?)8k#csgI=hq3o)mNFTKziHmt+oYRMspuUv6G% zzuIx7BT=!;E%zs#2a}au^L?L{SH7Vp_Py}}k+$c}g17P2v%ff-n0cSOR+4NQNHq^yqinPi6Ndp|2@E}U7IglIKg zX-d?;wR@$eb$KjRb7X^QNHMNe#+BF$L7IJMcKOV&Vk^75)|l=sy-R1V&R&^KoOvsD zo&PxaQE+AS$v;o8_yTLgf#s3++kfBro6c+PzwNr_|J~tFI*zYQhu52CQcPq8E9;E% z2@|=~OF@o(X+Xt$ZxprPv7>^9+Z=`R9dk$jbrPs7NLf)U9D581=dVtaOyYd$sUL0f1z!6{ICw?IS(*cbVPWU|QQn!P`}+Stcd|l7C_XJHawc`f5yyRlJ|P+Q'); - process.exit(2); -} - -let files = 0; -let lines = 0; -for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) { - const path = join(dir, file); - const raw = readFileSync(path, 'utf8'); - let changed = 0; - const out = raw - .split('\n') - .map((line) => { - if (!line.trim()) return line; - const entry = JSON.parse(line) as Record; - if (entry.t !== 'seat' || typeof entry.token !== 'string') return line; - const { token, ...rest } = entry; - changed += 1; - return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' }); - }) - .join('\n'); - if (!changed) continue; - const { uid, gid } = statSync(path); - writeFileSync(path + '.tmp', out); - chownSync(path + '.tmp', uid, gid); - renameSync(path + '.tmp', path); - files += 1; - lines += changed; -} -console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`); diff --git a/deploy/hnefatafl-backup.sh b/deploy/hnefatafl-backup.sh index 7049fed..ea1d6a0 100755 --- a/deploy/hnefatafl-backup.sh +++ b/deploy/hnefatafl-backup.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Nightly ledger backup to DigitalOcean Spaces, one prefix per game in the shared bucket. # current/ - exact mirror of /var/lib/hnefatafl # snapshots/ - one dated copy per day, pruned after 90 days diff --git a/deploy/pulse.sh b/deploy/hnefatafl-pulse.sh similarity index 96% rename from deploy/pulse.sh rename to deploy/hnefatafl-pulse.sh index 4d45738..9f54d5a 100755 --- a/deploy/pulse.sh +++ b/deploy/hnefatafl-pulse.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # The operations pulse, printed ON the droplet: the game server's health, # errors in its journal, the last week of the rollup, the backup's last word, # and the box's vitals. Read by the hnefatafl-pulse skill. @@ -10,7 +10,7 @@ systemctl is-active caddy >/dev/null && echo "caddy: active" || echo "caddy: NOT echo "restarts in 7 days: $(journalctl -u hnefatafl --since '7 days ago' -o cat | grep -c 'Started hnefatafl')" ERR=$(journalctl -u hnefatafl --since '7 days ago' -p err -o cat | grep -vc '^$') echo "journal errors in 7 days: $ERR" -journalctl -u hnefatafl --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | grep -v "Rate" | tail -3 | sed 's/^/ /' +journalctl -u hnefatafl --since '7 days ago' -o cat | grep -iE "error|unhandled|exception" | tail -3 | sed 's/^/ /' echo "--- rollup, last 7 days (people / requests / rooms opened / turns)" if [ -s /var/lib/hnefatafl/rollup.jsonl ]; then tail -7 /var/lib/hnefatafl/rollup.jsonl | python3 -c ' diff --git a/deploy/hnefatafl-rollup.sh b/deploy/hnefatafl-rollup.sh index d81ffe8..a3a6a07 100755 --- a/deploy/hnefatafl-rollup.sh +++ b/deploy/hnefatafl-rollup.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Nightly rollup: one JSON line per day in /var/lib/hnefatafl/rollup.jsonl: # the day's traffic from Caddy's access log (people and bots apart, by page, # with referrers and the campaign tags links carry), the rooms opened, @@ -24,45 +24,13 @@ checkin() { mkdir -p "$(dirname "$LOG")" checkin in_progress if python3 - "$DAY" "$OUT" <<'PY' >> "$LOG" 2>&1 -import collections, datetime, glob, gzip, json, os, re, shutil, sys +import datetime, glob, json, os, shutil, sys +sys.path.insert(0, "/usr/local/lib/hnefatafl") +import traffic day, out = sys.argv[1], sys.argv[2] d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() -BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) - -req = human = bot = 0 -hips, bips = set(), set() -pages, joins, refs, campaigns = collections.Counter(), collections.Counter(), collections.Counter(), collections.Counter() -seen_campaign = set() -for f in sorted(glob.glob("/var/lib/caddy/access*.log*")): - if os.path.getmtime(f) < t0: continue - opener = gzip.open if f.endswith(".gz") else open - with opener(f, "rt", errors="ignore") as fh: - for line in fh: - try: r = json.loads(line) - except ValueError: continue - ts = r.get("ts", 0) - if not (t0 <= ts < t1): continue - q = r.get("request", {}); h = q.get("headers", {}) - ua = " ".join(h.get("User-Agent", [""])) - ip = q.get("client_ip") or q.get("remote_ip") or "?" - uri = q.get("uri", ""); path = uri.split("?")[0] - req += 1 - if BOT.search(ua) or not ua: - bot += 1; bips.add(ip); continue - if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): continue - human += 1; hips.add(ip) - if path == "/": pages["hall"] += 1 - elif path == "/rules": pages["rules"] += 1 - elif path == "/guide": pages["guide"] += 1 - elif path.startswith("/join/"): joins[path.split("/")[2].upper()] += 1 - ref = " ".join(h.get("Referer", [""])) - if ref and "tafl.kestrelsnest.social" not in ref: - refs[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 - m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) - if m and (ip, m.group(1)) not in seen_campaign: - seen_campaign.add((ip, m.group(1))) - campaigns[m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1 +t = traffic.scan(day) created = started = finished = turned = turns = 0 humans, bots = set(), 0 @@ -80,20 +48,18 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"): if x["bot"]: bots += 1 else: humans.add(x["name"]) elif x["t"] == "unseat": bots -= 1 - day_turns = [x for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1] - turns += len(day_turns) - # A game finished today: its last turn is today's and the engine would say so; approximate by replay-free means: - # the ledger's final turn falls today and no turn follows it within the day's end. - if day_turns and day_turns[-1] is lines[-1] and False: finished += 1 + turns += sum(1 for x in lines if x["t"] == "turn" and t0 <= x["at"] / 1000 < t1) + # The over line is written with the turn that ends the game, so a finish counts on its own day. + if any(x["t"] == "over" and t0 <= x["at"] / 1000 < t1 for x in lines): finished += 1 disk = shutil.disk_usage("/") mem = {} for line in open("/proc/meminfo"): k, v = line.split(":", 1); mem[k] = int(v.split()[0]) load = os.getloadavg()[1] -row = dict(day=day, requests=req, human=human, bot=bot, humanAddresses=len(hips), botAddresses=len(bips), - paths=dict(pages), roomLinks=sum(joins.values()), referrers=dict(refs), campaigns=dict(campaigns), - rooms=dict(created=created, started=started, withTurns=turned, humanNames=len(humans), botSeats=bots), +row = dict(day=day, requests=t["requests"], human=t["human"], bot=t["bot"], humanAddresses=len(t["addresses"]), botAddresses=len(t["botAddresses"]), + paths=dict(t["pages"]), roomLinks=sum(t["joins"].values()), referrers=dict(t["referrers"]), campaigns=dict(t["campaigns"]), + rooms=dict(created=created, started=started, withTurns=turned, finished=finished, humanNames=len(humans), botSeats=bots), turns=turns, ledgers=len(glob.glob("/var/lib/hnefatafl/rooms/*.jsonl")), vitals=dict(diskUsedPct=round(disk.used * 100 / disk.total), memAvailableMb=mem.get("MemAvailable", 0) // 1024, load5=round(load, 2)), at=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")) @@ -105,6 +71,6 @@ rows.sort(key=lambda r: r["day"]) with open(out + ".tmp", "w") as fh: for r in rows: fh.write(json.dumps(r) + "\n") os.replace(out + ".tmp", out) -print("%s rolled: %d people, %d requests, %d rooms" % (day, len(hips), human, created)) +print("%s rolled: %d people, %d requests, %d rooms" % (day, len(t["addresses"]), t["human"], created)) PY then checkin ok; else checkin error; fi diff --git a/deploy/visitors.sh b/deploy/hnefatafl-visitors.sh similarity index 59% rename from deploy/visitors.sh rename to deploy/hnefatafl-visitors.sh index 036b1b2..9d9d17e 100755 --- a/deploy/visitors.sh +++ b/deploy/hnefatafl-visitors.sh @@ -1,13 +1,14 @@ -#!/bin/bash +#!/usr/bin/env bash # The visitors digest: who is at the table right now and who came today, # the rooms opened and how far each got, with today's traffic so far. -# Runs ON the droplet: visitors.sh [YYYY-MM-DD] (default: today, UTC) +# Runs ON the droplet: hnefatafl-visitors.sh [YYYY-MM-DD] (default: today, UTC) # Every game, bot games included, is a room with a ledger, so all show in full. set -u DAY="${1:-$(date -u +%F)}" python3 - "$DAY" <<'PY' -import datetime, glob, json, re, subprocess, sys -from collections import Counter +import datetime, glob, json, subprocess, sys +sys.path.insert(0, "/usr/local/lib/hnefatafl") +import traffic day = sys.argv[1] now = datetime.datetime.now(datetime.timezone.utc) @@ -24,50 +25,10 @@ print("live sockets: %s | rooms touched in the last hour: %s" % ( sh("find /var/lib/hnefatafl/rooms -name '*.jsonl' -mmin -60 2>/dev/null | wc -l"))) # --- traffic: Caddy's access log, people and bots apart --- -BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|preview|fetch|curl|python|go-http|headless|scan|monitor|uptime", re.I) -PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"} -addresses, bots = set(), set() -pages, joins, referrers, campaigns, agents = Counter(), Counter(), Counter(), Counter(), Counter() -requests = 0 -for path in sorted(glob.glob("/var/lib/caddy/access.log*")): - try: - with open(path) as f: - for line in f: - try: - x = json.loads(line) - except Exception: - continue - ts = datetime.datetime.fromtimestamp(x["ts"], datetime.timezone.utc) - if ts.strftime("%F") != day: - continue - req = x["request"] - ua = " ".join(req.get("headers", {}).get("User-Agent", [""])) - ip = req.get("remote_ip") or req.get("client_ip", "?") - uri = req.get("uri", "") - path_only = uri.split("?")[0] - if BOT.search(ua) or not ua: - bots.add(ip) - continue - if path_only.startswith("/_app/") or path_only.startswith("/api/") or path_only == "/ws" or path_only.endswith((".png", ".ico", ".txt")): - continue - requests += 1 - addresses.add(ip) - if path_only in PAGES: - pages[PAGES[path_only]] += 1 - elif path_only.startswith("/join/"): - joins[path_only.split("/")[2].upper()] += 1 - ref = " ".join(req.get("headers", {}).get("Referer", [""])) - if ref and "tafl.kestrelsnest.social" not in ref: - referrers[re.sub(r"^https?://", "", ref).split("/")[0]] += 1 - m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) - if m: - campaigns[m.group(1) + "=" + m.group(2)[:24]] += 1 - agents[re.sub(r"\(.*?\)", "", ua)[:40].strip()] += 1 - except OSError: - continue +t = traffic.scan(day) print("traffic: %d addresses of people, %d page requests (%d bot addresses); hall %d, rules %d, guide %d, room links %s; referrers %s; campaigns %s" % ( - len(addresses), requests, len(bots), pages["hall"], pages["rules"], pages["guide"], - dict(joins) or "none", dict(referrers) or "none", dict(campaigns) or "none")) + len(t["addresses"]), t["human"], len(t["botAddresses"]), t["pages"]["hall"], t["pages"]["rules"], t["pages"]["guide"], + dict(t["joins"]) or "none", dict(t["referrers"]) or "none", dict(t["campaigns"]) or "none")) # --- rooms: every ledger, today's in detail --- first_seen = {} @@ -87,7 +48,7 @@ for f in glob.glob("/var/lib/hnefatafl/rooms/*.jsonl"): (bots_seated if x["bot"] else humans).append(x["name"]) names[x["id"]] = x["name"] elif x["t"] == "unseat": - bots_seated.remove(names.pop(x["id"], None)) if x["id"] in names else None + if x["id"] in names: bots_seated.remove(names.pop(x["id"])) elif x["t"] == "start": started = True elif x["t"] == "turn": diff --git a/deploy/hnefatafl.service b/deploy/hnefatafl.service index 1279ce5..3f2f241 100644 --- a/deploy/hnefatafl.service +++ b/deploy/hnefatafl.service @@ -12,7 +12,7 @@ Environment=KEEPER_TZ=America/New_York # Caddy terminates TLS; the plaintext port must not face the internet. Environment=HOST=127.0.0.1 Environment=DATA_DIR=/var/lib/hnefatafl/rooms -# Create the Sentry project (the Sentry MCP can) and paste its DSN here; empty means no error reporting. +# Empty means no error reporting. Environment=SENTRY_DSN=https://2e3589c41ea45a58049740d3af64face@o4509525984149504.ingest.us.sentry.io/4512136791130112 ExecStart=/opt/hnefatafl/app/server/node_modules/.bin/tsx src/index.ts Restart=always diff --git a/deploy/pull-reports.sh b/deploy/pull-reports.sh index a5b00e7..04b6027 100755 --- a/deploy/pull-reports.sh +++ b/deploy/pull-reports.sh @@ -1,6 +1,7 @@ -#!/bin/bash +#!/usr/bin/env bash # Mirror the player reports, the keeper's replies and the players' screenshots -# to a local folder, and write a digest beside them for reading. +# to a local folder, and write a digest beside them for reading. Run from the +# repository root: # deploy/pull-reports.sh [host] [folder] (default ~/Desktop/hnefatafl-reports) set -euo pipefail HOST="${1:-209.97.148.244}" @@ -8,34 +9,4 @@ OUT="${2:-$HOME/Desktop/hnefatafl-reports}" mkdir -p "$OUT/images" scp -q "root@$HOST:/var/lib/hnefatafl/feedback.jsonl" "$OUT/feedback.jsonl" 2>/dev/null || : > "$OUT/feedback.jsonl" rsync -aq "root@$HOST:/var/lib/hnefatafl/feedback-images/" "$OUT/images/" 2>/dev/null || true -python3 - "$OUT" <<'PY' -import json, sys, os -out = sys.argv[1] -reports, order = {}, [] -for raw in open(os.path.join(out, "feedback.jsonl"), encoding="utf8"): - raw = raw.strip() - if not raw: continue - line = json.loads(raw) - if "reportId" in line: - r = reports.get(line["reportId"]) - if not r: continue - if "image" in line: r["image"] = line["image"] - else: r["replies"].append(line) # the keeper's replies and the player's answers alike, in order - continue - reports[line["id"]] = dict(line, replies=[]); order.append(line["id"]) -lines = ["# Hnefatafl reports", "", f"{len(order)} reports; newest first. Pictures are in `images/`.", ""] -for rid in reversed(order): - r = reports[rid] - lines.append(f"## {r.get('at','')[:16].replace('T',' ')} — {r.get('player','?')} in {r.get('roomId','?')} (turn {r.get('turn','?')}, seq {r.get('seq','?')}) — id {rid}") - lines.append("") - lines.append(f"**What happened:** {r.get('happened','').strip()}") - if r.get("expected","").strip(): lines.append(f"**What they expected:** {r['expected'].strip()}") - if r.get("image"): lines.append(f"**Picture:** ![{r['image']}](images/{r['image']})") - for rep in r["replies"]: - who = f"{r.get('player','the player')} answers" if rep.get("from") == "player" else f"**{rep.get('status','')}**" - lines.append(f"> {who} ({rep.get('at','')[:16].replace('T',' ')}): {rep.get('text','').strip()}") - if not r["replies"] or r["replies"][-1].get("from") == "player": lines.append("> _awaiting the keeper_") - lines.append("") -open(os.path.join(out, "reports.md"), "w", encoding="utf8").write("\n".join(lines)) -print(f"{len(order)} reports, {sum(1 for r in reports.values() if r.get('image'))} with pictures -> {out}/reports.md") -PY +npx tsx deploy/report-digest.ts "$OUT" diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts index 0b56fdf..ef9cb42 100644 --- a/deploy/replay-ledgers.ts +++ b/deploy/replay-ledgers.ts @@ -40,7 +40,7 @@ async function main(): Promise { for (const line of lines) { if (line.t === 'seat') seats.push(line); else if (line.t === 'unseat') seats.splice(seats.findIndex((s) => s.id === line.id), 1); - else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, options); + else if (line.t === 'start') state = game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, options); else if (line.t === 'turn' && state) { state = game.resolve(state, line.inputs); turns += 1; diff --git a/deploy/report-digest.ts b/deploy/report-digest.ts new file mode 100644 index 0000000..640a0c3 --- /dev/null +++ b/deploy/report-digest.ts @@ -0,0 +1,32 @@ +// The reports digest: every report with its exchange, newest first, written +// as reports.md beside a mirrored feedback.jsonl. Used by deploy/pull-reports.sh. +// tsx deploy/report-digest.ts + +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Reports } from '../server/src/reports'; + +const dir = process.argv[2]; +if (!dir) { + console.error('usage: report-digest.ts '); + process.exit(2); +} + +const when = (iso: string) => iso.slice(0, 16).replace('T', ' '); +const reports = new Reports(dir).read().reverse(); +const lines = ['# Hnefatafl reports', '', `${reports.length} reports; newest first. Pictures are in \`images/\`.`, '']; +for (const r of reports) { + lines.push(`## ${when(r.at)} — ${r.player} in ${r.roomId} (turn ${r.turn ?? '?'}, seq ${r.seq}) — id ${r.id}`, ''); + lines.push(`**What happened:** ${r.happened.trim()}`); + if (r.expected.trim()) lines.push(`**What they expected:** ${r.expected.trim()}`); + if (r.image) lines.push(`**Picture:** ![${r.image}](images/${r.image})`); + for (const line of r.thread) { + const who = line.from === 'player' ? `${r.player} answers` : `**${line.status}**`; + lines.push(`> ${who} (${when(line.at)}): ${line.text.trim()}`); + } + const last = r.thread[r.thread.length - 1]; + if (!last || last.from === 'player') lines.push('> _awaiting the keeper_'); + lines.push(''); +} +writeFileSync(join(dir, 'reports.md'), lines.join('\n')); +console.log(`${reports.length} reports, ${reports.filter((r) => r.image).length} with pictures -> ${join(dir, 'reports.md')}`); diff --git a/deploy/report-reply.sh b/deploy/report-reply.sh index d06805d..6d2029e 100755 --- a/deploy/report-reply.sh +++ b/deploy/report-reply.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Answer a player's report; the reply appears under it in their hall. # deploy/report-reply.sh set -euo pipefail diff --git a/deploy/sentry-slack-alert.sh b/deploy/sentry-slack-alert.sh index 5cbc629..72e8c50 100755 --- a/deploy/sentry-slack-alert.sh +++ b/deploy/sentry-slack-alert.sh @@ -1,10 +1,10 @@ -#!/bin/bash +#!/usr/bin/env bash # Route every Hnefatafl issue to a Slack channel: new issues (any level or # priority), regressions, and reappearances. Creates one Sentry workflow # via the alerts API. The token never leaves your shell: -# SENTRY_TOKEN=sntryu_... deploy/sentry-slack-alert.sh [#channel] -# The channel defaults to #hnefatafl-notifications (__SLACK_CHANNEL_ID__); pass -# SLACK_CHANNEL_ID with the channel name to route elsewhere. +# SENTRY_TOKEN=sntryu_... SLACK_CHANNEL_ID=C... deploy/sentry-slack-alert.sh [#channel] +# SLACK_CHANNEL_ID is the channel's Slack ID; the name defaults to +# #hnefatafl-notifications and is shown in Sentry beside the ID. # The token needs the alerts:write scope (an org auth token from # https://locallygrownnet.sentry.io/settings/auth-tokens/), and Slack must # already be connected to the org under Settings → Integrations. @@ -20,7 +20,8 @@ AUTH="Authorization: Bearer $SENTRY_TOKEN" # project's error detector is looked up by project id; a workflow is bound # to its detectors by PUT after creation. SLACK_ID="${SENTRY_SLACK_INTEGRATION:-345334}" -CHANNEL_ID="${SLACK_CHANNEL_ID:-__SLACK_CHANNEL_ID__}" +: "${SLACK_CHANNEL_ID:?SLACK_CHANNEL_ID=C... is required (the Slack ID of the channel)}" +CHANNEL_ID="$SLACK_CHANNEL_ID" PROJECT_ID="${SENTRY_PROJECT_ID:-4512136791130112}" echo "Slack integration $SLACK_ID; project $PROJECT_ID; channel $CHANNEL ($CHANNEL_ID)" diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index c600a89..3299a37 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -4,8 +4,8 @@ # bash setup-droplet.sh 'tafl.kestrelsnest.social, hnefatafl..sslip.io' # The argument is the Caddy site address line: one name, or several # separated by commas. Every name must already resolve to this droplet. -# Hnefatafl is a static site: Caddy serves the built files and terminates -# TLS. There is no application process to install or supervise. +# Caddy serves the built files and terminates TLS; setup-server.sh adds the +# game server behind it. set -euo pipefail HOST="${1:?usage: setup-droplet.sh }" diff --git a/deploy/traffic.py b/deploy/traffic.py new file mode 100644 index 0000000..633f4d5 --- /dev/null +++ b/deploy/traffic.py @@ -0,0 +1,67 @@ +"""Caddy's access log for one day, people and bots apart. The visitors +digest and the nightly rollup both count through here, so they agree.""" + +import datetime +import glob +import gzip +import json +import os +import re +from collections import Counter + +BOT = re.compile(r"bot|crawl|spider|slurp|facebookexternalhit|slack|discord|twitter|preview|fetch|curl|python|go-http|wget|headless|scan|monitor|uptime", re.I) +PAGES = {"/": "hall", "/rules": "rules", "/guide": "guide"} +SITE = "tafl.kestrelsnest.social" +LOGS = "/var/lib/caddy/access*.log*" + + +def scan(day, logs=LOGS): + """Counts for one UTC day (YYYY-MM-DD): every request, page requests by + people, the addresses of people and of bots, requests by page, room links + opened, referrers, and campaign tags (each counted once per address).""" + d0 = datetime.datetime.fromisoformat(day).replace(tzinfo=datetime.timezone.utc) + t0, t1 = d0.timestamp(), (d0 + datetime.timedelta(days=1)).timestamp() + t = dict(requests=0, human=0, bot=0, addresses=set(), botAddresses=set(), pages=Counter(), joins=Counter(), referrers=Counter(), campaigns=Counter()) + seen_campaign = set() + for f in sorted(glob.glob(logs)): + if os.path.getmtime(f) < t0: + continue + opener = gzip.open if f.endswith(".gz") else open + try: + with opener(f, "rt", errors="ignore") as fh: + for line in fh: + try: + r = json.loads(line) + except ValueError: + continue + if not (t0 <= r.get("ts", 0) < t1): + continue + q = r.get("request", {}) + h = q.get("headers", {}) + ua = " ".join(h.get("User-Agent", [""])) + ip = q.get("client_ip") or q.get("remote_ip") or "?" + uri = q.get("uri", "") + path = uri.split("?")[0] + t["requests"] += 1 + if BOT.search(ua) or not ua: + t["bot"] += 1 + t["botAddresses"].add(ip) + continue + if path.startswith(("/_app/", "/api/")) or path == "/ws" or path.endswith((".png", ".ico", ".txt")): + continue + t["human"] += 1 + t["addresses"].add(ip) + if path in PAGES: + t["pages"][PAGES[path]] += 1 + elif path.startswith("/join/"): + t["joins"][path.split("/")[2].upper()] += 1 + ref = " ".join(h.get("Referer", [""])) + if ref and SITE not in ref: + t["referrers"][re.sub(r"^https?://", "", ref).split("/")[0]] += 1 + m = re.search(r"[?&](ref|utm_source|fbclid)=([^&]*)", uri) + if m and (ip, m.group(1)) not in seen_campaign: + seen_campaign.add((ip, m.group(1))) + t["campaigns"][m.group(1) + ("=" + m.group(2)[:24] if m.group(1) != "fbclid" else "")] += 1 + except OSError: + continue + return t diff --git a/deploy/verify-ledgers.sh b/deploy/verify-ledgers.sh index adef66c..a347432 100755 --- a/deploy/verify-ledgers.sh +++ b/deploy/verify-ledgers.sh @@ -6,7 +6,7 @@ # deploy/verify-ledgers.sh [https://site] set -euo pipefail HOST="${1:?usage: verify-ledgers.sh [https://site]}" -# SITE in the environment overrides the name, for a machine whose DNS has not caught up with a new record. +# SITE overrides the public name: the sslip.io address, or a name not yet in DNS. SITE="${2:-${SITE:-https://tafl.kestrelsnest.social}}" DIR="$(mktemp -d)" trap 'rm -rf "$DIR"' EXIT diff --git a/docs/artwork.md b/docs/artwork.md index a7856e2..1e6772b 100644 --- a/docs/artwork.md +++ b/docs/artwork.md @@ -2,8 +2,8 @@ `src/lib/components/BoardArtwork.svelte` owns both palettes, surface filters, wear, grid, inlays and counters. `Board.svelte` supplies interactive state; -`Hall.svelte` supplies an opening position from the game engine. Update the -shared component to change either illustration and the game together. +`Hall.svelte` supplies an opening position from the game engine. Edit the +component and the game, the hall's still and the share card change together. After editing the artwork, run: @@ -15,5 +15,8 @@ This renders the same Svelte component into `static/board-copenhagen.svg` and `static/board-tablut.svg`, then regenerates `docs/og-card.svg` and the 1200 × 630 `static/og.png`. Preview the share composition in `docs/og-card.html`. The share composition and copy live in `docs/render-art.mjs`. Commit generated -assets with source changes. The export uses Sharp as a development dependency; -it does not add any image-processing code to the shipped app. +assets with source changes. + +`docs/icon.html` draws the king's counter on the oak for the icons: +`static/favicon.svg` is its drawing, and `static/apple-touch-icon.png` is the +page screenshotted at 180 px. diff --git a/docs/conventions.md b/docs/conventions.md index a88f95d..a8c5fd1 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -68,6 +68,17 @@ README. variant, a side, a length): the game declares them in `GameSpec.options`, the hall renders them as selects, the room line records them, and `create` receives them. A game with one way to play declares none. +- **Preferences** are what a browser remembers about its player: a panel + opened from *preferences* in the hall and the masthead, kept in + localStorage and nowhere else. The kit's own settings are motion (full or + reduced, and the system's reduced-motion request counts too) and + confirming a move before it is sent; a game declares more in + `GameSpec.preferences` and the panel renders them. The hall also + remembers the host's last table options as the next table's defaults. +- **The tally** sits in the about panel: what the ledgers add up to + (tables, games begun and finished, names seated, turns, time at the + table, games with a bot, talk), counted on demand from the files and + cached a minute. A game adds its own lines through `GameSpec.tally`. ## The engine diff --git a/docs/og-card.svg b/docs/og-card.svg index 349cbda..6d2241a 100644 --- a/docs/og-card.svg +++ b/docs/og-card.svg @@ -1,6 +1,6 @@ - + Hnefatafl The Viking board game. diff --git a/docs/render-art.mjs b/docs/render-art.mjs index deaf2bd..18a5760 100644 --- a/docs/render-art.mjs +++ b/docs/render-art.mjs @@ -6,6 +6,9 @@ import sharp from 'sharp'; import { createGame } from '../src/lib/game/index.ts'; const root = new URL('../', import.meta.url); const source = await readFile(new URL('src/lib/components/BoardArtwork.svelte', root), 'utf8'); +const app = await readFile(new URL('src/app.css', root), 'utf8'); +/** The site's design tokens, from the :root block of app.css. */ +const tokens = Object.fromEntries([...app.slice(0, app.indexOf('}')).matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(m => [m[1], m[2]])); const compiled = compile(source.replace("'$lib/game'", "'../src/lib/game/index.ts'"), { generate: 'server', css: 'injected', filename: 'BoardArtwork.svelte' }); const temporary = new URL(`.board-art-${process.pid}.mjs`, import.meta.url); try { @@ -21,10 +24,8 @@ try { const paletteSource = source.slice(source.indexOf('/* Copenhagen:')); const base = paletteSource.slice(0, paletteSource.indexOf('/* Tablut:')); const overrides = set === 'tablut' ? paletteSource.slice(paletteSource.indexOf('/* Tablut:'), paletteSource.indexOf('\n\t.artwork {')) : ''; - const values = Object.fromEntries([...`${base}\n${overrides}`.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(m => [m[1], m[2]])); + const values = { ...tokens, ...Object.fromEntries([...`${base}\n${overrides}`.matchAll(/(--[\w-]+):\s*([^;]+);/g)].map(m => [m[1], m[2]])) }; values['--piece-rim'] = values['--defender-rim']; - values['--font'] = 'Georgia, serif'; - values['--frost'] = '#9ccbdd'; svg = svg.replace(/:where\((\.[\w-]+)\)/g, '$1'); svg = svg.replace(/var\((--[\w-]+)\)/g, (_, key) => values[key] ?? 'inherit'); svg = svg.replace('', ['attacker', 'defender', 'king'].map(kind => `.piece.${kind} .base{fill:${values[`--${kind}-rim`]}}.piece.${kind} .body,.piece.${kind} .turned{stroke:${values[`--${kind}-rim`]}}`).join('') + ''); diff --git a/server/src/index.ts b/server/src/index.ts index 31ed8de..35921cd 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,18 +2,24 @@ // websocket only to say "something changed, fetch the view again". // // POST /api/rooms {name, size?, options?} create a room and take seat A; options are the game's table choices +// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins -// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins -// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view +// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/turn {token, input} this seat's move -// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room +// POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room; +// {name, text} or from the gallery, signed, when the host allows it +// POST /api/rooms/:id/gallery {token, on} the host lets the Peanut Gallery talk, or hushes it +// POST /api/rooms/:id/challenge {token} call the keeper to a held seat; their phone rings +// POST /api/rooms/:id/rematch {token} a finished table opens the next one +// POST /api/rooms/:id/transfer {token} mint a phrase that carries this seat to another device +// POST /api/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 a screenshot for a report just filed +// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies // GET /api/tally what the ledgers add up to, for the hall -// POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // a seat in the Peanut Gallery, counted for the table // @@ -48,10 +54,11 @@ process.on('unhandledRejection', (reason) => { Sentry.captureException(reason); }); -const rooms = new Rooms(game, new Store(DATA_DIR), { name: KEEPER, zone: KEEPER_TZ }); +const store = new Store(DATA_DIR); +const rooms = new Rooms(game, store, { name: KEEPER, zone: KEEPER_TZ }); /** Reports live beside the room ledgers, not among them. */ const reports = new Reports(dirname(DATA_DIR)); -const tallies = new Tallies(game, new Store(DATA_DIR)); +const tallies = new Tallies(game, store); /** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */ const doors = new RateLimit(40, 60 * 60 * 1000); /** Table talk: a lively table says a few lines a minute, not hundreds. */ @@ -305,7 +312,6 @@ server.on('upgrade', (req, socket, head) => { }; const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); ws.on('close', unsubscribe); - ws.send(JSON.stringify({ type: 'hello', seat: seatId })); }); }); diff --git a/server/src/reports.ts b/server/src/reports.ts index 4a36369..81996b5 100644 --- a/server/src/reports.ts +++ b/server/src/reports.ts @@ -7,39 +7,15 @@ import { randomBytes } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { SeatId } from '../../src/lib/game/spec'; +import type { Report } from '../../src/lib/net/view'; import { RoomError, type Room, type Seat } from './rooms'; -export const TEXT_MAX = 2000; +const TEXT_MAX = 2000; export const IMAGE_MAX_BYTES = 2_500_000; /** How long after filing a picture may still be attached. */ const IMAGE_WINDOW_MS = 60 * 60 * 1000; -export interface ReportLine { - at: string; - text: string; - from: 'desk' | 'player'; - status?: string; -} - -export interface Report { - id: string; - at: string; - roomId: string; - /** The reporter's name, or "(gallery)" for a watcher. */ - player: string; - seat: SeatId | null; - /** The round being written when the report was filed, and the ledger's length. */ - turn: number | null; - seq: number; - happened: string; - expected: string; - /** The whole exchange in order: the keeper's replies and the player's answers. */ - thread: ReportLine[]; - /** A screenshot's file name under images/, when one was sent. */ - image?: string; -} - -export function cleanText(raw: unknown): string { +function cleanText(raw: unknown): string { return String(raw ?? '') .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ') .trim() diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 84230cc..e5e95f3 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -22,7 +22,7 @@ export interface Seat { bot: boolean; } -export function hashToken(token: string): string { +function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); } @@ -88,11 +88,11 @@ const TRANSFER_WORDS = [ const TRANSFER_TTL_MS = 10 * 60 * 1000; const TRANSFER_FAIL_LIMIT = 20; -export function normalizeCode(raw: string): string { +function normalizeCode(raw: string): string { return raw.trim().toUpperCase(); } -export function cleanName(raw: unknown): string { +function cleanName(raw: unknown): string { const name = String(raw ?? '') .replace(/\s+/g, ' ') .trim() @@ -106,6 +106,10 @@ export class Rooms { private listeners = new Map) => void>>(); /** Sockets watching from the gallery, by room. */ private gallery = new Map(); + /** Phrases waiting to be claimed, with the seat and the token they carry. */ + private transfers = new Map(); + private failedClaims = 0; + private failWindowStart = 0; constructor( private game: GameSpec, @@ -258,11 +262,6 @@ export class Rooms { return { roomId: next.room.id, seat: next.seat, token: next.token, created: true }; } - /** Phrases waiting to be claimed, with the seat and the token they carry. */ - private transfers = new Map(); - private failedClaims = 0; - private failWindowStart = 0; - /** Mint a phrase that brings this seat to another device: four words, ten minutes, one use. */ transfer(room: Room, token: string | undefined): { phrase: string; expiresAt: number } { const seat = this.seatOf(room, token); @@ -474,7 +473,7 @@ export class Rooms { room.over = true; break; case 'seat': - room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot }); + room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash, bot: line.bot }); break; case 'galleryTalk': room.galleryTalk = line.on; @@ -493,7 +492,7 @@ export class Rooms { room.seats = room.seats.filter((s) => s.id !== line.id); break; case 'start': - room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, room.options); + room.state = this.game.create(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules, room.options); break; case 'turn': if (!room.state) return; diff --git a/server/src/store.ts b/server/src/store.ts index e90af79..cc57110 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -4,19 +4,19 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import type { SeatId } from '../../src/lib/game/spec'; +import type { SeatId, TableOptions } from '../../src/lib/game/spec'; export type LedgerLine = /** `rematchOf` and `expected` mark a table opened for a rematch: whose it follows, and whose seats are held. */ - | { t: 'room'; id: string; seats: number; createdAt: number; rematchOf?: string; expected?: string[]; options?: Record } - /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */ - | { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean } + | { 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 } /** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */ | { t: 'input'; at: number; id: SeatId; input: unknown } /** A bot sent away by the host before the game began; its seat id is free again. */ | { t: 'unseat'; id: SeatId } - /** Ledgers from before revisions were recorded resolve under rules 1. */ - | { t: 'start'; seed: number; rules?: number } + /** The seed and the rules revision the game began under; a later fix keeps this revision's path for it. */ + | { t: 'start'; seed: number; rules: number } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the game, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: SeatId | null; reason: string } diff --git a/server/src/tally.ts b/server/src/tally.ts index b038885..51922ca 100644 --- a/server/src/tally.ts +++ b/server/src/tally.ts @@ -4,25 +4,9 @@ // the kit's; a game may add its own lines through GameSpec.tally. import type { GameSpec, SeatId } from '../../src/lib/game/spec'; +import type { Tally } from '../../src/lib/net/view'; import type { LedgerLine, Store } from './store'; -export interface Tally { - tablesOpened: number; - gamesBegun: number; - gamesFinished: number; - /** Distinct names of people who have taken a seat; a bot's name is not counted. */ - namesSeated: number; - turnsPlayed: number; - /** Minutes at the table: gaps under ten minutes between one move and the next, summed. */ - minutesAtTable: number; - longestGameTurns: number; - gamesWithBot: number; - talkLines: number; - firstGameAt: string | null; - /** The game's own lines, summed over finished games. */ - byGame: Record; -} - const GAP_LIMIT_MS = 10 * 60 * 1000; const CACHE_MS = 60 * 1000; @@ -66,7 +50,7 @@ export class Tallies { if (hasBot) t.gamesWithBot += 1; if (lines[0].createdAt < firstStart) firstStart = lines[0].createdAt; try { - state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1, lines[0].options); + state = this.game.create(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules, lines[0].options); } catch { state = null; } diff --git a/server/tsconfig.json b/server/tsconfig.json index c88f1f1..8ea97bd 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -15,11 +15,10 @@ "src/**/*.ts", "../src/lib/game/**/*.ts", "../src/lib/net/view.ts", - "../deploy/replay-ledgers.ts" + "../deploy/replay-ledgers.ts", + "../deploy/report-digest.ts" ], "exclude": [ - "../src/lib/game/*.test.ts", - "../src/lib/game/*.svelte.ts", - "../src/lib/game/test-helpers.ts" + "../src/lib/game/*.test.ts" ] } diff --git a/src/app.css b/src/app.css index 848f4d2..e795ef1 100644 --- a/src/app.css +++ b/src/app.css @@ -4,7 +4,6 @@ /* Sea-dark iron, driftwood bone, a coal ember, and northern ice. */ --slate: #1f2426; --slate-deep: #14181a; - --slate-raised: #2a3134; --bone: #e6dfcf; --bone-dim: #9ea59e; --bone-faint: #626b66; @@ -94,6 +93,31 @@ button:disabled { text-decoration: underline; } +/* The same in the running colour, underlined only under the pointer: a rail of moves. */ +.link.plain { + color: inherit; + text-decoration: none; + text-align: left; +} + +.link.plain:hover { + text-decoration: underline; +} + +/* The × that sends a housecarl away or forgets a game. */ +.dismiss { + background: none; + border: 0; + color: var(--bone-faint); + font-size: 1.1rem; + padding: 0 0.4rem; + line-height: 1; +} + +.dismiss:hover { + color: var(--blood); +} + /* The one button that commits a move. */ .commit { background: var(--bone); @@ -134,6 +158,10 @@ button:disabled { color: var(--bone-dim); } +.small { + font-size: 0.85rem; +} + .warning { color: var(--blood); } @@ -151,6 +179,42 @@ button:disabled { height: 1px; overflow: hidden; clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* A page of reading beside the game: the rules, the guide. */ +.reading { + max-width: 46rem; + margin: 0 auto; + padding: 1.5rem var(--gutter) 4rem; +} + +.reading .back { + color: var(--bone-dim); +} + +.reading h1 { + font-size: 2.2rem; + font-weight: 300; + margin: 0.6rem 0 0.4rem; +} + +.reading .lede { + color: var(--bone-dim); +} + +.reading section { + margin-top: 1.6rem; +} + +.reading h2 { + font-size: 1.3rem; + margin-bottom: 0.4rem; +} + +.reading .word { + margin-top: 2rem; + color: var(--bone-dim); } @media (prefers-reduced-motion: reduce) { diff --git a/src/app.html b/src/app.html index 14fd5f9..31b4cf1 100644 --- a/src/app.html +++ b/src/app.html @@ -4,7 +4,7 @@ - + Hnefatafl diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte index ecefdfa..3104396 100644 --- a/src/lib/components/Board.svelte +++ b/src/lib/components/Board.svelte @@ -4,11 +4,11 @@ // move. Above it, a strip says whose move it is and what the side is // for; beside it, the record of moves, each one a click from being shown // on the board. Copenhagen is a newly crafted oak reenactment set; - // Tablut evokes an excavated stone board and worn counters. This is an - // artistic treatment, not a claim to reconstruct a particular find. + // Tablut evokes an excavated stone board and worn counters. Both looks + // are invented, not reconstructions of any find. import TableTalk from './TableTalk.svelte'; - import BoardArtwork from './BoardArtwork.svelte'; - import { beyond, cellOf, cornersOf, movesFrom, rulesetOf, sideOf, throneOf, type Move, type Side } from '$lib/game'; + import BoardArtwork, { type Token } from './BoardArtwork.svelte'; + import { beyond, cellOf, cornersOf, movesFrom, pieceWord, rulesetOf, sideOf, squareName, throneOf, type Move, type Side } from '$lib/game'; import type { Room } from '$lib/net/room.svelte'; import { prefs, reducedMotion } from '$lib/prefs.svelte'; @@ -35,26 +35,15 @@ const objective = (side: Side) => (side === 'attackers' ? 'Take the king.' : set.escape === 'corner' ? 'Get the crowned king to a corner.' : 'Get the crowned king to any edge square.'); const sideOfPly = (ply: number): Side => (ply % 2 === 1 ? 'attackers' : 'defenders'); - // --- Coordinates --------------------------------------------------------- - const label = (i: number) => { - const c = cellOf(size, i); - return `${String.fromCharCode(97 + c.x)}${size - c.y}`; - }; - const pieceWord = (p: string) => (p === 'k' ? 'the king' : p === 'a' ? 'an attacker' : 'a defender'); + const label = (i: number) => squareName(size, i); // --- Tokens: pieces with a life of their own, so a move slides and a capture fades --- - interface Token { - id: number; - piece: string; - at: number; - dying: boolean; - } let tokens = $state([]); /** How many of the game's moves the tokens have played through. */ let shown = $state(0); let shownSet = $state(''); let animating = $state(false); - const calm = $derived(prefs.motion === 'reduced' || reducedMotion()); + const calm = $derived(reducedMotion()); const showCoords = $derived(prefs.coordinates !== false); const wait = (ms: number) => new Promise((r) => setTimeout(r, calm ? 0 : ms)); @@ -234,7 +223,7 @@ {#if room.error}

{room.error}

{/if}
- +

{liveText}

@@ -266,7 +255,7 @@ {@const ply = g.moves.length - i} {@const side = sideOfPly(ply)}
  • -
  • @@ -350,7 +339,6 @@ margin: 0 auto; } - .how { max-width: 620px; margin: 0.5rem auto 0; @@ -402,8 +390,7 @@ background: #ece4d0; } - .small { - font-size: 0.85rem; + .sides p { margin-top: 0.5rem; } @@ -424,27 +411,4 @@ color: var(--ember); font-weight: 500; } - - .link { - background: none; - border: 0; - padding: 0; - font: inherit; - color: inherit; - cursor: pointer; - text-align: left; - } - - .link:hover { - text-decoration: underline; - } - - .visually-hidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; - } diff --git a/src/lib/components/BoardArtwork.svelte b/src/lib/components/BoardArtwork.svelte index b2a08db..965c697 100644 --- a/src/lib/components/BoardArtwork.svelte +++ b/src/lib/components/BoardArtwork.svelte @@ -1,9 +1,21 @@ + + - - - - - - - - - - - - - - - - - - - - - - - {#each ['attacker', 'defender', 'king'] as kind (kind)} - - - - - - {/each} - - - - {#if worn} - - {:else} - {#each [0.22, 0.51, 0.79] as part (part)} - - {/each} - {/if} - + {/each} + + + diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index c1fed39..b52aad6 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -6,18 +6,16 @@ import { otherGames, type FamilyGame } from '$lib/family'; import Preferences from './Preferences.svelte'; import { prefs, setPref } from '$lib/prefs.svelte'; - import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client'; + import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; - import type { RoomView } from '$lib/net/view'; - import type { State } from '$lib/game'; - import { game, createGame } from '$lib/game'; + import type { Report, RoomView, Tally } from '$lib/net/view'; + import { createGame, game, type State } from '$lib/game'; import BoardArtwork from './BoardArtwork.svelte'; let name = $state(playerName()); let code = $state(''); - /** The host's choices for the next table, from the game's declared options. */ - // A new table starts from the preferences' table defaults; the hall's selectors set the same keys. + /** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */ const options = $derived>( Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default])) ); @@ -85,7 +83,7 @@ } } - // The desk is asked once per visit for every seat this browser holds. + // The desk is asked for every seat this browser holds, again whenever that set changes. $effect(() => { const held = seats; if (!held.length) return; @@ -166,8 +164,8 @@

    Hnefatafl

    The Viking board game. One king, one throne, four corners, and a ring of attackers closing in.

    Hnefatafl was played across the Norse world for a thousand years before chess. Every piece moves like a rook and is taken by being sandwiched; the attackers must capture the king, the king must reach safety. Play a housecarl, or open a table for a friend and play by turns. Copenhagen rules on the big board, or Tablut, the older game Linnaeus wrote down in 1732.

    - - + +
    @@ -179,7 +177,7 @@
    {#each game.options as o (o.key)} {@const chosen = o.choices.find((c) => c.value === options[o.key])} -
    +
    {/each} {#if reports.length} @@ -384,10 +382,6 @@ text-align: center; } - .small { - font-size: 0.85rem; - } - .ways { display: flex; flex-wrap: wrap; @@ -425,7 +419,6 @@ border-radius: 6px; } - /* the games ledger */ .ledger { grid-area: ledger; @@ -486,18 +479,6 @@ color: var(--frost); } - .ledger-forget { - background: none; - border: 0; - color: var(--bone-faint); - font-size: 1.1rem; - padding: 0 0.4rem; - } - - .ledger-forget:hover { - color: var(--blood); - } - .reports-head { margin-top: 0.9rem; } @@ -588,6 +569,7 @@ font-size: 0.85rem; color: var(--bone-faint); } + .family { margin: 0.4rem 0 0.8rem 1.2rem; padding: 0; @@ -596,6 +578,7 @@ .family li + li { margin-top: 0.3rem; } + .options { display: flex; flex-wrap: wrap; @@ -614,6 +597,7 @@ margin: 0.2rem 0 0; max-width: 22em; } + .still { width: 100%; max-width: 18rem; @@ -637,13 +621,4 @@ .tally dd { margin: 0; } - .link-like { - background: none; - border: 0; - padding: 0; - font: inherit; - color: inherit; - text-decoration: underline; - cursor: pointer; - } diff --git a/src/lib/components/Lobby.svelte b/src/lib/components/Lobby.svelte index 1375585..16f0d6a 100644 --- a/src/lib/components/Lobby.svelte +++ b/src/lib/components/Lobby.svelte @@ -2,15 +2,11 @@ import TableTalk from './TableTalk.svelte'; import type { Room } from '$lib/net/room.svelte'; - let { room, link }: { room: Room; link: string } = $props(); + let { room, link, copied, copyLink }: { room: Room; link: string; copied: boolean; copyLink: () => void } = $props(); const v = $derived(room.view); const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host'); - const seatFree = $derived(v.seats.length < v.size); - /** A seat is free for a newcomer once the held ones are counted. */ - const seatOpen = $derived(v.seats.length + v.expected.length < v.size); const keeperSeated = $derived(v.seats.some((s) => s.name.toLowerCase() === v.keeper.toLowerCase())); - let copied = $state(false); /** The hour where the keeper lives, in words, so a caller knows whether to wait. */ let minute = $state(0); @@ -31,22 +27,12 @@ return ''; } }); - - async function copyLink() { - try { - await navigator.clipboard.writeText(link); - copied = true; - setTimeout(() => (copied = false), 1600); - } catch { - // The link is on screen to copy by hand. - } - }
    {#if room.spectating}

    At the table

    - {#if seatFree} + {#if room.seatEmpty}

    {hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.

    {:else}

    The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.

    @@ -62,7 +48,7 @@
  • {s.name} {s.id === v.host ? 'opened the table' : s.bot ? 'a housecarl' : 'seated'}{s.id === v.me ? ', you' : ''}{#if s.bot && room.isHost} - {/if} + {/if}
  • {/each} {#each v.expected as name (name)} @@ -78,7 +64,7 @@ {/if} {#if !room.spectating}
    - {#if seatFree && room.isHost} + {#if room.seatEmpty && room.isHost} {/if} {#if room.isHost} @@ -94,13 +80,13 @@ Let the Peanut Gallery talk at the table {/if} - {#if v.keeper && !v.challenge && !keeperSeated && seatOpen} + {#if v.keeper && !v.challenge && !keeperSeated && room.seatUnheld}
    -

    {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.

    +

    {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.

    {:else if v.challenge} -

    {v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a housecarl, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.

    +

    {v.keeper} has been called to the table by {room.nameOf(v.challenge.by)}.{#if keeperHour} It is {keeperHour} there.{/if} A seat is held: if they can come now they will take it; if they are asleep or away they will leave a word below when they can. Meanwhile the table is yours: seat a housecarl, invite a friend, or open another game in a new tab and play on. This table keeps its place in your hall until you come back.

    {/if} {/if} {#if room.error}

    {room.error}

    {/if} @@ -148,20 +134,6 @@ font-weight: 500; } - .unseat { - background: none; - border: 0; - color: var(--bone-faint); - font-size: 1.1rem; - padding: 0 0.3rem; - margin-left: 0.3rem; - line-height: 1; - } - - .unseat:hover { - color: var(--blood); - } - .ways { display: flex; flex-wrap: wrap; @@ -203,8 +175,7 @@ max-width: 38em; } - .small { - font-size: 0.85rem; + .note { margin-top: 0.4rem; } diff --git a/src/lib/components/Preferences.svelte b/src/lib/components/Preferences.svelte index 952d0ae..bdd2d3f 100644 --- a/src/lib/components/Preferences.svelte +++ b/src/lib/components/Preferences.svelte @@ -1,6 +1,7 @@ Hnefatafl: how to play -
    +
    {#if room}Back to your game · {/if}Back to the hall · The rules

    How to play

    @@ -85,40 +85,11 @@
    diff --git a/src/routes/join/[code]/+page.svelte b/src/routes/join/[code]/+page.svelte index 66f27af..c0fdd48 100644 --- a/src/routes/join/[code]/+page.svelte +++ b/src/routes/join/[code]/+page.svelte @@ -22,10 +22,8 @@ let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let rematching = $state(false); - const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`); + const link = $derived(`${location.origin}/join/${roomId}`); const watching = $derived(room?.spectating ?? false); - /** A watcher may still sit while the table is laid and a seat is free. */ - const seatFree = $derived(!!room && !room.started && room.view.seats.length < room.view.size); // Return to a held seat, or take a place in the gallery and offer one. $effect(() => { @@ -157,12 +155,12 @@

    {/if} -{#if joining && seatFree} +{#if joining && room?.seatEmpty}

    room {roomId}

    Take a seat

    A player has opened this table and is gathering company. Choose a name and sit; the game begins when the host says.

    -
    +