diff --git a/deploy/README.md b/deploy/README.md index 17ec891..9800cc4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,16 +1,18 @@ # Deploying Waving Hands -The game runs entirely in the browser: the duel state lives in local storage -and nothing is rendered or stored on a server. Production is therefore a -single DigitalOcean droplet running only Caddy, which terminates TLS with -automatic certificates and serves the static build from -/opt/waving-hands/build. No Node, no service, no database. +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/waving-hands/rooms. Production is one +DigitalOcean droplet: Caddy terminates TLS with automatic certificates, +serves the static build from /opt/waving-hands/build, and proxies /api and +/ws to the duel server on port 8788, which runs as the `waving-hands` user +under systemd from /opt/waving-hands/app. ## Sizing -The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build -is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to -`s-1vcpu-1gb` only if the site ever gains a server side. +The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy +and the duel 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 @@ -34,7 +36,8 @@ uptime and upgrades independent of anything else. 2. `scp deploy/setup-droplet.sh root@:/root/ && ssh root@ \ "bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands..sslip.io'"` (point the A record at the new IP first, or leave the real name out until it is). -3. `deploy/deploy.sh ` +3. `scp deploy/setup-server.sh root@:/root/ && ssh root@ "bash /root/setup-server.sh"` +4. `deploy/deploy.sh ` The sslip.io hostname works with no DNS at all. To add a real name, point an A record at the droplet and add the name to the first line of @@ -45,13 +48,18 @@ fetches the certificate on first request. deploy/deploy.sh -Runs the type-check, the tests and the build locally, rsyncs `build/` to the -droplet keeping the previous week's hashed assets, and reloads Caddy. A -player mid-duel loses nothing: their game is in their own browser, and the -next page load picks up the new build. +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 duel server. A +single-player game is in the player's own browser and loses nothing. A room +is replayed from its ledger when the server comes back, which takes a few +seconds; Caddy holds requests that land in the gap. ## Operations -- Logs: `ssh root@ journalctl -u caddy -f`, access log in - /var/lib/caddy/access.log -- Nothing to back up on the server. Player duels are in their browsers. +- Logs: `ssh root@ journalctl -u waving-hands -f` for the duel server, + `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side. +- Restart: `ssh root@ systemctl restart waving-hands` +- Rooms: `/var/lib/waving-hands/rooms/.jsonl`, one ledger per duel. + Copy that directory to back them up; single-player games are in players' + browsers. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index c768e8a..f976e26 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -# Build locally and push the static site to the droplet. -# Usage: deploy/deploy.sh +# Build locally, push the static site and the duel server to the droplet, +# and restart the server. Usage: deploy/deploy.sh set -euo pipefail HOST="${1:?usage: deploy.sh }" npm run check +npm run check:server npm test npm run build @@ -12,11 +13,27 @@ npm run build # fetch the module it was built against instead of failing mid-duel. rsync -az --delete --filter='P _app/immutable/*' \ build/ "root@$HOST:/opt/waving-hands/build/" + +# The server runs from source with tsx; it needs the engine, its own code, +# and the dependency manifest. Ledgers live outside this tree and survive. +rsync -az --delete \ + --include='/server/***' --include='/src/' --include='/src/lib/' --include='/src/lib/game/***' \ + --include='/package.json' --include='/package-lock.json' --include='/deploy/' --include='/deploy/waving-hands.service' \ + --exclude='*' \ + ./ "root@$HOST:/opt/waving-hands/app/" + ssh "root@$HOST" ' find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete - chown -R root:caddy /opt/waving-hands - chmod -R g+rX /opt/waving-hands + chown -R root:caddy /opt/waving-hands/build + chmod -R g+rX /opt/waving-hands/build + cd /opt/waving-hands/app && npm install --no-audit --no-fund --omit=optional + chown -R waving-hands:waving-hands /opt/waving-hands/app + cp /opt/waving-hands/app/deploy/waving-hands.service /etc/systemd/system/waving-hands.service + systemctl daemon-reload + systemctl enable --now waving-hands + systemctl restart waving-hands systemctl reload caddy - systemctl --no-pager -l status caddy | head -3 + sleep 1 + systemctl --no-pager -l status waving-hands | head -3 ' echo "deployed." diff --git a/deploy/setup-server.sh b/deploy/setup-server.sh new file mode 100755 index 0000000..8ec2dc5 --- /dev/null +++ b/deploy/setup-server.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Adds the duel server to a droplet that setup-droplet.sh already prepared. +# Run ON the droplet as root; safe to run again. Installs Node 22, creates +# the service user and data directory, and teaches Caddy to hand /api and +# /ws to the server while it keeps serving the static site itself. +set -euo pipefail + +if ! command -v node >/dev/null || [[ "$(node -v)" != v22* ]]; then + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -qy nodejs +fi + +id -u waving-hands &>/dev/null || useradd -r -m -d /opt/waving-hands-home waving-hands +mkdir -p /opt/waving-hands/app /var/lib/waving-hands/rooms +chown -R waving-hands:waving-hands /var/lib/waving-hands + +# Caddy: the two proxied paths go in before the static file handling. +if ! grep -q 'reverse_proxy' /etc/caddy/Caddyfile; then + python3 - <<'PY' +import pathlib +p = pathlib.Path('/etc/caddy/Caddyfile'); s = p.read_text() +marker = 'try_files {path} {path}.html /index.html' +proxy = '''@duel path /api/* /ws +reverse_proxy @duel localhost:8788 { + lb_try_duration 30s + lb_try_interval 250ms +} + +''' +assert marker in s +p.write_text(s.replace(marker, proxy + marker)) +PY + caddy validate --config /etc/caddy/Caddyfile + systemctl reload caddy +fi + +echo "server prerequisites ready: now run deploy/deploy.sh from your machine" diff --git a/deploy/waving-hands.service b/deploy/waving-hands.service new file mode 100644 index 0000000..91a1bf7 --- /dev/null +++ b/deploy/waving-hands.service @@ -0,0 +1,32 @@ +[Unit] +Description=Waving Hands duel server +After=network.target + +[Service] +Type=simple +User=waving-hands +WorkingDirectory=/opt/waving-hands/app +Environment=PORT=8788 +# Caddy terminates TLS; the plaintext port must not face the internet. +Environment=HOST=127.0.0.1 +Environment=WH_DATA_DIR=/var/lib/waving-hands/rooms +ExecStart=/opt/waving-hands/app/node_modules/.bin/tsx server/src/index.ts +Restart=always +RestartSec=3 + +# Sandbox: the process reads /opt/waving-hands and writes only its data dir. +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/var/lib/waving-hands +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +# A runaway process gets killed and restarted before it can take the box down. +MemoryMax=300M +LimitNOFILE=4096 + +[Install] +WantedBy=multi-user.target diff --git a/src/app.css b/src/app.css index 39e05fd..721760f 100644 --- a/src/app.css +++ b/src/app.css @@ -46,7 +46,8 @@ p { } button, -select { +select, +input { font: inherit; color: inherit; } diff --git a/src/lib/components/Hall.svelte b/src/lib/components/Hall.svelte index 663be62..b57f3bc 100644 --- a/src/lib/components/Hall.svelte +++ b/src/lib/components/Hall.svelte @@ -1,16 +1,20 @@ -
-

Duel a friend

-

A duel between people is played by turns, whenever each of you has a moment. Open one and send the link, or join with a code.

-
- -
- or join one - - -
+
+
+

Waving Hands

+

Two wizards. Two hands each. The right run of gestures is a spell.

+

Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn both wizards choose a gesture per hand and reveal them together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or a friend by turns.

+ (aboutOpen = true)}>about this game — a labor of love
- {#if error}

{error}

{/if} - {#if seats.length} +
+ + + {#if local} +

Or start afresh from the board's "New duel".

+ {/if} +
+ +
+ or join one + + +
+
+ {#if error}

{error}

{/if} +
+ + + + {#if seats.length || local}
your duels
+ {#if local} + + {/if} {#each seats as held (held.roomId)} {/if} -
- about this game — a labor of love +
+ about this game
-

Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. Each wizard writes down a gesture for each hand, both reveal at once, and the right run of gestures on one hand is a spell. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's Spellcast brought it to university X terminals in 1993, which is where the maker of this page first met it.

-

This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the rules page. The bot is a heuristic that reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.

-

It is free, keeps no accounts, and stores your single-player duel in your own browser. Duels between people live on a small server as an append-only ledger of moves, so a game can be replayed from its first gesture.

+

Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's Spellcast brought it to university X terminals in 1993, which is where the maker of this page first met it.

+

This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the rules page. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.

+

It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.

diff --git a/src/routes/play/+page.svelte b/src/routes/play/+page.svelte new file mode 100644 index 0000000..4f5a059 --- /dev/null +++ b/src/routes/play/+page.svelte @@ -0,0 +1,201 @@ + + + + Waving Hands: against the bot + + +
+
+

Waving Hands

+ {#if renaming} +
+ + + +
+ {:else} +

+ You are {duel.you.name}, duelling {duel.foe.name}. + {#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if} +

+ {/if} +
+ +
+ +{#if confirmingNew} +
+

Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.

+
+ + +
+
+{/if} + + + +
+

After Richard Bartle's Waving Hands (1977), also known as Spellbinder and Spellcaster. Read the full rules.

+
+ +