Compare commits

...
7 Commits
Author SHA1 Message Date
Eric WagonerandClaude Fable 5.1 605f8c511b Caddy routes the duel server's paths with handle blocks, from one template
try_files ran before reverse_proxy and rewrote /api to the shell; handle
blocks are exclusive. Both setup scripts render deploy/Caddyfile.tmpl.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:15:58 -04:00
Eric WagonerandClaude Fable 5.1 29e908efeb The server carries its own manifest and runs from its own folder
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:13:18 -04:00
Eric WagonerandClaude Fable 5.1 6369765378 Rooms are served by the fallback shell, not prerendered
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:12:11 -04:00
Eric WagonerandClaude Fable 5.1 6c6edca4be The hall comes first, in the shape of wizwar's landing
The home page is a centred card: title and pitch, your name, play the
bot or continue the duel under way, create or join a duel by code, a
sample ledger, the duels this browser holds with whose move it is, and
the story of the game. The single-player board lives at /play. The
droplet gains the duel server as a systemd service behind Caddy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:11:45 -04:00
Eric WagonerandClaude Fable 5.1 e1bda87987 Duels between people: rooms, the hall, and a games ledger
The store plays any seat, locally against the bot or remotely through
the server. A room has a four-letter code that is also its invite link;
the hall opens or joins one and lists the seats this browser holds with
whose move it is. The ledger and summary show every seat, threats name
their wizard, and the board is one component shared by the hall and the
room page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 17:07:51 -04:00
Eric WagonerandClaude Fable 5.1 8366c9d7ff A duel server: rooms, seat tokens, ledgers, and resolution when all have moved
Plain HTTP for actions and a websocket that only says "fetch again". A
room is its append-only ledger replayed from the top; the engine is
deterministic given the seed and the recorded inputs. Each seat sees a
view filtered to what the rules let it know. Bot seats are filled at
resolution time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:54:29 -04:00
Eric WagonerandClaude Fable 5.1 95a1915cae The engine seats any number of wizards
Seats are a list on the state; a spell with no named target goes at the
first opponent still standing; a wizard who dies or surrenders is out
and their creatures go with them; the duel ends when one wizard stands,
or when a surrender and a death coincide, as the rules decide. The bot
attacks the weakest opponent and reads threats from all of them. The
two-seat duel is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:50:50 -04:00
33 changed files with 3601 additions and 729 deletions

No files matched your search

+3
View File
@@ -20,3 +20,6 @@ vite.config.ts.timestamp-*
# Playwright MCP output # Playwright MCP output
.playwright-mcp .playwright-mcp
# Duel ledgers written by the server
/data
+42
View File
@@ -0,0 +1,42 @@
__HOST__
root * /opt/waving-hands/build
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
log {
output file /var/lib/caddy/access.log {
roll_size 10MiB
roll_keep 30
}
}
# The duel server answers the API and the websocket; a deploy restarts it for
# a few seconds, and the proxy holds requests that land in that gap.
@duel path /api/* /ws
handle @duel {
reverse_proxy localhost:8788 {
lb_try_duration 30s
lb_try_interval 250ms
}
}
# Everything else is the static site. SvelteKit writes each route as
# <route>.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.
handle {
@immutable path /_app/immutable/*
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
file_server
}
+25 -17
View File
@@ -1,16 +1,18 @@
# Deploying Waving Hands # Deploying Waving Hands
The game runs entirely in the browser: the duel state lives in local storage The single-player game runs entirely in the browser. Duels between people
and nothing is rendered or stored on a server. Production is therefore a go through a small Node process that keeps each room as an append-only
single DigitalOcean droplet running only Caddy, which terminates TLS with ledger of moves in /var/lib/waving-hands/rooms. Production is one
automatic certificates and serves the static build from DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
/opt/waving-hands/build. No Node, no service, no database. 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 ## Sizing
The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to and the duel server comfortably: the server is one small Node process capped
`s-1vcpu-1gb` only if the site ever gains a server side. 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 The zero-cost alternative is a second site block in an existing Caddy
server's configuration pointing at a second directory; the deploy script server's configuration pointing at a second directory; the deploy script
@@ -31,10 +33,11 @@ uptime and upgrades independent of anything else.
1. `doctl compute droplet create waving-hands --region nyc3 \ 1. `doctl compute droplet create waving-hands --region nyc3 \
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \ --size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
--ssh-keys <your-key-ids> --tag-name waving-hands --wait` --ssh-keys <your-key-ids> --tag-name waving-hands --wait`
2. `scp deploy/setup-droplet.sh root@<ip>:/root/ && ssh root@<ip> \ 2. `scp deploy/setup-droplet.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> \
"bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<ip>.sslip.io'"` "bash /root/setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<ip>.sslip.io'"`
(point the A record at the new IP first, or leave the real name out until it is). (point the A record at the new IP first, or leave the real name out until it is).
3. `deploy/deploy.sh <ip>` 3. `scp deploy/setup-server.sh deploy/Caddyfile.tmpl root@<ip>:/root/ && ssh root@<ip> "bash /root/setup-server.sh"`
4. `deploy/deploy.sh <ip>`
The sslip.io hostname works with no DNS at all. To add a real name, point an 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 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 <ip> deploy/deploy.sh <ip>
Runs the type-check, the tests and the build locally, rsyncs `build/` to the Runs the type-checks, the tests and the build locally, rsyncs `build/` to
droplet keeping the previous week's hashed assets, and reloads Caddy. A the droplet keeping the previous week's hashed assets, rsyncs the server and
player mid-duel loses nothing: their game is in their own browser, and the engine sources, installs dependencies, and restarts the duel server. A
next page load picks up the new build. 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 ## Operations
- Logs: `ssh root@<ip> journalctl -u caddy -f`, access log in - Logs: `ssh root@<ip> journalctl -u waving-hands -f` for the duel server,
/var/lib/caddy/access.log `journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
- Nothing to back up on the server. Player duels are in their browsers. - Restart: `ssh root@<ip> systemctl restart waving-hands`
- Rooms: `/var/lib/waving-hands/rooms/<CODE>.jsonl`, one ledger per duel.
Copy that directory to back them up; single-player games are in players'
browsers.
+22 -5
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build locally and push the static site to the droplet. # Build locally, push the static site and the duel server to the droplet,
# Usage: deploy/deploy.sh <droplet-ip-or-host> # and restart the server. Usage: deploy/deploy.sh <droplet-ip-or-host>
set -euo pipefail set -euo pipefail
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}" HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
npm run check npm run check
npm run check:server
npm test npm test
npm run build npm run build
@@ -12,11 +13,27 @@ npm run build
# fetch the module it was built against instead of failing mid-duel. # fetch the module it was built against instead of failing mid-duel.
rsync -az --delete --filter='P _app/immutable/*' \ rsync -az --delete --filter='P _app/immutable/*' \
build/ "root@$HOST:/opt/waving-hands/build/" build/ "root@$HOST:/opt/waving-hands/build/"
# The server runs from source with tsx and carries its own small manifest;
# 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='/deploy/' --include='/deploy/waving-hands.service' \
--exclude='*' \
./ "root@$HOST:/opt/waving-hands/app/"
ssh "root@$HOST" ' ssh "root@$HOST" '
find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete
chown -R root:caddy /opt/waving-hands chown -R root:caddy /opt/waving-hands/build
chmod -R g+rX /opt/waving-hands chmod -R g+rX /opt/waving-hands/build
cd /opt/waving-hands/app/server && npm install --no-audit --no-fund
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 reload caddy
systemctl --no-pager -l status caddy | head -3 sleep 1
systemctl --no-pager -l status waving-hands | head -3
' '
echo "deployed." echo "deployed."
+5 -34
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-time droplet setup. Run ON the droplet as root: # One-time droplet setup. Copy this script and Caddyfile.tmpl to the droplet
# and run ON the droplet as root:
# bash setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<droplet-ip>.sslip.io' # bash setup-droplet.sh 'hands.kestrelsnest.social, waving-hands.<droplet-ip>.sslip.io'
# The argument is the Caddy site address line: one name, or several # The argument is the Caddy site address line: one name, or several
# separated by commas. Every name must already resolve to this droplet. # separated by commas. Every name must already resolve to this droplet.
@@ -24,39 +25,9 @@ mkdir -p /opt/waving-hands/build
chown -R root:caddy /opt/waving-hands chown -R root:caddy /opt/waving-hands
chmod -R g+rX /opt/waving-hands chmod -R g+rX /opt/waving-hands
# Caddy vhost: auto-TLS, security headers, static files. SvelteKit writes # Caddy: the site and the duel server's paths, from the template beside this script.
# each route as <route>.html, so /rules is tried as /rules.html before sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
# falling back to the app shell. Hashed assets under _app/immutable are caddy validate --config /etc/caddy/Caddyfile
# cached for a year; every other response is revalidated so a deploy shows
# up on the next load. The two header matchers are disjoint, so their order
# does not matter.
cat > /etc/caddy/Caddyfile <<CADDY
$HOST
root * /opt/waving-hands/build
encode gzip zstd
header {
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
@mutable not path /_app/immutable/*
header @mutable Cache-Control "no-cache"
log {
output file /var/lib/caddy/access.log {
roll_size 10MiB
roll_keep 30
}
}
try_files {path} {path}.html /index.html
file_server
CADDY
systemctl reload caddy systemctl reload caddy
# Firewall: ssh + web only. # Firewall: ssh + web only.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Adds the duel server to a droplet that setup-droplet.sh already prepared.
# Copy this script and Caddyfile.tmpl to the droplet and 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: rewrite the site's configuration from the template, keeping its
# address line, so the duel server's paths are routed before the files.
HOST_LINE=$(head -1 /etc/caddy/Caddyfile)
sed "s|__HOST__|$HOST_LINE|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
echo "server prerequisites ready: now run deploy/deploy.sh <ip> from your machine"
+32
View File
@@ -0,0 +1,32 @@
[Unit]
Description=Waving Hands duel server
After=network.target
[Service]
Type=simple
User=waving-hands
WorkingDirectory=/opt/waving-hands/app/server
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/server/node_modules/.bin/tsx 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
+557
View File
@@ -7,17 +7,465 @@
"": { "": {
"name": "waving-hands", "name": "waving-hands",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"ws": "^8.21.3"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.6.2",
"@types/ws": "^8.18.1",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tsx": "^4.23.15",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^5.0.1" "vitest": "^5.0.1"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -488,6 +936,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz",
"integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
@@ -659,6 +1127,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -1350,6 +1860,25 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
@@ -1364,6 +1893,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
@@ -1572,6 +2108,27 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+10 -1
View File
@@ -11,16 +11,25 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"server": "tsx watch server/src/index.ts",
"server:start": "tsx server/src/index.ts",
"check:server": "tsc --noEmit -p server"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.6.2",
"@types/ws": "^8.18.1",
"svelte": "^5.56.1", "svelte": "^5.56.1",
"svelte-check": "^4.6.0", "svelte-check": "^4.6.0",
"tsx": "^4.23.15",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^5.0.1" "vitest": "^5.0.1"
},
"dependencies": {
"ws": "^8.21.3"
} }
} }
+524
View File
@@ -0,0 +1,524 @@
{
"name": "waving-hands-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "waving-hands-server",
"dependencies": {
"tsx": "^4.23.15",
"ws": "^8.21.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "waving-hands-server",
"private": true,
"type": "module",
"description": "The duel server: rooms, ledgers, and turns between people. Runs from source with tsx.",
"scripts": {
"start": "tsx src/index.ts"
},
"dependencies": {
"tsx": "^4.23.15",
"ws": "^8.21.3"
}
}
+124
View File
@@ -0,0 +1,124 @@
// The front door for duels between people. Plain HTTP for actions, a
// websocket only to say "something changed, fetch the view again".
//
// POST /api/rooms {name, size?} create a duel and take seat A
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may)
// GET /api/rooms/:id?token= the view for that seat
// POST /api/rooms/:id/turn {token, input} this seat's move
// WS /ws?room=:id&token= {type:"update", seq} whenever the room changes
//
// Caddy serves the static site and proxies /api and /ws here.
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
import { RoomError, Rooms } from './rooms';
import { Store } from './store';
const PORT = Number(process.env.PORT ?? 8788);
const HOST = process.env.HOST ?? '127.0.0.1';
const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms';
const BODY_LIMIT = 16 * 1024;
const rooms = new Rooms(new Store(DATA_DIR));
function send(res: ServerResponse, status: number, body: unknown): void {
const json = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(json);
}
function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > BODY_LIMIT) {
reject(new RoomError('That is more than a turn needs.', 413));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (chunks.length === 0) return resolve({});
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch {
reject(new RoomError('The request was not JSON.'));
}
});
req.on('error', reject);
});
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'http://localhost');
const parts = url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404);
if (parts.length === 2 && req.method === 'POST') {
const body = await readBody(req);
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2));
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}
const room = rooms.get(parts[2] ?? '');
const action = parts[3];
if (!action && req.method === 'GET') {
const seat = rooms.seatOf(room, url.searchParams.get('token') ?? undefined);
return send(res, 200, rooms.view(room, seat.id));
}
if (req.method !== 'POST') throw new RoomError('Not here.', 404);
const body = await readBody(req);
if (action === 'join') {
const seat = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
}
if (action === 'bot') {
rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined);
const seat = rooms.seatOf(room, body.token as string);
return send(res, 200, rooms.view(room, seat.id));
}
if (action === 'turn') {
const token = typeof body.token === 'string' ? body.token : undefined;
rooms.submit(room, token, body.input);
const seat = rooms.seatOf(room, token);
return send(res, 200, rooms.view(room, seat.id));
}
throw new RoomError('Not here.', 404);
}
const server = createServer((req, res) => {
handle(req, res).catch((err: unknown) => {
if (err instanceof RoomError) return send(res, err.status, { error: err.message });
console.error(err);
send(res, 500, { error: 'Something went wrong on the server.' });
});
});
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname !== '/ws') return socket.destroy();
let seatId: string;
let roomId: string;
try {
roomId = url.searchParams.get('room') ?? '';
seatId = rooms.seatOf(rooms.get(roomId), url.searchParams.get('token') ?? undefined).id;
} catch {
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const unsubscribe = rooms.subscribe(roomId, (room) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: room.seq }));
});
ws.on('close', unsubscribe);
ws.send(JSON.stringify({ type: 'hello', seat: seatId }));
});
});
server.listen(PORT, HOST, () => {
console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`);
});
+245
View File
@@ -0,0 +1,245 @@
// Rooms: who sits where, whose move it is, and the duel itself. Every change
// goes to the ledger first and is then applied, so a restart replays to the
// same place. Inputs wait in memory until every seat that must move has moved;
// then the turn resolves at once, bots included.
import { randomBytes } from 'node:crypto';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
import { viewFor, type RoomView } from '../../src/lib/game/view';
import type { LedgerLine, Store } from './store';
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
export const MAX_SEATS = SEAT_IDS.length;
export interface Seat {
id: WizardId;
name: string;
token: string;
bot: boolean;
}
export interface Room {
id: string;
size: number;
createdAt: number;
seats: Seat[];
state: GameState | null;
/** Inputs received for the turn in progress, by seat. */
pending: Record<WizardId, TurnInput>;
/** Bumped on every change a client might want to see. */
seq: number;
updatedAt: number;
}
export class RoomError extends Error {
constructor(
message: string,
public status = 400
) {
super(message);
}
}
function newId(bytes: number): string {
return randomBytes(bytes).toString('base64url');
}
/** Room codes: four letters or digits, none that read alike, easy to say aloud. */
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function newCode(): string {
const bytes = randomBytes(4);
return [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
}
export function normalizeCode(raw: string): string {
return raw.trim().toUpperCase();
}
export class Rooms {
private rooms = new Map<string, Room>();
private listeners = new Map<string, Set<(room: Room) => void>>();
constructor(private store: Store) {
for (const id of store.roomIds()) {
const room = replay(id, store.read(id));
if (room) this.rooms.set(id, room);
}
}
get(id: string): Room {
const room = this.rooms.get(id) ?? this.rooms.get(normalizeCode(id));
if (!room) throw new RoomError('No duel answers to that code.', 404);
return room;
}
/** The seat a token unlocks. */
seatOf(room: Room, token: string | undefined): Seat {
const seat = room.seats.find((s) => !s.bot && s.token === token);
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
return seat;
}
create(name: string, size: number): { room: Room; seat: Seat } {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`);
let id = newCode();
while (this.rooms.has(id)) id = newCode();
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
const seat = this.sit(room, name, false);
return { room, seat };
}
join(room: Room, name: string): Seat {
return this.sit(room, name, false);
}
addBot(room: Room, token: string | undefined): Seat {
this.seatOf(room, token);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase()));
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
return this.sit(room, name, true);
}
private sit(room: Room, rawName: string, bot: boolean): Seat {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot };
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
return seat;
}
/** Record a seat's move; resolve the turn once nobody else is awaited. */
submit(room: Room, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The duel has not begun: seats are still empty.', 409);
if (room.state.over) throw new RoomError('The duel is over.', 409);
if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
room.pending[seat.id] = cleanInput(raw);
room.updatedAt = Date.now();
room.seq += 1;
if (waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
}
private resolve(room: Room): void {
const state = room.state!;
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
for (const seat of room.seats) {
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
}
this.commit(room, { t: 'turn', at: Date.now(), inputs });
// A time stop owed to a bot resolves at once; a human's waits for them.
while (room.state && !room.state.over && room.state.timeStops[0] && room.seats.find((s) => s.id === room.state!.timeStops[0])?.bot) {
const actor = room.state.timeStops[0];
this.commit(room, { t: 'turn', at: Date.now(), inputs: { [actor]: chooseBotTurn(room.state, actor) } });
}
}
view(room: Room, seatId: WizardId): RoomView {
return {
roomId: room.id,
seq: room.seq,
size: room.size,
me: seatId,
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
waitingOn: waitingOn(room),
state: room.state ? viewFor(room.state, seatId) : null
};
}
subscribe(roomId: string, fn: (room: Room) => void): () => void {
if (!this.listeners.has(roomId)) this.listeners.set(roomId, new Set());
this.listeners.get(roomId)!.add(fn);
return () => this.listeners.get(roomId)?.delete(fn);
}
/** Write to the ledger, then apply; the file is the truth a restart returns to. */
private commit(room: Room, line: LedgerLine): void {
this.store.append(room.id, line);
apply(room, line);
room.seq += 1;
room.updatedAt = Date.now();
this.notify(room);
}
private notify(room: Room): void {
for (const fn of this.listeners.get(room.id) ?? []) fn(room);
}
}
/** Seats whose input the next resolution needs and has not yet received. */
export function waitingOn(room: Room): WizardId[] {
if (!room.state || room.state.over) return [];
return room.seats
.filter((s) => !s.bot && needsInput(room.state!, s.id) && !(s.id in room.pending))
.map((s) => s.id);
}
function needsInput(state: GameState, id: WizardId): boolean {
if (state.timeStops.length > 0) return state.timeStops[0] === id;
return inDuel(state.wizards[id]);
}
function apply(room: Room, line: LedgerLine): void {
switch (line.t) {
case 'room':
break;
case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
break;
case 'start':
room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed);
break;
case 'turn':
if (!room.state) return;
room.state = resolveTurn(room.state, line.inputs);
room.pending = {};
break;
}
}
function replay(id: string, lines: LedgerLine[]): Room | null {
const first = lines[0];
if (!first || first.t !== 'room') return null;
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) {
apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at;
}
return room;
}
export function cleanName(raw: unknown): string {
const name = String(raw ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 24);
if (!name) throw new RoomError('A wizard needs a name.');
return name;
}
/** Only the shapes the engine understands get through; the engine validates the rest. */
function cleanInput(raw: unknown): TurnInput {
const r = (raw ?? {}) as Record<string, unknown>;
const gesture = (g: unknown): Gesture => (GESTURES.includes(g as Gesture) ? (g as Gesture) : '-');
const casts = Array.isArray(r.casts) ? (r.casts as TurnInput['casts']).slice(0, 4) : [];
const input: TurnInput = { left: gesture(r.left), right: gesture(r.right), casts };
if (r.second && typeof r.second === 'object') {
const s = r.second as Record<string, unknown>;
input.second = { left: gesture(s.left), right: gesture(s.right), casts: Array.isArray(s.casts) ? (s.casts as TurnInput['casts']).slice(0, 4) : [] };
if (typeof s.stabTarget === 'string') input.second.stabTarget = s.stabTarget;
}
if (r.release && typeof r.release === 'object') input.release = r.release as TurnInput['release'];
if (typeof r.stabTarget === 'string') input.stabTarget = r.stabTarget;
if (r.monsterOrders && typeof r.monsterOrders === 'object') input.monsterOrders = r.monsterOrders as TurnInput['monsterOrders'];
if (typeof r.charmGesture === 'string') input.charmGesture = gesture(r.charmGesture);
return input;
}
+42
View File
@@ -0,0 +1,42 @@
// One append-only JSONL file per room. A room is its ledger replayed from the
// top: the engine is deterministic given the seed and the recorded inputs, so
// nothing else needs saving.
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import type { TurnInput, WizardId } from '../../src/lib/game/state';
export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean }
| { t: 'start'; seed: number }
| { t: 'turn'; at: number; inputs: Record<WizardId, TurnInput> };
export class Store {
constructor(private dir: string) {
mkdirSync(dir, { recursive: true });
}
private path(roomId: string): string {
return join(this.dir, `${roomId}.jsonl`);
}
append(roomId: string, line: LedgerLine): void {
appendFileSync(this.path(roomId), JSON.stringify(line) + '\n');
}
read(roomId: string): LedgerLine[] {
const path = this.path(roomId);
if (!existsSync(path)) return [];
return readFileSync(path, 'utf8')
.split('\n')
.filter((l) => l.trim())
.map((l) => JSON.parse(l) as LedgerLine);
}
roomIds(): string[] {
return readdirSync(this.dir)
.filter((f) => f.endsWith('.jsonl'))
.map((f) => f.slice(0, -'.jsonl'.length));
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "../src/lib/game/**/*.ts"],
"exclude": ["../src/lib/game/*.test.ts", "../src/lib/game/*.svelte.ts", "../src/lib/game/test-helpers.ts"]
}
+2 -1
View File
@@ -46,7 +46,8 @@ p {
} }
button, button,
select { select,
input {
font: inherit; font: inherit;
color: inherit; color: inherit;
} }
+395
View File
@@ -0,0 +1,395 @@
<script lang="ts">
import Chronicle from './Chronicle.svelte';
import HandPicker from './HandPicker.svelte';
import Ledger from './Ledger.svelte';
import MobileBar from './MobileBar.svelte';
import SpellSheet from './SpellSheet.svelte';
import TurnSummary from './TurnSummary.svelte';
import WizardStatus from './WizardStatus.svelte';
import { castKey, tokensText, type Duel } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
let { duel }: { duel: Duel } = $props();
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner !== duel.me));
const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === duel.me);
const charmedName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].name : '');
const charmedHandName = $derived(duel.charmedFoe ? duel.state.wizards[duel.charmedFoe].constraints.charmed?.hand : '');
</script>
<main class="board">
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
{#if outcome}
<div class="verdict" id="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p>
{#if duel.remote}
<a class="reveal again" href="/">Back to the hall</a>
{:else}
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
{/if}
</div>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
{:else}
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
{/if}
{#if duel.threats.length && !duel.timeStopped}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
<ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)}
{@const active = duel.isHighlighted(t)}
<li>
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if duel.threatsNext.length}
<details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{@const active = duel.isHighlighted(t)}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{duel.others.length > 1 ? `${duel.state.wizards[t.who].name}, ` : ''}{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{:else if !duel.timeStopped}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
<div class="hands">
<HandPicker {duel} hand="left" />
<HandPicker {duel} hand="right" />
</div>
<div class="prefs">
<label class="pref">
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
Show spells under way beneath each hand
</label>
<label class="pref">
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
Explain rules as they come up
</label>
</div>
{#if duel.hasted}
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
<div class="hands">
<HandPicker {duel} hand="left" set="haste" />
<HandPicker {duel} hand="right" set="haste" />
</div>
{/if}
<div class="orders">
{#if duel.stabbing}
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
{#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option>
{/each}
{#each foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label class="field">
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
{#each duel.others as id (id)}
<option value={id}>{duel.state.wizards[id].name}</option>
{/each}
{#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option>
{/each}
</select>
</label>
{/each}
{#if duel.bankedSpell}
<label class="field">
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
<option value={t.id}>at {t.label}</option>
{/each}
</select>
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label class="field">
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label class="field">
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
<label class="field">
<span>{charmedName}'s charmed {charmedHandName} hand makes</span>
<select bind:value={duel.charmGesture}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if duel.remote?.error}
<p class="warning">{duel.remote.error}</p>
{/if}
{#if duel.moved}
<p class="waiting">Your move is in. Waiting for {duel.awaiting.join(' and ')}.</p>
{/if}
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
{/if}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.moved ? 'Waiting' : duel.sending ? 'Sending' : duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
</section>
<aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
{#each duel.others as id (id)}
<WizardStatus wizard={duel.state.wizards[id]} monsters={duel.state.monsters.filter((m) => m.owner === id)} />
{/each}
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
</main>
<MobileBar {duel} />
<style>
details.next summary {
cursor: pointer;
list-style: none;
}
details.next summary::before {
content: '\25B8 ';
color: var(--bone-faint);
}
details.next[open] summary::before {
content: '\25BE ';
}
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
.play {
min-width: 0;
}
.rail {
min-width: 0;
}
.move {
padding: 0.6rem 0 1.5rem;
}
.move h2 {
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
.threats {
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
.threats ul {
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.threat {
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
.threat.soon {
color: var(--bone);
}
.threat:hover,
.threat.active {
border-color: var(--frost);
}
.threat.active {
background: rgba(140, 200, 224, 0.12);
}
.thand {
font-style: italic;
font-size: 0.8rem;
}
.tseq {
letter-spacing: 0.04em;
}
.tseq strong {
font-weight: 600;
color: var(--ember);
}
.prefs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
.pref {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
.orders {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
}
.haste-note {
color: var(--frost);
font-size: 0.95rem;
margin: 1rem 0 0.6rem;
}
.move > .reveal {
margin-top: 0.8rem;
}
.verdict {
padding: 1.5rem 0;
}
.verdict h2 {
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
.verdict.won h2 {
color: var(--ember);
}
.verdict p {
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
display: none;
}
.rail {
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
.hands {
grid-template-columns: 1fr;
}
}
.waiting {
color: var(--frost);
margin-top: 0.8rem;
}
.reveal.again {
display: inline-block;
text-decoration: none;
}
</style>
+464
View File
@@ -0,0 +1,464 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { allSeats, api, forgetSeat } from '$lib/game/client';
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
import type { RoomView } from '$lib/game/view';
let name = $state(playerName() || duel.you.name);
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView | null>>({});
let aboutOpen = $state(false);
/** The single-player duel saved in this browser, if one is under way. */
const local = $derived(duel.inProgress ? duel : null);
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it is.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => {
games[held.roomId] = v;
})
.catch(() => {
games[held.roomId] = null;
});
}
});
async function playBot() {
rememberName(name);
await goto('/play');
}
async function create() {
busy = true;
error = '';
try {
rememberName(name);
const remote = await Duel.createRoom(name.trim(), 2);
await goto(`/join/${remote.roomId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'The duel could not be opened.';
} finally {
busy = false;
}
}
function join(e: SubmitEvent) {
e.preventDefault();
rememberName(name);
const c = code.trim().toUpperCase();
if (c) void goto(`/join/${c}`);
}
function forget(roomId: string) {
forgetSeat(roomId);
seats = allSeats();
}
function status(held: { roomId: string; seat: string }): string {
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.name);
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
if (g.state.over) {
if (g.state.over.winner === held.seat) return 'you won';
if (g.state.over.winner === null) return 'a draw';
return `${g.state.wizards[g.state.over.winner].name} won`;
}
const turn = `turn ${g.state.turn + 1}`;
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
}
function yourMove(held: { roomId: string; seat: string }): boolean {
const g = games[held.roomId];
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
}
/* A few turns of a duel, as the ledger writes them. */
const sample = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
{ turn: 3, a: ['S', 'F'], b: ['F', 'D'], notes: { a0: 'Counter-spell' } },
{ turn: 4, a: ['F', 'S'], b: ['F', 'D'], notes: { b0: 'Paralysis', b1: 'Lightning bolt' } }
];
</script>
<section class="lid">
<div class="lid-head">
<h1>Waving Hands</h1>
<p class="pitch">Two wizards. Two hands each. The right run of gestures is a spell.</p>
<p class="tag">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.</p>
<a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a>
</div>
<div class="lid-play">
<label class="name">
<span>Your wizard's name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
</label>
<button type="button" class="reveal primary" disabled={!ready} onclick={playBot}>
{local ? `Continue against ${local.foe.name} (turn ${local.turnNumber})` : 'Play now against the bot'}
</button>
{#if local}
<p class="muted small">Or start afresh from the board's "New duel".</p>
{/if}
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Create a duel</button>
<form class="joinform" onsubmit={join}>
<span class="or">or join one</span>
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
</form>
</div>
{#if error}<p class="warning">{error}</p>{/if}
</div>
<div class="lid-look" aria-hidden="true">
<table class="sample">
<thead>
<tr><th></th><th colspan="2">Corwin</th><th colspan="2">Ysolde</th></tr>
</thead>
<tbody>
{#each sample as row (row.turn)}
<tr>
<td class="t">{row.turn}</td>
{#each ['a0', 'a1', 'b0', 'b1'] as cell, i (cell)}
{@const g = i < 2 ? row.a[i] : row.b[i - 2]}
{@const note = (row.notes as Record<string, string | undefined>)[cell]}
<td class:cast={!!note}><span class="letter">{g}</span>{#if note}<span class="note">{note}</span>{/if}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
</div>
{#if seats.length || local}
<div class="ledger">
<div class="ledger-head">your duels</div>
{#if local}
<div class="ledger-row your-move">
<a class="ledger-resume" href="/play">
<span class="ledger-code">bot</span>
<span class="ledger-info">your move · turn {local.turnNumber} · against {local.foe.name}</span>
</a>
</div>
{/if}
{#each seats as held (held.roomId)}
<div class="ledger-row" class:your-move={yourMove(held)}>
<a class="ledger-resume" href={`/join/${held.roomId}`}>
<span class="ledger-code">{held.roomId}</span>
<span class="ledger-info">{status(held)}</span>
</a>
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
</div>
{/each}
</div>
{/if}
<details class="about" id="about" bind:open={aboutOpen}>
<summary>about this game</summary>
<div class="about-body">
<p>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 <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
<p>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 <a href="/rules">rules page</a>. 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.</p>
<p>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.</p>
</div>
</details>
</section>
<style>
.lid {
width: 100%;
max-width: 54rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
gap: 1.2rem 2.2rem;
}
@media (min-width: 901px) {
.lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
}
}
.lid-head {
grid-area: head;
text-align: center;
}
h1 {
font-size: clamp(2.6rem, 6vw, 3.6rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.pitch {
font-style: italic;
font-size: 1.2rem;
margin-top: 0.3rem;
}
.tag {
color: var(--bone-dim);
max-width: 40em;
margin: 0.6rem auto 0.4rem;
font-size: 0.95rem;
}
.about-link {
display: inline-block;
color: var(--frost);
font-style: italic;
text-decoration: underline dotted;
font-size: 0.95rem;
}
.lid-play {
grid-area: play;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.name {
display: block;
}
.name span {
display: block;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.name input {
width: 100%;
}
.reveal.primary {
width: 100%;
text-align: center;
}
.small {
font-size: 0.85rem;
margin-top: -0.4rem;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.joinform {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.or {
color: var(--bone-dim);
font-style: italic;
font-size: 0.95rem;
}
.code {
width: 6.5em;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.15em;
font-weight: 600;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.45rem 0.9rem;
color: var(--bone);
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.warning {
color: var(--blood);
}
.lid-look {
grid-area: look;
}
.sample {
width: 100%;
border-collapse: collapse;
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 6px;
overflow: hidden;
}
.sample th {
font-weight: 500;
font-style: italic;
padding: 0.4rem 0 0.2rem;
border-bottom: 1px solid var(--rule-strong);
}
.sample td {
text-align: center;
padding: 0.3rem 0.2rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
.sample td.t {
width: 2rem;
color: var(--bone-faint);
font-size: 0.8rem;
}
.sample td:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.sample .letter {
display: block;
font-size: 1.6rem;
}
.sample td.cast .letter {
color: var(--ember);
}
.sample .note {
display: block;
font-size: 0.65rem;
font-style: italic;
color: var(--ember);
}
.caption {
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.5rem;
text-align: center;
}
/* the games ledger */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.ledger-head {
font-style: italic;
color: var(--bone-dim);
margin-bottom: 0.3rem;
}
.ledger-row {
display: flex;
align-items: center;
gap: 0.4rem;
border-radius: 4px;
padding: 0.15rem 0.3rem;
}
.ledger-row.your-move {
background: rgba(240, 165, 58, 0.12);
}
.ledger-resume {
flex: 1;
display: flex;
align-items: baseline;
gap: 0.7rem;
padding: 0.3rem 0.2rem;
color: var(--bone);
text-decoration: none;
font-size: 0.95rem;
}
.ledger-resume:hover .ledger-code {
text-decoration: underline;
}
.ledger-code {
font-weight: 600;
letter-spacing: 0.12em;
min-width: 3.5em;
}
.ledger-info {
color: var(--bone-dim);
}
.ledger-row.your-move .ledger-info {
color: var(--ember);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.05rem;
padding: 0 0.3rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.about {
grid-area: about;
border-top: 1px solid var(--rule);
padding-top: 0.6rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--frost);
}
.about-body {
max-width: 44em;
color: var(--bone-dim);
padding: 0.6rem 0 0.2rem;
}
.about-body p + p {
margin-top: 0.6rem;
}
.about-body em {
color: var(--bone);
}
.about-body a {
color: var(--frost);
}
</style>
+34 -16
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs'; import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells'; import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state'; import type { HistoryEntry, WizardId } from '$lib/game/state';
@@ -21,13 +21,16 @@
cells: Record<WizardId, Placed[]>; cells: Record<WizardId, Placed[]>;
} }
/** You first, then the others in table order. */
const columns = $derived<WizardId[]>([duel.me, ...duel.others]);
function placed(who: WizardId, turn: number, phases: string[]): Placed[] { function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === YOU ? duel.you.history : duel.foeVisible; const history = who === duel.me ? duel.you.history : duel.visible(who);
const raw = duel.state.wizards[who].history; const raw = duel.state.wizards[who].history;
const out: Placed[] = []; const out: Placed[] = [];
history.forEach((entry, index) => { history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) { if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(YOU) }); out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(duel.me) });
} }
}); });
return out; return out;
@@ -36,10 +39,14 @@
const rows = $derived.by(() => { const rows = $derived.by(() => {
const out: Row[] = []; const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) { for (let t = 0; t < duel.state.turn; t++) {
out.push({ key: `t${t}`, label: String(t + 1), cells: { A: placed(YOU, t, ['main', 'haste']), B: placed(FOE, t, ['main', 'haste']) } }); const cells: Record<WizardId, Placed[]> = {};
for (const who of [YOU, FOE] as WizardId[]) { for (const who of columns) cells[who] = placed(who, t, ['main', 'haste']);
out.push({ key: `t${t}`, label: String(t + 1), cells });
for (const who of columns) {
for (const p of placed(who, t, ['timestop'])) { for (const p of placed(who, t, ['timestop'])) {
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: { A: who === YOU ? [p] : [], B: who === FOE ? [p] : [] } }); const only: Record<WizardId, Placed[]> = {};
for (const c of columns) only[c] = c === who ? [p] : [];
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: only });
} }
} }
} }
@@ -47,7 +54,7 @@
}); });
const drafting = $derived(!duel.state.over); const drafting = $derived(!duel.state.over);
const turns = $derived(duel.you.history.length + duel.foe.history.length); const turns = $derived(columns.reduce((n, id) => n + duel.state.wizards[id].history.length, 0));
function notesFor(who: WizardId, index: number, hand: Hand): string[] { function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts return duel.state.wizards[who].casts
@@ -55,6 +62,11 @@
.map((c) => SPELL_BY_ID[c.spellId].name); .map((c) => SPELL_BY_ID[c.spellId].name);
} }
function lit(who: WizardId, hand: Hand, index: number): boolean {
const h = duel.highlight;
return !!h && h.who === who && h.hand === hand && index >= h.from && index <= h.to;
}
$effect(() => { $effect(() => {
void turns; void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight; if (scroller) scroller.scrollTop = scroller.scrollHeight;
@@ -66,29 +78,29 @@
<thead> <thead>
<tr> <tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th> <th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th> {#each columns as who (who)}
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th> <th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me}&nbsp;(you){/if}</th>
{/each}
</tr> </tr>
<tr class="hands"> <tr class="hands">
<th></th> <th></th>
{#each columns as who (who)}
<th scope="col">left</th> <th scope="col">left</th>
<th scope="col">right</th> <th scope="col">right</th>
<th scope="col">left</th> {/each}
<th scope="col">right</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each rows as row (row.key)} {#each rows as row (row.key)}
<tr class:stopped={!!row.note}> <tr class:stopped={!!row.note}>
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td> <td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each [YOU, FOE] as const as who (who)} {#each columns as who (who)}
{#each ['left', 'right'] as const as hand (hand)} {#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === YOU} class:foe={who === FOE}> <td class="cell" class:you={who === duel.me} class:foe={who !== duel.me}>
{#each row.cells[who] as p (p.index)} {#each row.cells[who] as p (p.index)}
{@const lit = who === FOE && duel.highlight?.hand === hand && p.index >= duel.highlight.from && p.index <= duel.highlight.to}
{@const notes = notesFor(who, p.index, hand)} {@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left} {@const other = hand === 'left' ? p.entry.right : p.entry.left}
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit> <span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit={lit(who, hand, p.index)}>
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span> <span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if} {#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span> </span>
@@ -113,8 +125,10 @@
{/each} {/each}
</td> </td>
{/each} {/each}
{#each duel.others as who (who)}
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td> <td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td> <td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
{/each}
</tr> </tr>
{/if} {/if}
</tbody> </tbody>
@@ -193,10 +207,14 @@
background: rgba(140, 200, 224, 0.06); background: rgba(140, 200, 224, 0.06);
} }
td.cell.you:nth-child(3) { td.cell:nth-child(odd) {
border-right: 1px solid var(--rule-strong); border-right: 1px solid var(--rule-strong);
} }
td.cell:last-child {
border-right: 0;
}
.entry { .entry {
display: block; display: block;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte'; import type { Duel } from '$lib/game/duel.svelte';
let { duel }: { duel: Duel } = $props(); let { duel }: { duel: Duel } = $props();
@@ -14,10 +14,10 @@
<section class="result" id="turn-result" aria-label="What the last reveal did"> <section class="result" id="turn-result" aria-label="What the last reveal did">
<h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2> <h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
<dl> <dl>
{#each [YOU, FOE] as const as who (who)} {#each duel.state.seats as who (who)}
{@const w = summary.wizards[who]} {@const w = summary.wizards[who]}
<div> <div>
<dt>{who === YOU ? 'You' : duel.foe.name}</dt> <dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
<dd> <dd>
{#if w.effects.length === 0} {#if w.effects.length === 0}
<span class="muted">untouched</span> <span class="muted">untouched</span>
+27 -14
View File
@@ -22,6 +22,7 @@ import {
MAX_HP, MAX_HP,
enemyOf, enemyOf,
isEnchanted, isEnchanted,
opponentsOf,
visibleHistory, visibleHistory,
type CastChoice, type CastChoice,
type GameState, type GameState,
@@ -90,40 +91,52 @@ function hash01(text: string): number {
return ((h >>> 0) % 1000) / 1000; return ((h >>> 0) % 1000) / 1000;
} }
/** The opponent to attack: the weakest still standing. */
function pickFoe(state: GameState, me: WizardId): WizardId {
const standing = opponentsOf(state, me);
if (standing.length === 0) return enemyOf(state, me);
return [...standing].sort((a, b) => state.wizards[a].hp - state.wizards[b].hp)[0];
}
function assess(state: GameState, me: WizardId, rng: () => number): Situation { function assess(state: GameState, me: WizardId, rng: () => number): Situation {
const foe = enemyOf(me); const foe = pickFoe(state, me);
const foeW = state.wizards[foe]; const foes = opponentsOf(state, me);
const hist = visibleHistory(state, me, foe); const seqOf = (id: WizardId): Record<Hand, HandTurn[]> => {
const foeSeq: Record<Hand, HandTurn[]> = { const hist = visibleHistory(state, me, id);
left: handSequence(hist, 'left'), return { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') };
right: handSequence(hist, 'right')
}; };
const foeSeq = seqOf(foe);
let threatNow = 0; let threatNow = 0;
let threatNowCounterable = 0; let threatNowCounterable = 0;
let threatNext = 0; let threatNext = 0;
let missileNow = false; let missileNow = false;
let foeFirePlan = false; let foeFirePlan = false;
let foeIcePlan = false; let foeIcePlan = false;
// Danger comes from every opponent, not only the one being attacked.
for (const id of foes.length ? foes : [foe]) {
const seq = id === foe ? foeSeq : seqOf(id);
const start = state.wizards[id].sequenceStart;
for (const hand of HANDS) { for (const hand of HANDS) {
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) { for (const c of completableIn(seq[hand], start, 1)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
const v = THREAT_VALUE[c.spell.id] ?? 1; const v = THREAT_VALUE[c.spell.id] ?? 1;
threatNow = Math.max(threatNow, v); threatNow = Math.max(threatNow, v);
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v); if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
if (c.spell.id === 'missile') missileNow = true; if (c.spell.id === 'missile') missileNow = true;
} }
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) { for (const c of completableIn(seq[hand], start, 2)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1); threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
} }
for (const spell of SPELLS) { for (const spell of SPELLS) {
const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart); const k = progressToward(spell.tokens[0], seq[hand], start);
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true; if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true; if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
} }
} }
}
const foeMonsterDamage = state.monsters const foeMonsterDamage = state.monsters
.filter((m) => m.owner === foe && !isElemental(m.kind)) .filter((m) => m.owner !== me && !isElemental(m.kind))
.reduce((sum, m) => sum + m.attack, 0); .reduce((sum, m) => sum + m.attack, 0);
return { return {
state, state,
@@ -147,7 +160,7 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
function baseValue(s: Situation, spell: Spell): number { function baseValue(s: Situation, spell: Spell): number {
const meW = s.state.wizards[s.me]; const meW = s.state.wizards[s.me];
const foeW = s.state.wizards[s.foe]; const foeW = s.state.wizards[s.foe];
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe); const foeMonsters = s.state.monsters.filter((m) => m.owner !== s.me);
const foeEnchanted = isEnchanted(foeW); const foeEnchanted = isEnchanted(foeW);
switch (spell.id) { switch (spell.id) {
case 'fireball': case 'fireball':
@@ -394,7 +407,7 @@ function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean)
if (left === 'P' && right === 'P') score -= 100; if (left === 'P' && right === 'P') score -= 100;
if (left === '>' && right === '>') score -= 50; if (left === '>' && right === '>') score -= 50;
if (left === '-' && right === '-') score -= 3; if (left === '-' && right === '-') score -= 3;
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15; if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner !== s.me && m.hp === 1) ? 1.5 : 0.15;
score += s.rng() * 0.5; score += s.rng() * 0.5;
if (!bestPair || score > bestPair.score) bestPair = { left, right, score }; if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
} }
@@ -415,7 +428,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
const timeStopped = state.timeStops[0] === me; const timeStopped = state.timeStops[0] === me;
const first = choosePair(s, meW.history, timeStopped); const first = choosePair(s, meW.history, timeStopped);
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1); const weakFoeMonster = state.monsters.find((m) => m.owner !== me && m.hp === 1);
const monsterOrders: Record<string, TargetId> = {}; const monsterOrders: Record<string, TargetId> = {};
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe; for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
@@ -440,6 +453,6 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
input.release = { target: choice.target, monsterTarget: choice.monsterTarget }; input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
} }
} }
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s); if (state.seats.some((id) => state.wizards[id].constraints.charmed?.by === me)) input.charmGesture = charmGesture(s);
return input; return input;
} }
+111
View File
@@ -0,0 +1,111 @@
// The browser's side of the duel server: a few JSON calls, and the seats this
// browser holds, remembered so a shared link never has to carry a token.
import type { TurnInput, WizardId } from './state';
import type { RoomView } from './view';
export interface HeldSeat {
seat: WizardId;
token: string;
}
const SEATS_KEY = 'wh:seats';
function heldSeats(): Record<string, HeldSeat> {
try {
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
} catch {
return {};
}
}
export function seatFor(roomId: string): HeldSeat | null {
return heldSeats()[roomId] ?? null;
}
/** Every seat this browser holds, newest last. */
export function allSeats(): { roomId: string; seat: WizardId; token: string }[] {
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
}
export function forgetSeat(roomId: string): void {
try {
const seats = heldSeats();
delete seats[roomId];
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
} catch {
// Nothing to forget without storage.
}
}
export function rememberSeat(roomId: string, held: HeldSeat): void {
try {
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
} catch {
// Without storage the seat lasts for this page only.
}
}
export class ServerError extends Error {
constructor(
message: string,
public status: number
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
headers: body ? { 'content-type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
return data as T;
}
type Seated = { seat: WizardId; token: string; view: RoomView };
export const api = {
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
addBot: (roomId: string, token: string) => call<RoomView>('POST', `/api/rooms/${roomId}/bot`, { token }),
view: (roomId: string, token: string) => call<RoomView>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
turn: (roomId: string, token: string, input: TurnInput) => call<RoomView>('POST', `/api/rooms/${roomId}/turn`, { token, input })
};
/** A socket that only ever says "fetch the view again"; reconnects on its own. */
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
let socket: WebSocket | null = null;
let closed = false;
let delay = 1000;
const open = () => {
if (closed) return;
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}&token=${encodeURIComponent(token)}`);
socket.onopen = () => {
delay = 1000;
onStatus(true);
onUpdate();
};
socket.onmessage = (e) => {
try {
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
} catch {
// Not a message this client understands.
}
};
socket.onclose = () => {
onStatus(false);
if (!closed) setTimeout(open, delay);
delay = Math.min(delay * 2, 30000);
};
};
open();
return () => {
closed = true;
socket?.close();
};
}
+216 -51
View File
@@ -1,7 +1,10 @@
// Reactive wrapper around the engine for a duel of one person against the bot. // Reactive wrapper around the engine for the wizard at this keyboard. A local
// duel resolves against the bot in this browser; a remote one sends each turn
// to the server and shows whatever view comes back.
import { tick } from 'svelte'; import { tick } from 'svelte';
import { chooseBotTurn } from './bot'; import { chooseBotTurn } from './bot';
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
import { import {
cellsOverlap, cellsOverlap,
completableIn, completableIn,
@@ -31,6 +34,7 @@ import {
import { import {
NOWHERE, NOWHERE,
createGame, createGame,
enemyOf,
visibleHistory, visibleHistory,
type CastChoice, type CastChoice,
type GameState, type GameState,
@@ -39,9 +43,11 @@ import {
type TurnInput, type TurnInput,
type WizardId type WizardId
} from './state'; } from './state';
import type { RoomView } from './view';
export const YOU: WizardId = 'A'; /** The seats of a duel played in this browser against the bot. */
export const FOE: WizardId = 'B'; const LOCAL_ME: WizardId = 'A';
const LOCAL_BOT: WizardId = 'B';
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */ /** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
export const COMPACT_QUERY = '(max-width: 860px)'; export const COMPACT_QUERY = '(max-width: 860px)';
@@ -113,8 +119,9 @@ export interface Plan {
pinned: boolean; pinned: boolean;
} }
/** A spell the opponent's hand could finish soon, with the gestures that say so. */ /** A spell an opponent's hand could finish soon, with the gestures that say so. */
export interface Threat { export interface Threat {
who: WizardId;
hand: Hand; hand: Hand;
spell: Spell; spell: Spell;
done: Token[]; done: Token[];
@@ -126,6 +133,7 @@ export interface Threat {
} }
export interface Highlight { export interface Highlight {
who: WizardId;
hand: Hand; hand: Hand;
from: number; from: number;
to: number; to: number;
@@ -160,6 +168,7 @@ function loadSaved(): SavedDuel | null {
const saved = JSON.parse(raw) as SavedDuel; const saved = JSON.parse(raw) as SavedDuel;
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null; if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
// Saves at this version may lack fields that came later; the engine expects them present. // Saves at this version may lack fields that came later; the engine expects them present.
saved.state.seats ??= Object.keys(saved.state.wizards);
saved.state.lessonsShown ??= []; saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) { if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= []; saved.state.lastTurn.events ??= [];
@@ -188,10 +197,24 @@ function readPreference(key: string, fallback: boolean): boolean {
} }
} }
/** A seat at a duel the server is keeping. */
export interface Remote extends HeldSeat {
roomId: string;
size: number;
seats: RoomView['seats'];
waitingOn: WizardId[];
started: boolean;
connected: boolean;
error: string;
}
export class Duel { export class Duel {
state = $state<GameState>(createGame(pickNames())); state = $state<GameState>(createGame(pickNames()));
/** The seat this browser plays. */
me = $state<WizardId>(LOCAL_ME);
remote = $state<Remote | null>(null);
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets()); sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
stabTarget = $state<TargetId>(FOE); stabTarget = $state<TargetId | ''>('');
monsterOrders = $state<Record<string, TargetId>>({}); monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */ /** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false); compact = $state(false);
@@ -209,16 +232,22 @@ export class Duel {
sheetOpen = $state(false); sheetOpen = $state(false);
/** Opponent gestures to light up in the ledger. */ /** Opponent gestures to light up in the ledger. */
highlight = $state<Highlight | null>(null); highlight = $state<Highlight | null>(null);
/** A turn is on its way to the server. */
sending = $state(false);
constructor(initial?: GameState) { constructor(initial?: GameState | Remote) {
const saved = typeof localStorage === 'undefined' ? null : loadSaved(); const saved = typeof localStorage === 'undefined' ? null : loadSaved();
if (initial) { if (initial && 'roomId' in initial) {
this.remote = initial;
this.me = initial.seat;
this.state = createGame({ [initial.seat]: 'You' });
} else if (initial) {
this.state = initial; this.state = initial;
} else if (saved) { } else if (saved) {
this.state = saved.state; this.state = saved.state;
this.pins = saved.pins ?? { left: null, right: null }; this.pins = saved.pins ?? { left: null, right: null };
this.sets = saved.sets ?? blankSets(); this.sets = saved.sets ?? blankSets();
this.stabTarget = saved.stabTarget ?? FOE; this.stabTarget = saved.stabTarget ?? '';
this.monsterOrders = saved.monsterOrders ?? {}; this.monsterOrders = saved.monsterOrders ?? {};
this.release = saved.release ?? ''; this.release = saved.release ?? '';
} }
@@ -243,15 +272,17 @@ export class Duel {
} catch { } catch {
// The name still applies to this duel. // The name still applies to this duel.
} }
if (this.remote) return;
const next = name || pickNames('').A; const next = name || pickNames('').A;
if (next.toLowerCase() === this.foe.name.toLowerCase()) { if (next.toLowerCase() === this.foe.name.toLowerCase()) {
this.state.wizards[FOE].name = pickNames(next).B; this.state.wizards[LOCAL_BOT].name = pickNames(next).B;
} }
this.state.wizards[YOU].name = next; this.state.wizards[LOCAL_ME].name = next;
} }
/** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */ /** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
persist(): void { persist(): void {
if (this.remote) return;
const bundle: SavedDuel = { const bundle: SavedDuel = {
v: SAVE_VERSION, v: SAVE_VERSION,
state: $state.snapshot(this.state), state: $state.snapshot(this.state),
@@ -271,11 +302,21 @@ export class Duel {
/** A duel worth asking about before it is thrown away. */ /** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over); inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[YOU]); you = $derived(this.state.wizards[this.me]);
foe = $derived(this.state.wizards[FOE]); /** The opponent spells go at by default: the first still standing. */
foeId = $derived(enemyOf(this.state, this.me));
foe = $derived(this.state.wizards[this.foeId]);
/** Every other seat, in table order. */
others = $derived(this.state.seats.filter((id) => id !== this.me));
turnNumber = $derived(this.state.turn + 1); turnNumber = $derived(this.state.turn + 1);
/** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */ /** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */
timeStopped = $derived(this.state.timeStops[0] === YOU); timeStopped = $derived(this.state.timeStops[0] === this.me);
/** Your move is in and the turn waits on someone else. */
moved = $derived(!!this.remote && this.remote.started && !this.state.over && !this.remote.waitingOn.includes(this.me));
/** Names of the seats the turn is waiting on. */
awaiting = $derived(
this.remote ? this.remote.waitingOn.filter((id) => id !== this.me).map((id) => this.state.wizards[id]?.name ?? id) : []
);
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped); hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']); activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
@@ -295,8 +336,9 @@ export class Duel {
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand]; return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
} }
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand); charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
/** True when the bot's hand is yours to command this turn. */ /** The opponent whose hand is yours to command this turn, if any. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped); charmedFoe = $derived(this.others.find((id) => this.state.wizards[id].constraints.charmed?.by === this.me));
youCharmedFoe = $derived(this.charmedFoe !== undefined && !this.timeStopped);
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */ /** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null { chosenGesture(set: SetName, hand: Hand): Gesture | null {
@@ -392,20 +434,28 @@ export class Duel {
} }
summary = $derived(this.state.lastTurn); summary = $derived(this.state.lastTurn);
foeVisible = $derived(visibleHistory(this.state, YOU, FOE)); /** Each opponent's gestures as you are allowed to see them. */
/** Hostile spells the opponent could finish this turn or next, with the evidence. */ visible(id: WizardId): HistoryEntry[] {
return visibleHistory(this.state, this.me, id);
}
/** Hostile spells any opponent could finish this turn or next, with the evidence. */
threats = $derived.by(() => { threats = $derived.by(() => {
const out: Threat[] = []; const out: Threat[] = [];
for (const who of this.others) {
const w = this.state.wizards[who];
if (!w.alive || w.surrendered) continue;
const hist = this.visible(who);
for (const hand of HANDS) { for (const hand of HANDS) {
const seq = handSequence(this.foeVisible, hand); const seq = handSequence(hist, hand);
for (const turnsAway of [1, 2]) { for (const turnsAway of [1, 2]) {
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) { for (const c of completableIn(seq, w.sequenceStart, turnsAway)) {
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue; if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue; if (c.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) continue;
const tokens = c.spell.tokens[c.seqIndex]; const tokens = c.spell.tokens[c.seqIndex];
const done = tokens.slice(0, tokens.length - turnsAway); const done = tokens.slice(0, tokens.length - turnsAway);
if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue; if (out.some((t) => t.who === who && t.hand === hand && t.spell.id === c.spell.id)) continue;
out.push({ hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 }); out.push({ who, hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
}
} }
} }
} }
@@ -415,8 +465,15 @@ export class Duel {
threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6)); threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6));
toggleHighlight(t: Threat): void { toggleHighlight(t: Threat): void {
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to; const h = this.highlight;
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to }; const same = h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
this.highlight = same ? null : { who: t.who, hand: t.hand, from: t.from, to: t.to };
}
/** Whether a threat is the one lit up in the ledger. */
isHighlighted(t: Threat): boolean {
const h = this.highlight;
return !!h && h.who === t.who && h.hand === t.hand && h.from === t.from && h.to === t.to;
} }
/** Spells each hand is part-way through, based on the gestures already on the ledger. */ /** Spells each hand is part-way through, based on the gestures already on the ledger. */
@@ -506,9 +563,11 @@ export class Duel {
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P')); surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>'))); stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU)); yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
ready = $derived( ready = $derived(
!this.state.over && !this.state.over &&
!this.moved &&
!this.sending &&
this.activeSets.every((set) => this.activeSets.every((set) =>
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null) HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null)
) && ) &&
@@ -518,14 +577,14 @@ export class Duel {
/** A delayed effect is waiting for a spell to bank, counting one cast this turn. */ /** A delayed effect is waiting for a spell to bank, counting one cast this turn. */
bankingActive = $derived( bankingActive = $derived(
!this.you.banked && !this.you.banked &&
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || YOU) === YOU)) (this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || this.me) === this.me))
); );
bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []); bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []);
willBank = $derived( willBank = $derived(
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0] this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
); );
permanencyActive = $derived( permanencyActive = $derived(
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || YOU) === YOU) this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || this.me) === this.me)
); );
permanentCandidates = $derived( permanentCandidates = $derived(
this.permanencyActive this.permanencyActive
@@ -539,17 +598,15 @@ export class Duel {
/** Where a spell goes unless the player says otherwise. */ /** Where a spell goes unless the player says otherwise. */
defaultTarget(spell: Spell | undefined): TargetId | '' { defaultTarget(spell: Spell | undefined): TargetId | '' {
return spell ? likelyTarget(this.state, YOU, spell) : ''; return spell ? likelyTarget(this.state, this.me, spell) : '';
} }
/** Every possible subject, the likeliest first, creatures marked by whose they are. */ /** Every possible subject, the likeliest first, creatures marked by whose they are. */
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] { targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [ const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
{ id: YOU, label: 'yourself' }, for (const id of this.others) list.push({ id, label: this.state.wizards[id].name });
{ id: FOE, label: this.foe.name }
];
for (const m of this.state.monsters) { for (const m of this.state.monsters) {
list.push({ id: m.id, label: `${m.name} (${m.owner === YOU ? 'yours' : `${this.foe.name}'s`})` }); list.push({ id: m.id, label: `${m.name} (${m.owner === this.me ? 'yours' : `${this.state.wizards[m.owner].name}'s`})` });
} }
const likely = this.defaultTarget(spell); const likely = this.defaultTarget(spell);
list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely)); list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely));
@@ -588,21 +645,21 @@ export class Duel {
}; };
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental; if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand; if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
if (spell.category === 'summons') cast.monsterTarget = FOE; if (spell.category === 'summons') cast.monsterTarget = this.foeId;
if (p === this.willBank) cast.bank = true; if (p === this.willBank) cast.bank = true;
if (p === this.willExtend) cast.permanent = true; if (p === this.willExtend) cast.permanent = true;
return cast; return cast;
} }
reveal(): void { /** The move as the engine wants it. */
if (!this.ready) return; private input(): TurnInput {
const orders: Record<string, TargetId> = {}; const orders: Record<string, TargetId> = {};
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE; for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? this.foeId;
const yours: TurnInput = { const yours: TurnInput = {
left: this.sets.main.left.gesture ?? '-', left: this.sets.main.left.gesture ?? '-',
right: this.sets.main.right.gesture ?? '-', right: this.sets.main.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)), casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
stabTarget: this.stabTarget, stabTarget: this.stabTarget || this.foeId,
monsterOrders: orders monsterOrders: orders
}; };
if (this.hasted) { if (this.hasted) {
@@ -610,23 +667,87 @@ export class Duel {
left: this.sets.haste.left.gesture ?? '-', left: this.sets.haste.left.gesture ?? '-',
right: this.sets.haste.right.gesture ?? '-', right: this.sets.haste.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)), casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)),
stabTarget: this.stabTarget stabTarget: this.stabTarget || this.foeId
}; };
} }
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE }; if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: this.foeId };
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture; if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
return yours;
}
reveal(): void {
if (!this.ready) return;
const yours = this.input();
if (this.remote) {
void this.send(yours);
return;
}
// The engine clones plain data; hand it a snapshot rather than the reactive proxy. // The engine clones plain data; hand it a snapshot rather than the reactive proxy.
let plain = $state.snapshot(this.state); let plain = $state.snapshot(this.state);
const theirs = chooseBotTurn(plain, FOE); const theirs = chooseBotTurn(plain, LOCAL_BOT);
plain = resolveTurn(plain, { A: yours, B: theirs }); plain = resolveTurn(plain, { [LOCAL_ME]: yours, [LOCAL_BOT]: theirs });
// The bot takes any stopped moments it is owed straight away. // The bot takes any stopped moments it is owed straight away.
while (!plain.over && plain.timeStops[0] === FOE) { while (!plain.over && plain.timeStops[0] === LOCAL_BOT) {
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) }); plain = resolveTurn(plain, { [LOCAL_BOT]: chooseBotTurn(plain, LOCAL_BOT) });
} }
this.state = plain; this.turnOver(plain);
}
private async send(yours: TurnInput): Promise<void> {
const r = this.remote!;
this.sending = true;
r.error = '';
try {
this.apply(await api.turn(r.roomId, r.token, yours));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The move did not reach the server.';
} finally {
this.sending = false;
}
}
/** Take a fresh view from the server; a new turn clears the drafts. */
apply(view: RoomView): void {
const r = this.remote!;
r.seats = view.seats;
r.size = view.size;
r.waitingOn = view.waitingOn;
const wasStarted = r.started;
r.started = view.state !== null;
if (!view.state) return;
const advanced = !wasStarted || view.state.turn !== this.state.turn || view.state.log.length !== this.state.log.length;
if (advanced) this.turnOver(view.state);
else this.state = view.state;
}
/** Fetch the view now, and keep fetching whenever the server says something changed. */
connect(): () => void {
const r = this.remote;
if (!r) return () => {};
const refresh = () => {
api.view(r.roomId, r.token).then((v) => this.apply(v)).catch((e: Error) => (r.error = e.message));
};
return watchRoom(r.roomId, r.token, refresh, (open) => (r.connected = open));
}
/** Seat the bot in the empty chair. */
async addBot(): Promise<void> {
const r = this.remote;
if (!r) return;
try {
this.apply(await api.addBot(r.roomId, r.token));
} catch (e) {
r.error = e instanceof Error ? e.message : 'The bot could not be seated.';
}
}
/** A resolved turn has arrived, from the engine here or from the server. */
private turnOver(next: GameState): void {
const finished = this.planned;
this.state = next;
for (const h of HANDS) { for (const h of HANDS) {
const pinned = this.pins[h]; const pinned = this.pins[h];
if (pinned && this.planned.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null; if (pinned && finished.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
} }
this.resetDrafts(); this.resetDrafts();
// On a phone the hands sit far below the ledger; bring the result into view. // On a phone the hands sit far below the ledger; bring the result into view.
@@ -642,14 +763,15 @@ export class Duel {
private resetDrafts(): void { private resetDrafts(): void {
this.sets = blankSets(); this.sets = blankSets();
this.syncAll(); this.syncAll();
this.stabTarget = FOE; this.stabTarget = '';
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone. // Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
const orders: Record<string, TargetId> = {}; const orders: Record<string, TargetId> = {};
for (const m of this.state.monsters) { for (const m of this.state.monsters) {
if (m.owner !== YOU) continue; if (m.owner !== this.me) continue;
const last = m.lastTarget; const last = m.lastTarget;
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE); const standing = last !== undefined && this.others.includes(last) && this.state.wizards[last].alive;
orders[m.id] = valid && last ? last : FOE; const creature = this.state.monsters.some((o) => o.id === last && o.owner !== this.me);
orders[m.id] = last && (standing || creature) ? last : this.foeId;
} }
this.monsterOrders = orders; this.monsterOrders = orders;
this.release = ''; this.release = '';
@@ -659,11 +781,54 @@ export class Duel {
} }
newGame(): void { newGame(): void {
if (this.remote) return;
this.state = createGame(pickNames()); this.state = createGame(pickNames());
this.pins = { left: null, right: null }; this.pins = { left: null, right: null };
this.resetDrafts(); this.resetDrafts();
} }
/** Open a duel for another person on the server, seated as the first wizard. */
static async createRoom(name: string, size = 2): Promise<Remote> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(seated.view, seated.token);
} }
/** The one duel this browser is playing; it survives moving between pages. */ static async joinRoom(roomId: string, name: string): Promise<Remote> {
const seated = await api.join(roomId, name);
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
return remoteFrom(seated.view, seated.token);
}
/** Return to a seat this browser already holds. */
static async resumeRoom(roomId: string, held: HeldSeat): Promise<Remote> {
return remoteFrom(await api.view(roomId, held.token), held.token);
}
}
function remoteFrom(view: RoomView, token: string): Remote {
return {
roomId: view.roomId,
seat: view.me,
token,
size: view.size,
seats: view.seats,
waitingOn: view.waitingOn,
started: view.state !== null,
connected: false,
error: ''
};
}
/** The single-player duel this browser is playing; it survives moving between pages. */
export const duel = new Duel(); export const duel = new Duel();
/** The name the player last chose, for seating them at a shared duel. */
export function playerName(): string {
return readName();
}
/** Keep a chosen name for every duel to come; the local duel takes it at once. */
export function rememberName(raw: string): void {
duel.setName(raw);
}
+40
View File
@@ -406,3 +406,43 @@ describe('lessons', () => {
expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true); expect(s.lastTurn?.lessons.some((l) => l.includes('protects its subject, not its caster'))).toBe(true);
}); });
}); });
describe('more than two wizards', () => {
it('keeps going until one wizard stands, and each sees only what they may', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
expect(s.seats).toEqual(['A', 'B', 'C']);
// Black hurls a finger of death at Grey while White does nothing; the duel continues.
for (let i = 0; i < 8; i++) {
s = resolveTurn(s, {
A: { ...noop(), left: 'PWPFSSSD'[i] as Gesture, casts: i === 7 ? [{ hand: 'left', spellId: 'finger_of_death', target: 'C' }] : [] },
B: noop(),
C: noop()
});
}
expect(s.wizards.C.alive).toBe(false);
expect(s.over).toBeNull();
// A spell with no named target goes at the first opponent still standing.
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(14);
// Grey is out and takes no further part: no gestures, no input needed.
expect(s.wizards.C.history).toHaveLength(8);
// White surrenders; Black is the last wizard standing.
s = resolveTurn(s, { A: noop(), B: { ...noop(), left: 'P', right: 'P' } });
expect(s.over).toEqual({ winner: 'A', reason: 'White surrenders. Black wins the duel.' });
});
it('hides an invisible wizard from every opponent and a blind wizard from everything', () => {
let s = createGame({ A: 'Black', B: 'White', C: 'Grey' }, 1);
const left = 'PPWS';
const right = '--WS';
for (let i = 0; i < 4; i++) {
s = resolveTurn(s, {
A: { ...noop(), left: left[i] as Gesture, right: right[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'invisibility' }] : [] },
B: noop(),
C: noop()
});
}
s = resolveTurn(s, { A: { ...noop(), left: 'F' }, B: noop(), C: noop() });
expect(s.wizards.A.history.at(-1)?.hiddenFrom).toEqual(['B', 'C']);
});
});
+62 -49
View File
@@ -31,6 +31,7 @@ import {
controllerOf, controllerOf,
enemyOf, enemyOf,
findMonster, findMonster,
inDuel,
isWizardId, isWizardId,
nextRandom, nextRandom,
type CastChoice, type CastChoice,
@@ -62,7 +63,7 @@ interface ActiveCast {
nullified?: string; nullified?: string;
} }
const IDS: WizardId[] = ['A', 'B']; const REST: TurnInput = { left: '-', right: '-', casts: [] };
export function paralysedForm(g: Gesture): Gesture { export function paralysedForm(g: Gesture): Gesture {
if (g === 'C') return 'F'; if (g === 'C') return 'F';
@@ -143,14 +144,17 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const state = structuredClone(previous); const state = structuredClone(previous);
if (state.over) return state; if (state.over) return state;
const W = state.wizards; const W = state.wizards;
const seats = state.seats;
/** A seat that is out of the duel makes no gestures; its input, if any, is ignored. */
const input = (id: WizardId): TurnInput => inputs[id] ?? REST;
const actor = state.timeStops.shift() ?? null; const actor = state.timeStops.shift() ?? null;
const phase: Phase = actor ? 'timestop' : 'main'; const phase: Phase = actor ? 'timestop' : 'main';
const acting: WizardId[] = actor ? [actor] : IDS; const acting: WizardId[] = actor ? [actor] : seats.filter((id) => inDuel(W[id]));
/** Time-stop entries and log lines belong to the turn just played. */ /** Time-stop entries and log lines belong to the turn just played. */
const turn = actor ? state.turn - 1 : state.turn; const turn = actor ? state.turn - 1 : state.turn;
const events: string[] = []; const events: string[] = [];
const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage') => { const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage', hiddenFrom?: WizardId[]) => {
state.log.push({ turn, kind, text }); state.log.push(hiddenFrom?.length ? { turn, kind, text, hiddenFrom } : { turn, kind, text });
if (surface) events.push(text); if (surface) events.push(text);
}; };
const rng = () => nextRandom(state); const rng = () => nextRandom(state);
@@ -161,13 +165,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const c = controllerOf(state, id); const c = controllerOf(state, id);
return c !== undefined && frozen(c); return c !== undefined && frozen(c);
}; };
const isWizard = (id: TargetId | undefined): id is WizardId => isWizardId(state, id);
const isBeing = (id: TargetId | undefined): id is TargetId => const isBeing = (id: TargetId | undefined): id is TargetId =>
id !== undefined && id !== NOWHERE && (isWizardId(id) ? W[id].alive : !!findMonster(state, id)); id !== undefined && id !== NOWHERE && (isWizard(id) ? W[id].alive : !!findMonster(state, id));
const monsterOf = (id: TargetId) => findMonster(state, id); const monsterOf = (id: TargetId) => findMonster(state, id);
/** Under time stop, those who cannot move have no resistance to anything. */ /** Under time stop, those who cannot move have no resistance to anything. */
const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => { const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => {
if (frozenBeing(id)) return false; if (frozenBeing(id)) return false;
const b = isWizardId(id) ? W[id] : monsterOf(id); const b = isWizard(id) ? W[id] : monsterOf(id);
if (!b) return false; if (!b) return false;
return type === 'heat' ? b.resistHeat : b.resistCold; return type === 'heat' ? b.resistHeat : b.resistCold;
}; };
@@ -175,10 +180,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const summary: TurnSummary = { const summary: TurnSummary = {
turn, turn,
phase, phase,
wizards: { wizards: Object.fromEntries(seats.map((id) => [id, { before: W[id].hp, after: W[id].hp, effects: [] }])),
A: { before: W.A.hp, after: W.A.hp, effects: [] },
B: { before: W.B.hp, after: W.B.hp, effects: [] }
},
events, events,
lessons: [] lessons: []
}; };
@@ -187,7 +189,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return stripped.charAt(0).toUpperCase() + stripped.slice(1); return stripped.charAt(0).toUpperCase() + stripped.slice(1);
}; };
const note = (id: TargetId, label: string, delta: number) => { const note = (id: TargetId, label: string, delta: number) => {
if (isWizardId(id)) summary.wizards[id].effects.push({ label, delta }); if (isWizard(id)) summary.wizards[id].effects.push({ label, delta });
}; };
if (actor) log(`Time stands still. ${W[actor].name} alone can move.`, 'spell'); if (actor) log(`Time stands still. ${W[actor].name} alone can move.`, 'spell');
/** Facts gathered along the way, turned into explanations at the end. */ /** Facts gathered along the way, turned into explanations at the end. */
@@ -212,7 +214,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return [prev.left, prev.right]; return [prev.left, prev.right];
} }
if (c.charmed) { if (c.charmed) {
const g = c.charmedGesture ?? inputs[c.charmed.by].charmGesture ?? '-'; const g = c.charmedGesture ?? input(c.charmed.by).charmGesture ?? '-';
if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g; if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
if (c.charmed.hand === 'left') left = g; if (c.charmed.hand === 'left') left = g;
else right = g; else right = g;
@@ -241,9 +243,9 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}; };
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]); const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]);
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => { const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
const hiddenFrom: WizardId[] = []; const hiddenFrom = seats.filter(
const foe = enemyOf(w.id); (other) => other !== w.id && (w.invisibleTurns > 0 || W[other].blindTurns > 0 || phase === 'timestop')
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe); );
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name); if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
for (const hand of HANDS) { for (const hand of HANDS) {
const g = hand === 'left' ? left : right; const g = hand === 'left' ? left : right;
@@ -254,13 +256,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
w.history.push({ left, right, turn, phase: entryPhase, hiddenFrom }); w.history.push({ left, right, turn, phase: entryPhase, hiddenFrom });
if (left === 'P' && right === 'P') w.surrendered = true; if (left === 'P' && right === 'P') w.surrendered = true;
const prefix = entryPhase === 'haste' ? `${w.name}, hastened, adds` : `${w.name}:`; const prefix = entryPhase === 'haste' ? `${w.name}, hastened, adds` : `${w.name}:`;
log(`${prefix} left ${lone(left, right)}, right ${lone(right, left)}.`, 'gesture'); log(`${prefix} left ${lone(left, right)}, right ${lone(right, left)}.`, 'gesture', false, hiddenFrom);
}; };
/** History indexes of the entries each acting wizard made this turn. */ /** History indexes of the entries each acting wizard made this turn. */
const entries: Record<WizardId, number[]> = { A: [], B: [] }; const entries: Record<WizardId, number[]> = Object.fromEntries(seats.map((id) => [id, []]));
for (const id of acting) { for (const id of acting) {
const w = W[id]; const w = W[id];
const inp = inputs[id]; const inp = input(id);
w.surrendered = false; w.surrendered = false;
const c = phase === 'main' ? w.constraints : {}; const c = phase === 'main' ? w.constraints : {};
const [prevMain, prevHaste] = previousPairs(w); const [prevMain, prevHaste] = previousPairs(w);
@@ -289,7 +291,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
left: completedSpells(handSequence(upto, 'left'), w.sequenceStart), left: completedSpells(handSequence(upto, 'left'), w.sequenceStart),
right: completedSpells(handSequence(upto, 'right'), w.sequenceStart) right: completedSpells(handSequence(upto, 'right'), w.sequenceStart)
}; };
const choices = half === 0 ? inputs[id].casts : (inputs[id].second?.casts ?? []); const choices = half === 0 ? input(id).casts : (input(id).second?.casts ?? []);
for (const choice of choices) { for (const choice of choices) {
if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue; if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue;
const options = completions[choice.hand].filter((c) => c.spell.id === choice.spellId); const options = completions[choice.hand].filter((c) => c.spell.id === choice.spellId);
@@ -306,7 +308,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle'); log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
continue; continue;
} }
let target = choice.target ?? (completion.spell.usualTarget === 'self' ? id : enemyOf(id)); let target = choice.target ?? (completion.spell.usualTarget === 'self' ? id : enemyOf(state, id));
if (completion.spell.id === 'summon_elemental' && target === NOWHERE) target = id; if (completion.spell.id === 'summon_elemental' && target === NOWHERE) target = id;
const earlier = w.casts.find( const earlier = w.casts.find(
(c) => c.hand === choice.hand && c.index >= 0 && c.index < index && c.index >= index - completion.length + 1 (c) => c.hand === choice.hand && c.index >= 0 && c.index < index && c.index >= index - completion.length + 1
@@ -322,10 +324,10 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
} }
}); });
const release = inputs[id].release; const release = input(id).release;
if (release && w.banked) { if (release && w.banked) {
const spell = SPELL_BY_ID[w.banked.spellId]; const spell = SPELL_BY_ID[w.banked.spellId];
const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(id)); const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(state, id));
accepted.push({ accepted.push({
caster: id, caster: id,
hand: 'left', hand: 'left',
@@ -358,7 +360,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
const destroyedAfterAttack = new Set<string>(); const destroyedAfterAttack = new Set<string>();
const orders = new Map<string, TargetId>(); const orders = new Map<string, TargetId>();
for (const m of state.monsters) { for (const m of state.monsters) {
orders.set(m.id, inputs[m.owner]?.monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(m.owner)); orders.set(m.id, input(m.owner).monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(state, m.owner));
} }
// ---- 3. Dispel magic overrides everything ---- // ---- 3. Dispel magic overrides everything ----
@@ -368,7 +370,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell'); log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell');
for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled'; for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled';
for (const d of dispels) shielded.set(d.target, 'dispel magic'); for (const d of dispels) shielded.set(d.target, 'dispel magic');
for (const id of IDS) clearEnchantments(W[id]); for (const id of seats) clearEnchantments(W[id]);
for (const m of state.monsters) { for (const m of state.monsters) {
clearMonsterEnchantments(m); clearMonsterEnchantments(m);
destroyedAfterAttack.add(m.id); destroyedAfterAttack.add(m.id);
@@ -447,11 +449,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (c.spell.id === 'shield') shielded.set(c.target, 'shield'); if (c.spell.id === 'shield') shielded.set(c.target, 'shield');
if (c.spell.id === 'counter_spell') shielded.set(c.target, 'counter-spell'); if (c.spell.id === 'counter_spell') shielded.set(c.target, 'counter-spell');
if (c.spell.id === 'protection_from_evil') shielded.set(c.target, 'protection from evil'); if (c.spell.id === 'protection_from_evil') shielded.set(c.target, 'protection from evil');
if (c.spell.id === 'counter_spell' && isWizardId(c.target) && c.target !== c.caster) { if (c.spell.id === 'counter_spell' && isWizard(c.target) && c.target !== c.caster) {
facts.counterAimedAway = `${W[c.caster].name}'s counter-spell on ${W[c.target].name}`; facts.counterAimedAway = `${W[c.caster].name}'s counter-spell on ${W[c.target].name}`;
} }
} }
for (const id of IDS) if (W[id].protectionTurns > 0 && !shielded.has(id)) shielded.set(id, 'protection from evil'); for (const id of seats) if (W[id].protectionTurns > 0 && !shielded.has(id)) shielded.set(id, 'protection from evil');
for (const m of state.monsters) if (m.protectionTurns > 0 && !shielded.has(m.id)) shielded.set(m.id, 'protection from evil'); for (const m of state.monsters) if (m.protectionTurns > 0 && !shielded.has(m.id)) shielded.set(m.id, 'protection from evil');
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id); const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
const guard = (id: TargetId) => `${beingName(state, id)}'s ${shielded.get(id) ?? 'shield'}`; const guard = (id: TargetId) => `${beingName(state, id)}'s ${shielded.get(id) ?? 'shield'}`;
@@ -492,7 +494,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
createdTurn: turn createdTurn: turn
}; };
state.monsters.push(m); state.monsters.push(m);
orders.set(m.id, c.choice.monsterTarget ?? enemyOf(controller)); orders.set(m.id, c.choice.monsterTarget ?? enemyOf(state, controller));
log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell'); log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell');
} }
@@ -571,7 +573,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle'); log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle');
} }
for (const c of live()) { for (const c of live()) {
const wiz = isWizardId(c.target) ? W[c.target] : undefined; const wiz = isWizard(c.target) ? W[c.target] : undefined;
const mon = monsterOf(c.target); const mon = monsterOf(c.target);
const target = beingName(state, c.target); const target = beingName(state, c.target);
const forever = c.permanent ? ' for the rest of the duel' : ''; const forever = c.permanent ? ' for the rest of the duel' : '';
@@ -588,7 +590,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
wiz.pending.confusion = true; wiz.pending.confusion = true;
if (c.permanent) wiz.permanent.confusion = true; if (c.permanent) wiz.permanent.confusion = true;
} else if (mon) { } else if (mon) {
const candidates = [...IDS.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)]; const candidates = [...seats.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)];
const t = pick(candidates as TargetId[]); const t = pick(candidates as TargetId[]);
orders.set(mon.id, t); orders.set(mon.id, t);
log(`${mon.name} is confused and lunges at ${beingName(state, t)}.`); log(`${mon.name} is confused and lunges at ${beingName(state, t)}.`);
@@ -608,7 +610,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
case 'charm_monster': case 'charm_monster':
if (mon && !isElemental(mon.kind)) { if (mon && !isElemental(mon.kind)) {
mon.owner = c.caster; mon.owner = c.caster;
orders.set(mon.id, inputs[c.caster].monsterOrders?.[mon.id] ?? enemyOf(c.caster)); orders.set(mon.id, input(c.caster).monsterOrders?.[mon.id] ?? enemyOf(state, c.caster));
log(`${mon.name} now serves ${W[c.caster].name}.`); log(`${mon.name} now serves ${W[c.caster].name}.`);
} else { } else {
c.nullified = 'not a charmable monster'; c.nullified = 'not a charmable monster';
@@ -710,7 +712,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const id of extendedNow) W[id].permanencyTurns = 0; for (const id of extendedNow) W[id].permanencyTurns = 0;
for (const c of live()) { for (const c of live()) {
if (c.spell.id !== 'remove_enchantment' && c.spell.id !== 'raise_dead') continue; if (c.spell.id !== 'remove_enchantment' && c.spell.id !== 'raise_dead') continue;
const wiz = isWizardId(c.target) ? W[c.target] : undefined; const wiz = isWizard(c.target) ? W[c.target] : undefined;
const mon = monsterOf(c.target); const mon = monsterOf(c.target);
if (wiz) { if (wiz) {
clearEnchantments(wiz); clearEnchantments(wiz);
@@ -745,7 +747,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break; break;
case 'finger_of_death': case 'finger_of_death':
slain.add(t); slain.add(t);
note(t, 'Finger of death', -(isWizardId(t) ? W[t].hp : 0)); note(t, 'Finger of death', -(isWizard(t) ? W[t].hp : 0));
log(`The finger of death touches ${beingName(state, t)}.`, 'death'); log(`The finger of death touches ${beingName(state, t)}.`, 'death');
break; break;
case 'lightning_bolt': case 'lightning_bolt':
@@ -773,7 +775,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break; break;
case 'cure_heavy_wounds': case 'cure_heavy_wounds':
heal(t, 2, 'Cure heavy wounds'); heal(t, 2, 'Cure heavy wounds');
if (isWizardId(t) && W[t].diseaseTurns !== null) { if (isWizard(t) && W[t].diseaseTurns !== null) {
W[t].diseaseTurns = null; W[t].diseaseTurns = null;
log(`${W[t].name}'s disease is cured.`); log(`${W[t].name}'s disease is cured.`);
} }
@@ -784,7 +786,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
} }
const everyone = (): TargetId[] => [ const everyone = (): TargetId[] => [
...IDS.filter((id) => W[id].alive), ...seats.filter((id) => W[id].alive),
...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id) ...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id)
]; ];
if (fireStorm) { if (fireStorm) {
@@ -821,7 +823,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (phase === 'main') { if (phase === 'main') {
if (resists(id, type)) continue; if (resists(id, type)) continue;
if (isShielded(id)) continue; if (isShielded(id)) continue;
if (isWizardId(id) && W[id].invisibleTurns > 0) continue; if (isWizard(id) && W[id].invisibleTurns > 0) continue;
} }
hurt(id, m.attack, `the ${m.name.toLowerCase()}`); hurt(id, m.attack, `the ${m.name.toLowerCase()}`);
} }
@@ -836,7 +838,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (isShielded(target)) { if (isShielded(target)) {
facts.shieldBlocked.add(target); facts.shieldBlocked.add(target);
log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle'); log(`${m.name}'s blows glance off ${guard(target)}.`, 'fizzle');
} else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) { } else if (isWizard(target) && W[target].invisibleTurns > 0 && !frozen(target)) {
log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle'); log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle');
} else { } else {
if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name; if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name;
@@ -850,7 +852,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
entries[id].forEach((index, half) => { entries[id].forEach((index, half) => {
const g = w.history[index]; const g = w.history[index];
if (g.left !== '>' && g.right !== '>') return; if (g.left !== '>' && g.right !== '>') return;
const target = (half === 0 ? inputs[id].stabTarget : inputs[id].second?.stabTarget) ?? enemyOf(id); const target = (half === 0 ? input(id).stabTarget : input(id).second?.stabTarget) ?? enemyOf(state, id);
if (target === id || !isBeing(target)) { if (target === id || !isBeing(target)) {
log(`${w.name} stabs at nothing.`, 'fizzle'); log(`${w.name} stabs at nothing.`, 'fizzle');
} else if (isShielded(target)) { } else if (isShielded(target)) {
@@ -863,7 +865,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
// ---- 12. Apply the ledger ---- // ---- 12. Apply the ledger ----
for (const id of IDS) { for (const id of seats) {
const w = W[id]; const w = W[id];
if (!w.alive) continue; if (!w.alive) continue;
const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0); const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0);
@@ -895,7 +897,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
// ---- 13. The turn ends ---- // ---- 13. The turn ends ----
if (phase === 'main') { if (phase === 'main') {
for (const id of IDS) { for (const id of seats) {
const w = W[id]; const w = W[id];
w.constraints = { ...w.pending, ...w.permanent }; w.constraints = { ...w.pending, ...w.permanent };
w.pending = {}; w.pending = {};
@@ -931,7 +933,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
} else { } else {
// A stopped moment: nothing runs down, but what was set in it carries into the next turn. // A stopped moment: nothing runs down, but what was set in it carries into the next turn.
for (const id of IDS) { for (const id of seats) {
const w = W[id]; const w = W[id];
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent }; w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
w.pending = {}; w.pending = {};
@@ -942,20 +944,31 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
} }
} }
const a = W.A; // A wizard who is out takes their creatures with them: nobody is left to command them.
const b = W.B; state.monsters = state.monsters.filter((m) => inDuel(W[m.owner]));
if (!a.alive && !b.alive) state.over = { winner: null, reason: 'Both wizards fall together. A posthumous draw.' }; const standing = seats.filter((id) => inDuel(W[id]));
else if (!a.alive) state.over = { winner: 'B', reason: `${a.name} is dead. ${b.name} wins the duel.` }; const fell = (id: WizardId) => (W[id].alive ? `${W[id].name} surrenders` : `${W[id].name} is dead`);
else if (!b.alive) state.over = { winner: 'A', reason: `${b.name} is dead. ${a.name} wins the duel.` }; const wonBy = (winner: WizardId) => {
else if (a.surrendered && b.surrendered) state.over = { winner: null, reason: 'Both wizards surrender at once. A draw.' }; // Name those who went out this turn; the rest were already gone.
else if (a.surrendered) state.over = { winner: 'B', reason: `${a.name} surrenders. ${b.name} wins the duel.` }; const gone = seats.filter((id) => id !== winner && !inDuel(W[id]));
else if (b.surrendered) state.over = { winner: 'A', reason: `${b.name} surrenders. ${a.name} wins the duel.` }; const thisTurn = gone.filter((id) => inDuel(previous.wizards[id]));
return { winner, reason: `${(thisTurn.length ? thisTurn : gone).map(fell).join('; ')}. ${W[winner].name} wins the duel.` };
};
if (standing.length === 0) {
// A wizard who surrenders as the last opponent dies is not the loser: the rules let the killing spells count.
const survivors = seats.filter((id) => W[id].alive);
if (survivors.length === 1) state.over = wonBy(survivors[0]);
else if (survivors.length === 0) state.over = { winner: null, reason: 'Every wizard falls together. A posthumous draw.' };
else state.over = { winner: null, reason: 'Every wizard yields at once. A draw.' };
} else if (standing.length === 1 && seats.length > 1) {
state.over = wonBy(standing[0]);
}
if (state.over) { if (state.over) {
log(state.over.reason, 'death'); log(state.over.reason, 'death');
state.timeStops = []; state.timeStops = [];
} }
for (const id of IDS) summary.wizards[id].after = W[id].hp; for (const id of seats) summary.wizards[id].after = W[id].hp;
const lessons: { key: string; text: string }[] = []; const lessons: { key: string; text: string }[] = [];
const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target)); const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target));
if (through) { if (through) {
+27 -9
View File
@@ -1,7 +1,8 @@
import type { TurnGestures } from './gestures'; import type { TurnGestures } from './gestures';
import type { Gesture, Hand, MonsterKind, SpellId } from './spells'; import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
export type WizardId = 'A' | 'B'; /** A seat at the table. The single-player duel uses 'A' for the player and 'B' for the bot. */
export type WizardId = string;
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */ /** A wizard, a monster id, or nowhere (a spell released harmlessly). */
export type TargetId = WizardId | string; export type TargetId = WizardId | string;
export const NOWHERE = 'nowhere'; export const NOWHERE = 'nowhere';
@@ -106,6 +107,8 @@ export interface LogEntry {
turn: number; turn: number;
kind: LogKind; kind: LogKind;
text: string; text: string;
/** Wizards who may not read this line: it describes gestures they could not see. */
hiddenFrom?: WizardId[];
} }
/** What a reveal did to each wizard's health, for the result strip. */ /** What a reveal did to each wizard's health, for the result strip. */
@@ -126,6 +129,8 @@ export interface Outcome {
export interface GameState { export interface GameState {
turn: number; turn: number;
/** Seats in table order; every wizard in play, whether still standing or not. */
seats: WizardId[];
wizards: Record<WizardId, WizardState>; wizards: Record<WizardId, WizardState>;
monsters: Monster[]; monsters: Monster[];
log: LogEntry[]; log: LogEntry[];
@@ -175,8 +180,19 @@ export function isEnchanted(w: WizardState): boolean {
return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0; return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0;
} }
export function enemyOf(id: WizardId): WizardId { /** Still gesturing: neither dead nor surrendered. */
return id === 'A' ? 'B' : 'A'; export function inDuel(w: WizardState): boolean {
return w.alive && !w.surrendered;
}
/** The other wizards still in the duel, in table order. */
export function opponentsOf(state: GameState, id: WizardId): WizardId[] {
return state.seats.filter((s) => s !== id && inDuel(state.wizards[s]));
}
/** The wizard a spell goes at when no target is named: the first opponent still standing. */
export function enemyOf(state: GameState, id: WizardId): WizardId {
return opponentsOf(state, id)[0] ?? state.seats.find((s) => s !== id) ?? id;
} }
function makeWizard(id: WizardId, name: string): WizardState { function makeWizard(id: WizardId, name: string): WizardState {
@@ -207,10 +223,12 @@ function makeWizard(id: WizardId, name: string): WizardState {
}; };
} }
export function createGame(names: { A: string; B: string }, seed = Date.now()): GameState { export function createGame(names: Record<WizardId, string>, seed = Date.now()): GameState {
const seats = Object.keys(names);
return { return {
turn: 0, turn: 0,
wizards: { A: makeWizard('A', names.A), B: makeWizard('B', names.B) }, seats,
wizards: Object.fromEntries(seats.map((id) => [id, makeWizard(id, names[id])])),
monsters: [], monsters: [],
log: [], log: [],
over: null, over: null,
@@ -232,8 +250,8 @@ export function nextRandom(state: GameState): number {
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
} }
export function isWizardId(id: TargetId | undefined): id is WizardId { export function isWizardId(state: GameState, id: TargetId | undefined): id is WizardId {
return id === 'A' || id === 'B'; return id !== undefined && id in state.wizards;
} }
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined { export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
@@ -242,13 +260,13 @@ export function findMonster(state: GameState, id: TargetId | undefined): Monster
/** The wizard who commands a being. */ /** The wizard who commands a being. */
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined { export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
if (isWizardId(id)) return id; if (isWizardId(state, id)) return id;
return findMonster(state, id)?.owner; return findMonster(state, id)?.owner;
} }
export function beingName(state: GameState, id: TargetId | undefined): string { export function beingName(state: GameState, id: TargetId | undefined): string {
if (id === undefined || id === NOWHERE) return 'nowhere'; if (id === undefined || id === NOWHERE) return 'nowhere';
if (isWizardId(id)) return state.wizards[id].name; if (isWizardId(state, id)) return state.wizards[id].name;
return findMonster(state, id)?.name ?? 'a vanished creature'; return findMonster(state, id)?.name ?? 'a vanished creature';
} }
+2 -2
View File
@@ -5,9 +5,9 @@ import { isElemental, type Spell } from './spells';
import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state'; import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state';
export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId { export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId {
const foe = enemyOf(me); const foe = enemyOf(state, me);
const monsters = state.monsters; const monsters = state.monsters;
const foeMonsters = monsters.filter((m) => m.owner === foe && !isElemental(m.kind)); const foeMonsters = monsters.filter((m) => m.owner !== me && !isElemental(m.kind));
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0]; const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
const iceElemental = monsters.find((m) => m.kind === 'ice_elemental'); const iceElemental = monsters.find((m) => m.kind === 'ice_elemental');
const fireElemental = monsters.find((m) => m.kind === 'fire_elemental'); const fireElemental = monsters.find((m) => m.kind === 'fire_elemental');
+28
View File
@@ -0,0 +1,28 @@
// What one wizard is allowed to know. Gestures made while they were blind, or
// while the other wizard was invisible, are unknown to them; the log lines that
// describe those gestures are withheld too. Spells cast are always visible, as
// the rules say.
import { visibleHistory, type GameState, type WizardId } from './state';
export function viewFor(state: GameState, viewer: WizardId): GameState {
const view = structuredClone(state);
for (const id of view.seats) {
if (id === viewer) continue;
view.wizards[id].history = visibleHistory(state, viewer, id);
}
view.log = view.log.filter((l) => !l.hiddenFrom?.includes(viewer));
return view;
}
/** What a seated player is told by the server. Other seats' moves and tokens never leave it. */
export interface RoomView {
roomId: string;
seq: number;
size: number;
me: WizardId;
seats: { id: WizardId; name: string; bot: boolean }[];
/** Seats that still have to move before the turn resolves. */
waitingOn: WizardId[];
state: GameState | null;
}
+12 -527
View File
@@ -1,552 +1,37 @@
<script lang="ts"> <script lang="ts">
import Chronicle from '$lib/components/Chronicle.svelte'; import Hall from '$lib/components/Hall.svelte';
import HandPicker from '$lib/components/HandPicker.svelte';
import Ledger from '$lib/components/Ledger.svelte';
import MobileBar from '$lib/components/MobileBar.svelte';
import SpellSheet from '$lib/components/SpellSheet.svelte';
import TurnSummary from '$lib/components/TurnSummary.svelte';
import WizardStatus from '$lib/components/WizardStatus.svelte';
import { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
let confirmingNew = $state(false);
let renaming = $state(false);
let nameDraft = $state('');
function startRename() {
nameDraft = duel.you.name;
renaming = true;
}
function saveName(e: SubmitEvent) {
e.preventDefault();
duel.setName(nameDraft);
renaming = false;
}
function startNew() {
if (duel.inProgress && !confirmingNew) {
confirmingNew = true;
return;
}
confirmingNew = false;
duel.newGame();
}
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner === FOE));
const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === YOU);
</script> </script>
<svelte:head> <svelte:head>
<title>Waving Hands</title> <title>Waving Hands</title>
</svelte:head> </svelte:head>
<header class="masthead"> <main class="hall-page">
<div> <Hall />
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label class="field">
<span>Your name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
</label>
<button type="submit" class="reveal small">Save</button>
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
</form>
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#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}
</p>
{/if}
</div>
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
</nav>
</header>
{#if confirmingNew}
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
<div class="choices">
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
</div>
</div>
{/if}
<main class="board">
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
{#if outcome}
<div class="verdict" id="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p>
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
</div>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
{:else}
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
{/if}
{#if duel.threats.length && !duel.timeStopped}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
<ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from}
<li>
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → <strong>{tokensText(t.remaining)}</strong></span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if duel.threatsNext.length}
<details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)} → {tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{:else if !duel.timeStopped}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
<div class="hands">
<HandPicker {duel} hand="left" />
<HandPicker {duel} hand="right" />
</div>
<div class="prefs">
<label class="pref">
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
Show spells under way beneath each hand
</label>
<label class="pref">
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
Explain rules as they come up
</label>
</div>
{#if duel.hasted}
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
<div class="hands">
<HandPicker {duel} hand="left" set="haste" />
<HandPicker {duel} hand="right" set="haste" />
</div>
{/if}
<div class="orders">
{#if duel.stabbing}
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
<option value={FOE}>{duel.foe.name}</option>
{#each foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label class="field">
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
<option value={FOE}>{duel.foe.name}</option>
{#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option>
{/each}
</select>
</label>
{/each}
{#if duel.bankedSpell}
<label class="field">
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
<option value={t.id}>at {t.label}</option>
{/each}
</select>
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label class="field">
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label class="field">
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
<label class="field">
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
<select bind:value={duel.charmGesture}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
{/if}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
</section>
<aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
<WizardStatus wizard={duel.foe} monsters={foeMonsters} />
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
</main>
<MobileBar {duel} />
<footer class="colophon"> <footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p> <p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer> </footer>
</main>
<style> <style>
.masthead { .hall-page {
min-height: 100vh;
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.5rem 0.75rem; padding: clamp(1rem, 4vh, 3rem) var(--gutter) 2rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
details.next summary {
cursor: pointer;
list-style: none;
}
details.next summary::before {
content: '\25B8 ';
color: var(--bone-faint);
}
details.next[open] summary::before {
content: '\25BE ';
}
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
.play {
min-width: 0;
}
.rail {
min-width: 0;
}
.move {
padding: 0.6rem 0 1.5rem;
}
.move h2 {
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
.threats {
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
.threats ul {
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.threat {
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
.threat.soon {
color: var(--bone);
}
.threat:hover,
.threat.active {
border-color: var(--frost);
}
.threat.active {
background: rgba(140, 200, 224, 0.12);
}
.thand {
font-style: italic;
font-size: 0.8rem;
}
.tseq {
letter-spacing: 0.04em;
}
.tseq strong {
font-weight: 600;
color: var(--ember);
}
.prefs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
.pref {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
.orders {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
}
.haste-note {
color: var(--frost);
font-size: 0.95rem;
margin: 1rem 0 0.6rem;
}
.move > .reveal {
margin-top: 0.8rem;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.verdict {
padding: 1.5rem 0;
}
.verdict h2 {
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
.verdict.won h2 {
color: var(--ember);
}
.verdict p {
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
} }
.colophon { .colophon {
max-width: 1280px; max-width: 54rem;
margin: 0 auto; width: 100%;
padding: 2rem var(--gutter) 3rem; padding: 1.5rem 0 0;
color: var(--bone-faint); color: var(--bone-faint);
font-size: 0.85rem; font-size: 0.85rem;
text-align: center;
} }
.colophon a { .colophon a {
color: var(--frost); color: var(--frost);
} }
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
.colophon {
padding-bottom: 6rem;
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
display: none;
}
.rail {
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
.hands {
grid-template-columns: 1fr;
}
.masthead {
flex-direction: column;
}
}
</style> </style>
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import { page } from '$app/state';
import Board from '$lib/components/Board.svelte';
import { seatFor } from '$lib/game/client';
import { COMPACT_QUERY, Duel, NAME_MAX, playerName } from '$lib/game/duel.svelte';
const roomId = $derived((page.params.code ?? '').toUpperCase());
let room = $state<Duel | null>(null);
let name = $state('');
let joining = $state(false);
let busy = $state(false);
let error = $state('');
let copied = $state(false);
const link = $derived(typeof location === 'undefined' ? '' : `${location.origin}/join/${roomId}`);
const lobby = $derived(room !== null && !room.remote?.started);
// Return to a held seat, or offer one.
$effect(() => {
const id = roomId;
room = null;
error = '';
const held = seatFor(id);
if (!held) {
name = playerName();
joining = true;
return;
}
joining = false;
Duel.resumeRoom(id, held)
.then((remote) => (room = new Duel(remote)))
.catch((e: Error) => (error = e.message));
});
// Keep the view fresh for as long as the room is on screen.
$effect(() => {
const r = room;
if (!r) return;
const stop = r.connect();
return stop;
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const r = room;
if (!r) return;
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (r.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
async function takeSeat(e: SubmitEvent) {
e.preventDefault();
busy = true;
error = '';
try {
room = new Duel(await Duel.joinRoom(roomId, name));
joining = false;
} catch (err) {
error = err instanceof Error ? err.message : 'The seat could not be taken.';
} finally {
busy = false;
}
}
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.
}
}
</script>
<svelte:head>
<title>Waving Hands: a duel</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if room?.remote?.started}
<p class="standfirst">
You are <em>{room.you.name}</em>, duelling
{#each room.others as id, i (id)}{#if i > 0}{i === room.others.length - 1 ? ' and ' : ', '}{/if}<em>{room.state.wizards[id].name}</em>{/each}.
{#if !room.remote.connected}<span class="muted">Reconnecting to the duel.</span>{/if}
</p>
{:else}
<p class="standfirst">A duel between people, played by turns whenever each of you has a moment.</p>
{/if}
</div>
<nav class="actions">
{#if room}
<button type="button" class="quiet room" title="Copy the invite link" onclick={copyLink}>
<span class="muted">invite friends · room</span> <b>{roomId}</b>{copied ? ' · link copied' : ''}
</button>
{/if}
<a class="quiet" href="/rules">Rules</a>
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if error}
<p class="notice">{error}</p>
{/if}
{#if joining}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Take a seat</h2>
<p>Another wizard has opened this duel and is waiting for an opponent.</p>
<form class="rename" onsubmit={takeSeat}>
<label class="field">
<span>Your name</span>
<input type="text" bind:value={name} maxlength={NAME_MAX} autocomplete="nickname" required />
</label>
<button type="submit" class="reveal small" disabled={busy}>Sit down</button>
</form>
</section>
{:else if room && lobby}
<section class="lobby">
<p class="eyebrow">room {roomId}</p>
<h2>Waiting for {room.remote!.size - room.remote!.seats.length === 1 ? 'one more wizard' : `${room.remote!.size - room.remote!.seats.length} more wizards`}</h2>
<p>Send this link, or the code. Whoever opens it takes the empty seat, and the duel begins.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
<p class="muted">so far: {room.remote!.seats.map((s) => s.name).join(', ')}</p>
<p><button type="button" class="quiet" onclick={() => room?.addBot()}>Seat the bot instead</button></p>
</section>
{:else if room}
<Board duel={room} />
{:else if !error}
<p class="notice muted">Finding your seat.</p>
{/if}
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.quiet.room b {
letter-spacing: 0.12em;
font-weight: 600;
}
.eyebrow {
font-size: 0.85rem;
letter-spacing: 0.12em;
color: var(--bone-faint);
margin-bottom: 0.2rem;
}
.notice {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--gutter) 0.5rem;
color: var(--blood);
}
.notice.muted {
color: var(--bone-dim);
}
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.8rem;
}
.rename input {
width: 14em;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.masthead {
flex-direction: column;
}
.colophon {
padding-bottom: 6rem;
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
// A room is known only at runtime: the fallback shell serves it and the client reads the code from the address.
export const prerender = false;
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Board from '$lib/components/Board.svelte';
import { COMPACT_QUERY, duel, NAME_MAX } from '$lib/game/duel.svelte';
let confirmingNew = $state(false);
let renaming = $state(false);
let nameDraft = $state('');
function startRename() {
nameDraft = duel.you.name;
renaming = true;
}
function saveName(e: SubmitEvent) {
e.preventDefault();
duel.setName(nameDraft);
renaming = false;
}
function startNew() {
if (duel.inProgress && !confirmingNew) {
confirmingNew = true;
return;
}
confirmingNew = false;
duel.newGame();
}
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
</script>
<svelte:head>
<title>Waving Hands: against the bot</title>
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label class="field">
<span>Your name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
</label>
<button type="submit" class="reveal small">Save</button>
<button type="button" class="quiet" onclick={() => (renaming = false)}>Cancel</button>
</form>
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#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}
</p>
{/if}
</div>
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
<a class="quiet" href="/">The hall</a>
</nav>
</header>
{#if confirmingNew}
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
<div class="choices">
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
</div>
</div>
{/if}
<Board {duel} />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
}
.rename {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-top: 0.4rem;
}
.rename input {
width: 14em;
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.colophon {
padding-bottom: 6rem;
}
.masthead {
flex-direction: column;
}
}
</style>
+7
View File
@@ -14,6 +14,13 @@ export default defineConfig({
adapter: adapter({ fallback: 'index.html' }) adapter: adapter({ fallback: 'index.html' })
}) })
], ],
server: {
// In development the duel server runs beside Vite; in production Caddy does this.
proxy: {
'/api': 'http://localhost:8788',
'/ws': { target: 'ws://localhost:8788', ws: true }
}
},
test: { test: {
include: ['src/**/*.test.ts'] include: ['src/**/*.test.ts']
} }