Compare commits
7
Commits
cf58193aac
...
605f8c511b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
605f8c511b | ||
|
|
29e908efeb | ||
|
|
6369765378 | ||
|
|
6c6edca4be | ||
|
|
e1bda87987 | ||
|
|
8366c9d7ff | ||
|
|
95a1915cae |
No files matched your search
@@ -20,3 +20,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Playwright MCP output
|
||||
.playwright-mcp
|
||||
|
||||
# Duel ledgers written by the server
|
||||
/data
|
||||
@@ -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
@@ -1,16 +1,18 @@
|
||||
# Deploying Waving Hands
|
||||
|
||||
The game runs entirely in the browser: the duel state lives in local storage
|
||||
and nothing is rendered or stored on a server. Production is therefore a
|
||||
single DigitalOcean droplet running only Caddy, which terminates TLS with
|
||||
automatic certificates and serves the static build from
|
||||
/opt/waving-hands/build. No Node, no service, no database.
|
||||
The single-player game runs entirely in the browser. Duels between people
|
||||
go through a small Node process that keeps each room as an append-only
|
||||
ledger of moves in /var/lib/waving-hands/rooms. Production is one
|
||||
DigitalOcean droplet: Caddy terminates TLS with automatic certificates,
|
||||
serves the static build from /opt/waving-hands/build, and proxies /api and
|
||||
/ws to the duel server on port 8788, which runs as the `waving-hands` user
|
||||
under systemd from /opt/waving-hands/app.
|
||||
|
||||
## Sizing
|
||||
|
||||
The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build
|
||||
is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to
|
||||
`s-1vcpu-1gb` only if the site ever gains a server side.
|
||||
The smallest droplet, `s-1vcpu-512mb-10gb` ($4 a month), carries both Caddy
|
||||
and the duel server comfortably: the server is one small Node process capped
|
||||
at 300 MB by its unit file, and a room is a few kilobytes of ledger.
|
||||
|
||||
The zero-cost alternative is a second site block in an existing Caddy
|
||||
server's configuration pointing at a second directory; the deploy script
|
||||
@@ -31,10 +33,11 @@ uptime and upgrades independent of anything else.
|
||||
1. `doctl compute droplet create waving-hands --region nyc3 \
|
||||
--size s-1vcpu-512mb-10gb --image ubuntu-24-04-x64 \
|
||||
--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'"`
|
||||
(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
|
||||
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>
|
||||
|
||||
Runs the type-check, the tests and the build locally, rsyncs `build/` to the
|
||||
droplet keeping the previous week's hashed assets, and reloads Caddy. A
|
||||
player mid-duel loses nothing: their game is in their own browser, and the
|
||||
next page load picks up the new build.
|
||||
Runs the type-checks, the tests and the build locally, rsyncs `build/` to
|
||||
the droplet keeping the previous week's hashed assets, rsyncs the server and
|
||||
engine sources, installs dependencies, and restarts the duel server. A
|
||||
single-player game is in the player's own browser and loses nothing. A room
|
||||
is replayed from its ledger when the server comes back, which takes a few
|
||||
seconds; Caddy holds requests that land in the gap.
|
||||
|
||||
## Operations
|
||||
|
||||
- Logs: `ssh root@<ip> journalctl -u caddy -f`, access log in
|
||||
/var/lib/caddy/access.log
|
||||
- Nothing to back up on the server. Player duels are in their browsers.
|
||||
- Logs: `ssh root@<ip> journalctl -u waving-hands -f` for the duel server,
|
||||
`journalctl -u caddy -f` and /var/lib/caddy/access.log for the web side.
|
||||
- Restart: `ssh root@<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
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build locally and push the static site to the droplet.
|
||||
# Usage: deploy/deploy.sh <droplet-ip-or-host>
|
||||
# Build locally, push the static site and the duel server to the droplet,
|
||||
# and restart the server. Usage: deploy/deploy.sh <droplet-ip-or-host>
|
||||
set -euo pipefail
|
||||
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
||||
|
||||
npm run check
|
||||
npm run check:server
|
||||
npm test
|
||||
npm run build
|
||||
|
||||
@@ -12,11 +13,27 @@ npm run build
|
||||
# fetch the module it was built against instead of failing mid-duel.
|
||||
rsync -az --delete --filter='P _app/immutable/*' \
|
||||
build/ "root@$HOST:/opt/waving-hands/build/"
|
||||
|
||||
# The server runs from source with tsx 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" '
|
||||
find /opt/waving-hands/build/_app/immutable -type f -mtime +7 -delete
|
||||
chown -R root:caddy /opt/waving-hands
|
||||
chmod -R g+rX /opt/waving-hands
|
||||
chown -R root:caddy /opt/waving-hands/build
|
||||
chmod -R g+rX /opt/waving-hands/build
|
||||
cd /opt/waving-hands/app/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 --no-pager -l status caddy | head -3
|
||||
sleep 1
|
||||
systemctl --no-pager -l status waving-hands | head -3
|
||||
'
|
||||
echo "deployed."
|
||||
+5
-34
@@ -1,5 +1,6 @@
|
||||
#!/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'
|
||||
# The argument is the Caddy site address line: one name, or several
|
||||
# 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
|
||||
chmod -R g+rX /opt/waving-hands
|
||||
|
||||
# Caddy vhost: auto-TLS, security headers, static files. SvelteKit writes
|
||||
# each route as <route>.html, so /rules is tried as /rules.html before
|
||||
# falling back to the app shell. 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, 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
|
||||
# Caddy: the site and the duel server's paths, from the template beside this script.
|
||||
sed "s|__HOST__|$HOST|" "$(dirname "$0")/Caddyfile.tmpl" > /etc/caddy/Caddyfile
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
|
||||
# Firewall: ssh + web only.
|
||||
|
||||
Executable
+25
@@ -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"
|
||||
@@ -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
|
||||
Generated
+557
@@ -7,17 +7,465 @@
|
||||
"": {
|
||||
"name": "waving-hands",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -488,6 +936,26 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||
@@ -659,6 +1127,48 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||
@@ -1350,6 +1860,25 @@
|
||||
"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": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@@ -1364,6 +1893,13 @@
|
||||
"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": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
@@ -1572,6 +2108,27 @@
|
||||
"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": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
|
||||
|
||||
+10
-1
@@ -11,16 +11,25 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"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": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
}
|
||||
Generated
+524
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -46,7 +46,8 @@ p {
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
select,
|
||||
input {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { SPELL_BY_ID, type Hand } from '$lib/game/spells';
|
||||
import type { HistoryEntry, WizardId } from '$lib/game/state';
|
||||
@@ -21,13 +21,16 @@
|
||||
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[] {
|
||||
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 out: Placed[] = [];
|
||||
history.forEach((entry, index) => {
|
||||
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;
|
||||
@@ -36,10 +39,14 @@
|
||||
const rows = $derived.by(() => {
|
||||
const out: Row[] = [];
|
||||
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']) } });
|
||||
for (const who of [YOU, FOE] as WizardId[]) {
|
||||
const cells: Record<WizardId, Placed[]> = {};
|
||||
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'])) {
|
||||
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 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[] {
|
||||
return duel.state.wizards[who].casts
|
||||
@@ -55,6 +62,11 @@
|
||||
.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(() => {
|
||||
void turns;
|
||||
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||
@@ -66,29 +78,29 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
|
||||
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
|
||||
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
|
||||
{#each columns as who (who)}
|
||||
<th class:you={who === duel.me} class:foe={who !== duel.me} colspan="2" scope="colgroup">{duel.state.wizards[who].name}{#if who === duel.me} (you){/if}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
<tr class="hands">
|
||||
<th></th>
|
||||
{#each columns as who (who)}
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
<th scope="col">left</th>
|
||||
<th scope="col">right</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.key)}
|
||||
<tr class:stopped={!!row.note}>
|
||||
<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)}
|
||||
<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)}
|
||||
{@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 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>
|
||||
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
|
||||
</span>
|
||||
@@ -113,8 +125,10 @@
|
||||
{/each}
|
||||
</td>
|
||||
{/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>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
@@ -193,10 +207,14 @@
|
||||
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);
|
||||
}
|
||||
|
||||
td.cell:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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();
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<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>
|
||||
<dl>
|
||||
{#each [YOU, FOE] as const as who (who)}
|
||||
{#each duel.state.seats as who (who)}
|
||||
{@const w = summary.wizards[who]}
|
||||
<div>
|
||||
<dt>{who === YOU ? 'You' : duel.foe.name}</dt>
|
||||
<dt>{who === duel.me ? 'You' : duel.state.wizards[who].name}</dt>
|
||||
<dd>
|
||||
{#if w.effects.length === 0}
|
||||
<span class="muted">untouched</span>
|
||||
|
||||
+27
-14
@@ -22,6 +22,7 @@ import {
|
||||
MAX_HP,
|
||||
enemyOf,
|
||||
isEnchanted,
|
||||
opponentsOf,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -90,40 +91,52 @@ function hash01(text: string): number {
|
||||
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 {
|
||||
const foe = enemyOf(me);
|
||||
const foeW = state.wizards[foe];
|
||||
const hist = visibleHistory(state, me, foe);
|
||||
const foeSeq: Record<Hand, HandTurn[]> = {
|
||||
left: handSequence(hist, 'left'),
|
||||
right: handSequence(hist, 'right')
|
||||
const foe = pickFoe(state, me);
|
||||
const foes = opponentsOf(state, me);
|
||||
const seqOf = (id: WizardId): Record<Hand, HandTurn[]> => {
|
||||
const hist = visibleHistory(state, me, id);
|
||||
return { left: handSequence(hist, 'left'), right: handSequence(hist, 'right') };
|
||||
};
|
||||
const foeSeq = seqOf(foe);
|
||||
let threatNow = 0;
|
||||
let threatNowCounterable = 0;
|
||||
let threatNext = 0;
|
||||
let missileNow = false;
|
||||
let foeFirePlan = 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 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;
|
||||
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
||||
threatNow = Math.max(threatNow, v);
|
||||
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
||||
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;
|
||||
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
||||
}
|
||||
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 === 'ice_storm') foeIcePlan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
return {
|
||||
state,
|
||||
@@ -147,7 +160,7 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
||||
function baseValue(s: Situation, spell: Spell): number {
|
||||
const meW = s.state.wizards[s.me];
|
||||
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);
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
@@ -394,7 +407,7 @@ function choosePair(s: Situation, history: TurnGestures[], timeStopped: boolean)
|
||||
if (left === 'P' && right === 'P') score -= 100;
|
||||
if (left === '>' && right === '>') score -= 50;
|
||||
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;
|
||||
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 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> = {};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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
@@ -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 { chooseBotTurn } from './bot';
|
||||
import { api, rememberSeat, watchRoom, type HeldSeat } from './client';
|
||||
import {
|
||||
cellsOverlap,
|
||||
completableIn,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
import {
|
||||
NOWHERE,
|
||||
createGame,
|
||||
enemyOf,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
@@ -39,9 +43,11 @@ import {
|
||||
type TurnInput,
|
||||
type WizardId
|
||||
} from './state';
|
||||
import type { RoomView } from './view';
|
||||
|
||||
export const YOU: WizardId = 'A';
|
||||
export const FOE: WizardId = 'B';
|
||||
/** The seats of a duel played in this browser against the bot. */
|
||||
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. */
|
||||
export const COMPACT_QUERY = '(max-width: 860px)';
|
||||
@@ -113,8 +119,9 @@ export interface Plan {
|
||||
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 {
|
||||
who: WizardId;
|
||||
hand: Hand;
|
||||
spell: Spell;
|
||||
done: Token[];
|
||||
@@ -126,6 +133,7 @@ export interface Threat {
|
||||
}
|
||||
|
||||
export interface Highlight {
|
||||
who: WizardId;
|
||||
hand: Hand;
|
||||
from: number;
|
||||
to: number;
|
||||
@@ -160,6 +168,7 @@ function loadSaved(): SavedDuel | null {
|
||||
const saved = JSON.parse(raw) as SavedDuel;
|
||||
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.
|
||||
saved.state.seats ??= Object.keys(saved.state.wizards);
|
||||
saved.state.lessonsShown ??= [];
|
||||
if (saved.state.lastTurn) {
|
||||
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 {
|
||||
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());
|
||||
stabTarget = $state<TargetId>(FOE);
|
||||
stabTarget = $state<TargetId | ''>('');
|
||||
monsterOrders = $state<Record<string, TargetId>>({});
|
||||
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
|
||||
compact = $state(false);
|
||||
@@ -209,16 +232,22 @@ export class Duel {
|
||||
sheetOpen = $state(false);
|
||||
/** Opponent gestures to light up in the ledger. */
|
||||
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();
|
||||
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;
|
||||
} else if (saved) {
|
||||
this.state = saved.state;
|
||||
this.pins = saved.pins ?? { left: null, right: null };
|
||||
this.sets = saved.sets ?? blankSets();
|
||||
this.stabTarget = saved.stabTarget ?? FOE;
|
||||
this.stabTarget = saved.stabTarget ?? '';
|
||||
this.monsterOrders = saved.monsterOrders ?? {};
|
||||
this.release = saved.release ?? '';
|
||||
}
|
||||
@@ -243,15 +272,17 @@ export class Duel {
|
||||
} catch {
|
||||
// The name still applies to this duel.
|
||||
}
|
||||
if (this.remote) return;
|
||||
const next = name || pickNames('').A;
|
||||
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. */
|
||||
persist(): void {
|
||||
if (this.remote) return;
|
||||
const bundle: SavedDuel = {
|
||||
v: SAVE_VERSION,
|
||||
state: $state.snapshot(this.state),
|
||||
@@ -271,11 +302,21 @@ export class Duel {
|
||||
/** A duel worth asking about before it is thrown away. */
|
||||
inProgress = $derived(this.state.turn > 0 && !this.state.over);
|
||||
|
||||
you = $derived(this.state.wizards[YOU]);
|
||||
foe = $derived(this.state.wizards[FOE]);
|
||||
you = $derived(this.state.wizards[this.me]);
|
||||
/** 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);
|
||||
/** 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);
|
||||
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
|
||||
|
||||
@@ -295,8 +336,9 @@ export class Duel {
|
||||
return set === 'haste' ? this.forcedSecond[hand] : this.forced[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. */
|
||||
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
|
||||
/** The opponent whose hand is yours to command this turn, if any. */
|
||||
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. */
|
||||
chosenGesture(set: SetName, hand: Hand): Gesture | null {
|
||||
@@ -392,20 +434,28 @@ export class Duel {
|
||||
}
|
||||
|
||||
summary = $derived(this.state.lastTurn);
|
||||
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
||||
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
||||
/** Each opponent's gestures as you are allowed to see them. */
|
||||
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(() => {
|
||||
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) {
|
||||
const seq = handSequence(this.foeVisible, hand);
|
||||
const seq = handSequence(hist, hand);
|
||||
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 (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 done = tokens.slice(0, tokens.length - turnsAway);
|
||||
if (out.some((t) => 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 });
|
||||
if (out.some((t) => t.who === who && t.hand === hand && t.spell.id === c.spell.id)) continue;
|
||||
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));
|
||||
|
||||
toggleHighlight(t: Threat): void {
|
||||
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to;
|
||||
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to };
|
||||
const h = this.highlight;
|
||||
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. */
|
||||
@@ -506,9 +563,11 @@ export class Duel {
|
||||
|
||||
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) === '>')));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
|
||||
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === this.me));
|
||||
ready = $derived(
|
||||
!this.state.over &&
|
||||
!this.moved &&
|
||||
!this.sending &&
|
||||
this.activeSets.every((set) =>
|
||||
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. */
|
||||
bankingActive = $derived(
|
||||
!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') : []);
|
||||
willBank = $derived(
|
||||
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
|
||||
);
|
||||
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(
|
||||
this.permanencyActive
|
||||
@@ -539,17 +598,15 @@ export class Duel {
|
||||
|
||||
/** Where a spell goes unless the player says otherwise. */
|
||||
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. */
|
||||
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
|
||||
const list: { id: TargetId; label: string }[] = [
|
||||
{ id: YOU, label: 'yourself' },
|
||||
{ id: FOE, label: this.foe.name }
|
||||
];
|
||||
const list: { id: TargetId; label: string }[] = [{ id: this.me, label: 'yourself' }];
|
||||
for (const id of this.others) list.push({ id, label: this.state.wizards[id].name });
|
||||
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);
|
||||
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 === '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.willExtend) cast.permanent = true;
|
||||
return cast;
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
if (!this.ready) return;
|
||||
/** The move as the engine wants it. */
|
||||
private input(): TurnInput {
|
||||
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 = {
|
||||
left: this.sets.main.left.gesture ?? '-',
|
||||
right: this.sets.main.right.gesture ?? '-',
|
||||
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
|
||||
stabTarget: this.stabTarget,
|
||||
stabTarget: this.stabTarget || this.foeId,
|
||||
monsterOrders: orders
|
||||
};
|
||||
if (this.hasted) {
|
||||
@@ -610,23 +667,87 @@ export class Duel {
|
||||
left: this.sets.haste.left.gesture ?? '-',
|
||||
right: this.sets.haste.right.gesture ?? '-',
|
||||
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;
|
||||
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.
|
||||
let plain = $state.snapshot(this.state);
|
||||
const theirs = chooseBotTurn(plain, FOE);
|
||||
plain = resolveTurn(plain, { A: yours, B: theirs });
|
||||
const theirs = chooseBotTurn(plain, LOCAL_BOT);
|
||||
plain = resolveTurn(plain, { [LOCAL_ME]: yours, [LOCAL_BOT]: theirs });
|
||||
// The bot takes any stopped moments it is owed straight away.
|
||||
while (!plain.over && plain.timeStops[0] === FOE) {
|
||||
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) });
|
||||
while (!plain.over && plain.timeStops[0] === LOCAL_BOT) {
|
||||
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) {
|
||||
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();
|
||||
// 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 {
|
||||
this.sets = blankSets();
|
||||
this.syncAll();
|
||||
this.stabTarget = FOE;
|
||||
this.stabTarget = '';
|
||||
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
|
||||
const orders: Record<string, TargetId> = {};
|
||||
for (const m of this.state.monsters) {
|
||||
if (m.owner !== YOU) continue;
|
||||
if (m.owner !== this.me) continue;
|
||||
const last = m.lastTarget;
|
||||
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE);
|
||||
orders[m.id] = valid && last ? last : FOE;
|
||||
const standing = last !== undefined && this.others.includes(last) && this.state.wizards[last].alive;
|
||||
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.release = '';
|
||||
@@ -659,11 +781,54 @@ export class Duel {
|
||||
}
|
||||
|
||||
newGame(): void {
|
||||
if (this.remote) return;
|
||||
this.state = createGame(pickNames());
|
||||
this.pins = { left: null, right: null };
|
||||
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();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -406,3 +406,43 @@ describe('lessons', () => {
|
||||
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
@@ -31,6 +31,7 @@ import {
|
||||
controllerOf,
|
||||
enemyOf,
|
||||
findMonster,
|
||||
inDuel,
|
||||
isWizardId,
|
||||
nextRandom,
|
||||
type CastChoice,
|
||||
@@ -62,7 +63,7 @@ interface ActiveCast {
|
||||
nullified?: string;
|
||||
}
|
||||
|
||||
const IDS: WizardId[] = ['A', 'B'];
|
||||
const REST: TurnInput = { left: '-', right: '-', casts: [] };
|
||||
|
||||
export function paralysedForm(g: Gesture): Gesture {
|
||||
if (g === 'C') return 'F';
|
||||
@@ -143,14 +144,17 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const state = structuredClone(previous);
|
||||
if (state.over) return state;
|
||||
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 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. */
|
||||
const turn = actor ? state.turn - 1 : state.turn;
|
||||
const events: string[] = [];
|
||||
const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage') => {
|
||||
state.log.push({ turn, kind, text });
|
||||
const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage', hiddenFrom?: WizardId[]) => {
|
||||
state.log.push(hiddenFrom?.length ? { turn, kind, text, hiddenFrom } : { turn, kind, text });
|
||||
if (surface) events.push(text);
|
||||
};
|
||||
const rng = () => nextRandom(state);
|
||||
@@ -161,13 +165,14 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const c = controllerOf(state, id);
|
||||
return c !== undefined && frozen(c);
|
||||
};
|
||||
const isWizard = (id: TargetId | undefined): id is WizardId => isWizardId(state, id);
|
||||
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);
|
||||
/** Under time stop, those who cannot move have no resistance to anything. */
|
||||
const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => {
|
||||
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;
|
||||
return type === 'heat' ? b.resistHeat : b.resistCold;
|
||||
};
|
||||
@@ -175,10 +180,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const summary: TurnSummary = {
|
||||
turn,
|
||||
phase,
|
||||
wizards: {
|
||||
A: { before: W.A.hp, after: W.A.hp, effects: [] },
|
||||
B: { before: W.B.hp, after: W.B.hp, effects: [] }
|
||||
},
|
||||
wizards: Object.fromEntries(seats.map((id) => [id, { before: W[id].hp, after: W[id].hp, effects: [] }])),
|
||||
events,
|
||||
lessons: []
|
||||
};
|
||||
@@ -187,7 +189,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
return stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
||||
};
|
||||
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');
|
||||
/** 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];
|
||||
}
|
||||
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 (c.charmed.hand === 'left') left = 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 record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
|
||||
const hiddenFrom: WizardId[] = [];
|
||||
const foe = enemyOf(w.id);
|
||||
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
|
||||
const hiddenFrom = seats.filter(
|
||||
(other) => other !== w.id && (w.invisibleTurns > 0 || W[other].blindTurns > 0 || phase === 'timestop')
|
||||
);
|
||||
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
|
||||
for (const hand of HANDS) {
|
||||
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 });
|
||||
if (left === 'P' && right === 'P') w.surrendered = true;
|
||||
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. */
|
||||
const entries: Record<WizardId, number[]> = { A: [], B: [] };
|
||||
const entries: Record<WizardId, number[]> = Object.fromEntries(seats.map((id) => [id, []]));
|
||||
for (const id of acting) {
|
||||
const w = W[id];
|
||||
const inp = inputs[id];
|
||||
const inp = input(id);
|
||||
w.surrendered = false;
|
||||
const c = phase === 'main' ? w.constraints : {};
|
||||
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),
|
||||
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) {
|
||||
if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue;
|
||||
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');
|
||||
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;
|
||||
const earlier = w.casts.find(
|
||||
(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) {
|
||||
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({
|
||||
caster: id,
|
||||
hand: 'left',
|
||||
@@ -358,7 +360,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
const destroyedAfterAttack = new Set<string>();
|
||||
const orders = new Map<string, TargetId>();
|
||||
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 ----
|
||||
@@ -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');
|
||||
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 id of IDS) clearEnchantments(W[id]);
|
||||
for (const id of seats) clearEnchantments(W[id]);
|
||||
for (const m of state.monsters) {
|
||||
clearMonsterEnchantments(m);
|
||||
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 === '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 === '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}`;
|
||||
}
|
||||
}
|
||||
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');
|
||||
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
|
||||
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
|
||||
};
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
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 target = beingName(state, c.target);
|
||||
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;
|
||||
if (c.permanent) wiz.permanent.confusion = true;
|
||||
} 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[]);
|
||||
orders.set(mon.id, 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':
|
||||
if (mon && !isElemental(mon.kind)) {
|
||||
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}.`);
|
||||
} else {
|
||||
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 c of live()) {
|
||||
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);
|
||||
if (wiz) {
|
||||
clearEnchantments(wiz);
|
||||
@@ -745,7 +747,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
break;
|
||||
case 'finger_of_death':
|
||||
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');
|
||||
break;
|
||||
case 'lightning_bolt':
|
||||
@@ -773,7 +775,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
break;
|
||||
case '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;
|
||||
log(`${W[t].name}'s disease is cured.`);
|
||||
}
|
||||
@@ -784,7 +786,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
}
|
||||
}
|
||||
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)
|
||||
];
|
||||
if (fireStorm) {
|
||||
@@ -821,7 +823,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (phase === 'main') {
|
||||
if (resists(id, type)) 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()}`);
|
||||
}
|
||||
@@ -836,7 +838,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
if (isShielded(target)) {
|
||||
facts.shieldBlocked.add(target);
|
||||
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');
|
||||
} else {
|
||||
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) => {
|
||||
const g = w.history[index];
|
||||
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)) {
|
||||
log(`${w.name} stabs at nothing.`, 'fizzle');
|
||||
} else if (isShielded(target)) {
|
||||
@@ -863,7 +865,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
}
|
||||
|
||||
// ---- 12. Apply the ledger ----
|
||||
for (const id of IDS) {
|
||||
for (const id of seats) {
|
||||
const w = W[id];
|
||||
if (!w.alive) continue;
|
||||
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 ----
|
||||
if (phase === 'main') {
|
||||
for (const id of IDS) {
|
||||
for (const id of seats) {
|
||||
const w = W[id];
|
||||
w.constraints = { ...w.pending, ...w.permanent };
|
||||
w.pending = {};
|
||||
@@ -931,7 +933,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
}
|
||||
} else {
|
||||
// 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];
|
||||
w.constraints = { ...w.constraints, ...w.pending, ...w.permanent };
|
||||
w.pending = {};
|
||||
@@ -942,20 +944,31 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
|
||||
}
|
||||
}
|
||||
|
||||
const a = W.A;
|
||||
const b = W.B;
|
||||
if (!a.alive && !b.alive) state.over = { winner: null, reason: 'Both wizards fall together. A posthumous draw.' };
|
||||
else if (!a.alive) state.over = { winner: 'B', reason: `${a.name} is dead. ${b.name} wins the duel.` };
|
||||
else if (!b.alive) state.over = { winner: 'A', reason: `${b.name} is dead. ${a.name} wins the duel.` };
|
||||
else if (a.surrendered && b.surrendered) state.over = { winner: null, reason: 'Both wizards surrender at once. A draw.' };
|
||||
else if (a.surrendered) state.over = { winner: 'B', reason: `${a.name} surrenders. ${b.name} wins the duel.` };
|
||||
else if (b.surrendered) state.over = { winner: 'A', reason: `${b.name} surrenders. ${a.name} wins the duel.` };
|
||||
// A wizard who is out takes their creatures with them: nobody is left to command them.
|
||||
state.monsters = state.monsters.filter((m) => inDuel(W[m.owner]));
|
||||
const standing = seats.filter((id) => inDuel(W[id]));
|
||||
const fell = (id: WizardId) => (W[id].alive ? `${W[id].name} surrenders` : `${W[id].name} is dead`);
|
||||
const wonBy = (winner: WizardId) => {
|
||||
// Name those who went out this turn; the rest were already gone.
|
||||
const gone = seats.filter((id) => id !== winner && !inDuel(W[id]));
|
||||
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) {
|
||||
log(state.over.reason, 'death');
|
||||
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 through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target));
|
||||
if (through) {
|
||||
|
||||
+27
-9
@@ -1,7 +1,8 @@
|
||||
import type { TurnGestures } from './gestures';
|
||||
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). */
|
||||
export type TargetId = WizardId | string;
|
||||
export const NOWHERE = 'nowhere';
|
||||
@@ -106,6 +107,8 @@ export interface LogEntry {
|
||||
turn: number;
|
||||
kind: LogKind;
|
||||
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. */
|
||||
@@ -126,6 +129,8 @@ export interface Outcome {
|
||||
|
||||
export interface GameState {
|
||||
turn: number;
|
||||
/** Seats in table order; every wizard in play, whether still standing or not. */
|
||||
seats: WizardId[];
|
||||
wizards: Record<WizardId, WizardState>;
|
||||
monsters: Monster[];
|
||||
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;
|
||||
}
|
||||
|
||||
export function enemyOf(id: WizardId): WizardId {
|
||||
return id === 'A' ? 'B' : 'A';
|
||||
/** Still gesturing: neither dead nor surrendered. */
|
||||
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 {
|
||||
@@ -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 {
|
||||
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: [],
|
||||
log: [],
|
||||
over: null,
|
||||
@@ -232,8 +250,8 @@ export function nextRandom(state: GameState): number {
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
export function isWizardId(id: TargetId | undefined): id is WizardId {
|
||||
return id === 'A' || id === 'B';
|
||||
export function isWizardId(state: GameState, id: TargetId | undefined): id is WizardId {
|
||||
return id !== undefined && id in state.wizards;
|
||||
}
|
||||
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
|
||||
export function beingName(state: GameState, id: TargetId | undefined): string {
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { isElemental, type Spell } from './spells';
|
||||
import { enemyOf, isEnchanted, type GameState, type TargetId, type WizardId } from './state';
|
||||
|
||||
export function likelyTarget(state: GameState, me: WizardId, spell: Spell): TargetId {
|
||||
const foe = enemyOf(me);
|
||||
const foe = enemyOf(state, me);
|
||||
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 iceElemental = monsters.find((m) => m.kind === 'ice_elemental');
|
||||
const fireElemental = monsters.find((m) => m.kind === 'fire_elemental');
|
||||
|
||||
@@ -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
@@ -1,552 +1,37 @@
|
||||
<script lang="ts">
|
||||
import Chronicle from '$lib/components/Chronicle.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);
|
||||
import Hall from '$lib/components/Hall.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Waving Hands</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>
|
||||
</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} />
|
||||
|
||||
<main class="hall-page">
|
||||
<Hall />
|
||||
<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>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.masthead {
|
||||
.hall-page {
|
||||
min-height: 100vh;
|
||||
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;
|
||||
flex-direction: column;
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
padding: clamp(1rem, 4vh, 3rem) var(--gutter) 2rem;
|
||||
}
|
||||
|
||||
.colophon {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem var(--gutter) 3rem;
|
||||
max-width: 54rem;
|
||||
width: 100%;
|
||||
padding: 1.5rem 0 0;
|
||||
color: var(--bone-faint);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.colophon a {
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -14,6 +14,13 @@ export default defineConfig({
|
||||
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: {
|
||||
include: ['src/**/*.test.ts']
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user