Waving Hands: a browser duel after Bartle's 1977 game
SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Playwright MCP output
|
||||||
|
.playwright-mcp
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Waving Hands
|
||||||
|
|
||||||
|
A browser remake of Richard Bartle's 1977 pencil-and-paper wizard duel, also known as
|
||||||
|
Spellbinder and Spellcaster, and the game behind Andrew Plotkin's *Spellcast* for X Windows.
|
||||||
|
|
||||||
|
Each turn both wizards secretly choose a gesture for each hand, then reveal them together.
|
||||||
|
The right run of gestures on one hand is a spell. Gestures can overlap, so `F-F-F` casts
|
||||||
|
Paralysis and following it with `S-S-D-D` finishes a Fireball. Both palms at once is surrender.
|
||||||
|
|
||||||
|
The full rules, as transcribed from the *Duel Purpose* fanzine, are in
|
||||||
|
`docs/waving-hands-rules.txt`.
|
||||||
|
|
||||||
|
## Playing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You duel a heuristic bot. Choose a gesture per hand, pick which completed spell (if any) each
|
||||||
|
hand casts and at whom, and reveal. Beneath each hand is a list of the spells that hand is
|
||||||
|
part-way through; selecting one makes its next gesture, and the diamond pins it. Every entry
|
||||||
|
in the spell sheet offers "Plan with left hand" and "Plan with right hand", showing what that
|
||||||
|
hand still has to make, so a duel can start from a chosen spell rather than a guessed gesture.
|
||||||
|
When a rule decides something surprising (a shield that stopped a goblin but not a wound
|
||||||
|
spell, a countered spell, a one-handed clap, a reused run of gestures) the result strip
|
||||||
|
explains it once per duel; the checkbox under the hands turns this off. Warnings
|
||||||
|
above the hands cite the opponent's gestures that could finish a hostile spell, and selecting
|
||||||
|
a warning lights those gestures up in the ledger. After each reveal a short strip shows what
|
||||||
|
changed each wizard's health and everything else worth knowing, with the full chronicle in
|
||||||
|
the sidebar. The rules are at `/rules`.
|
||||||
|
|
||||||
|
The duel is saved to the browser's local storage after every change, so it survives reading
|
||||||
|
the rules, a refresh, or a phone putting the tab to sleep. Starting a new duel asks first if
|
||||||
|
one is under way. Nothing is rendered on the server.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `src/lib/game/spells.ts` the spell book and monster stats
|
||||||
|
- `src/lib/game/gestures.ts` sequence matching, threat detection, gesture-sharing conflicts
|
||||||
|
- `src/lib/game/resolve.ts` simultaneous turn resolution
|
||||||
|
- `src/lib/game/bot.ts` the opponent
|
||||||
|
- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI
|
||||||
|
- `src/lib/components/` ledger, hand pickers, status, chronicle, spell sheet
|
||||||
|
|
||||||
|
## Simplifications
|
||||||
|
|
||||||
|
Raise dead only heals; there is no one left to raise in a two-wizard duel once someone dies.
|
||||||
|
Paralysis and charm person pick the hand at cast time rather than after the reveal.
|
||||||
|
A hastened wizard's second pair of gestures is bound by the same enchantments as the first.
|
||||||
|
Under time stop the frozen wizard's ledger shows a row of question marks, so they know a
|
||||||
|
stopped moment passed even if nothing touched them.
|
||||||
|
|
||||||
|
## The bot
|
||||||
|
|
||||||
|
The opponent scores every pair of gestures by the spells they finish, the spells they move
|
||||||
|
toward, and what your visible gestures threaten. Spells it cast in the last few turns are
|
||||||
|
discounted so it does not fall into a rut, and each named opponent weights the spell book a
|
||||||
|
little differently.
|
||||||
|
|
||||||
|
## Deploying
|
||||||
|
|
||||||
|
See `deploy/README.md`: one small droplet running Caddy serves the static build.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
The gesture silhouettes on the buttons and the favicon are adapted from the bitmaps in
|
||||||
|
Andrew Plotkin's *Spellcast* for X Windows (1993). Its notice: "This implementation is by
|
||||||
|
Andrew Plotkin. It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
|
||||||
|
distributed, and modified, as long as this copyright notice is retained. The source code and
|
||||||
|
any derivative works may not be sold for profit without the permission of Andrew Plotkin and
|
||||||
|
Richard Bartle."
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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 zero-cost alternative is a second site block in the wizwar droplet's
|
||||||
|
Caddyfile pointing at a second directory; the deploy script works unchanged
|
||||||
|
against that host. A separate droplet keeps the two games' uptime and
|
||||||
|
upgrades independent.
|
||||||
|
|
||||||
|
## New droplet from scratch
|
||||||
|
|
||||||
|
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> \
|
||||||
|
"bash /root/setup-droplet.sh waving-hands.<ip>.sslip.io"`
|
||||||
|
3. `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
|
||||||
|
/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy
|
||||||
|
fetches the certificate on first request.
|
||||||
|
|
||||||
|
## Everyday deploys
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
Executable
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build locally and push the static site to the droplet.
|
||||||
|
# Usage: deploy/deploy.sh <droplet-ip-or-host>
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
||||||
|
|
||||||
|
npm run check
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Old hashed chunks stay a week: a page loaded before this deploy can still
|
||||||
|
# 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/"
|
||||||
|
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
|
||||||
|
systemctl reload caddy
|
||||||
|
systemctl --no-pager -l status caddy | head -3
|
||||||
|
'
|
||||||
|
echo "deployed."
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time droplet setup. Run ON the droplet as root:
|
||||||
|
# bash setup-droplet.sh waving-hands.<droplet-ip>.sslip.io
|
||||||
|
# Waving Hands is a static site: Caddy serves the built files and terminates
|
||||||
|
# TLS. There is no application process, so nothing but Caddy and rsync is
|
||||||
|
# installed.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||||
|
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -qy curl rsync
|
||||||
|
|
||||||
|
# Caddy (auto-HTTPS)
|
||||||
|
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||||
|
| tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
apt-get update -q && apt-get install -qy caddy
|
||||||
|
|
||||||
|
# Site directory, readable by Caddy.
|
||||||
|
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; everything else is revalidated so a deploy shows up
|
||||||
|
# on the next load.
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
header /_app/immutable/* Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
header /index.html Cache-Control "no-cache"
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/lib/caddy/access.log {
|
||||||
|
roll_size 10MiB
|
||||||
|
roll_keep 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try_files {path} {path}.html /index.html
|
||||||
|
file_server
|
||||||
|
CADDY
|
||||||
|
systemctl reload caddy
|
||||||
|
|
||||||
|
# Firewall: ssh + web only.
|
||||||
|
ufw allow OpenSSH
|
||||||
|
ufw allow 80/tcp
|
||||||
|
ufw allow 443/tcp
|
||||||
|
ufw --force enable
|
||||||
|
|
||||||
|
echo "droplet ready: now run deploy/deploy.sh <ip> from your machine"
|
||||||
@@ -0,0 +1,748 @@
|
|||||||
|
Spellcaster
|
||||||
|
Rules for the Spellcaster Game
|
||||||
|
This game originally appeared on rec.games.abstract. I have
|
||||||
|
merely formatted it and put it online. I have deleted some
|
||||||
|
things that aren't useful to playing the game.-- Brian
|
||||||
|
WAVING HANDS
|
||||||
|
============
|
||||||
|
(also known as: SPELLCASTER)
|
||||||
|
Richard Bartle
|
||||||
|
Taken from Duel Purpose fanzine, written by Mike Lean
|
||||||
|
Scanned, OCRed & edited by Andrew Buchanan
|
||||||
|
Introduction
|
||||||
|
This is a game concerning the imaginary conflict between 2
|
||||||
|
powerful wizards in a duel of sorcery. The 2 opponents perform magical
|
||||||
|
gestures with their hands to create their supernatural weapons -
|
||||||
|
spells. Some are so potent as to be able to blind a man, call forth
|
||||||
|
terrifying creatures, or even kill the unfortunate victim instantly.
|
||||||
|
Consequently each wizard must rely on his own cunning to be able to
|
||||||
|
time enough defensive spells to avoid the brunt of his adversary's
|
||||||
|
attack, yet force in sufficient offensive spells of his own to crack
|
||||||
|
the magical armour of his opponent, and kill the wizard outright. The
|
||||||
|
game is an attempt to capture the spirit of such a battle in as simple
|
||||||
|
yet exciting way as possible. The only equipment needed is pencil,
|
||||||
|
paper and opponent. Time taken varies between 15 - 30 minutes. The
|
||||||
|
inventor wishes to state that he has never been involved in a magical
|
||||||
|
duel but would be interested to discover how realistic the game is for
|
||||||
|
those who have...
|
||||||
|
The Turn
|
||||||
|
The activities of wizards and those of the monsters they
|
||||||
|
create, are written down simultaneously in secret, both players for
|
||||||
|
their respective selves and monsters but occasionally for their
|
||||||
|
opponent or one of his creatures as dictated by certain spells. After
|
||||||
|
both have completed this, their moves are exposed at the same time and
|
||||||
|
dealt with as if they occurred coincidentally. Certain spells may
|
||||||
|
alter these circumstances - for example, you don't get to see all your
|
||||||
|
opponent's moves if he is invisible.
|
||||||
|
Each of these periods of activity is a "turn". In a turn, the
|
||||||
|
player's wizard can either gesture with his hands for part of a spell,
|
||||||
|
stab with his knife, or do nothing. They use both hands and both hands
|
||||||
|
can act either independently - or in concord with the other. Monsters
|
||||||
|
cannot make magical gestures but will obey their master's commands
|
||||||
|
exactly - although the identity of the master could change as a result
|
||||||
|
of enchantment. Since wizards are trained intelligent humans they are
|
||||||
|
able to gesture and attack using both hands independently or in
|
||||||
|
conjunction. Each monster, being an untrained, unintelligent biped,
|
||||||
|
attacks the same way every time and picks whichever victim its master
|
||||||
|
decides. As a result, only wizards can gesture and cast spells.
|
||||||
|
Players personally acquainted with monsters who wish to vouch for
|
||||||
|
their ability to cast spells are requested to keep quiet.
|
||||||
|
Gestures
|
||||||
|
Spells are created by strings of gestures made with the hands.
|
||||||
|
There are 5 single-handed gestures, these being with the fingers (F),
|
||||||
|
with the palm (P), the snap (S), the wave (W) and the digit pointing
|
||||||
|
(D). There is one 2-handed gesture, the clap (C) which must be done
|
||||||
|
simultaneously with both hands to be valid. The meaning of each
|
||||||
|
gesture can be safely forgotten as only the abbreviations are used,
|
||||||
|
for simplicity. The other things which can be done with the hand are
|
||||||
|
the non-gestures: stab (stab) and nothing ( ). To cast a spell,
|
||||||
|
gestures are put in an order characteristic of a spell. A list of
|
||||||
|
spells including the gestures needed for them is given in the last
|
||||||
|
section. For example, 3 finger gestures on consecutive turns (F-F-F)
|
||||||
|
initiates a 'paralysis' spell. The uniqueness of the game, however, is
|
||||||
|
that gestures can be made to operate in more than one spell, provided
|
||||||
|
that a) the gestures have been made in the correct sequence without
|
||||||
|
interruption, b) not more than one spell is created per gesture, c)
|
||||||
|
All gestures for one spell are made with the same hand. For instance,
|
||||||
|
the left hand could cast the F-F-F above and could be followed by
|
||||||
|
S-S-D-D in the next 4 turns to finish off a 'fireball' spell
|
||||||
|
(F-S-S-D-D) as the last 5 gestures are those associated with that
|
||||||
|
spell. Another alternative is to simply perform another F for a second
|
||||||
|
paralysis spell, as the last 3 gestures are still F-F-F. Thus, it is
|
||||||
|
apparent that if spells are used in a wise manner and overlap a lot,
|
||||||
|
the overall number of gestures needed to cast them can be cut quite
|
||||||
|
dramatically.
|
||||||
|
If a gesture can be construed to create 2 or more spells then
|
||||||
|
the caster chooses which one he wants to use. For example, the last 2
|
||||||
|
gestures of a 'finger of death' are the same as 'missile', yet only on
|
||||||
|
odd occasions would the latter be used. Another example of the
|
||||||
|
one-spell-per-gesture concept is the following:-
|
||||||
|
Right hand: P P W S Last 4 gestures form 'invisibility'
|
||||||
|
Left hand: W W W S Last 3 gestures form 'counter-spell'
|
||||||
|
The trouble here is the 'invisibility' spell needs both hands
|
||||||
|
to perform certain gestures. However, since the final S of the left
|
||||||
|
hand cannot complete 2 spells it is clear that a choice must be made
|
||||||
|
between the W-W-S of the 'counter- spell' and the P-P-(w-(s of the
|
||||||
|
invisibility. Here and in the rest of the document an open bracket,
|
||||||
|
followed by a lower-case letter, denotes a gesture to be performed
|
||||||
|
with *each* hand simultaneously. E.g. (w denotes a W by each hand.
|
||||||
|
The caster must choose one spell if the gestures are completed in the
|
||||||
|
correct sequence. Most of these are shot off to nowhere if not
|
||||||
|
required but some cannot be, for example 'fire storm' which gets you
|
||||||
|
no matter where it is released. Some of the larger spells have smaller
|
||||||
|
ones incorporated within.
|
||||||
|
All spells and monsters need a target, that is someone or
|
||||||
|
something to be the victim. This is usually obvious, for example all
|
||||||
|
enchantments and damaging spells are usually cast at the opponent,
|
||||||
|
whereas protection and summons spells are used by the caster himself.
|
||||||
|
However, if your target is not the usual one, (e.g. you wish to cast a
|
||||||
|
'counter-spell' at your own monster to stop a 'charm monster' spell
|
||||||
|
from making it attack you), then the target must be written down with
|
||||||
|
the gestures. The target need not exist, for example, "the elemental
|
||||||
|
he's about to create", but if it still doesn't exist when you loose
|
||||||
|
the spell then you lose the spell. Monsters will usually attack their
|
||||||
|
controller's opponent but if the target is different, e.g. another
|
||||||
|
monster, then it must be written.
|
||||||
|
Spells can be aborted any way along their development simply
|
||||||
|
by performing a gesture with the hand doing the spell which is not one
|
||||||
|
needed for that spell. There is no penalty, save having wasted some
|
||||||
|
time. Note that no spells contain "stab" or "nothing" (C with just one
|
||||||
|
hand is the same as nothing) and consequently after pursuing one of
|
||||||
|
these alternatives, any spell must start from scratch. Note also that
|
||||||
|
wizards only have one dagger each so cannot stab twice at the same
|
||||||
|
time, although they can change hands for stabbing without wasting
|
||||||
|
time. Such are the disadvantages of physical violence...
|
||||||
|
Certain spells cancel each other if they take effect
|
||||||
|
simultaneously. An obvious example is 'finger of death' and 'raise
|
||||||
|
dead'. Cancellation occurs when the subject for the spells concerned
|
||||||
|
is the same person, although there are some of the heat versus cold
|
||||||
|
variety which don't care who is the subject. Other spells which cancel
|
||||||
|
harmlessly are mostly the enchantments which direct that something be
|
||||||
|
done which it is impossible to obey due to some contradiction (e.g.
|
||||||
|
you cannot both repeat last turn's gestures and give a random gesture
|
||||||
|
with one hand, as you would if the subject of the spells 'amnesia' and
|
||||||
|
'confusion' at once). If you have a situation where someone is the
|
||||||
|
subject of some spells which seem to cancel yet which aren't mentioned
|
||||||
|
in the section on spells, use your common sense (or someone else's)
|
||||||
|
and you can't go far wrong.
|
||||||
|
Since spells detonate simultaneously, there is occasionally
|
||||||
|
confusion over spells which don't cancel yet which seem to depend on
|
||||||
|
which happened first. The best example is when a monster is created
|
||||||
|
and, on the same turn, hit by a 'fireball', or something else
|
||||||
|
sufficient to kill it. Since both are simultaneous, the monster will
|
||||||
|
attack that turn whilst being destroyed. There are some examples
|
||||||
|
explicitly mentioned, for example ice elementals in 'ice storm' or
|
||||||
|
'counter-spell'/'dispel magic' against all other spells but these are
|
||||||
|
clearly stated in the final section. Another example of a seeming
|
||||||
|
conflict is when someone who is resistant to fire is the subject of
|
||||||
|
both a 'remove enchantment' and 'fireball'; the enchantment is removed
|
||||||
|
as the fireball explodes (since they are simultaneous) hence the poor
|
||||||
|
victim is fried. If instead, he were not resistant to fire and was hit
|
||||||
|
by a 'resist fire' and 'fireball' at once then he would start to
|
||||||
|
resist fire as the fireball exploded and thus be saved.
|
||||||
|
Before the battle commences, the referee casts a 'dispel
|
||||||
|
magic' followed by an 'anti-spell' at each of the wizards. This is so
|
||||||
|
that they cannot commence gesturing prematurely so that they've nearly
|
||||||
|
finished a spell when they start the battle. Thus being resistant to
|
||||||
|
fire in your last battle doesn't do you any good in the next.
|
||||||
|
Winning
|
||||||
|
Each wizard can sustain 14 points of damage but on the 15th or
|
||||||
|
above he dies and the surviving wizard is declared the winner.
|
||||||
|
Simultaneous death is a posthumous draw. Damage given to wizards and
|
||||||
|
monsters is cumulative (so you don't have to do it all in one go!)
|
||||||
|
Dead monsters take no further part in the game. There is another
|
||||||
|
alternative to being killed, namely the 'surrender'. This is not a
|
||||||
|
spell, but a pair of P gestures made by both hands at the same time.
|
||||||
|
See the final section for details.
|
||||||
|
Spells
|
||||||
|
There now follows, in 4 sections, a list of spells (plus
|
||||||
|
'surrender') what they do, how long they last. In the gestures given
|
||||||
|
for the spells, upper case letters are used as abbreviations except
|
||||||
|
when 2 simultaneous single-handed gestures are required using both
|
||||||
|
hands, when an open bracket followed by a lower case letter will be
|
||||||
|
used instead e.g. (w. Afterwards is a short list of spells in forward
|
||||||
|
and reverse order, intended for use as a reference sheet for players
|
||||||
|
during the battle. To see if a spell has been cast, look up in the
|
||||||
|
reverse section the last gesture and follow back.
|
||||||
|
Protection spells
|
||||||
|
'Shield' P
|
||||||
|
This spell protects the subject from all attacks from monsters (that
|
||||||
|
is, creatures created by a summons class spell), from missile spells,
|
||||||
|
and from stabs by wizards. The shield lasts for that turn only, but
|
||||||
|
one shield will cover all such attacks made against the subject that
|
||||||
|
turn.
|
||||||
|
'Remove enchantment' P-D-W-P
|
||||||
|
If the subject of this spell is currently being effected by any of the
|
||||||
|
spells in the spells in the "enchantments" section, and/or if spells
|
||||||
|
from that section are cast at him at the same time as the remove
|
||||||
|
enchantment, then any such spells terminate immediately although their
|
||||||
|
effect for that turn might already have passed. For example, the
|
||||||
|
victim of a 'blindness' spell would not be able to see what his
|
||||||
|
opponent's gestures were on the turn his sight is restored by a
|
||||||
|
'remove enchantment'. Note that the 'remove enchantment' affects all
|
||||||
|
spells from the "Enchantment" section whether the caster wants them to
|
||||||
|
all go or not. A second effect of the spell is to destroy any monster
|
||||||
|
upon which it is cast, although the monster can attack in that turn.
|
||||||
|
It does not affect wizards unless cast on a wizard as he creates a
|
||||||
|
monster when the monster is destroyed, and the effects described above
|
||||||
|
apply.
|
||||||
|
'Magic mirror' C-(w
|
||||||
|
Any spell cast at the subject of this spell is reflected back at the
|
||||||
|
caster of that spell for that turn only. This includes spells like
|
||||||
|
'missile' and 'lightning bolt' but does not include attacks by
|
||||||
|
monsters already in existence, or stabs from wizards. Note that
|
||||||
|
certain spells are cast by wizards usually upon themselves, e.g.
|
||||||
|
spells from this section and the "Summons" section, in which case the
|
||||||
|
mirror would have no effect. It is countered totally, with no effect
|
||||||
|
whatsoever, if the subject is the simultaneous subject of either a
|
||||||
|
'counter-spell' or 'dispel magic'. It has no effect on spells which
|
||||||
|
affect more than one person, such as 'fire storm' and 2 mirrors cast
|
||||||
|
at someone simultaneously combine to form a single mirror.
|
||||||
|
'Counter-spell' W-P-P *or* W-W-S
|
||||||
|
Any other spell cast upon the subject in the same turn has no effect
|
||||||
|
whatever. In the case of blanket-type spells, which affect more than 1
|
||||||
|
person, the subject of the 'counter-spell' alone is protected. For
|
||||||
|
example, a 'fire storm' spell could kill off a monster but not if it
|
||||||
|
was simultaneously the subject of a 'counter-spell' although everyone
|
||||||
|
else would be affected as usual unless they had their own protection.
|
||||||
|
The 'counter-spell' will cancel all the spells cast at the subject for
|
||||||
|
that turn including 'remove enchantment' and 'magic mirror' but not
|
||||||
|
'dispel magic' or 'finger of death'. It will combine with another
|
||||||
|
spell of its own type for the same effect as if it were alone. The
|
||||||
|
'counter-spell' will also act as a 'shield' on the final gesture in
|
||||||
|
addition to its other properties, but the shield effect is on the same
|
||||||
|
subject as its other effect. The spell has 2 alternative gesture
|
||||||
|
sequences, either of which may, be used at any time.
|
||||||
|
'Dispel magic' C-D-P-W
|
||||||
|
This spell acts as a combination of 'counter-spell' and 'remove
|
||||||
|
enchantment', but its effects are universal rather than limited to the
|
||||||
|
subject of the spell. It will stop any spell cast in the same turn
|
||||||
|
from working (apart from another dispel magic spell which combines
|
||||||
|
with it for the same result), and will remove all enchantments from
|
||||||
|
all beings before they have effect. In addition, all monsters are
|
||||||
|
destroyed although they can attack that turn. 'Counter-spells' and
|
||||||
|
'magic mirrors' have no effect. The spell will not work on stabs or
|
||||||
|
surrenders. As with a 'counter-spell' it also acts as a 'shield' for
|
||||||
|
its subject.
|
||||||
|
'Raise dead' D-W-W-F-W-C
|
||||||
|
The subject of this spell is usually a recently-dead (not yet
|
||||||
|
decomposing) human corpse though it may be used on a dead monster.
|
||||||
|
When the spell is cast, life is instilled back into the corpse and any
|
||||||
|
damage which it has sustained is cured until the owner is back to his
|
||||||
|
usual state of health. A 'remove enchantment' effect is also manifest
|
||||||
|
so any 'diseases' or 'poisons' will be neutralized (plus any other
|
||||||
|
enchantments). If swords, knives, etc still remain in the corpse when
|
||||||
|
it is raised, they will of course cause it damage as usual. The
|
||||||
|
subject will be able to act as normal immediately under the influence
|
||||||
|
of the caster of the 'raise dead' spell, so that next turn he can
|
||||||
|
gesture, fight etc (in fact he will be able to fight on the turn upon
|
||||||
|
which the spell is cast.) If the spell is cast on a live individual,
|
||||||
|
the effect is that of a 'cure light wounds' recovering 5 points of
|
||||||
|
damage, or as many as have been sustained if less than 5. This is the
|
||||||
|
only spell which affects corpses properly. It therefore cannot be
|
||||||
|
stopped in such circumstances by a 'counter-spell'. A 'dispel magic'
|
||||||
|
spell will prevent it, since it affects all spells no matter what
|
||||||
|
their subject. Once alive the subject is treated as normal.
|
||||||
|
'Cure light wounds' D-F-W
|
||||||
|
If the subject has received damage then he is cured by 1 point as if
|
||||||
|
that point had not been inflicted. Thus, for example, if a wizard was
|
||||||
|
at 10 points of damage and was hit simultaneously by a 'cure light
|
||||||
|
wounds' and a 'lightning bolt' he would finish that turn on 14 points
|
||||||
|
rather than 15 (or 9 if there had been no 'lightning bolt'). The
|
||||||
|
effect is not removed by a 'dispel magic' or 'remove enchantment'.
|
||||||
|
'Cure heavy wounds' D-F-P-W
|
||||||
|
This spell is the same as 'cure light wounds' for its effect, but 2
|
||||||
|
points of damage are cured instead of 1, or only 1 if only 1 had been
|
||||||
|
sustained. A side effect is that the spell will also cure a disease
|
||||||
|
(note 'raise dead' on a live individual won't).
|
||||||
|
Summons spells
|
||||||
|
'Summon Goblin' S-F-W
|
||||||
|
This spell creates a goblin under the control of the subject upon whom
|
||||||
|
the spell is cast (or if cast on a monster, *its* controller, even if
|
||||||
|
the monster later dies or changes loyalty). The goblin can attack
|
||||||
|
immediately and its victim can be any any wizard or other monster the
|
||||||
|
controller desires, stating which at the time he writes his gestures.
|
||||||
|
It does 1 point of damage to its victim per turn and is destroyed
|
||||||
|
after 1 point of damage is inflicted upon it. The summoning spell
|
||||||
|
cannot be cast at an elemental, and if cast at something which doesn't
|
||||||
|
exist, the spell has no effect.
|
||||||
|
'Summon Ogre' P-S-F-W
|
||||||
|
This spell is the same as 'summon goblin' but the ogre created
|
||||||
|
inflicts and is destroyed by 2 points of damage rather than l.
|
||||||
|
'Summon Troll' F-P-S-F-W
|
||||||
|
This spell is the same as 'summon goblin' but the troll created
|
||||||
|
inflicts and is destroyed by 3 points of damage rather than l.
|
||||||
|
'Summon Giant' W-F-P-S-F-W
|
||||||
|
This spell is the same as 'summon goblin' but the giant created
|
||||||
|
inflicts and is destroyed by 4 points of damage rather than l.
|
||||||
|
'Summon Elemental' C-S-W-W-S
|
||||||
|
This spell creates either a fire elemental or an ice elemental at the
|
||||||
|
discretion of the person upon whom the spell is cast after he has seen
|
||||||
|
all the gestures made that turn.
|
||||||
|
Elementals must be cast at someone and cannot be "shot off"
|
||||||
|
harmlessly at some inanimate object. The elemental will, for that turn
|
||||||
|
and until destroyed, attack everyone who is not resistant to its type
|
||||||
|
(heat or cold), causing 3 points of damage per turn. The elemental
|
||||||
|
takes 3 points of damage to be killed but may be destroyed by spells
|
||||||
|
of the opposite type (e.g. 'fire storm', 'resist cold' or 'fireball'
|
||||||
|
will kill an ice elemental), and will also neutralize the canceling
|
||||||
|
spell. Elementals will not attack on the turn they are destroyed by
|
||||||
|
such a spell. An elemental will also be engulfed and destroyed by a
|
||||||
|
storm of its own type but, in such an event, the storm is not
|
||||||
|
neutralized although the elemental still does not attack in that turn.
|
||||||
|
2 elementals of the opposite type will also destroy each other before
|
||||||
|
attacking, and 2 of the same type will join together to form a single
|
||||||
|
elemental of normal strength. Note that only wizards or monsters
|
||||||
|
resistant to the type of elemental, or who are casting a spell which
|
||||||
|
has the effect of a 'shield' do not get attacked by the elemental.
|
||||||
|
Casting a 'fireball' upon yourself when being attacked by an ice
|
||||||
|
elemental is no defence! (Cast it at the elemental...)
|
||||||
|
Damaging Spells
|
||||||
|
'Missile' S-D
|
||||||
|
This spell creates a material object of hard substance which is hurled
|
||||||
|
towards the subject of the spell and causes him 1 point of damage. The
|
||||||
|
spell is thwarted by a 'shield' in addition to the usual
|
||||||
|
'counter-spell', 'dispel magic' and 'magic mirror' (the latter causing
|
||||||
|
it to hit whoever cast it instead).
|
||||||
|
'Finger of Death' P-W-P-F-S-S-S-D
|
||||||
|
Kills the subject stone dead. This spell is so powerful that it is
|
||||||
|
unaffected by a 'counter-spell' although a 'dispel magic' spell cast
|
||||||
|
upon the final gesture will stop it. The usual way to prevent being
|
||||||
|
harmed by this spell is to disrupt it during casting using, for
|
||||||
|
example, an 'anti-spell'.
|
||||||
|
'Lightning Bolt' D-F-F-D-D or W-D-D-C
|
||||||
|
The subject of this spell is hit by a bolt of lightning and sustains 5
|
||||||
|
points of damage. Resistance to heat or cold is irrelevant. There are
|
||||||
|
2 gesture combinations for the spell, but the shorter one may be used
|
||||||
|
only once per day (i.e. per battle) by any wizard. The longer one may
|
||||||
|
be used without restriction. A 'shield' spell offers no defence.
|
||||||
|
'Cause Light Wounds' W-F-P
|
||||||
|
The subject of this spell is inflicted with 2 points of damage.
|
||||||
|
Resistance to heat or cold offers no defence. A simultaneous 'cure
|
||||||
|
light wounds' will serve only to reduce the damage to 1 point. A
|
||||||
|
'shield' has no effect.
|
||||||
|
'Cause Heavy Wounds' W-P-F-D
|
||||||
|
This has the same effect as 'cause light wounds' but inflicts 3 points
|
||||||
|
of damage instead of 2.
|
||||||
|
'Fireball' F-S-S-D-D
|
||||||
|
The subject of this spell is hit by a ball of fire and sustains 5
|
||||||
|
points of damage unless he is resistant to fire. If at the same time
|
||||||
|
an 'ice storm' prevails, the subject of the 'fireball' is instead not
|
||||||
|
harmed by either spell, although the storm will affect others as
|
||||||
|
normal. If directed at an ice elemental, the fireball will destroy it
|
||||||
|
before it can attack, but has no other effect on the creatures.
|
||||||
|
'Fire storm' S-W-W-C
|
||||||
|
Everything not resistant to heat sustains 5 points of damage that
|
||||||
|
turn. The spell cancels wholly, causing no damage, with either an 'ice
|
||||||
|
storm' or an ice elemental. It will destroy but not be destroyed by a
|
||||||
|
fire elemental. Two 'fire storms' act as one.
|
||||||
|
'Ice storm' W-S-S-C
|
||||||
|
Everything not resistant to cold sustains 5 points of damage that
|
||||||
|
turn. The spell cancels wholly, causing no damage, with either a 'fire
|
||||||
|
storm' or a fire elemental, and will cancel locally with a 'fireball'.
|
||||||
|
It will destroy but not be destroyed by an ice elemental. Two 'ice
|
||||||
|
storms' act as one.
|
||||||
|
Enchantments
|
||||||
|
'Amnesia' D-P-P
|
||||||
|
If the subject of this spell is a wizard, next turn he must repeat
|
||||||
|
identically the gestures he made in the current turn, including stabs.
|
||||||
|
If the subject is a monster it will attack whoever it attacked this
|
||||||
|
turn. If the subject is simultaneously the subject of any of
|
||||||
|
'confusion', 'charm person', 'charm monster', 'paralysis' or 'fear'
|
||||||
|
then none of the spells work.
|
||||||
|
'Confusion' D-S-F
|
||||||
|
If the subject of this spell is a wizard, next turn he writes down his
|
||||||
|
gestures as usual and after exposing them, rolls 2 dice to determine
|
||||||
|
which gesture is superseded due to his being confused. The first die
|
||||||
|
indicates left hand on 1-3, right on 4-6. The second roll determines
|
||||||
|
what the gesture for that hand shall be replaced with: 1=C, 2=D, 3=F,
|
||||||
|
4=P, 5=S, 6=W. If the subject of the spell is a monster, it attacks at
|
||||||
|
random that turn. If the subject is also the subject of any of:
|
||||||
|
'amnesia', 'charm person', 'charm monster', 'paralysis' or 'fear',
|
||||||
|
none of the spells work.
|
||||||
|
'Charm Person' P-S-D-F
|
||||||
|
Except for cancellation with other enchantments, this spell only
|
||||||
|
affects humans. The subject is told which of his hands will be
|
||||||
|
controlled at the time the spell hits, and in the following turn, the
|
||||||
|
caster of the spell writes down the gesture he wants the subject's
|
||||||
|
named hand to perform. This could be a stab or nothing. (Some people,
|
||||||
|
myself included do not allow the gesture to be nothing. Makes the
|
||||||
|
game more exciting, as there is no reason to choose any non-nothing
|
||||||
|
gesture when you have the choice. -- B)
|
||||||
|
If the subject
|
||||||
|
is only so because of a reflection from a 'magic mirror' the subject
|
||||||
|
of the mirror assumes the role of caster and writes down his
|
||||||
|
opponent's gesture. If the subject is also the subject of any of
|
||||||
|
'amnesia', 'confusion', 'charm monster', 'paralysis' or 'fear', none
|
||||||
|
of the spells work.
|
||||||
|
'Charm Monster' P-S-D-D
|
||||||
|
Except for cancellation with other enchantments, this spell only
|
||||||
|
affects monsters (excluding elementals). Control of the monster is
|
||||||
|
transferred to the caster of the spell (or retained by him) as of this
|
||||||
|
turn, i.e. the monster will attack whosoever its new controller
|
||||||
|
dictates from that turn onwards including that turn. Further charms
|
||||||
|
are, of course, possible, transferring as before. If the subject of
|
||||||
|
the charm is also the subject of any of: 'amnesia', 'confusion',
|
||||||
|
'charm person', 'fear' or 'paralysis', none of the spells work.
|
||||||
|
'Paralysis' F-F-F
|
||||||
|
If the subject of the spell is a wizard, then on the turn the spell is
|
||||||
|
cast, after gestures have been revealed, the caster selects one of the
|
||||||
|
wizard's hands and on the next turn that hand is paralyzed into the
|
||||||
|
position it is in this turn. If the wizard already had a paralyzed
|
||||||
|
hand, it must be the same hand which is paralyzed again. Certain
|
||||||
|
gestures remain the same but if the hand being paralyzed is performing
|
||||||
|
a C, S or W it is instead paralyzed into F, D or P respectively,
|
||||||
|
otherwise it will remain in the position written down (this allows
|
||||||
|
repeated stabs). A favourite ploy is to continually paralyze a hand
|
||||||
|
(F-F-F-F-F-F etc.) into a non-P gesture and then set a monster on the
|
||||||
|
subject so that he has to use his other hand to protect himself, but
|
||||||
|
then has no defence against other magical attacks. If the subject of
|
||||||
|
the spell is a monster (excluding elementals which are unaffected) it
|
||||||
|
simply does not attack in the turn following the one in which the
|
||||||
|
spell was cast. If the subject of the spell is also the subject of any
|
||||||
|
of 'amnesia', 'confusion', 'charm person', 'charm monster' or 'fear',
|
||||||
|
none of the spells work.
|
||||||
|
'Fear' S-W-D
|
||||||
|
In the turn following the casting of this spell, the subject cannot
|
||||||
|
perform a C, D, F or S gesture. This obviously has no effect on
|
||||||
|
monsters. If the subject is also the subject of 'amnesia',
|
||||||
|
'confusion', 'charm person', 'charm monster' or 'paralysis', then none
|
||||||
|
of the spells work.
|
||||||
|
'Anti-spell' S-P-F
|
||||||
|
On the turn following the casting of this spell, the subject cannot
|
||||||
|
include any gestures made on or before this turn in a spell sequence
|
||||||
|
and must restart a new spell from the beginning of that spell
|
||||||
|
sequence. The spell does not affect spells which are cast on the same
|
||||||
|
turn nor does it affect monsters.
|
||||||
|
'Protection from Evil' W-W-P
|
||||||
|
For this turn and the following 3 turns the subject of this spell is
|
||||||
|
protected as if using a 'shield' spell, thus leaving both hands free.
|
||||||
|
Concurrent 'shield' spells offer no further protection and compound
|
||||||
|
'protection from evil' spells merely overlap offering no extra cover.
|
||||||
|
'Resist Heat' W-W-F-P
|
||||||
|
The subject of this spell becomes totally resistant to all forms of
|
||||||
|
heat attack ('fireball', 'fire storm' and fire elementals). Only
|
||||||
|
'dispel magic' or 'remove enchantment' will terminate this resistance
|
||||||
|
once started (although a 'counter-spell' will prevent it from working
|
||||||
|
if cast at the subject at the same time as this spell). A 'resist
|
||||||
|
heat' cast directly on a fire elemental will destroy it before it can
|
||||||
|
attack that turn, but there is no effect on ice elementals.
|
||||||
|
'Resist Cold' S-S-F-P
|
||||||
|
The effects of this spell are identical to 'resist heat' but
|
||||||
|
resistance is to cold ('ice storm' and ice elementals) and it destroys
|
||||||
|
ice elementals if they are the subject of the spell but doesn't affect
|
||||||
|
fire elementals.
|
||||||
|
'Disease' D-S-F-F-F-C
|
||||||
|
The subject of this spell immediately contracts a deadly
|
||||||
|
(non-contagious) disease which will kill him at the end of 6 turns
|
||||||
|
counting from the one upon which the spell is cast. The malady is
|
||||||
|
cured by 'remove enchantment' or 'cure heavy wounds' or 'dispel magic'
|
||||||
|
in the meantime.
|
||||||
|
'Poison' D-W-W-F-W-D
|
||||||
|
This is the same as the disease spell except that 'cure heavy wounds'
|
||||||
|
does not stop its effects.
|
||||||
|
'Blindness' D-W-F-F-(d
|
||||||
|
For the next 3 turns not including the one in which the spell was
|
||||||
|
cast, the subject is unable to see. If he is a wizard, he cannot tell
|
||||||
|
what his opponent's gestures are, although he must be informed of any
|
||||||
|
which affect him (e.g. summons spells, 'missile' etc cast at him) but
|
||||||
|
not 'counter- spells' to his own 'attacks. Indeed he will not know if
|
||||||
|
his own spells work unless they also affect him (e.g. a 'fire storm'
|
||||||
|
when he isn't resistant to fire.) He can control his monsters (e.g.
|
||||||
|
"Attack whatever it was that just attacked me"). Blinded monsters are
|
||||||
|
instantly destroyed and cannot attack in that turn.
|
||||||
|
'Invisibility' P-P-(w-(s
|
||||||
|
This spell is similar to 'blindness' only the subject of the spell
|
||||||
|
becomes invisible to his opponent and his monsters. All spells he
|
||||||
|
creates, though not gestures, can be seen by his opponent and
|
||||||
|
identified. The subject cannot be attacked by any monsters although
|
||||||
|
they can be directed at him in case he becomes visible prematurely.
|
||||||
|
Wizards can still stab and direct spells at him, with the same hope.
|
||||||
|
Any monster made invisible is destroyed due to the unstable nature of
|
||||||
|
such magically created creatures.
|
||||||
|
'Haste' P-W-P-W-W-C
|
||||||
|
For the next 3 turns, the subject (but not his monsters if a wizard)
|
||||||
|
makes an extra set of gestures due to being speeded up. This takes
|
||||||
|
effect in the following turn so that instead of giving one pair of
|
||||||
|
gestures, 2 are given, the effect of both being taken simultaneously
|
||||||
|
at the end of the turn. Thus a single 'counter-spell' from his
|
||||||
|
adversary could cancel 2 spells cast by the hastened wizard on 2
|
||||||
|
half-turns if the phasing is right. Non-hastened wizards and monsters
|
||||||
|
can see everything the hastened individual is doing. Hastened
|
||||||
|
monsters can change target in the extra turns if desired.
|
||||||
|
'Time stop' S-P-P-C
|
||||||
|
The subject of this spell immediately takes an extra turn, on which
|
||||||
|
no-one can see or know about unless they are harmed. All non-affected
|
||||||
|
beings have no resistance to any form of attack, e.g. a wizard halfway
|
||||||
|
through the duration of a 'protection from evil' spell can be harmed
|
||||||
|
by a monster which has had its time stopped. Time-stopped monsters
|
||||||
|
attack whoever their controller instructs, and time-stopped elementals
|
||||||
|
affect everyone, resistance to heat or cold being immaterial in that
|
||||||
|
turn.
|
||||||
|
'Delayed effect' D-W-S-S-S-P
|
||||||
|
This spell only works if cast upon a wizard. The next spell he
|
||||||
|
completes, provided it is on this turn or one of the next 3 is
|
||||||
|
"banked" until needed, i.e. it fails to work until its caster desires.
|
||||||
|
This next spell which is to be banked does not include the actual
|
||||||
|
spell doing the banking. The spell must be written down to be used by
|
||||||
|
its caster at the same time that he writes his gestures. Note that
|
||||||
|
spells banked are those cast by the subject not those cast at him. If
|
||||||
|
he casts more than one spell at the same time he chooses which is to
|
||||||
|
be banked. Remember that P is a 'shield' spell, and surrender is not a
|
||||||
|
spell. A wizard may only have one spell banked at any one time.
|
||||||
|
'Permanency' S-P-F-P-S-D-W
|
||||||
|
This spell only works if cast upon a wizard. The next spell he
|
||||||
|
completes, provided it is on this turn or one of the next 3, and which
|
||||||
|
falls into the category of "Enchantments" (except 'anti-spell',
|
||||||
|
'disease', 'poison', or 'time-stop') will have its effect made
|
||||||
|
permanent. This means that the effect of the extended spell on the
|
||||||
|
first turn of its duration is repeated eternally. For example, a
|
||||||
|
'confusion' spell will be the same gesture rather than re-rolling the
|
||||||
|
dice, a 'charm person' will mean repetition of the chosen gesture,
|
||||||
|
etc. If the subject of the 'permanency' casts more than one spell at
|
||||||
|
the same time eligible for permanency, he chooses which has its
|
||||||
|
duration extended. Note that the person who has his spell made
|
||||||
|
permanent does not necessarily have to make himself the subject of the
|
||||||
|
spell. A 'permanency' spell cannot increase its own duration, nor the
|
||||||
|
duration of spells saved by a 'delayed effect' (so if both a
|
||||||
|
'permanency' and 'delayed effect' are eligible for the same spell to
|
||||||
|
be banked or extended, a choice must be made, the losing spell being
|
||||||
|
neutralized and working on the next spell instead).
|
||||||
|
Non-spells
|
||||||
|
'Surrender' (p
|
||||||
|
This is not a spell and consequently cannot be cast at anyone. The
|
||||||
|
wizard making these gestures, irrespective of whether they terminate
|
||||||
|
spells or not, surrenders and the contest is over. The surrendering
|
||||||
|
wizard is deemed to have lost unless his gestures completed spells
|
||||||
|
which killed his opponent. Two simultaneous surrenders count as a
|
||||||
|
draw. It is a skill for wizards to work their spells so that they
|
||||||
|
never accidentally perform 2 P gestures simultaneously. Wizards can
|
||||||
|
be killed as they surrender if hit with appropriate spells or attacked
|
||||||
|
physically, but the "referees" will cure any diseases, poisons etc
|
||||||
|
immediately after the surrender for them.
|
||||||
|
'Stab' stab
|
||||||
|
This is not a spell but an attack which can be directed at any
|
||||||
|
individual monster or wizard. Unless protected in that turn by a
|
||||||
|
'shield' spell or another spell with the same effect, the being
|
||||||
|
stabbed suffers 1 point of damage. The wizard only has one knife so
|
||||||
|
can only stab with one hand in any turn, although which hand doesn't
|
||||||
|
matter. The stab cannot be reflected by a 'magic mirror' or stopped by
|
||||||
|
'dispel magic' (although its 'shield' effect *could* stop the stab).
|
||||||
|
Wizards are not allowed to stab themselves and must choose a target
|
||||||
|
for the stab. Knives cannot be thrown.
|
||||||
|
Reference Sheet
|
||||||
|
Players are not expected to remember all the spells and their gestures so here
|
||||||
|
is a brief list in forward and reverse order:
|
||||||
|
Spells in Forward Order
|
||||||
|
-----------------------
|
||||||
|
C-D-P-W Dispel magic P-S-D-F Charm person
|
||||||
|
C-S-W-W-S Summon elemental P-S-F-W Summon ogre
|
||||||
|
C-(w Magic mirror P-W-P-F-S-S-S-D Finger of death
|
||||||
|
D-F-F-D-D Lightning bolt P-W-P-W-W-C Haste
|
||||||
|
D-F-P-W Cure heavy wounds S-D Missile
|
||||||
|
D-F-W Cure light wounds S-F-W Summon goblin
|
||||||
|
D-P-P Amnesia S-P-F Anti-spell
|
||||||
|
D-S-F Confusion S-P-F-P-S-D-W Permanency
|
||||||
|
D-S-F-F-F-C Disease S-P-P-C Time stop
|
||||||
|
D-W-F-F-(d Blindness S-S-F-P Resist cold
|
||||||
|
D-W-S-S-S-P Delayed effect S-W-D Fear
|
||||||
|
D-W-W-F-W-C Raise dead S-W-W-C Fire storm
|
||||||
|
D-W-W-F-W-D Poison W-D-D-C + Lightning bolt
|
||||||
|
F-F-F Paralysis W-F-P Cause light wounds
|
||||||
|
F-P-S-F-W Summon troll W-F-P-S-F-W Summon giant
|
||||||
|
F-S-S-D-D Fireball W-P-F-D Cause heavy wounds
|
||||||
|
P Shield W-P-P Counter-spell
|
||||||
|
(p ! Surrender W-S-S-C Ice storm
|
||||||
|
P-D-W-P Remove enchantment W-W-F-P Resist heat
|
||||||
|
P-P-(w-(s Invisibility W-W-P Protection from evil
|
||||||
|
P-S-D-D Charm monster W-W-S Counter-spell
|
||||||
|
! This is not a spell
|
||||||
|
+ Each wizard may use this spell once per battle, then the gestures have no
|
||||||
|
effect.
|
||||||
|
Spells in Reverse Order
|
||||||
|
(read your last few gestures backwards to see if you've made a spell)
|
||||||
|
-----------------------
|
||||||
|
C-D-D-W + Lightning bolt (p ! Surrender
|
||||||
|
C-F-F-F-S-D Disease P-F-S-S Resist cold
|
||||||
|
C-P-P-S Time Stop P-F-W Cause Light wounds
|
||||||
|
C-S-S-W Ice storm P-F-W-W Resist heat
|
||||||
|
C-W-F-W-W-D Raise dead P-P-D Amnesia
|
||||||
|
C-W-W-P-W-P Haste P-P-W Counter spell
|
||||||
|
C-W-W-S Fire storm P-S-S-S-W-D Delayed effect
|
||||||
|
D-D-F-F-D Lightning bolt P-W-D-P Remove enchantment
|
||||||
|
D-D-S-P Charm monster P-W-W Protection from evil
|
||||||
|
D-D-S-S-F Fireball (s-(w-P-P Invisibility
|
||||||
|
(d-F-F-W-D Blindness S-W-W Counter-spell
|
||||||
|
D-F-P-W Cause heavy wounds S-W-W-S-C Summon elemental
|
||||||
|
D-S Missile (w-C Magic mirror
|
||||||
|
D-S-S-S-F-P-W-P Finger of death W-D-S-P-F-P-S Permanency
|
||||||
|
D-W-F-W-W-D Poison W-F-D Cure light wounds
|
||||||
|
D-W-S Fear W-F-S Summon goblin
|
||||||
|
F-D-S-P Charm person W-F-S-P Summon ogre
|
||||||
|
F-F-F Paralysis W-F-S-P-F Summon troll
|
||||||
|
F-P-S Anti-spell W-F-S-P-F-W Summon giant
|
||||||
|
F-S-D Confusion W-D-D-C Dispel magic
|
||||||
|
P Shield W-P-F-D Cure heavy wounds
|
||||||
|
Sample Game
|
||||||
|
I now include a sample game with following comments.
|
||||||
|
Significant spells are marked +,|,x. Null gestures are in brackets.
|
||||||
|
Underlined gestures and above cannot be used further due to the
|
||||||
|
effects of an 'anti-spell'.
|
||||||
|
Turn BLACK WHITE
|
||||||
|
Left Right Damage Left Right Damage
|
||||||
|
1 W W + 0 W + P 0
|
||||||
|
2 W P | W | D
|
||||||
|
3 D F | F | + W
|
||||||
|
4 D + D x P x | W 3
|
||||||
|
5 F | P x S x S +
|
||||||
|
6 F x P 1 W D x
|
||||||
|
7 S D W + + D
|
||||||
|
8 S W W | | S +
|
||||||
|
9 F F + S x x F |
|
||||||
|
10 P x (C) x D + W x+
|
||||||
|
11 Stab x P P x| D |
|
||||||
|
12 P + S P x D |
|
||||||
|
13 P | S 6 C C x
|
||||||
|
14 W | W S + + S
|
||||||
|
15 + S x + S W | | P x
|
||||||
|
16 x D x D W | x F 5
|
||||||
|
-------------
|
||||||
|
17 W + S + 11 C x C
|
||||||
|
18 D | P | + S + S
|
||||||
|
19 D | F x 13 x D x D
|
||||||
|
-------------
|
||||||
|
20 C x + C S + S 10
|
||||||
|
21 W x W P | W
|
||||||
|
22 P P F x W
|
||||||
|
Annotation
|
||||||
|
1. Black starts with 2 W's hoping to use the left as a possible W-W-S
|
||||||
|
'counter- spell' in the event of White opening with an attack, or as a
|
||||||
|
possible W-W-F-P 'resist heat' spell. His right hand also opens for a
|
||||||
|
possible 'counter-spell' or W-P-F-D 'cause heavy wounds' if White has
|
||||||
|
no possible attack; the second P will also cover against any S-D
|
||||||
|
'missile attack'. White has the same ideas for his left hand as Black
|
||||||
|
and opens with a P for his right hand in case Black tries a stab,
|
||||||
|
leading to P-D-W-P 'remove enchantment' hopefully.
|
||||||
|
2. Both complete their second gestures as intended and both notice
|
||||||
|
that neither can use an offensive spell on the third turn so they can
|
||||||
|
switch their left hands to W-W-F-P 'resist fire'. Black notices that
|
||||||
|
the only spell White could be preparing with his right hand is remove
|
||||||
|
enchantment and hence his own 'resist fire' would be canceled. Since
|
||||||
|
he cannot adjust his right hand sequence to produce a 'counter-spell'
|
||||||
|
he resolves to use his latest W to start a W-D-D-C 'lightning bolt'.
|
||||||
|
3. White had to continue his 'remove enchantment' in case Black called
|
||||||
|
his bluff but now knows it is pointless to continue as there will be
|
||||||
|
nothing to cancel. Black is certain to hit White with an unstoppable
|
||||||
|
'cause heavy wounds' next time but notices too late that, had he
|
||||||
|
instead of gesturing a D with his left hand, gestured a W, he could
|
||||||
|
have cast a W-W-S 'counter-spell' at White and stopped his 'resist
|
||||||
|
heat' from working. However, he has a 'lightning bolt' coming as
|
||||||
|
compensation...
|
||||||
|
4. Black hits White with his 'cause heavy wounds' and White becomes
|
||||||
|
fire- resistant. White notes that had he gone ahead with his 'remove
|
||||||
|
enchantment' spell he would have been doing two P gestures this turn -
|
||||||
|
surrendering! Instead he performs a W with his right hand to time a
|
||||||
|
'counter-spell' after Black's last continuation.
|
||||||
|
5. Black aborts his 'lightning bolt' spell, but White still follows
|
||||||
|
through with his 'counter-spell', just in case. Black switches to the
|
||||||
|
other combination for 'lightning bolt', D-F-F-D-D and sneaks in the
|
||||||
|
start of an amnesia D-P-P. White commences a 'summon ogre' P-S-F-W
|
||||||
|
with his left hand, but upon seeing that next time he will be struck
|
||||||
|
by 'amnesia' realizes that he won't be able to continue it. Knowing
|
||||||
|
the nature of the spell, he decides to switch next time to a spell
|
||||||
|
starting off with 2 identical gestures, a W-W-S 'counter-spell' for
|
||||||
|
safety.
|
||||||
|
6. Black hits White with the predicted D-P-P amnesia spell, meaning
|
||||||
|
that White must repeat his gestures next time with the same hands.
|
||||||
|
White knows that he has a "free" S-D 'missile' on Black since Black
|
||||||
|
cannot do a P shield to defend himself or he would be performing the
|
||||||
|
'surrender' gestures.
|
||||||
|
7. Since Black knows White's gestures, he can tell that White will be
|
||||||
|
able to time a 'counter-spell' to ward off the proposed 'lightning
|
||||||
|
bolt' D-F-F-D-D, so he changes to a 'fireball' F-S-S-D-D. However,
|
||||||
|
White is resistant to fire so a P-D-W-P 'remove enchantment' is called
|
||||||
|
for from the right hand.
|
||||||
|
8. White surprised Black by repeating his W gesture in the left hand
|
||||||
|
so he can now time a counter spell for the next round and thus prevent
|
||||||
|
the 'remove enchantment' from affecting his fire invulnerability
|
||||||
|
(although his resistance to fire will be unaffected by the
|
||||||
|
'counter-spell' since he has already cast the spell on himself in
|
||||||
|
round 4). With his right hand, White half-heartedly starts off a
|
||||||
|
'confusion' spell to interrupt Black next time.
|
||||||
|
9. Black does 2 F gestures having to change in mid-spells after
|
||||||
|
White's cunning gestures last round. He decides to go for a
|
||||||
|
'blindness' spell D-W-F-F-(d with his right hand, and for 'cold
|
||||||
|
resistance' S-S-F-P with his left, to cut his losses. White casts the
|
||||||
|
counter spell W-W-S on himself so that his 'confusion' D-S-F will work
|
||||||
|
on Black.
|
||||||
|
10. Black writes down P for his left hand and F for his right, and
|
||||||
|
White writes D and W. Since Black was hit by a 'confusion' last time
|
||||||
|
one of his gestures will be affected. He rolls a C on the right hand
|
||||||
|
and this supersedes the F he had written down. Since he didn't C with
|
||||||
|
his other hand and C is a 2-handed gesture, it counts as a null. Note
|
||||||
|
that if he had rolled for a P he would have surrendered. White
|
||||||
|
finishes off a 'summon goblin' spell S-F-W, and a 'missile' S-D.
|
||||||
|
Black's left hand finishes a 'resist cold' S-S-F-P but he decides to
|
||||||
|
use only the last gesture as a P 'shield' so that neither of White's
|
||||||
|
attacks harm him. Now he will wish to dispose of the goblin as soon as
|
||||||
|
possible so he can stop performing P's to protect himself.
|
||||||
|
11. Black stabs the goblin and, since only 1 hit is required to kill
|
||||||
|
it, eliminates it. He also gestures a P 'shield' so that it cannot
|
||||||
|
harm him in that turn. White gestures for an 'amnesia' D-P-P and a
|
||||||
|
'lightning bolt' W-D-D-C (the sort which can only be used once per
|
||||||
|
wizard). He also performed a P 'shield' but didn't notice and since it
|
||||||
|
is in the "protection" section it was cast upon himself. If he had
|
||||||
|
cast it at the goblin it wouldn't have been killed. Black realizes
|
||||||
|
that he will have no defence against either of White's forthcoming
|
||||||
|
attacks, so chooses to work 2 spells beginning with the same gesture
|
||||||
|
next time so that the amnesia won't be too damaging.
|
||||||
|
12. Black must start off both gestures from scratch after the stab and
|
||||||
|
half-C and opts for P-P-(w-(s 'invisibility' and 'resist cold'
|
||||||
|
S-S-F-P. White's 'amnesia' spell is completed so Black must repeat
|
||||||
|
next time as expected.
|
||||||
|
13. Black repeats, and White does a C gesture with both hands to
|
||||||
|
terminate a W-D-D-C 'lightning bolt' giving 5 points of damage to
|
||||||
|
Black. White cannot cast another 'lightning bolt' using the same
|
||||||
|
gestures now, even if he does W-D-D-C since this type is allowed only
|
||||||
|
once per battle. However, he may use the alternate gestures if he
|
||||||
|
desires.
|
||||||
|
14. Black aborts his right-hand spell since a W with both hands is
|
||||||
|
needed for the 'invisibility'. White does 2 S gestures, hoping for a
|
||||||
|
C-S-W-W-S 'summon elemental' and either an 'anti-spell' S-F-F or
|
||||||
|
'summon goblin' S-F-W.
|
||||||
|
15. Black goes invisible for the next 3 turns so White won't be able
|
||||||
|
to see his gestures. White continues with his 'summon elemental' but
|
||||||
|
has the option to change to a 'fire storm' sequence if he so desires.
|
||||||
|
16. Realizing that he is about to be hit by an 'anti-spell' S-P-F from
|
||||||
|
White, Black opens up with 2 'missiles' S-D and catches White off
|
||||||
|
guard. Next time Black must gesture from scratch again. Although White
|
||||||
|
does not see Black's gestures he knows he has been hit by 2 'missiles'
|
||||||
|
so can work them out.
|
||||||
|
17. For the second time, Black starts up for a 'lightning' bolt
|
||||||
|
W-D-D-C, hoping this time to complete it, and a covering 'anti-spell'
|
||||||
|
S-P-F so that White will be unable to defend himself from it.
|
||||||
|
Meanwhile White realizes he is resistant to fire and unleashes a 'fire
|
||||||
|
storm' giving 5 points of damage to all those not fire-resistant which
|
||||||
|
just happens to be Black.
|
||||||
|
18. White does 2 S gestures again hoping to fool Black into thinking
|
||||||
|
that he will repeat the sequence he used after the last C gesture,
|
||||||
|
whereas he intends to hit out with 2 S-D 'missiles' next turn (when
|
||||||
|
Black becomes visible again). Black continues with his plan.
|
||||||
|
19. Black is surprised by the 2 'missiles' from White and is not
|
||||||
|
performing a P 'shield' which would protect him. White is hit by
|
||||||
|
Black's 'anti-spell' S-P-F but is unaware of the impending 'lightning
|
||||||
|
bolt' anyway...
|
||||||
|
20. Black hits White with his 'lightning bolt' as White prepares 2
|
||||||
|
more 'missiles' which would kill Black if they hit, but, noting
|
||||||
|
Black's C he daren't risk the possibility of it being followed by a
|
||||||
|
pair of W's for a C-(w 'magic mirror' which would reflect them back at
|
||||||
|
White. Hence White decides to go for another 'anti-spell' and 'fire
|
||||||
|
storm'.
|
||||||
|
21. Black does perform the 2 W's planning to 'cause heavy wounds' or
|
||||||
|
defend with 'counter-spell'. White continues as planned and Black
|
||||||
|
becomes aware that he is about to be burned to death.
|
||||||
|
22. Resigning himself to defeat Black surrenders just as White's
|
||||||
|
'anti-spell' hits him. The battle ends before White can make the final
|
||||||
|
C gesture which would have disposed of his enemy. White wins the
|
||||||
|
contest.
|
||||||
|
Back to Main List
|
||||||
Generated
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "waving-hands",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
:root {
|
||||||
|
--slate: #1c2830;
|
||||||
|
--slate-deep: #121a20;
|
||||||
|
--slate-raised: #24333d;
|
||||||
|
--bone: #e9e2d2;
|
||||||
|
--bone-dim: #98a4ab;
|
||||||
|
--bone-faint: #5d6b73;
|
||||||
|
--ember: #f0a53a;
|
||||||
|
--frost: #8cc8e0;
|
||||||
|
--blood: #e0655c;
|
||||||
|
--rule: rgba(233, 226, 210, 0.14);
|
||||||
|
--rule-strong: rgba(233, 226, 210, 0.32);
|
||||||
|
--font: 'Fraunces', 'Iowan Old Style', Georgia, serif;
|
||||||
|
--gutter: clamp(16px, 3vw, 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background: var(--slate-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--slate-deep);
|
||||||
|
color: var(--bone);
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-variation-settings: 'opsz' 14;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
summary:focus-visible {
|
||||||
|
outline: 2px solid var(--frost);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
background: var(--slate-deep);
|
||||||
|
border: 1px solid var(--rule-strong);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ember {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||||
|
<title>Waving Hands</title>
|
||||||
|
<!--
|
||||||
|
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
|
||||||
|
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
|
||||||
|
The bitmap borders were removed and the contours converted to SVG with lightly
|
||||||
|
rounded corners. The original poses and left/right mirroring are retained.
|
||||||
|
|
||||||
|
Original notice (Spellcast README):
|
||||||
|
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
|
||||||
|
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
|
||||||
|
distributed, and modified, as long as this copyright notice is retained.
|
||||||
|
The source code and any derivative works may not be sold for profit without
|
||||||
|
the permission of Andrew Plotkin and Richard Bartle.
|
||||||
|
-->
|
||||||
|
<rect width="48" height="48" rx="10" fill="#121a20" />
|
||||||
|
<g transform="translate(3 3) scale(.84)">
|
||||||
|
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45Z" fill="#f0a53a" fill-rule="evenodd" />
|
||||||
|
<path d="m22 3 2 4m9-4-1 4m5 3 5-2m-3 9h6m-7 6 3 3" fill="none" stroke="#e9e2d2" stroke-width="2.2" stroke-linecap="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { LogEntry } from '$lib/game/state';
|
||||||
|
|
||||||
|
let { log }: { log: LogEntry[] } = $props();
|
||||||
|
|
||||||
|
const turns = $derived.by(() => {
|
||||||
|
const groups = new Map<number, LogEntry[]>();
|
||||||
|
for (const entry of log) {
|
||||||
|
if (!groups.has(entry.turn)) groups.set(entry.turn, []);
|
||||||
|
groups.get(entry.turn)!.push(entry);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].reverse();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="chronicle">
|
||||||
|
<h2>Chronicle</h2>
|
||||||
|
{#if turns.length === 0}
|
||||||
|
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and reveal them.</p>
|
||||||
|
{/if}
|
||||||
|
{#each turns as [turn, entries] (turn)}
|
||||||
|
<article>
|
||||||
|
<h3>Turn {turn + 1}</h3>
|
||||||
|
<ul>
|
||||||
|
{#each entries as e, i (i)}
|
||||||
|
<li class={e.kind}>{e.text}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chronicle {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
max-height: 44vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
margin: 0.6rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.gesture {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.spell {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.damage {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.death {
|
||||||
|
color: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.fizzle {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Gesture, Hand } from '$lib/game/spells';
|
||||||
|
|
||||||
|
let { gesture, hand = 'left' }: { gesture: Gesture; hand?: Hand } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
|
||||||
|
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
|
||||||
|
The bitmap borders were removed and the contours converted to SVG with lightly
|
||||||
|
rounded corners. The original poses and left/right mirroring are retained.
|
||||||
|
|
||||||
|
Original notice (Spellcast README):
|
||||||
|
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
|
||||||
|
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
|
||||||
|
distributed, and modified, as long as this copyright notice is retained.
|
||||||
|
The source code and any derivative works may not be sold for profit without
|
||||||
|
the permission of Andrew Plotkin and Richard Bartle.
|
||||||
|
-->
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<g transform={hand === 'right' ? 'translate(48 0) scale(-1 1)' : undefined}>
|
||||||
|
{#if gesture === 'F'}
|
||||||
|
<path d="M4 46.4L4 45.6Q4 45 4.47 44.63L8.53 41.37Q9 41 9 40.4L9 39.6Q9 39 8.46 38.73L7.6 38.3Q7 38 7 37.33L7 33.3Q7 33 6.7 33L6.3 33Q6 33 6 32.7L6 23.2Q6 22 6.7 21.02L10.65 15.49Q11 15 11.6 15L12.4 15Q13 15 13 15.6L13 17.1Q13 18 12.36 18.64L10.85 20.15Q10 21 10 22.2L10 24.7Q10 25 10.3 25L10.7 25Q11 25 11.09 24.72L12.79 19.64Q13 19 13.6 18.7L14.4 18.3Q15 18 15.16 17.35L15.79 14.82Q16 14 16.6 13.4L17.58 12.42Q18 12 18.6 12L19.4 12Q20 12 20.33 11.5L21.47 9.79Q22 9 22.9 9.3L24.1 9.7Q25 10 24.38 10.72L19.78 16.09Q19 17 18.8 18.18L18.05 22.7Q18 23 17.7 23L17.3 23Q17 23 17 23.3L17 23.7Q17 24 17.3 24L18.4 24Q19 24 19.21 23.44L21.58 17.12Q22 16 22.85 15.15L28.15 9.85Q29 9 30.2 9L32.4 9Q33 9 33 9.6L33 10.4Q33 11 32.58 11.42L26.85 17.15Q26 18 26 19.2L26 22.33Q26 23 26.6 23.3L27.4 23.7Q28 24 28.47 23.53L29.4 22.6Q30 22 30.21 21.18L30.71 19.16Q31 18 31.85 17.15L35.15 13.85Q36 13 37.18 12.8L41.41 12.1Q42 12 42 12.6L42 13.4Q42 14 41.58 14.42L40.42 15.58Q40 16 39.4 16L38.6 16Q38 16 38 16.6L38 17.4Q38 18 37.46 18.27L36.54 18.73Q36 19 36 19.6L36 20.4Q36 21 35.58 21.42L34.6 22.4Q34 23 34 23.85L34 26.7Q34 27 33.7 27L33.3 27Q33 27 33 27.3L33 32.4Q33 33 33.6 33L34.4 33Q35 33 35.42 32.58L38.58 29.42Q39 29 39 28.4L39 27.6Q39 27 39.6 27L41.1 27Q42 27 42 27.9L42 30.8Q42 32 41.22 32.91L36.39 38.54Q36 39 35.4 39L34.6 39Q34 39 33.67 39.5L32.6 41.1Q32 42 31.01 42.43L25.55 44.76Q25 45 25 45.6L25 46.4Q25 47 24.4 47L4.6 47Q4 47 4 46.4Z" />
|
||||||
|
{:else if gesture === 'P'}
|
||||||
|
<path d="M10 35.7L10 21.3Q10 21 10.3 21L10.7 21Q11 21 11 20.7L11 17.67Q11 17 11.6 16.7L12.62 16.19Q13 16 13.3 16.3L13.7 16.7Q14 17 14 17.42L14 25.7Q14 26 14.3 26L14.7 26Q15 26 15.02 25.7L15.95 8.95Q16 8 16.9 7.7L18.43 7.19Q19 7 19 7.6L19 8.7Q19 9 19.3 9L19.7 9Q20 9 20 9.3L20 22.33Q20 23 20.6 22.7L21.4 22.3Q22 22 22 21.33L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 3.7L23 2.6Q23 2 23.6 2L24.58 2Q25 2 25.3 2.3L25.7 2.7Q26 3 26 3.42L26 20.33Q26 21 26.6 21.3L27.4 21.7Q28 22 28 21.33L28 6.3Q28 6 28.3 6L28.7 6Q29 6 29 5.7L29 4.6Q29 4 29.57 4.19L31.1 4.7Q32 5 32.04 5.95L32.95 29.8Q33 31 33.96 30.28L36.04 28.72Q37 28 37.47 26.9L39.76 21.55Q40 21 40.6 21L41.4 21Q42 21 42 21.6L42 27.7Q42 28 41.7 28L41.3 28Q41 28 40.95 28.3L40.2 32.82Q40 34 39.15 34.85L36.85 37.15Q36 38 35.53 39.1L33.24 44.45Q33 45 32.4 45L31.6 45Q31 45 31 45.6L31 46.4Q31 47 30.4 47L15.85 47Q15 47 14.4 46.4L13.6 45.6Q13 45 12.83 44.17L12.06 40.29Q12 40 11.7 40L11.3 40Q11 40 11 39.7L11 36.3Q11 36 10.7 36L10.3 36Q10 36 10 35.7Z" />
|
||||||
|
{:else if gesture === 'S'}
|
||||||
|
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45ZM20.7 2.7L20.3 2.3Q20 2 20.3 2.3L20.7 2.7Q21 3 20.7 2.7ZM21.7 3.7L21.3 3.3Q21 3 21.3 3.3L21.7 3.7Q22 4 21.7 3.7ZM22 5.7L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 4.3L23 5.7Q23 6 22.7 6L22.3 6Q22 6 22 5.7ZM23.7 6.7L23.3 6.3Q23 6 23.3 6.3L23.7 6.7Q24 7 23.7 6.7ZM32 6.7L32 5.6Q32 5 32.33 4.5L33.4 2.9Q34 2 33.79 3.06L33.06 6.71Q33 7 32.7 7L32.3 7Q32 7 32 6.7ZM36.79 10.79L36.3 10.3Q36 10 36.4 9.87L41.2 8.27Q42 8 41.4 8.6L40.6 9.4Q40 10 39.15 10L37.3 10Q37 10 37 10.3L37 10.7Q37 11 36.79 10.79ZM37.7 22.7L37.3 22.3Q37 22 37.3 22.3L37.7 22.7Q38 23 37.7 22.7ZM38.7 23.7L38.3 23.3Q38 23 38.3 23.3L38.7 23.7Q39 24 38.7 23.7ZM39 16.7L39 16.3Q39 16 39.3 16L45.7 16Q46 16 46 16.3L46 16.7Q46 17 45.7 17L39.3 17Q39 17 39 16.7ZM39.7 24.7L39.3 24.3Q39 24 39.3 24.3L39.7 24.7Q40 25 39.7 24.7ZM40.7 25.7L40.3 25.3Q40 25 40.3 25.3L40.7 25.7Q41 26 40.7 25.7Z" />
|
||||||
|
{:else if gesture === 'W'}
|
||||||
|
<path d="M9 28.1L9 26.9Q9 26 9.64 25.36L15.58 19.42Q16 19 16 18.4L16 17.6Q16 17 16.42 16.58L17.4 15.6Q18 15 18.8 14.73L22.86 13.38Q24 13 24.85 12.15L27.15 9.85Q28 9 29.2 9L31.05 9Q32 9 32.3 9.9L32.81 11.43Q33 12 32.4 12L31.6 12Q31 12 30.67 12.5L29.33 14.5Q29 15 28.4 15L27.6 15Q27 15 26.58 15.42L22.85 19.15Q22 20 21.62 21.14L20.38 24.86Q20 26 20.75 26.94L23.63 30.53Q24 31 24.6 31L25.4 31Q26 31 26.37 31.47L29.63 35.53Q30 36 30.6 36L31.4 36Q32 36 32.42 36.42L34.15 38.15Q35 39 35.2 40.18L35.86 44.16Q36 45 35.4 45.6L34.6 46.4Q34 47 33.15 47L18.6 47Q18 47 18 46.4L18 45.6Q18 45 18.54 44.73L19.4 44.3Q20 44 20 43.33L20 40.2Q20 39 19.11 38.19L9.67 29.61Q9 29 9 28.1Z" />
|
||||||
|
{:else if gesture === 'D'}
|
||||||
|
<path d="M9 37.8L9 28.67Q9 28 9.6 27.7L10.46 27.27Q11 27 11 26.4L11 25.6Q11 25 11.6 25L13.1 25Q14 25 14.28 24.15L14.72 22.85Q15 22 15.9 22L17.4 22Q18 22 18 21.4L18 20.6Q18 20 18.6 20L21.15 20Q22 20 22.6 19.4L23.4 18.6Q24 18 24.32 17.21L29.78 3.56Q30 3 30.6 3L31.4 3Q32 3 32.1 3.59L32.82 7.93Q33 9 32.4 9.9L31.6 11.1Q31 12 31 13.08L31 15.7Q31 16 30.7 16L30.3 16Q30 16 30 16.3L30 22.8Q30 24 31.18 23.76L34.41 23.12Q35 23 35 23.6L35 24.4Q35 25 34.67 25.5L33.6 27.1Q33 28 33 29.08L33 33.7Q33 34 32.7 34L32.3 34Q32 34 31.91 34.28L30.34 38.97Q30 40 29.1 40.6L27.5 41.67Q27 42 27 42.6L27 43.4Q27 44 26.43 44.19L24.57 44.81Q24 45 24 45.6L24 46.4Q24 47 23.4 47L10.85 47Q10 47 10.6 46.4L11.4 45.6Q12 45 12 44.15L12 42.9Q12 42 11.36 41.36L9.85 39.85Q9 39 9 37.8Z" />
|
||||||
|
{:else if gesture === 'C'}
|
||||||
|
<path d="M19 46.7L19 46.3Q19 46 19.28 45.91L21.1 45.3Q22 45 22.3 44.1L22.79 42.64Q23 42 23.6 41.7L24.4 41.3Q25 41 25 40.33L25 38.3Q25 38 25.3 38L25.7 38Q26 38 26 37.7L26 27.3Q26 27 25.7 27L25.3 27Q25 27 25.03 26.7L25.97 17.3Q26 17 26.3 17L26.7 17Q27 17 27.06 16.71L27.94 12.29Q28 12 28.3 12L28.7 12Q29 12 29 11.7L29 9.67Q29 9 29.6 8.7L30.4 8.3Q31 8 31.13 7.34L31.88 3.59Q32 3 32.6 3L33.4 3Q34 3 34.12 3.59L34.79 6.94Q35 8 34.4 8.9L33.6 10.1Q33 11 33 12.08L33 14.7Q33 15 32.7 15L32.3 15Q32 15 32 15.3L32 26.7Q32 27 32.3 27L32.7 27Q33 27 33 27.3L33 31.15Q33 32 33.6 32.6L34.4 33.4Q35 34 35 34.85L35 36.7Q35 37 35.3 37L35.7 37Q36 37 35.95 37.3L35.2 41.82Q35 43 34.28 43.96L32.72 46.04Q32 47 30.8 47L19.3 47Q19 47 19 46.7Z" />
|
||||||
|
{:else if gesture === '>'}
|
||||||
|
<path d="M10 46.7L10 46.3Q10 46 10.17 45.75L13.33 41Q14 40 14.09 38.8L14.98 27.3Q15 27 15.3 27L15.7 27Q16 27 16 26.7L16 22.3Q16 22 16.3 22L16.7 22Q17 22 17 21.7L17 17.3Q17 17 16.7 17L16.3 17Q16 17 15.94 16.71L13.2 2.06Q13 1 13.6 1.9L14.4 3.1Q15 4 15 5.08L15 8.7Q15 9 15.3 9L15.7 9Q16 9 16.05 9.3L17.79 18.82Q18 20 18.85 19.15L20.58 17.42Q21 17 21.6 17L22.4 17Q23 17 23 16.4L23 15.6Q23 15 22.46 14.73L21.54 14.27Q21 14 21 13.4L21 12.6Q21 12 20.64 11.52L18.54 8.72Q18 8 18 7.1L18 5.85Q18 5 17.4 4.4L16.42 3.42Q16 3 16 2.4L16 1.3Q16 1 16.3 1L16.7 1Q17 1 17.13 1.27L23.49 14.91Q24 16 25.16 16.29L27.18 16.79Q28 17 28.6 17.6L29.4 18.4Q30 19 30 19.85L30 22.4Q30 23 30.6 23L31.4 23Q32 23 32.42 23.42L33.4 24.4Q34 25 33.79 25.82L33.16 28.35Q33 29 33.6 29.3L34.4 29.7Q35 30 34.87 30.66L34.13 34.34Q34 35 34.6 35.3L35.4 35.7Q36 36 36 36.67L36 38.8Q36 40 35.3 40.98L31.7 46.02Q31 47 29.8 47L10.3 47Q10 47 10 46.7Z" />
|
||||||
|
{:else}
|
||||||
|
<!-- Spellcast left this tile blank; the letter row already shows a dash. -->
|
||||||
|
{/if}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.icon {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
|
||||||
|
import GestureIcon from './GestureIcon.svelte';
|
||||||
|
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
|
||||||
|
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
|
||||||
|
|
||||||
|
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
|
||||||
|
|
||||||
|
const draft = $derived(duel.sets[set][hand]);
|
||||||
|
const forced = $derived(duel.forcedFor(set, hand));
|
||||||
|
const charmed = $derived(duel.charmedHand === hand);
|
||||||
|
const options = $derived(duel.completions[set][hand]);
|
||||||
|
const chosen = $derived(duel.chosen[set][hand]);
|
||||||
|
const spell = $derived(chosen?.spell);
|
||||||
|
const other = $derived<Hand>(hand === 'left' ? 'right' : 'left');
|
||||||
|
const key = $derived(castKey(set, hand));
|
||||||
|
const banking = $derived(duel.willBank && castKey(duel.willBank.set, duel.willBank.hand) === key);
|
||||||
|
const extending = $derived(duel.willExtend && castKey(duel.willExtend.set, duel.willExtend.hand) === key);
|
||||||
|
let morePlans = $state(false);
|
||||||
|
const plans = $derived(morePlans ? duel.plans[hand] : duel.plans[hand].slice(0, duel.planLimit(hand)));
|
||||||
|
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
|
||||||
|
const whyForced = $derived(
|
||||||
|
duel.you.constraints.amnesia
|
||||||
|
? 'Amnesia: you must repeat last turn.'
|
||||||
|
: duel.you.constraints.paralysedHand === hand
|
||||||
|
? 'Paralysed in last turn’s position.'
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<fieldset class="hand">
|
||||||
|
<legend>{hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
|
||||||
|
|
||||||
|
{#if forced}
|
||||||
|
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
|
||||||
|
{:else if charmed}
|
||||||
|
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand’s gesture.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
|
||||||
|
{#each GESTURES as g (g)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="gesture"
|
||||||
|
class:on={draft.gesture === g}
|
||||||
|
disabled={!duel.allowed[hand].includes(g)}
|
||||||
|
aria-pressed={draft.gesture === g}
|
||||||
|
onclick={() => duel.choose(set, hand, g)}
|
||||||
|
>
|
||||||
|
<GestureIcon gesture={g} {hand} />
|
||||||
|
<span class="letter">{glyph(g)}</span>
|
||||||
|
<span class="name">{GESTURE_SHORT[g]}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if duel.you.constraints.fear && !duel.timeStopped}
|
||||||
|
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
|
||||||
|
{/if}
|
||||||
|
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
|
||||||
|
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
|
||||||
|
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
|
||||||
|
<ul class="plans" aria-label="{hand} hand, spells under way">
|
||||||
|
{#each plans as plan (plan.spell.id)}
|
||||||
|
<li class:pinned={plan.pinned}>
|
||||||
|
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
|
||||||
|
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
|
||||||
|
<span class="pname">{plan.spell.name}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if hiddenPlans > 0 || morePlans}
|
||||||
|
<li class="more">
|
||||||
|
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if options.length > 0}
|
||||||
|
<div class="cast">
|
||||||
|
<label>
|
||||||
|
<span>Finishes</span>
|
||||||
|
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
|
||||||
|
{#each options as c (c.spell.id + c.seqIndex)}
|
||||||
|
<option value={c.spell.id}>{c.spell.name} ({c.spell.sequences[c.seqIndex]})</option>
|
||||||
|
{/each}
|
||||||
|
<option value="">nothing, let it pass</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if spell}
|
||||||
|
<label>
|
||||||
|
<span>at</span>
|
||||||
|
<select bind:value={draft.target}>
|
||||||
|
{#each duel.targetsFor(spell) as t (t.id)}
|
||||||
|
<option value={t.id}>{t.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if spell.id === 'summon_elemental'}
|
||||||
|
<label>
|
||||||
|
<span>of</span>
|
||||||
|
<select bind:value={draft.elemental}>
|
||||||
|
<option value="fire">fire</option>
|
||||||
|
<option value="ice">ice</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
|
||||||
|
<label>
|
||||||
|
<span>their</span>
|
||||||
|
<select bind:value={draft.chosenHand}>
|
||||||
|
<option value="left">left hand</option>
|
||||||
|
<option value="right">right hand</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<p class="summary">
|
||||||
|
{spell.summary}
|
||||||
|
{#if banking}<strong>This spell will be banked by your delayed effect, not cast.</strong>{/if}
|
||||||
|
{#if extending}<strong>Your permanency will make this enchantment last the whole duel.</strong>{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hand {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.again {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestures {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture {
|
||||||
|
background: var(--slate-raised);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.3rem 0.2rem 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--bone);
|
||||||
|
transition: background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture:hover:not(:disabled) {
|
||||||
|
border-color: var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture.on {
|
||||||
|
background: var(--ember);
|
||||||
|
color: var(--slate-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture .letter {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gesture .name {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
background: var(--slate-raised);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast label span {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plans li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.1rem 0.2rem;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan:hover {
|
||||||
|
background: var(--slate-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq .done {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq .arrow {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
margin: 0 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq .next {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pname {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.pinned .pname {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.more .plan {
|
||||||
|
color: var(--frost);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.pinned .pin {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary,
|
||||||
|
.hint {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--frost);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary strong {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FOE, YOU, 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';
|
||||||
|
|
||||||
|
let { duel }: { duel: Duel } = $props();
|
||||||
|
|
||||||
|
let scroller: HTMLDivElement | undefined = $state();
|
||||||
|
|
||||||
|
interface Placed {
|
||||||
|
entry: HistoryEntry;
|
||||||
|
index: number;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
note?: string;
|
||||||
|
cells: Record<WizardId, Placed[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
|
||||||
|
const history = who === YOU ? duel.you.history : duel.foeVisible;
|
||||||
|
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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
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[]) {
|
||||||
|
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] : [] } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const drafting = $derived(!duel.state.over);
|
||||||
|
const turns = $derived(duel.you.history.length + duel.foe.history.length);
|
||||||
|
|
||||||
|
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
|
||||||
|
return duel.state.wizards[who].casts
|
||||||
|
.filter((c) => c.index === index && c.hand === hand)
|
||||||
|
.map((c) => SPELL_BY_ID[c.spellId].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void turns;
|
||||||
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scroller" bind:this={scroller}>
|
||||||
|
<table class="ledger">
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
<tr class="hands">
|
||||||
|
<th></th>
|
||||||
|
<th scope="col">left</th>
|
||||||
|
<th scope="col">right</th>
|
||||||
|
<th scope="col">left</th>
|
||||||
|
<th scope="col">right</th>
|
||||||
|
</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 ['left', 'right'] as const as hand (hand)}
|
||||||
|
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
|
||||||
|
{#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="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
|
||||||
|
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if drafting}
|
||||||
|
<tr class="draft" class:stopped={duel.timeStopped}>
|
||||||
|
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
|
||||||
|
{#each ['left', 'right'] as const as hand (hand)}
|
||||||
|
<td class="cell you">
|
||||||
|
{#each duel.activeSets as set (set)}
|
||||||
|
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
|
||||||
|
{@const charmed = duel.charmedHand === hand}
|
||||||
|
{@const cast = duel.chosen[set][hand]}
|
||||||
|
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
|
||||||
|
<span class="letter" class:placeholder={chosen === null || charmed}>
|
||||||
|
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
|
||||||
|
</span>
|
||||||
|
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scroller {
|
||||||
|
max-height: clamp(170px, 30vh, 400px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-top: 1px solid var(--rule-strong);
|
||||||
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--slate-deep);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 0 0.15rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th.you,
|
||||||
|
thead th.foe {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr.hands th {
|
||||||
|
top: 2.2rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
padding: 0 0 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
th.turn,
|
||||||
|
td.turn {
|
||||||
|
width: 2.9rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
vertical-align: top;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rownote {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
td.cell {
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0.35rem 0.2rem 0.3rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.stopped td.cell {
|
||||||
|
background: rgba(140, 200, 224, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
td.cell.you:nth-child(3) {
|
||||||
|
border-right: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.haste .letter {
|
||||||
|
font-size: clamp(1.1rem, 2.4vw, 1.4rem);
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(1.6rem, 3.5vw, 2.1rem);
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.cast .letter {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.hidden .letter {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.lit {
|
||||||
|
background: rgba(140, 200, 224, 0.18);
|
||||||
|
outline: 1px solid var(--frost);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry.lit .letter {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ember);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr.draft td.cell {
|
||||||
|
background: var(--slate);
|
||||||
|
border-bottom: 2px solid var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||||
|
import { glyph } from '$lib/game/glyphs';
|
||||||
|
import { MAX_HP } from '$lib/game/state';
|
||||||
|
|
||||||
|
let { duel }: { duel: Duel } = $props();
|
||||||
|
|
||||||
|
const left = $derived(duel.forcedFor('main', 'left') ?? duel.sets.main.left.gesture);
|
||||||
|
const right = $derived(duel.forcedFor('main', 'right') ?? duel.sets.main.right.gesture);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !duel.state.over}
|
||||||
|
<div class="bar">
|
||||||
|
<div class="health">
|
||||||
|
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
|
||||||
|
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gestures" aria-label="Chosen gestures">
|
||||||
|
<span class="letter" class:empty={left === null}>{duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')}</span>
|
||||||
|
<span class="letter" class:empty={right === null}>{duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||||
|
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 16px calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
|
background: var(--slate);
|
||||||
|
border-top: 1px solid var(--rule-strong);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health em {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gestures {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--ember);
|
||||||
|
min-width: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter.empty {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
background: var(--bone);
|
||||||
|
color: var(--slate-deep);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tokensText, type Duel } from '$lib/game/duel.svelte';
|
||||||
|
import { HANDS, SPELLS, type SpellCategory } from '$lib/game/spells';
|
||||||
|
|
||||||
|
let { duel }: { duel: Duel } = $props();
|
||||||
|
|
||||||
|
let open = $state<string | null>(null);
|
||||||
|
|
||||||
|
const groups: { category: SpellCategory; title: string }[] = [
|
||||||
|
{ category: 'protection', title: 'Protection' },
|
||||||
|
{ category: 'summons', title: 'Summons' },
|
||||||
|
{ category: 'damage', title: 'Damage' },
|
||||||
|
{ category: 'enchantment', title: 'Enchantments' }
|
||||||
|
];
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
|
||||||
|
<summary>Spell sheet</summary>
|
||||||
|
<p class="muted intro">Gestures you have already made light up; select a spell to read what it does. A bracketed lower-case letter, such as (w, is that gesture made with both hands at once; C, the clap, is always two-handed. Surrender is both palms at once: never a spell. <a href="/rules">Full rules</a>.</p>
|
||||||
|
{#each groups as g (g.category)}
|
||||||
|
<h3>{g.title}</h3>
|
||||||
|
<ul>
|
||||||
|
{#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)}
|
||||||
|
{@const k = Math.max(duel.progress[spell.id].left, duel.progress[spell.id].right)}
|
||||||
|
{@const done = duel.completedNow(spell.id)}
|
||||||
|
<li class:done class:open={open === spell.id}>
|
||||||
|
<button type="button" class="row" aria-expanded={open === spell.id} onclick={() => (open = open === spell.id ? null : spell.id)}>
|
||||||
|
<span class="seq">{#each spell.sequences as seq, si (seq)}{#if si > 0}<span class="or"> or </span>{/if}{#each seq.split('-') as part, i (i)}{#if i > 0}<span class="dash">-</span>{/if}<span class="tok" class:lit={done || i < k}>{part}</span>{/each}{/each}</span>
|
||||||
|
<span class="name">{spell.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if open === spell.id}
|
||||||
|
<p class="about">{spell.summary}</p>
|
||||||
|
{#if !duel.state.over}
|
||||||
|
<div class="planwith">
|
||||||
|
{#each HANDS as hand (hand)}
|
||||||
|
{@const plan = duel.planFor(hand, spell)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="planbtn"
|
||||||
|
disabled={!duel.handFree(hand)}
|
||||||
|
title={duel.handFree(hand) ? `Pin ${spell.name} to the ${hand} hand and make its next gesture` : `The ${hand} hand is not yours to move this turn`}
|
||||||
|
onclick={() => duel.follow(hand, plan)}
|
||||||
|
>
|
||||||
|
<span class="which">Plan with {hand} hand</span>
|
||||||
|
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/each}
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sheet {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-style: italic;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0.4rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
margin: 0.7rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(9.5rem, auto) 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.12rem 0.2rem;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: var(--slate-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.open .row {
|
||||||
|
background: var(--slate);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
padding: 0.2rem 0.2rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planwith {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0 0.2rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planbtn {
|
||||||
|
background: var(--slate-raised);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planbtn:hover:not(:disabled) {
|
||||||
|
border-color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planbtn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.which {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rest .done {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seq {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash,
|
||||||
|
.or {
|
||||||
|
color: var(--bone-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tok {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tok.lit {
|
||||||
|
color: var(--ember);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
li.done .name {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
|
||||||
|
|
||||||
|
let { duel }: { duel: Duel } = $props();
|
||||||
|
|
||||||
|
const summary = $derived(duel.summary);
|
||||||
|
|
||||||
|
function signed(n: number): string {
|
||||||
|
return n > 0 ? `+${n}` : String(n);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if summary}
|
||||||
|
<section class="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)}
|
||||||
|
{@const w = summary.wizards[who]}
|
||||||
|
<div>
|
||||||
|
<dt>{who === YOU ? 'You' : duel.foe.name}</dt>
|
||||||
|
<dd>
|
||||||
|
{#if w.effects.length === 0}
|
||||||
|
<span class="muted">untouched</span>
|
||||||
|
{:else}
|
||||||
|
{#each w.effects as e, i (i)}
|
||||||
|
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} → {w.after}{/if}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{#if summary.events?.length}
|
||||||
|
<ul class="events">
|
||||||
|
{#each summary.events as e, i (i)}
|
||||||
|
<li>{e}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if duel.showLessons && summary.lessons?.length}
|
||||||
|
<ul class="lessons">
|
||||||
|
{#each summary.lessons as l, i (i)}
|
||||||
|
<li>{l}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
background: var(--slate);
|
||||||
|
border-left: 2px solid var(--frost);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-style: italic;
|
||||||
|
min-width: 5.5em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect.hurt {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect.healed {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp.changed {
|
||||||
|
color: var(--bone);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lessons {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0.4rem 0 0.1rem 0.7rem;
|
||||||
|
border-left: 2px solid var(--ember);
|
||||||
|
color: var(--bone);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lessons li + li {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
padding: 0.35rem 0 0;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SPELL_BY_ID } from '$lib/game/spells';
|
||||||
|
import { MAX_HP, type Monster, type WizardState } from '$lib/game/state';
|
||||||
|
|
||||||
|
let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props();
|
||||||
|
|
||||||
|
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
|
||||||
|
|
||||||
|
const effects = $derived.by(() => {
|
||||||
|
const out: string[] = [];
|
||||||
|
if (wizard.resistHeat) out.push('resists heat');
|
||||||
|
if (wizard.resistCold) out.push('resists cold');
|
||||||
|
if (wizard.protectionTurns > 0) out.push(`protected from evil, ${more(wizard.protectionTurns)}`);
|
||||||
|
if (wizard.blindTurns > 0) out.push(`blind, ${more(wizard.blindTurns)}`);
|
||||||
|
if (wizard.invisibleTurns > 0) out.push(`invisible, ${more(wizard.invisibleTurns)}`);
|
||||||
|
if (wizard.hasteTurns > 0) out.push(`hastened, ${more(wizard.hasteTurns)}`);
|
||||||
|
if (wizard.banked) out.push(`holding a banked ${SPELL_BY_ID[wizard.banked.spellId].name.toLowerCase()}`);
|
||||||
|
else if (wizard.delayedTurns > 0) out.push(`next spell will be banked, ${more(wizard.delayedTurns)}`);
|
||||||
|
if (wizard.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(wizard.permanencyTurns)}`);
|
||||||
|
if (wizard.diseaseTurns !== null) out.push(`diseased, dies in ${wizard.diseaseTurns}`);
|
||||||
|
if (wizard.poisonTurns !== null) out.push(`poisoned, dies in ${wizard.poisonTurns}`);
|
||||||
|
const c = wizard.constraints;
|
||||||
|
if (c.amnesia) out.push(wizard.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
|
||||||
|
if (c.confusion) out.push(wizard.permanent.confusion ? 'confused for good' : 'confused this turn');
|
||||||
|
if (c.fear) out.push(wizard.permanent.fear ? 'afraid for good' : 'afraid this turn');
|
||||||
|
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${wizard.permanent.paralysedHand ? ' for good' : ''}`);
|
||||||
|
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${wizard.permanent.charmed ? ' for good' : ''}`);
|
||||||
|
if (wizard.usedQuickLightning) out.push('quick lightning spent');
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="wizard" class:you class:dead={!wizard.alive}>
|
||||||
|
<h2>{wizard.name}{#if you}<span class="tag"> (you)</span>{/if}</h2>
|
||||||
|
<div class="health">
|
||||||
|
<div class="pips" role="img" aria-label="{wizard.hp} of {MAX_HP} hit points">
|
||||||
|
{#each { length: MAX_HP } as _, i (i)}
|
||||||
|
<span class="pip" class:lost={i >= wizard.hp}></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<span class="hp">{wizard.hp} / {MAX_HP}</span>
|
||||||
|
</div>
|
||||||
|
{#if effects.length}
|
||||||
|
<p class="effects">{effects.join('; ')}.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="effects muted">Unenchanted.</p>
|
||||||
|
{/if}
|
||||||
|
{#if monsters.length}
|
||||||
|
<ul class="monsters">
|
||||||
|
{#each monsters as m (m.id)}
|
||||||
|
<li>
|
||||||
|
<span class="mname">{m.name}</span>
|
||||||
|
<span class="mhp">{m.hp}/{m.maxHp}, hits for {m.attack}</span>
|
||||||
|
{#if m.constraints.skipAttack}<span class="muted">paralysed</span>{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard {
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard.dead h2 {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
margin-left: 0.35em;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
margin: 0.4rem 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pips {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hp {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 16px;
|
||||||
|
height: 10px;
|
||||||
|
background: var(--bone);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pip.lost {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--bone-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effects {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monsters {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monsters li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mname {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mhp {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
// A heuristic opponent. Each turn it scores every pair of gestures by the
|
||||||
|
// spells they finish, the spells they move toward, and what the human's
|
||||||
|
// visible gestures threaten, then casts the most valuable spells it completed.
|
||||||
|
// Spells it has cast recently are discounted so it does not fall into a rut,
|
||||||
|
// and each named opponent weights the spell book a little differently.
|
||||||
|
|
||||||
|
import {
|
||||||
|
completableIn,
|
||||||
|
completedSpells,
|
||||||
|
handSequence,
|
||||||
|
progressToward,
|
||||||
|
usedCells,
|
||||||
|
type Completion,
|
||||||
|
type HandTurn,
|
||||||
|
type TurnGestures
|
||||||
|
} from './gestures';
|
||||||
|
import { allowedGestures, forcedGesture } from './resolve';
|
||||||
|
import { CONTROL_SPELLS, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
|
||||||
|
import {
|
||||||
|
MAX_HP,
|
||||||
|
enemyOf,
|
||||||
|
visibleHistory,
|
||||||
|
type CastChoice,
|
||||||
|
type GameState,
|
||||||
|
type TargetId,
|
||||||
|
type TurnInput,
|
||||||
|
type WizardId
|
||||||
|
} from './state';
|
||||||
|
|
||||||
|
const HOSTILE: SpellId[] = [
|
||||||
|
'missile',
|
||||||
|
'finger_of_death',
|
||||||
|
'lightning_bolt',
|
||||||
|
'lightning_bolt_quick',
|
||||||
|
'cause_light_wounds',
|
||||||
|
'cause_heavy_wounds',
|
||||||
|
'fireball',
|
||||||
|
'fire_storm',
|
||||||
|
'ice_storm',
|
||||||
|
...CONTROL_SPELLS,
|
||||||
|
'anti_spell',
|
||||||
|
'disease',
|
||||||
|
'poison',
|
||||||
|
'blindness',
|
||||||
|
'summon_goblin',
|
||||||
|
'summon_ogre',
|
||||||
|
'summon_troll',
|
||||||
|
'summon_giant',
|
||||||
|
'summon_elemental'
|
||||||
|
];
|
||||||
|
|
||||||
|
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
|
||||||
|
missile: 1,
|
||||||
|
finger_of_death: 10,
|
||||||
|
lightning_bolt: 5,
|
||||||
|
lightning_bolt_quick: 5,
|
||||||
|
cause_light_wounds: 2,
|
||||||
|
cause_heavy_wounds: 3,
|
||||||
|
fireball: 5,
|
||||||
|
fire_storm: 5,
|
||||||
|
ice_storm: 5,
|
||||||
|
amnesia: 2,
|
||||||
|
confusion: 1.5,
|
||||||
|
charm_person: 2.5,
|
||||||
|
charm_monster: 3,
|
||||||
|
paralysis: 2.5,
|
||||||
|
fear: 1.5,
|
||||||
|
anti_spell: 2,
|
||||||
|
disease: 4,
|
||||||
|
poison: 4,
|
||||||
|
blindness: 3,
|
||||||
|
summon_goblin: 2,
|
||||||
|
summon_ogre: 3,
|
||||||
|
summon_troll: 4,
|
||||||
|
summon_giant: 5,
|
||||||
|
summon_elemental: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Defensive spells whose worth is purely situational, so repeating them is fine. */
|
||||||
|
const NO_REPEAT_DISCOUNT: SpellId[] = ['shield', 'counter_spell', 'magic_mirror', 'dispel_magic', 'cure_light_wounds', 'cure_heavy_wounds'];
|
||||||
|
|
||||||
|
interface Situation {
|
||||||
|
state: GameState;
|
||||||
|
me: WizardId;
|
||||||
|
foe: WizardId;
|
||||||
|
foeSeq: Record<Hand, HandTurn[]>;
|
||||||
|
/** Hostile spells the foe could finish this very turn, by value. */
|
||||||
|
threatNow: number;
|
||||||
|
threatNowCounterable: number;
|
||||||
|
/** Hostile spells the foe could finish next turn. */
|
||||||
|
threatNext: number;
|
||||||
|
foeMonsterDamage: number;
|
||||||
|
missileOrMonsterNow: boolean;
|
||||||
|
foeFirePlan: boolean;
|
||||||
|
foeIcePlan: boolean;
|
||||||
|
iceElemental: boolean;
|
||||||
|
fireElemental: boolean;
|
||||||
|
rng: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable 0..1 number from a string, so each opponent has its own leanings. */
|
||||||
|
function hash01(text: string): number {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
h ^= text.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return ((h >>> 0) % 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
};
|
||||||
|
let threatNow = 0;
|
||||||
|
let threatNowCounterable = 0;
|
||||||
|
let threatNext = 0;
|
||||||
|
let missileNow = false;
|
||||||
|
let foeFirePlan = false;
|
||||||
|
let foeIcePlan = false;
|
||||||
|
for (const hand of ['left', 'right'] as Hand[]) {
|
||||||
|
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
||||||
|
if (!HOSTILE.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)) {
|
||||||
|
if (!HOSTILE.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);
|
||||||
|
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))
|
||||||
|
.reduce((sum, m) => sum + m.attack, 0);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
me,
|
||||||
|
foe,
|
||||||
|
foeSeq,
|
||||||
|
threatNow,
|
||||||
|
threatNowCounterable,
|
||||||
|
threatNext,
|
||||||
|
foeMonsterDamage,
|
||||||
|
missileOrMonsterNow: missileNow || foeMonsterDamage > 0,
|
||||||
|
foeFirePlan,
|
||||||
|
foeIcePlan,
|
||||||
|
iceElemental: state.monsters.some((m) => m.kind === 'ice_elemental'),
|
||||||
|
fireElemental: state.monsters.some((m) => m.kind === 'fire_elemental'),
|
||||||
|
rng
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much the bot would like to finish this spell right now, before its leanings and recent habits. */
|
||||||
|
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 foeEnchanted =
|
||||||
|
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||||
|
switch (spell.id) {
|
||||||
|
case 'fireball':
|
||||||
|
if (s.iceElemental) return 6;
|
||||||
|
return foeW.resistHeat ? 0.2 : 5;
|
||||||
|
case 'lightning_bolt':
|
||||||
|
return 5;
|
||||||
|
case 'lightning_bolt_quick':
|
||||||
|
return meW.usedQuickLightning ? 0 : 5.2;
|
||||||
|
case 'cause_heavy_wounds':
|
||||||
|
return 3.2;
|
||||||
|
case 'cause_light_wounds':
|
||||||
|
return 2.2;
|
||||||
|
case 'missile':
|
||||||
|
return 1.1;
|
||||||
|
case 'finger_of_death':
|
||||||
|
return 9;
|
||||||
|
case 'summon_goblin':
|
||||||
|
return 2.2;
|
||||||
|
case 'summon_ogre':
|
||||||
|
return 3.2;
|
||||||
|
case 'summon_troll':
|
||||||
|
return 4;
|
||||||
|
case 'summon_giant':
|
||||||
|
return 4.6;
|
||||||
|
case 'summon_elemental':
|
||||||
|
return meW.resistHeat || meW.resistCold ? 4.5 : 0.8;
|
||||||
|
case 'paralysis':
|
||||||
|
return 2.4;
|
||||||
|
case 'amnesia':
|
||||||
|
return 2;
|
||||||
|
case 'confusion':
|
||||||
|
return 1.6;
|
||||||
|
case 'fear':
|
||||||
|
return 1.8;
|
||||||
|
case 'charm_person':
|
||||||
|
return 2.6;
|
||||||
|
case 'charm_monster':
|
||||||
|
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
|
||||||
|
case 'anti_spell': {
|
||||||
|
let longest = 0;
|
||||||
|
for (const hand of ['left', 'right'] as Hand[]) {
|
||||||
|
for (const sp of SPELLS) {
|
||||||
|
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1 + longest * 0.6;
|
||||||
|
}
|
||||||
|
case 'disease':
|
||||||
|
return 3.5;
|
||||||
|
case 'poison':
|
||||||
|
return 3.8;
|
||||||
|
case 'blindness':
|
||||||
|
return 3;
|
||||||
|
case 'invisibility':
|
||||||
|
return 2.5 + s.foeMonsterDamage;
|
||||||
|
case 'haste':
|
||||||
|
return meW.hasteTurns > 0 ? 0 : 3.5;
|
||||||
|
case 'time_stop':
|
||||||
|
return 3;
|
||||||
|
case 'delayed_effect':
|
||||||
|
return meW.banked || meW.delayedTurns > 0 ? 0 : 1.5;
|
||||||
|
case 'permanency':
|
||||||
|
return meW.permanencyTurns > 0 ? 0 : 2.5;
|
||||||
|
case 'shield':
|
||||||
|
if (meW.protectionTurns > 0) return 0;
|
||||||
|
if (s.foeMonsterDamage > 0) return 1.5 + s.foeMonsterDamage;
|
||||||
|
if (s.missileOrMonsterNow) return 1.4;
|
||||||
|
return 0.3;
|
||||||
|
case 'counter_spell':
|
||||||
|
return Math.max(0.4, s.threatNowCounterable);
|
||||||
|
case 'magic_mirror':
|
||||||
|
return s.threatNowCounterable >= 3 ? s.threatNowCounterable : 0.3;
|
||||||
|
case 'dispel_magic':
|
||||||
|
return Math.max(0.5, s.foeMonsterDamage + (foeEnchanted ? 2 : 0) + (s.threatNow >= 5 ? s.threatNow : 0));
|
||||||
|
case 'remove_enchantment':
|
||||||
|
return foeEnchanted ? 2.5 : foeMonsters.length > 0 ? 1 + s.foeMonsterDamage : 0.3;
|
||||||
|
case 'protection_from_evil':
|
||||||
|
return meW.protectionTurns > 1 ? 0 : 2 + s.foeMonsterDamage;
|
||||||
|
case 'resist_heat':
|
||||||
|
if (s.fireElemental) return 4;
|
||||||
|
return s.foeFirePlan && !meW.resistHeat ? 3.5 : meW.resistHeat ? 0 : 0.6;
|
||||||
|
case 'resist_cold':
|
||||||
|
if (s.iceElemental) return 4;
|
||||||
|
return s.foeIcePlan && !meW.resistCold ? 3.5 : meW.resistCold ? 0 : 0.5;
|
||||||
|
case 'cure_light_wounds':
|
||||||
|
return meW.hp < MAX_HP ? 1 : 0;
|
||||||
|
case 'cure_heavy_wounds':
|
||||||
|
return meW.hp < MAX_HP - 1 ? 2 : meW.diseaseTurns !== null ? 5 : 0;
|
||||||
|
case 'raise_dead':
|
||||||
|
return meW.hp <= MAX_HP - 5 ? 4 : 0;
|
||||||
|
case 'fire_storm':
|
||||||
|
return meW.resistHeat && !foeW.resistHeat ? 5 : 0;
|
||||||
|
case 'ice_storm':
|
||||||
|
return meW.resistCold && !foeW.resistCold ? 5 : 0;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function spellValue(s: Situation, spell: Spell): number {
|
||||||
|
let v = baseValue(s, spell);
|
||||||
|
if (v <= 0) return 0;
|
||||||
|
const meW = s.state.wizards[s.me];
|
||||||
|
// Leanings: each opponent favours some spells over others.
|
||||||
|
v *= 0.7 + 0.6 * hash01(meW.name + spell.id);
|
||||||
|
// Habit: a spell cast in the last few turns is worth less again.
|
||||||
|
if (!NO_REPEAT_DISCOUNT.includes(spell.id)) {
|
||||||
|
const recent = meW.casts.filter((c) => c.spellId === spell.id && c.turn >= s.state.turn - 4).length;
|
||||||
|
v /= 1 + recent;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HandEval {
|
||||||
|
score: number;
|
||||||
|
planned?: SpellId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned?: SpellId): HandEval {
|
||||||
|
let best = 0;
|
||||||
|
for (const c of completedSpells(seq, start)) {
|
||||||
|
best = Math.max(best, spellValue(s, c.spell));
|
||||||
|
}
|
||||||
|
let progress = 0;
|
||||||
|
let planned: SpellId | undefined;
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
const value = spellValue(s, spell);
|
||||||
|
if (value <= 0) continue;
|
||||||
|
spell.tokens.forEach((tokens, i) => {
|
||||||
|
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
|
||||||
|
const k = progressToward(tokens, seq, start);
|
||||||
|
if (k === 0) return;
|
||||||
|
let v = value * Math.pow(k / tokens.length, 1.3);
|
||||||
|
if (k >= 2) v += 0.3;
|
||||||
|
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
|
||||||
|
if (spell.id === 'counter_spell' && k === tokens.length - 1) v = Math.max(v, s.threatNext * 0.8);
|
||||||
|
if (spell.id === otherPlanned) v *= 0.4;
|
||||||
|
if (v > progress) {
|
||||||
|
progress = v;
|
||||||
|
planned = spell.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { score: best + progress * 0.9, planned };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
||||||
|
const options: { hand: Hand; completion: Completion; value: number }[] = [];
|
||||||
|
for (const hand of ['left', 'right'] as Hand[]) {
|
||||||
|
for (const completion of completions[hand]) {
|
||||||
|
const value = spellValue(s, completion.spell);
|
||||||
|
if (value > 0.25) options.push({ hand, completion, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.sort((a, b) => b.value - a.value);
|
||||||
|
const chosen: typeof options = [];
|
||||||
|
for (const o of options) {
|
||||||
|
if (chosen.some((c) => c.hand === o.hand)) continue;
|
||||||
|
const cells = usedCells(o.completion, o.hand, turnCount);
|
||||||
|
const clash = chosen.some((c) => {
|
||||||
|
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (clash) continue;
|
||||||
|
chosen.push(o);
|
||||||
|
}
|
||||||
|
return chosen.map((o) => toChoice(s, o.hand, o.completion));
|
||||||
|
}
|
||||||
|
|
||||||
|
function foeDangerousHand(s: Situation): Hand {
|
||||||
|
const foeW = s.state.wizards[s.foe];
|
||||||
|
let bestHand: Hand = 'left';
|
||||||
|
let bestK = -1;
|
||||||
|
for (const hand of ['left', 'right'] as Hand[]) {
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
if (!HOSTILE.includes(spell.id)) continue;
|
||||||
|
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
|
||||||
|
if (k > bestK) {
|
||||||
|
bestK = k;
|
||||||
|
bestHand = hand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestHand;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
|
||||||
|
const meW = s.state.wizards[s.me];
|
||||||
|
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||||
|
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
|
||||||
|
const iceEl = s.state.monsters.find((m) => m.kind === 'ice_elemental');
|
||||||
|
const fireEl = s.state.monsters.find((m) => m.kind === 'fire_elemental');
|
||||||
|
switch (spell.id) {
|
||||||
|
case 'fireball':
|
||||||
|
choice.target = iceEl ? iceEl.id : s.foe;
|
||||||
|
break;
|
||||||
|
case 'resist_cold':
|
||||||
|
choice.target = iceEl ? iceEl.id : s.me;
|
||||||
|
break;
|
||||||
|
case 'resist_heat':
|
||||||
|
choice.target = fireEl ? fireEl.id : s.me;
|
||||||
|
break;
|
||||||
|
case 'charm_monster':
|
||||||
|
choice.target = strongestFoeMonster?.id ?? s.foe;
|
||||||
|
break;
|
||||||
|
case 'remove_enchantment': {
|
||||||
|
const foeW = s.state.wizards[s.foe];
|
||||||
|
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||||
|
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'summon_elemental':
|
||||||
|
choice.target = s.me;
|
||||||
|
choice.elemental = meW.resistCold ? 'ice' : 'fire';
|
||||||
|
break;
|
||||||
|
case 'paralysis':
|
||||||
|
case 'charm_person':
|
||||||
|
choice.target = s.foe;
|
||||||
|
choice.chosenHand = foeDangerousHand(s);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
choice.target = spell.usualTarget === 'self' ? s.me : s.foe;
|
||||||
|
}
|
||||||
|
if (spell.category === 'summons') choice.monsterTarget = s.foe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice {
|
||||||
|
const choice: CastChoice = { hand, spellId: completion.spell.id, seqIndex: completion.seqIndex };
|
||||||
|
targetFor(s, completion.spell, choice);
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gesture that most disrupts a charmed hand's sequences. */
|
||||||
|
function charmGesture(s: Situation): Gesture {
|
||||||
|
const foeW = s.state.wizards[s.foe];
|
||||||
|
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||||
|
let best: Gesture = 'P';
|
||||||
|
let bestScore = Infinity;
|
||||||
|
for (const g of ['C', 'D', 'F', 'P', 'S', 'W'] as Gesture[]) {
|
||||||
|
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
||||||
|
let score = 0;
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
if (!HOSTILE.includes(spell.id)) continue;
|
||||||
|
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
|
||||||
|
}
|
||||||
|
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
|
||||||
|
bestScore = score;
|
||||||
|
best = g;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PairChoice {
|
||||||
|
left: Gesture;
|
||||||
|
right: Gesture;
|
||||||
|
casts: CastChoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick one pair of gestures given the history so far, and what to cast with them. */
|
||||||
|
function choosePair(s: Situation, history: TurnGestures[], rng: () => number, timeStopped: boolean): PairChoice {
|
||||||
|
const meW = s.state.wizards[s.me];
|
||||||
|
const foe = s.foe;
|
||||||
|
const optionsFor = (hand: Hand): Gesture[] => {
|
||||||
|
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||||
|
const forced = forcedGesture(meW, hand);
|
||||||
|
if (forced) return [forced];
|
||||||
|
const allowed = allowedGestures(meW, hand);
|
||||||
|
return allowed.length ? allowed : ['-'];
|
||||||
|
};
|
||||||
|
const start = meW.sequenceStart;
|
||||||
|
let bestPair: { left: Gesture; right: Gesture; score: number } | undefined;
|
||||||
|
for (const left of optionsFor('left')) {
|
||||||
|
for (const right of optionsFor('right')) {
|
||||||
|
const next = [...history, { left, right }];
|
||||||
|
const seqL = handSequence(next, 'left');
|
||||||
|
const seqR = handSequence(next, 'right');
|
||||||
|
const evalL = evaluateHand(s, seqL, start);
|
||||||
|
const evalR = evaluateHand(s, seqR, start, evalL.planned);
|
||||||
|
let score = evalL.score + evalR.score;
|
||||||
|
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;
|
||||||
|
score += rng() * 0.5;
|
||||||
|
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pair = bestPair ?? { left: '-' as Gesture, right: '-' as Gesture, score: 0 };
|
||||||
|
const next = [...history, { left: pair.left, right: pair.right }];
|
||||||
|
const completions: Record<Hand, Completion[]> = {
|
||||||
|
left: completedSpells(handSequence(next, 'left'), start),
|
||||||
|
right: completedSpells(handSequence(next, 'right'), start)
|
||||||
|
};
|
||||||
|
return { left: pair.left, right: pair.right, casts: bestCasts(s, completions, next.length) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number = Math.random): TurnInput {
|
||||||
|
const s = assess(state, me, rng);
|
||||||
|
const meW = state.wizards[me];
|
||||||
|
const foe = s.foe;
|
||||||
|
const timeStopped = state.timeStops[0] === me;
|
||||||
|
|
||||||
|
const first = choosePair(s, meW.history, rng, timeStopped);
|
||||||
|
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
|
||||||
|
const monsterOrders: Record<string, TargetId> = {};
|
||||||
|
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
||||||
|
|
||||||
|
const input: TurnInput = {
|
||||||
|
left: first.left,
|
||||||
|
right: first.right,
|
||||||
|
casts: first.casts,
|
||||||
|
stabTarget: weakFoeMonster?.id ?? foe,
|
||||||
|
monsterOrders
|
||||||
|
};
|
||||||
|
if (meW.hasteTurns > 0 && !timeStopped) {
|
||||||
|
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
|
||||||
|
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
|
||||||
|
}
|
||||||
|
if (meW.banked) {
|
||||||
|
// Loose a banked spell as soon as it is worth anything; keep a shield for a rainy day.
|
||||||
|
const spell = SPELL_BY_ID[meW.banked.spellId];
|
||||||
|
const worth = baseValue(s, spell);
|
||||||
|
if (worth > 0.5 && (spell.id !== 'shield' || s.missileOrMonsterNow)) {
|
||||||
|
const choice: CastChoice = { hand: 'left', spellId: spell.id };
|
||||||
|
targetFor(s, spell, choice);
|
||||||
|
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
// Reactive wrapper around the engine for a duel of one person against the bot.
|
||||||
|
|
||||||
|
import { chooseBotTurn } from './bot';
|
||||||
|
import {
|
||||||
|
completableIn,
|
||||||
|
completedSpells,
|
||||||
|
handSequence,
|
||||||
|
progressToward,
|
||||||
|
usedCells,
|
||||||
|
type Completion,
|
||||||
|
type HandTurn
|
||||||
|
} from './gestures';
|
||||||
|
import type { Token } from './spells';
|
||||||
|
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
||||||
|
import {
|
||||||
|
CONTROL_SPELLS,
|
||||||
|
GESTURES,
|
||||||
|
HANDS,
|
||||||
|
PERMANENCY_EXCLUDED,
|
||||||
|
SPELLS,
|
||||||
|
SPELL_BY_ID,
|
||||||
|
type Gesture,
|
||||||
|
type Hand,
|
||||||
|
type Spell,
|
||||||
|
type SpellId
|
||||||
|
} from './spells';
|
||||||
|
import {
|
||||||
|
NOWHERE,
|
||||||
|
createGame,
|
||||||
|
visibleHistory,
|
||||||
|
type CastChoice,
|
||||||
|
type GameState,
|
||||||
|
type HistoryEntry,
|
||||||
|
type TargetId,
|
||||||
|
type TurnInput,
|
||||||
|
type WizardId
|
||||||
|
} from './state';
|
||||||
|
|
||||||
|
export const YOU: WizardId = 'A';
|
||||||
|
export const FOE: WizardId = 'B';
|
||||||
|
|
||||||
|
/** The first pair of gestures each turn, and the extra pair a hastened wizard makes. */
|
||||||
|
export type SetName = 'main' | 'haste';
|
||||||
|
export const SET_NAMES: SetName[] = ['main', 'haste'];
|
||||||
|
|
||||||
|
const NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
|
||||||
|
|
||||||
|
const HOSTILE: SpellId[] = [
|
||||||
|
'missile',
|
||||||
|
'finger_of_death',
|
||||||
|
'lightning_bolt',
|
||||||
|
'lightning_bolt_quick',
|
||||||
|
'cause_light_wounds',
|
||||||
|
'cause_heavy_wounds',
|
||||||
|
'fireball',
|
||||||
|
'fire_storm',
|
||||||
|
'ice_storm',
|
||||||
|
...CONTROL_SPELLS,
|
||||||
|
'anti_spell',
|
||||||
|
'disease',
|
||||||
|
'poison',
|
||||||
|
'blindness',
|
||||||
|
'remove_enchantment',
|
||||||
|
'summon_goblin',
|
||||||
|
'summon_ogre',
|
||||||
|
'summon_troll',
|
||||||
|
'summon_giant',
|
||||||
|
'summon_elemental'
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface HandDraft {
|
||||||
|
gesture: Gesture | null;
|
||||||
|
spellId: SpellId | '';
|
||||||
|
target: TargetId | '';
|
||||||
|
elemental: 'fire' | 'ice';
|
||||||
|
chosenHand: Hand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlannedCast {
|
||||||
|
set: SetName;
|
||||||
|
hand: Hand;
|
||||||
|
completion: Completion;
|
||||||
|
draft: HandDraft;
|
||||||
|
/** Length of the history the completing gesture sits at the end of. */
|
||||||
|
turnCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTarget(spell: Spell | undefined): TargetId | '' {
|
||||||
|
if (!spell) return '';
|
||||||
|
return spell.usualTarget === 'self' ? YOU : FOE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankDraft(): HandDraft {
|
||||||
|
return { gesture: null, spellId: '', target: '', elemental: 'fire', chosenHand: 'left' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankSets(): Record<SetName, Record<Hand, HandDraft>> {
|
||||||
|
return { main: { left: blankDraft(), right: blankDraft() }, haste: { left: blankDraft(), right: blankDraft() } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNames(): { A: string; B: string } {
|
||||||
|
const pool = [...NAMES];
|
||||||
|
const a = pool.splice(Math.floor(Math.random() * pool.length), 1)[0];
|
||||||
|
const b = pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
return { A: a, B: b };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function castKey(set: SetName, hand: Hand): string {
|
||||||
|
return `${set}:${hand}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spell a hand is part-way through, and what would finish it. */
|
||||||
|
export interface Plan {
|
||||||
|
spell: Spell;
|
||||||
|
seqIndex: number;
|
||||||
|
done: Token[];
|
||||||
|
remaining: Token[];
|
||||||
|
pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A spell the opponent's hand could finish soon, with the gestures that say so. */
|
||||||
|
export interface Threat {
|
||||||
|
hand: Hand;
|
||||||
|
spell: Spell;
|
||||||
|
done: Token[];
|
||||||
|
remaining: Token[];
|
||||||
|
/** History indexes of the gestures already made. */
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
soon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Highlight {
|
||||||
|
hand: Hand;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A token in Bartle's notation: a bracketed lower-case letter is a two-handed gesture. */
|
||||||
|
export function tokenText(t: Token): string {
|
||||||
|
return t.both && t.gesture !== 'C' ? `(${t.gesture.toLowerCase()}` : t.gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokensText(tokens: Token[]): string {
|
||||||
|
return tokens.map(tokenText).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVE_KEY = 'wh:duel';
|
||||||
|
const SAVE_VERSION = 2;
|
||||||
|
|
||||||
|
interface SavedDuel {
|
||||||
|
v: number;
|
||||||
|
state: GameState;
|
||||||
|
pins: Record<Hand, SpellId | null>;
|
||||||
|
sets: Record<SetName, Record<Hand, HandDraft>>;
|
||||||
|
stabTarget: TargetId;
|
||||||
|
monsterOrders: Record<string, TargetId>;
|
||||||
|
release: TargetId | '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSaved(): SavedDuel | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SAVE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const saved = JSON.parse(raw) as SavedDuel;
|
||||||
|
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
|
||||||
|
// Fields added since the save was written.
|
||||||
|
saved.state.lessonsShown ??= [];
|
||||||
|
if (saved.state.lastTurn) {
|
||||||
|
saved.state.lastTurn.events ??= [];
|
||||||
|
saved.state.lastTurn.lessons ??= [];
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePreference(key: string, on: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, on ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// Preferences are a convenience only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPreference(key: string, fallback: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(key);
|
||||||
|
return v === null ? fallback : v === '1';
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Duel {
|
||||||
|
state = $state<GameState>(createGame(pickNames()));
|
||||||
|
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
|
||||||
|
stabTarget = $state<TargetId>(FOE);
|
||||||
|
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);
|
||||||
|
charmGesture = $state<Gesture>('P');
|
||||||
|
/** Where to loose the banked spell this turn; empty keeps it. */
|
||||||
|
release = $state<TargetId | ''>('');
|
||||||
|
/** Which cast to bank or make permanent when more than one qualifies. */
|
||||||
|
bankPick = $state<string>('');
|
||||||
|
permanentPick = $state<string>('');
|
||||||
|
/** Spells you have chosen to pursue with each hand. */
|
||||||
|
pins = $state<Record<Hand, SpellId | null>>({ left: null, right: null });
|
||||||
|
showPlans = $state(readPreference('wh:plans', true));
|
||||||
|
showLessons = $state(readPreference('wh:lessons', true));
|
||||||
|
/** The spell sheet's open state, so a hint under a hand can open it. */
|
||||||
|
sheetOpen = $state(false);
|
||||||
|
/** Opponent gestures to light up in the ledger. */
|
||||||
|
highlight = $state<Highlight | null>(null);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const saved = typeof localStorage === 'undefined' ? null : loadSaved();
|
||||||
|
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.monsterOrders = saved.monsterOrders ?? {};
|
||||||
|
this.release = saved.release ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
|
||||||
|
persist(): void {
|
||||||
|
const bundle: SavedDuel = {
|
||||||
|
v: SAVE_VERSION,
|
||||||
|
state: $state.snapshot(this.state),
|
||||||
|
pins: $state.snapshot(this.pins),
|
||||||
|
sets: $state.snapshot(this.sets),
|
||||||
|
stabTarget: this.stabTarget,
|
||||||
|
monsterOrders: $state.snapshot(this.monsterOrders),
|
||||||
|
release: this.release
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SAVE_KEY, JSON.stringify(bundle));
|
||||||
|
} catch {
|
||||||
|
// Storage may be full or blocked; the duel still plays in memory.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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]);
|
||||||
|
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);
|
||||||
|
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
|
||||||
|
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
|
||||||
|
|
||||||
|
allowed = $derived<Record<Hand, Gesture[]>>({
|
||||||
|
left: this.timeStopped ? GESTURES : allowedGestures(this.you, 'left'),
|
||||||
|
right: this.timeStopped ? GESTURES : allowedGestures(this.you, 'right')
|
||||||
|
});
|
||||||
|
forced = $derived<Record<Hand, Gesture | undefined>>({
|
||||||
|
left: this.timeStopped ? undefined : forcedGesture(this.you, 'left'),
|
||||||
|
right: this.timeStopped ? undefined : forcedGesture(this.you, 'right')
|
||||||
|
});
|
||||||
|
forcedSecond = $derived<Record<Hand, Gesture | undefined>>({
|
||||||
|
left: this.timeStopped ? undefined : forcedGesture(this.you, 'left', true),
|
||||||
|
right: this.timeStopped ? undefined : forcedGesture(this.you, 'right', true)
|
||||||
|
});
|
||||||
|
forcedFor(set: SetName, hand: Hand): Gesture | undefined {
|
||||||
|
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);
|
||||||
|
|
||||||
|
gestureFor(set: SetName, hand: Hand): Gesture {
|
||||||
|
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
private entry(set: SetName): HistoryEntry {
|
||||||
|
return {
|
||||||
|
left: this.gestureFor(set, 'left'),
|
||||||
|
right: this.gestureFor(set, 'right'),
|
||||||
|
turn: this.state.turn,
|
||||||
|
phase: set === 'haste' ? 'haste' : this.timeStopped ? 'timestop' : 'main',
|
||||||
|
hiddenFrom: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Your history with this turn's drafts appended, so spells light up as you choose. */
|
||||||
|
previews = $derived<Record<SetName, HistoryEntry[]>>({
|
||||||
|
main: [...this.you.history, this.entry('main')],
|
||||||
|
haste: [...this.you.history, this.entry('main'), this.entry('haste')]
|
||||||
|
});
|
||||||
|
private seqs = $derived<Record<SetName, Record<Hand, HandTurn[]>>>({
|
||||||
|
main: { left: handSequence(this.previews.main, 'left'), right: handSequence(this.previews.main, 'right') },
|
||||||
|
haste: { left: handSequence(this.previews.haste, 'left'), right: handSequence(this.previews.haste, 'right') }
|
||||||
|
});
|
||||||
|
completions = $derived<Record<SetName, Record<Hand, Completion[]>>>({
|
||||||
|
main: {
|
||||||
|
left: completedSpells(this.seqs.main.left, this.you.sequenceStart),
|
||||||
|
right: completedSpells(this.seqs.main.right, this.you.sequenceStart)
|
||||||
|
},
|
||||||
|
haste: {
|
||||||
|
left: completedSpells(this.seqs.haste.left, this.you.sequenceStart),
|
||||||
|
right: completedSpells(this.seqs.haste.right, this.you.sequenceStart)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chosen = $derived<Record<SetName, Record<Hand, Completion | undefined>>>({
|
||||||
|
main: {
|
||||||
|
left: this.completions.main.left.find((c) => c.spell.id === this.sets.main.left.spellId),
|
||||||
|
right: this.completions.main.right.find((c) => c.spell.id === this.sets.main.right.spellId)
|
||||||
|
},
|
||||||
|
haste: {
|
||||||
|
left: this.completions.haste.left.find((c) => c.spell.id === this.sets.haste.left.spellId),
|
||||||
|
right: this.completions.haste.right.find((c) => c.spell.id === this.sets.haste.right.spellId)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
/** Every spell you have chosen to cast this turn, across hands and sets. */
|
||||||
|
planned = $derived.by(() => {
|
||||||
|
const out: PlannedCast[] = [];
|
||||||
|
for (const set of this.activeSets) {
|
||||||
|
for (const hand of HANDS) {
|
||||||
|
const completion = this.chosen[set][hand];
|
||||||
|
if (!completion) continue;
|
||||||
|
out.push({ set, hand, completion, draft: this.sets[set][hand], turnCount: this.previews[set].length });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
conflict = $derived.by(() => {
|
||||||
|
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
|
||||||
|
for (let i = 0; i < cells.length; i++) {
|
||||||
|
for (let j = i + 1; j < cells.length; j++) {
|
||||||
|
for (const cell of cells[i]) if (cells[j].has(cell)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Progress of each hand toward each spell, for the reference sheet. */
|
||||||
|
progress = $derived.by(() => {
|
||||||
|
const seq = this.seqs[this.hasted ? 'haste' : 'main'];
|
||||||
|
return Object.fromEntries(
|
||||||
|
SPELLS.map((s) => [
|
||||||
|
s.id,
|
||||||
|
{
|
||||||
|
left: Math.max(...s.tokens.map((t) => progressToward(t, seq.left, this.you.sequenceStart))),
|
||||||
|
right: Math.max(...s.tokens.map((t) => progressToward(t, seq.right, this.you.sequenceStart)))
|
||||||
|
}
|
||||||
|
])
|
||||||
|
) as Record<SpellId, Record<Hand, number>>;
|
||||||
|
});
|
||||||
|
completedNow(id: SpellId): boolean {
|
||||||
|
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
|
||||||
|
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
|
||||||
|
threats = $derived.by(() => {
|
||||||
|
const out: Threat[] = [];
|
||||||
|
for (const hand of HANDS) {
|
||||||
|
const seq = handSequence(this.foeVisible, hand);
|
||||||
|
for (const turnsAway of [1, 2]) {
|
||||||
|
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
|
||||||
|
if (!HOSTILE.includes(c.spell.id)) continue;
|
||||||
|
if (c.spell.id === 'lightning_bolt_quick' && this.foe.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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => Number(b.soon) - Number(a.soon) || b.done.length - a.done.length);
|
||||||
|
});
|
||||||
|
threatsNow = $derived(this.threats.filter((t) => t.soon));
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spells each hand is part-way through, based on the gestures already on the ledger. */
|
||||||
|
plans = $derived<Record<Hand, Plan[]>>({
|
||||||
|
left: this.plansFor('left'),
|
||||||
|
right: this.plansFor('right')
|
||||||
|
});
|
||||||
|
|
||||||
|
private plansFor(hand: Hand): Plan[] {
|
||||||
|
const seq = handSequence(this.you.history, hand);
|
||||||
|
const start = this.you.sequenceStart;
|
||||||
|
const pinned = this.pins[hand];
|
||||||
|
const best = new Map<SpellId, Plan>();
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
if (spell.id === 'lightning_bolt_quick' && this.you.usedQuickLightning) continue;
|
||||||
|
spell.tokens.forEach((tokens, seqIndex) => {
|
||||||
|
const k = progressToward(tokens, seq, start);
|
||||||
|
if (k === 0 && spell.id !== pinned) return;
|
||||||
|
const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: spell.id === pinned };
|
||||||
|
const prior = best.get(spell.id);
|
||||||
|
if (!prior || plan.remaining.length < prior.remaining.length) best.set(spell.id, plan);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const all = [...best.values()].sort(
|
||||||
|
(a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name)
|
||||||
|
);
|
||||||
|
return all.slice(0, pinned ? 7 : 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many plans to show before "More spells" on a narrow screen. */
|
||||||
|
planLimit(hand: Hand): number {
|
||||||
|
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
pin(hand: Hand, id: SpellId): void {
|
||||||
|
this.pins[hand] = this.pins[hand] === id ? null : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin a plan and make its next gesture, with the other hand too if the gesture needs both. */
|
||||||
|
follow(hand: Hand, plan: Plan): void {
|
||||||
|
this.pins[hand] = plan.spell.id;
|
||||||
|
const next = plan.remaining[0];
|
||||||
|
if (!next) return;
|
||||||
|
if (!this.forcedFor('main', hand) && this.charmedHand !== hand && this.allowed[hand].includes(next.gesture)) {
|
||||||
|
this.sets.main[hand].gesture = next.gesture;
|
||||||
|
}
|
||||||
|
const other: Hand = hand === 'left' ? 'right' : 'left';
|
||||||
|
if (next.both && !this.forcedFor('main', other) && this.charmedHand !== other && this.allowed[other].includes(next.gesture)) {
|
||||||
|
this.sets.main[other].gesture = next.gesture;
|
||||||
|
}
|
||||||
|
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowPlans(on: boolean): void {
|
||||||
|
this.showPlans = on;
|
||||||
|
savePreference('wh:plans', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowLessons(on: boolean): void {
|
||||||
|
this.showLessons = on;
|
||||||
|
savePreference('wh:lessons', on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortest way for a hand to finish a spell from where its gestures stand. */
|
||||||
|
planFor(hand: Hand, spell: Spell): Plan {
|
||||||
|
const seq = handSequence(this.you.history, hand);
|
||||||
|
const start = this.you.sequenceStart;
|
||||||
|
let best: Plan | undefined;
|
||||||
|
spell.tokens.forEach((tokens, seqIndex) => {
|
||||||
|
const k = progressToward(tokens, seq, start);
|
||||||
|
const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: this.pins[hand] === spell.id };
|
||||||
|
if (!best || plan.remaining.length < best.remaining.length) best = plan;
|
||||||
|
});
|
||||||
|
return best!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a hand can be pointed at a new plan this turn. */
|
||||||
|
handFree(hand: Hand): boolean {
|
||||||
|
return !this.forcedFor('main', hand) && this.charmedHand !== hand;
|
||||||
|
}
|
||||||
|
|
||||||
|
openSheet(): void {
|
||||||
|
this.sheetOpen = true;
|
||||||
|
document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = $derived(this.state.lastTurn);
|
||||||
|
|
||||||
|
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));
|
||||||
|
ready = $derived(
|
||||||
|
!this.state.over &&
|
||||||
|
this.activeSets.every((set) =>
|
||||||
|
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null)
|
||||||
|
) &&
|
||||||
|
!this.conflict
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 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))
|
||||||
|
);
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
permanentCandidates = $derived(
|
||||||
|
this.permanencyActive
|
||||||
|
? this.planned.filter(
|
||||||
|
(p) =>
|
||||||
|
p.completion.spell.category === 'enchantment' &&
|
||||||
|
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
|
||||||
|
p !== this.willBank
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
willExtend = $derived(
|
||||||
|
this.permanentCandidates.find((p) => castKey(p.set, p.hand) === this.permanentPick) ?? this.permanentCandidates[0]
|
||||||
|
);
|
||||||
|
bankedSpell = $derived(this.you.banked ? SPELL_BY_ID[this.you.banked.spellId] : undefined);
|
||||||
|
|
||||||
|
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
|
||||||
|
const list: { id: TargetId; label: string }[] = [
|
||||||
|
{ id: YOU, label: 'yourself' },
|
||||||
|
{ id: FOE, label: this.foe.name }
|
||||||
|
];
|
||||||
|
for (const m of this.state.monsters) list.push({ id: m.id, label: m.name });
|
||||||
|
if (spell && spell.id !== 'fire_storm' && spell.id !== 'ice_storm') list.push({ id: NOWHERE, label: 'nowhere (let it fade)' });
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
choose(set: SetName, hand: Hand, gesture: Gesture): void {
|
||||||
|
const draft = this.sets[set][hand];
|
||||||
|
draft.gesture = draft.gesture === gesture ? null : gesture;
|
||||||
|
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
|
||||||
|
syncSpell(set: SetName, hand: Hand): void {
|
||||||
|
const draft = this.sets[set][hand];
|
||||||
|
const options = this.completions[set][hand];
|
||||||
|
if (!options.some((c) => c.spell.id === draft.spellId)) {
|
||||||
|
draft.spellId = options[0]?.spell.id ?? '';
|
||||||
|
draft.target = defaultTarget(options[0]?.spell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpell(set: SetName, hand: Hand, id: SpellId | ''): void {
|
||||||
|
this.sets[set][hand].spellId = id;
|
||||||
|
this.sets[set][hand].target = defaultTarget(id ? SPELL_BY_ID[id] : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
private castFor(p: PlannedCast): CastChoice {
|
||||||
|
const spell = p.completion.spell;
|
||||||
|
const cast: CastChoice = {
|
||||||
|
hand: p.hand,
|
||||||
|
spellId: spell.id,
|
||||||
|
seqIndex: p.completion.seqIndex,
|
||||||
|
target: p.draft.target || (spell.usualTarget === 'self' ? YOU : FOE)
|
||||||
|
};
|
||||||
|
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 (p === this.willBank) cast.bank = true;
|
||||||
|
if (p === this.willExtend) cast.permanent = true;
|
||||||
|
return cast;
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(): void {
|
||||||
|
if (!this.ready) return;
|
||||||
|
const orders: Record<string, TargetId> = {};
|
||||||
|
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE;
|
||||||
|
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,
|
||||||
|
monsterOrders: orders
|
||||||
|
};
|
||||||
|
if (this.hasted) {
|
||||||
|
yours.second = {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE };
|
||||||
|
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
|
||||||
|
// 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 });
|
||||||
|
// 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) });
|
||||||
|
}
|
||||||
|
this.state = plain;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
this.resetDrafts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetDrafts(): void {
|
||||||
|
this.sets = blankSets();
|
||||||
|
this.stabTarget = FOE;
|
||||||
|
// 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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
this.monsterOrders = orders;
|
||||||
|
this.release = '';
|
||||||
|
this.bankPick = '';
|
||||||
|
this.permanentPick = '';
|
||||||
|
this.highlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
newGame(): void {
|
||||||
|
this.state = createGame(pickNames());
|
||||||
|
this.pins = { left: null, right: null };
|
||||||
|
this.resetDrafts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one duel this browser is playing; it survives moving between pages. */
|
||||||
|
export const duel = new Duel();
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { chooseBotTurn } from './bot';
|
||||||
|
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
|
||||||
|
import { resolveTurn } from './resolve';
|
||||||
|
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
||||||
|
import { createGame, type GameState, type TurnInput, type WizardId } from './state';
|
||||||
|
|
||||||
|
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
||||||
|
|
||||||
|
function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
|
||||||
|
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feed gesture strings turn by turn; the caster casts every spell it completes on the final turn. */
|
||||||
|
function runSequence(
|
||||||
|
state: GameState,
|
||||||
|
who: WizardId,
|
||||||
|
left: string,
|
||||||
|
right: string,
|
||||||
|
finalCasts: (turnIndex: number) => Partial<TurnInput>
|
||||||
|
): GameState {
|
||||||
|
const L = left.split('');
|
||||||
|
const R = right.split('');
|
||||||
|
for (let i = 0; i < L.length; i++) {
|
||||||
|
const extra = i === L.length - 1 ? finalCasts(i) : {};
|
||||||
|
const inp = { left: L[i] as Gesture, right: R[i] as Gesture, ...extra };
|
||||||
|
state = who === 'A' ? play(state, inp) : play(state, {}, inp);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('spell table', () => {
|
||||||
|
it('has 40 spells, all of them resolved', () => {
|
||||||
|
expect(SPELLS).toHaveLength(40);
|
||||||
|
expect(SPELLS.filter((s) => !s.implemented)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses two-handed tokens', () => {
|
||||||
|
expect(parseSequence('P-P-(w-(s')).toEqual([
|
||||||
|
{ gesture: 'P', both: false },
|
||||||
|
{ gesture: 'P', both: false },
|
||||||
|
{ gesture: 'W', both: true },
|
||||||
|
{ gesture: 'S', both: true }
|
||||||
|
]);
|
||||||
|
expect(parseSequence('W-D-D-C')[3]).toEqual({ gesture: 'C', both: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gesture matching', () => {
|
||||||
|
const seq = (own: string, other = '') =>
|
||||||
|
own.split('').map((g, i) => ({ own: g as Gesture, other: (other[i] ?? '-') as Gesture }));
|
||||||
|
|
||||||
|
it('finds every spell the tail completes, longest first', () => {
|
||||||
|
const names = completedSpells(seq('PWPFSSSD'), 0).map((c) => c.spell.id);
|
||||||
|
expect(names).toEqual(['finger_of_death', 'missile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets one gesture stream carry overlapping spells', () => {
|
||||||
|
expect(completedSpells(seq('FFF'), 0).map((c) => c.spell.id)).toEqual(['paralysis']);
|
||||||
|
expect(completedSpells(seq('FFFSSDD'), 0).map((c) => c.spell.id)).toEqual(['fireball']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the other hand for two-handed tokens', () => {
|
||||||
|
expect(completedSpells(seq('PPWS', '--WS'), 0).map((c) => c.spell.id)).toContain('invisibility');
|
||||||
|
expect(completedSpells(seq('PPWS', '--W-'), 0).map((c) => c.spell.id)).not.toContain('invisibility');
|
||||||
|
expect(completedSpells(seq('WDDC', '---C'), 0).map((c) => c.spell.id)).toContain('lightning_bolt_quick');
|
||||||
|
expect(completedSpells(seq('WDDC', '----'), 0).map((c) => c.spell.id)).not.toContain('lightning_bolt_quick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an anti-spell cut-off', () => {
|
||||||
|
expect(completedSpells(seq('SD'), 1)).toHaveLength(0);
|
||||||
|
expect(completedSpells(seq('SD'), 0).map((c) => c.spell.id)).toEqual(['missile']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports spells one gesture away and progress toward them', () => {
|
||||||
|
const soon = completableIn(seq('FSSD'), 0, 1).map((c) => c.spell.id);
|
||||||
|
expect(soon).toContain('fireball');
|
||||||
|
expect(progressToward(SPELL_BY_ID.fireball.tokens[0], seq('FSS'), 0)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags two spells that share a gesture', () => {
|
||||||
|
const history = ['PW', 'PW', 'WW', 'SS'].map((g) => ({ left: g[0] as Gesture, right: g[1] as Gesture }));
|
||||||
|
const inv = completedSpells(handSequence(history, 'left'), 0).find((c) => c.spell.id === 'invisibility')!;
|
||||||
|
const counter = completedSpells(handSequence(history, 'right'), 0).find((c) => c.spell.id === 'counter_spell')!;
|
||||||
|
expect(castsConflict({ completion: inv, hand: 'left' }, { completion: counter, hand: 'right' }, 4)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolution', () => {
|
||||||
|
it('a missile does one damage unless shielded', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = play(s, { left: 'S' });
|
||||||
|
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] });
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
s = play(s, { left: 'S' });
|
||||||
|
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] }, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a counter-spell stops a fireball but not a finger of death', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'FSSDD', 'PWPFS', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
|
s = play(s, {}, { left: 'W' });
|
||||||
|
s = play(s, { right: 'S' }, { left: 'W' });
|
||||||
|
s = play(s, { right: 'S' }, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
||||||
|
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'finger_of_death' }] }, { left: 'W' });
|
||||||
|
expect(s.wizards.B.alive).toBe(true);
|
||||||
|
s = play(s, {}, { left: 'W' });
|
||||||
|
s = play(s, {}, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
||||||
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
|
// Now the real finger of death lands through a counter-spell.
|
||||||
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
t = runSequence(t, 'A', 'PWPFSSSD', '--------', () => ({ casts: [{ hand: 'left', spellId: 'finger_of_death' }] }));
|
||||||
|
expect(t.wizards.B.alive).toBe(false);
|
||||||
|
expect(t.over?.winner).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counter-spell smothers a finger of death only via dispel magic', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
for (let i = 0; i < 7; i++) s = play(s, { left: 'PWPFSSS'[i] as Gesture }, { left: 'C', right: 'C' });
|
||||||
|
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'finger_of_death' }] }, { left: 'D' });
|
||||||
|
expect(s.wizards.B.alive).toBe(false);
|
||||||
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
const bLeft = '----CDPW';
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const last = i === 7;
|
||||||
|
t = play(
|
||||||
|
t,
|
||||||
|
{ left: 'PWPFSSSD'[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'finger_of_death' }] : [] },
|
||||||
|
{ left: bLeft[i] as Gesture, right: bLeft[i] === 'C' ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'dispel_magic' }] : [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(t.wizards.B.alive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a summoned goblin attacks the same turn and dies to a stab', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'SFW', '---', () => ({ casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] }));
|
||||||
|
expect(s.monsters).toHaveLength(1);
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
s = play(s, {}, { left: '>', right: 'P', stabTarget: 'm1', casts: [{ hand: 'right', spellId: 'shield' }] });
|
||||||
|
expect(s.monsters).toHaveLength(0);
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('amnesia forces last turn’s gestures and P+P is a surrender', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'DPP', 'W--', () => ({ casts: [{ hand: 'left', spellId: 'amnesia' }] }));
|
||||||
|
// B was idle, so amnesia makes B repeat nothing with both hands.
|
||||||
|
expect(s.wizards.B.constraints.amnesia).toBe(true);
|
||||||
|
s = play(s, {}, { left: 'F', right: 'F' });
|
||||||
|
const last = s.wizards.B.history.at(-1)!;
|
||||||
|
expect([last.left, last.right]).toEqual(['-', '-']);
|
||||||
|
s = play(s, { left: 'P', right: 'P' });
|
||||||
|
expect(s.over?.winner).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('paralysis freezes a hand with C, S, W transposed', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = play(s, { left: 'F' }, { left: 'W' });
|
||||||
|
s = play(s, { left: 'F' }, { left: 'W' });
|
||||||
|
s = play(s, { left: 'F', casts: [{ hand: 'left', spellId: 'paralysis', chosenHand: 'left' }] }, { left: 'W' });
|
||||||
|
s = play(s, {}, { left: 'S' });
|
||||||
|
expect(s.wizards.B.history.at(-1)!.left).toBe('P');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fear bans C, D, F and S next turn', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
||||||
|
s = play(s, {}, { left: 'D', right: 'W' });
|
||||||
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anti-spell wipes earlier gestures from the weave', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = play(s, { left: 'S' }, { left: 'F' });
|
||||||
|
s = play(s, { left: 'P' }, { left: 'S' });
|
||||||
|
s = play(s, { left: 'F', casts: [{ hand: 'left', spellId: 'anti_spell' }] }, { left: 'S' });
|
||||||
|
s = play(s, {}, { left: 'D' });
|
||||||
|
s = play(s, {}, { left: 'D', casts: [{ hand: 'left', spellId: 'fireball' }] });
|
||||||
|
expect(s.wizards.A.hp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resist heat makes a fireball harmless and a magic mirror reflects it', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'B', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
||||||
|
s = runSequence(s, 'A', 'FSSDD', '-----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const g = 'FSSDD'[i] as Gesture;
|
||||||
|
const mirror = i >= 3;
|
||||||
|
t = play(
|
||||||
|
t,
|
||||||
|
{ left: g, casts: i === 4 ? [{ hand: 'left', spellId: 'fireball' }] : [] },
|
||||||
|
{
|
||||||
|
left: i === 3 ? 'C' : mirror ? 'W' : '-',
|
||||||
|
right: i === 3 ? 'C' : mirror ? 'W' : '-',
|
||||||
|
casts: i === 4 ? [{ hand: 'left', spellId: 'magic_mirror' }] : []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(t.wizards.B.hp).toBe(15);
|
||||||
|
expect(t.wizards.A.hp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fire storm and ice storm cancel; fire storm spares the heat-resistant', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
||||||
|
s = runSequence(s, 'A', 'SWWC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'fire_storm' }] }));
|
||||||
|
expect(s.wizards.A.hp).toBe(15);
|
||||||
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
|
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const last = i === 3;
|
||||||
|
t = play(
|
||||||
|
t,
|
||||||
|
{ left: 'SWWC'[i] as Gesture, right: last ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'fire_storm' }] : [] },
|
||||||
|
{ left: 'WSSC'[i] as Gesture, right: last ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'ice_storm' }] : [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(t.wizards.A.hp).toBe(15);
|
||||||
|
expect(t.wizards.B.hp).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the quick lightning bolt works once per duel', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
|
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disease kills at the end of the sixth turn unless cured', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'DSFFFC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'disease' }] }));
|
||||||
|
expect(s.wizards.B.diseaseTurns).toBe(5);
|
||||||
|
for (let i = 0; i < 4; i++) s = play(s, {});
|
||||||
|
expect(s.wizards.B.alive).toBe(true);
|
||||||
|
s = play(s, {});
|
||||||
|
expect(s.wizards.B.alive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('protection from evil covers four turns and dispel magic ends it', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'B', 'WWP', '---', () => ({ casts: [{ hand: 'left', spellId: 'protection_from_evil' }] }));
|
||||||
|
expect(s.wizards.B.protectionTurns).toBe(3);
|
||||||
|
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bot', () => {
|
||||||
|
it('never surrenders by accident and only casts spells it completed', () => {
|
||||||
|
let s = createGame({ A: 'You', B: 'Bot' }, 7);
|
||||||
|
let rngSeed = 3;
|
||||||
|
const rng = () => {
|
||||||
|
rngSeed = (rngSeed * 1664525 + 1013904223) >>> 0;
|
||||||
|
return rngSeed / 4294967296;
|
||||||
|
};
|
||||||
|
for (let i = 0; i < 40 && !s.over; i++) {
|
||||||
|
const bot = chooseBotTurn(s, 'B', rng);
|
||||||
|
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
|
||||||
|
const before = s.log.length;
|
||||||
|
s = resolveTurn(s, { A: { ...noop(), left: 'W', right: 'P' }, B: bot });
|
||||||
|
const fizzles = s.log.slice(before).filter((l) => l.text.includes('cannot use the same gesture'));
|
||||||
|
expect(fizzles).toHaveLength(0);
|
||||||
|
}
|
||||||
|
expect(s.wizards.A.hp).toBeLessThan(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('haste, time stop, delayed effect and permanency', () => {
|
||||||
|
it('a hastened wizard makes two pairs of gestures a turn and can cast from both', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'PWPWWC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'haste' }] }));
|
||||||
|
expect(s.wizards.A.hasteTurns).toBe(3);
|
||||||
|
s = play(s, { left: 'S', right: 'S', second: { left: 'D', right: 'D', casts: [{ hand: 'left', spellId: 'missile' }, { hand: 'right', spellId: 'missile' }] } });
|
||||||
|
expect(s.wizards.A.history).toHaveLength(8);
|
||||||
|
expect(s.wizards.A.history.at(-1)?.phase).toBe('haste');
|
||||||
|
expect(s.wizards.B.hp).toBe(13);
|
||||||
|
expect(s.wizards.B.history).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('time stop grants a turn alone in which shields and resistances count for nothing', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
// B's protection from evil lands one turn before A's time stop, so it covers the stopped moment and the turn after.
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
s = play(
|
||||||
|
s,
|
||||||
|
{ left: 'SPPC'[i] as Gesture, right: i === 3 ? 'C' : '-', casts: i === 3 ? [{ hand: 'left', spellId: 'time_stop' }] : [] },
|
||||||
|
{ left: '-WWP'[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'protection_from_evil' }] : [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(s.timeStops).toEqual(['A']);
|
||||||
|
expect(s.wizards.B.protectionTurns).toBe(3);
|
||||||
|
const turnBefore = s.turn;
|
||||||
|
const bHistory = s.wizards.B.history.length;
|
||||||
|
s = play(s, { left: '>', right: 'S', stabTarget: 'B' }, { left: 'P', right: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||||
|
expect(s.turn).toBe(turnBefore);
|
||||||
|
expect(s.timeStops).toEqual([]);
|
||||||
|
expect(s.wizards.B.history).toHaveLength(bHistory);
|
||||||
|
expect(s.wizards.B.surrendered).toBe(false);
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
expect(s.wizards.A.history.at(-1)).toMatchObject({ phase: 'timestop', hiddenFrom: ['B'] });
|
||||||
|
// The snap made in the stopped moment still counts toward a missile.
|
||||||
|
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'missile' }] });
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
expect(s.log.some((l) => l.text.includes('shield turns the missile aside'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delayed effect banks the next spell until it is released', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'DWSSSP', '------', () => ({ casts: [{ hand: 'left', spellId: 'delayed_effect' }] }));
|
||||||
|
expect(s.wizards.A.delayedTurns).toBe(3);
|
||||||
|
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
||||||
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
|
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
||||||
|
expect(s.wizards.A.delayedTurns).toBe(0);
|
||||||
|
s = play(s, { release: { target: 'B' } });
|
||||||
|
expect(s.wizards.B.hp).toBe(14);
|
||||||
|
expect(s.wizards.A.banked).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a spell cast with the delayed effect on the same turn is banked, not the delayed effect itself', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
const left = 'DWSSSP';
|
||||||
|
const right = '----SD';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const last = i === 5;
|
||||||
|
s = play(s, {
|
||||||
|
left: left[i] as Gesture,
|
||||||
|
right: right[i] as Gesture,
|
||||||
|
casts: last ? [{ hand: 'left', spellId: 'delayed_effect' }, { hand: 'right', spellId: 'missile' }] : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(s.wizards.B.hp).toBe(15);
|
||||||
|
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
||||||
|
expect(s.wizards.A.delayedTurns).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('permanency makes the next enchantment last for the rest of the duel', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = runSequence(s, 'A', 'SPFPSDW', '-------', () => ({ casts: [{ hand: 'left', spellId: 'permanency' }] }));
|
||||||
|
expect(s.wizards.A.permanencyTurns).toBe(3);
|
||||||
|
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
||||||
|
expect(s.wizards.A.permanencyTurns).toBe(0);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
s = play(s, {}, { left: 'F', right: 'W' });
|
||||||
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||||
|
}
|
||||||
|
// Only a dispel magic can lift it now: B cannot point a digit to cast remove enchantment.
|
||||||
|
s = runSequence(s, 'A', 'CDPW', 'C---', () => ({ casts: [{ hand: 'left', spellId: 'dispel_magic' }] }));
|
||||||
|
s = play(s, {}, { left: 'F', right: 'W' });
|
||||||
|
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: 'F', right: 'W' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('haste under amnesia', () => {
|
||||||
|
it('repeats both of last turn\u2019s pairs', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
s = play(
|
||||||
|
s,
|
||||||
|
{ left: 'PWPWWC'[i] as Gesture, right: i === 5 ? 'C' : '-', casts: i === 5 ? [{ hand: 'left', spellId: 'haste' }] : [] },
|
||||||
|
{ left: i === 5 ? 'D' : '-' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(s.wizards.A.hasteTurns).toBe(3);
|
||||||
|
s = play(s, { left: 'W', right: 'W', second: { left: 'D', right: 'D', casts: [] } }, { left: 'P' });
|
||||||
|
// The amnesia lands on the turn A makes S-F and then, hastened, W-D.
|
||||||
|
s = play(s, { left: 'S', right: 'F', second: { left: 'W', right: 'D', casts: [] } }, { left: 'P', casts: [{ hand: 'left', spellId: 'amnesia' }] });
|
||||||
|
expect(s.wizards.A.constraints.amnesia).toBe(true);
|
||||||
|
expect(s.wizards.A.hasteTurns).toBe(1);
|
||||||
|
s = play(s, { left: 'C', right: 'C', second: { left: 'C', right: 'C', casts: [] } });
|
||||||
|
const last = s.wizards.A.history.slice(-2);
|
||||||
|
expect(last.map((h) => h.left + h.right)).toEqual(['SF', 'WD']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lessons', () => {
|
||||||
|
it('explains a shield that blocked a creature but not cause light wounds, once per duel', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
// A summons a goblin on turn 3 and lands cause light wounds on turn 3 too; B shields on turn 3.
|
||||||
|
const left = 'SFW';
|
||||||
|
const right = 'WFP';
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const last = i === 2;
|
||||||
|
s = play(
|
||||||
|
s,
|
||||||
|
{
|
||||||
|
left: left[i] as Gesture,
|
||||||
|
right: right[i] as Gesture,
|
||||||
|
casts: last ? [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }, { hand: 'right', spellId: 'cause_light_wounds' }] : []
|
||||||
|
},
|
||||||
|
{ left: last ? 'P' : '-', casts: last ? [{ hand: 'left', spellId: 'shield' }] : [] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(s.wizards.B.hp).toBe(13);
|
||||||
|
expect(s.lastTurn?.lessons.some((l) => l.includes('does not stop cause light wounds'))).toBe(true);
|
||||||
|
expect(s.lastTurn?.lessons.some((l) => l.includes('attacks on the very turn'))).toBe(false);
|
||||||
|
// The same situation again teaches nothing new.
|
||||||
|
s = runSequence(s, 'A', 'WFP', '---', () => ({ casts: [{ hand: 'left', spellId: 'cause_light_wounds' }] }));
|
||||||
|
s = play(s, {}, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||||
|
expect(s.lastTurn?.lessons).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notices a single-handed clap and a reused run', () => {
|
||||||
|
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||||
|
s = play(s, { left: 'C' });
|
||||||
|
expect(s.lastTurn?.lessons[0]).toContain('clap needs both hands');
|
||||||
|
s = runSequence(s, 'A', 'FFF', '---', () => ({ casts: [{ hand: 'left', spellId: 'paralysis' }] }));
|
||||||
|
s = runSequence(s, 'A', 'SSDD', '----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||||
|
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Gesture sequence matching: which spells a hand has completed, which it is
|
||||||
|
// working toward, and whether two chosen spells would reuse the same gesture.
|
||||||
|
|
||||||
|
import { SPELLS, type Gesture, type Hand, type Spell, type Token } from './spells';
|
||||||
|
|
||||||
|
/** One turn of gestures as seen from one hand. */
|
||||||
|
export interface HandTurn {
|
||||||
|
own: Gesture;
|
||||||
|
other: Gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnGestures {
|
||||||
|
left: Gesture;
|
||||||
|
right: Gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Completion {
|
||||||
|
spell: Spell;
|
||||||
|
seqIndex: number;
|
||||||
|
/** Number of turns the sequence spans. */
|
||||||
|
length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handSequence(history: TurnGestures[], hand: Hand): HandTurn[] {
|
||||||
|
return history.map((t) =>
|
||||||
|
hand === 'left' ? { own: t.left, other: t.right } : { own: t.right, other: t.left }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenMatches(token: Token, turn: HandTurn): boolean {
|
||||||
|
if (token.both) return turn.own === token.gesture && turn.other === token.gesture;
|
||||||
|
return turn.own === token.gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the first `count` tokens of a spell sequence match the most
|
||||||
|
* recent gestures of the hand, and none of those gestures fall before
|
||||||
|
* `start` (the first usable turn after an anti-spell).
|
||||||
|
*/
|
||||||
|
export function prefixMatchesTail(
|
||||||
|
tokens: Token[],
|
||||||
|
count: number,
|
||||||
|
seq: HandTurn[],
|
||||||
|
start: number
|
||||||
|
): boolean {
|
||||||
|
if (count === 0) return true;
|
||||||
|
const from = seq.length - count;
|
||||||
|
if (from < start) return false;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (!tokenMatches(tokens[i], seq[from + i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every spell the hand has just finished, longest first. */
|
||||||
|
export function completedSpells(seq: HandTurn[], start: number): Completion[] {
|
||||||
|
const out: Completion[] = [];
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
spell.tokens.forEach((tokens, seqIndex) => {
|
||||||
|
if (prefixMatchesTail(tokens, tokens.length, seq, start)) {
|
||||||
|
out.push({ spell, seqIndex, length: tokens.length });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.length - a.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spells the hand could finish `turnsAway` turns from now if it keeps to the
|
||||||
|
* sequence. With turnsAway = 1 these are the spells one more gesture completes.
|
||||||
|
*/
|
||||||
|
export function completableIn(
|
||||||
|
seq: HandTurn[],
|
||||||
|
start: number,
|
||||||
|
turnsAway: number
|
||||||
|
): { spell: Spell; seqIndex: number; remaining: Token[] }[] {
|
||||||
|
const out: { spell: Spell; seqIndex: number; remaining: Token[] }[] = [];
|
||||||
|
for (const spell of SPELLS) {
|
||||||
|
spell.tokens.forEach((tokens, seqIndex) => {
|
||||||
|
const done = tokens.length - turnsAway;
|
||||||
|
if (done < 1) return;
|
||||||
|
if (prefixMatchesTail(tokens, done, seq, start)) {
|
||||||
|
out.push({ spell, seqIndex, remaining: tokens.slice(done) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Longest prefix of `tokens` the hand's latest gestures satisfy (strictly shorter than the spell). */
|
||||||
|
export function progressToward(tokens: Token[], seq: HandTurn[], start: number): number {
|
||||||
|
for (let k = tokens.length - 1; k >= 1; k--) {
|
||||||
|
if (prefixMatchesTail(tokens, k, seq, start)) return k;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The (hand, turn) cells a completed spell consumes; two-handed tokens use both hands. */
|
||||||
|
export function usedCells(completion: Completion, hand: Hand, turnCount: number): Set<string> {
|
||||||
|
const tokens = completion.spell.tokens[completion.seqIndex];
|
||||||
|
const cells = new Set<string>();
|
||||||
|
const from = turnCount - tokens.length;
|
||||||
|
tokens.forEach((token, i) => {
|
||||||
|
const turn = from + i;
|
||||||
|
cells.add(`${hand}:${turn}`);
|
||||||
|
if (token.both) cells.add(`${hand === 'left' ? 'right' : 'left'}:${turn}`);
|
||||||
|
});
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No gesture may complete more than one spell. */
|
||||||
|
export function castsConflict(
|
||||||
|
a: { completion: Completion; hand: Hand },
|
||||||
|
b: { completion: Completion; hand: Hand },
|
||||||
|
turnCount: number
|
||||||
|
): boolean {
|
||||||
|
const cellsA = usedCells(a.completion, a.hand, turnCount);
|
||||||
|
for (const cell of usedCells(b.completion, b.hand, turnCount)) {
|
||||||
|
if (cellsA.has(cell)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSequence(spell: Spell, seqIndex = 0): string {
|
||||||
|
return spell.sequences[seqIndex];
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Gesture } from './spells';
|
||||||
|
|
||||||
|
/** How a gesture is written in the ledger. A clap needs both hands; alone it is nothing. */
|
||||||
|
export function glyph(g: Gesture, other: Gesture = 'C'): string {
|
||||||
|
if (g === '>') return '†';
|
||||||
|
if (g === '-') return '–';
|
||||||
|
if (g === 'C' && other !== 'C') return '(C)';
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GESTURE_SHORT: Record<Gesture, string> = {
|
||||||
|
F: 'fingers',
|
||||||
|
P: 'palm',
|
||||||
|
S: 'snap',
|
||||||
|
W: 'wave',
|
||||||
|
D: 'digit',
|
||||||
|
C: 'clap',
|
||||||
|
'>': 'stab',
|
||||||
|
'-': 'nothing'
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { chooseBotTurn } from './bot';
|
||||||
|
import { resolveTurn } from './resolve';
|
||||||
|
import { createGame } from './state';
|
||||||
|
|
||||||
|
function lcg(seed: number) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (s * 1664525 + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('bot versus bot', () => {
|
||||||
|
it('sixty duels end without errors and by death, not surrender', () => {
|
||||||
|
let finished = 0;
|
||||||
|
let byDeath = 0;
|
||||||
|
let totalTurns = 0;
|
||||||
|
for (let seed = 1; seed <= 60; seed++) {
|
||||||
|
let state = createGame({ A: 'Black', B: 'White' }, seed);
|
||||||
|
const rngA = lcg(seed * 7 + 1);
|
||||||
|
const rngB = lcg(seed * 13 + 5);
|
||||||
|
for (let turn = 0; turn < 80 && !state.over; turn++) {
|
||||||
|
const a = chooseBotTurn(state, 'A', rngA);
|
||||||
|
const b = chooseBotTurn(state, 'B', rngB);
|
||||||
|
state = resolveTurn(state, { A: a, B: b });
|
||||||
|
}
|
||||||
|
if (state.over) {
|
||||||
|
finished++;
|
||||||
|
totalTurns += state.turn;
|
||||||
|
if (!state.wizards.A.alive || !state.wizards.B.alive) byDeath++;
|
||||||
|
}
|
||||||
|
for (const w of Object.values(state.wizards)) {
|
||||||
|
expect(w.hp).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(w.hp).toBeLessThanOrEqual(15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(finished).toBeGreaterThan(45);
|
||||||
|
expect(byDeath).toBe(finished);
|
||||||
|
expect(totalTurns / finished).toBeLessThan(40);
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
// The spell book for Waving Hands (Richard Bartle, 1977), as transcribed in
|
||||||
|
// docs/waving-hands-rules.txt. Gesture notation: F fingers, P palm, S snap,
|
||||||
|
// W wave, D digit point, C clap (always both hands). A lower-case letter in
|
||||||
|
// parentheses, e.g. "(w", means both hands make that gesture at once.
|
||||||
|
|
||||||
|
export type Gesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C' | '>' | '-';
|
||||||
|
export type MagicGesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C';
|
||||||
|
export type Hand = 'left' | 'right';
|
||||||
|
|
||||||
|
export const HANDS: Hand[] = ['left', 'right'];
|
||||||
|
export const GESTURES: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||||
|
export const MAGIC_GESTURES: MagicGesture[] = ['F', 'P', 'S', 'W', 'D', 'C'];
|
||||||
|
|
||||||
|
export const GESTURE_NAMES: Record<Gesture, string> = {
|
||||||
|
F: 'wriggled fingers',
|
||||||
|
P: 'proffered palm',
|
||||||
|
S: 'snap',
|
||||||
|
W: 'wave',
|
||||||
|
D: 'pointed digit',
|
||||||
|
C: 'clap',
|
||||||
|
'>': 'stab',
|
||||||
|
'-': 'nothing'
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Token {
|
||||||
|
gesture: MagicGesture;
|
||||||
|
/** Both hands must make this gesture on the same turn. */
|
||||||
|
both: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpellCategory = 'protection' | 'summons' | 'damage' | 'enchantment';
|
||||||
|
|
||||||
|
export type SpellId =
|
||||||
|
| 'shield'
|
||||||
|
| 'remove_enchantment'
|
||||||
|
| 'magic_mirror'
|
||||||
|
| 'counter_spell'
|
||||||
|
| 'dispel_magic'
|
||||||
|
| 'raise_dead'
|
||||||
|
| 'cure_light_wounds'
|
||||||
|
| 'cure_heavy_wounds'
|
||||||
|
| 'summon_goblin'
|
||||||
|
| 'summon_ogre'
|
||||||
|
| 'summon_troll'
|
||||||
|
| 'summon_giant'
|
||||||
|
| 'summon_elemental'
|
||||||
|
| 'missile'
|
||||||
|
| 'finger_of_death'
|
||||||
|
| 'lightning_bolt'
|
||||||
|
| 'lightning_bolt_quick'
|
||||||
|
| 'cause_light_wounds'
|
||||||
|
| 'cause_heavy_wounds'
|
||||||
|
| 'fireball'
|
||||||
|
| 'fire_storm'
|
||||||
|
| 'ice_storm'
|
||||||
|
| 'amnesia'
|
||||||
|
| 'confusion'
|
||||||
|
| 'charm_person'
|
||||||
|
| 'charm_monster'
|
||||||
|
| 'paralysis'
|
||||||
|
| 'fear'
|
||||||
|
| 'anti_spell'
|
||||||
|
| 'protection_from_evil'
|
||||||
|
| 'resist_heat'
|
||||||
|
| 'resist_cold'
|
||||||
|
| 'disease'
|
||||||
|
| 'poison'
|
||||||
|
| 'blindness'
|
||||||
|
| 'invisibility'
|
||||||
|
| 'haste'
|
||||||
|
| 'time_stop'
|
||||||
|
| 'delayed_effect'
|
||||||
|
| 'permanency';
|
||||||
|
|
||||||
|
export interface Spell {
|
||||||
|
id: SpellId;
|
||||||
|
name: string;
|
||||||
|
category: SpellCategory;
|
||||||
|
/** Gesture sequences that complete the spell, in Bartle's notation. */
|
||||||
|
sequences: string[];
|
||||||
|
tokens: Token[][];
|
||||||
|
usualTarget: 'self' | 'enemy';
|
||||||
|
summary: string;
|
||||||
|
/** False for spells the engine recognises but does not yet resolve. */
|
||||||
|
implemented: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSequence(sequence: string): Token[] {
|
||||||
|
return sequence.split('-').map((part) => {
|
||||||
|
if (part.startsWith('(')) {
|
||||||
|
return { gesture: part[1].toUpperCase() as MagicGesture, both: true };
|
||||||
|
}
|
||||||
|
return { gesture: part as MagicGesture, both: part === 'C' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spell(
|
||||||
|
id: SpellId,
|
||||||
|
name: string,
|
||||||
|
category: SpellCategory,
|
||||||
|
sequences: string[],
|
||||||
|
usualTarget: 'self' | 'enemy',
|
||||||
|
summary: string,
|
||||||
|
implemented = true
|
||||||
|
): Spell {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
sequences,
|
||||||
|
tokens: sequences.map(parseSequence),
|
||||||
|
usualTarget,
|
||||||
|
summary,
|
||||||
|
implemented
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPELLS: Spell[] = [
|
||||||
|
// Protection
|
||||||
|
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
|
||||||
|
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
|
||||||
|
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
|
||||||
|
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Other spells cast at the subject this turn fail, and the subject is shielded. Cannot stop finger of death or dispel magic.'),
|
||||||
|
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
|
||||||
|
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
|
||||||
|
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),
|
||||||
|
spell('cure_heavy_wounds', 'Cure heavy wounds', 'protection', ['D-F-P-W'], 'self', 'Heals 2 damage and cures disease.'),
|
||||||
|
// Summons
|
||||||
|
spell('summon_goblin', 'Summon goblin', 'summons', ['S-F-W'], 'self', 'A goblin (1 hit point, 1 damage a turn) fights for the subject.'),
|
||||||
|
spell('summon_ogre', 'Summon ogre', 'summons', ['P-S-F-W'], 'self', 'An ogre (2 hit points, 2 damage a turn) fights for the subject.'),
|
||||||
|
spell('summon_troll', 'Summon troll', 'summons', ['F-P-S-F-W'], 'self', 'A troll (3 hit points, 3 damage a turn) fights for the subject.'),
|
||||||
|
spell('summon_giant', 'Summon giant', 'summons', ['W-F-P-S-F-W'], 'self', 'A giant (4 hit points, 4 damage a turn) fights for the subject.'),
|
||||||
|
spell('summon_elemental', 'Summon elemental', 'summons', ['C-S-W-W-S'], 'self', 'A fire or ice elemental (3 hit points) savages everyone not resistant to it for 3 a turn.'),
|
||||||
|
// Damage
|
||||||
|
spell('missile', 'Missile', 'damage', ['S-D'], 'enemy', '1 damage. Stopped by a shield.'),
|
||||||
|
spell('finger_of_death', 'Finger of death', 'damage', ['P-W-P-F-S-S-S-D'], 'enemy', 'Kills the subject outright. Only dispel magic can stop it.'),
|
||||||
|
spell('lightning_bolt', 'Lightning bolt', 'damage', ['D-F-F-D-D'], 'enemy', '5 damage. Shields do not help.'),
|
||||||
|
spell('lightning_bolt_quick', 'Lightning bolt (quick)', 'damage', ['W-D-D-C'], 'enemy', '5 damage. Each wizard may use this short form only once a duel.'),
|
||||||
|
spell('cause_light_wounds', 'Cause light wounds', 'damage', ['W-F-P'], 'enemy', '2 damage. Shields do not help.'),
|
||||||
|
spell('cause_heavy_wounds', 'Cause heavy wounds', 'damage', ['W-P-F-D'], 'enemy', '3 damage. Shields do not help.'),
|
||||||
|
spell('fireball', 'Fireball', 'damage', ['F-S-S-D-D'], 'enemy', '5 damage unless the subject resists heat. Destroys an ice elemental.'),
|
||||||
|
spell('fire_storm', 'Fire storm', 'damage', ['S-W-W-C'], 'enemy', '5 damage to everyone not resistant to heat, the caster included. Cancels with an ice storm or ice elemental.'),
|
||||||
|
spell('ice_storm', 'Ice storm', 'damage', ['W-S-S-C'], 'enemy', '5 damage to everyone not resistant to cold, the caster included. Cancels with a fire storm or fire elemental.'),
|
||||||
|
// Enchantments
|
||||||
|
spell('amnesia', 'Amnesia', 'enchantment', ['D-P-P'], 'enemy', 'Next turn the subject must repeat this turn’s gestures exactly.'),
|
||||||
|
spell('confusion', 'Confusion', 'enchantment', ['D-S-F'], 'enemy', 'Next turn one of the subject’s gestures is replaced at random.'),
|
||||||
|
spell('charm_person', 'Charm person', 'enchantment', ['P-S-D-F'], 'enemy', 'Next turn the caster chooses what one of the subject’s hands does.'),
|
||||||
|
spell('charm_monster', 'Charm monster', 'enchantment', ['P-S-D-D'], 'enemy', 'The caster takes control of the target monster from this turn on.'),
|
||||||
|
spell('paralysis', 'Paralysis', 'enchantment', ['F-F-F'], 'enemy', 'Next turn one of the subject’s hands is frozen in its current gesture (C becomes F, S becomes D, W becomes P). A monster misses its next attack.'),
|
||||||
|
spell('fear', 'Fear', 'enchantment', ['S-W-D'], 'enemy', 'Next turn the subject cannot make a C, D, F or S gesture.'),
|
||||||
|
spell('anti_spell', 'Anti-spell', 'enchantment', ['S-P-F'], 'enemy', 'The subject’s gestures so far can no longer be used in a spell. Every spell must start again.'),
|
||||||
|
spell('protection_from_evil', 'Protection from evil', 'enchantment', ['W-W-P'], 'self', 'Shields the subject this turn and for the next three.'),
|
||||||
|
spell('resist_heat', 'Resist heat', 'enchantment', ['W-W-F-P'], 'self', 'The subject ignores fireballs, fire storms and fire elementals until dispelled. Destroys a fire elemental.'),
|
||||||
|
spell('resist_cold', 'Resist cold', 'enchantment', ['S-S-F-P'], 'self', 'The subject ignores ice storms and ice elementals until dispelled. Destroys an ice elemental.'),
|
||||||
|
spell('disease', 'Disease', 'enchantment', ['D-S-F-F-F-C'], 'enemy', 'The subject dies at the end of the sixth turn unless cured by cure heavy wounds, remove enchantment or dispel magic.'),
|
||||||
|
spell('poison', 'Poison', 'enchantment', ['D-W-W-F-W-D'], 'enemy', 'As disease, but cure heavy wounds does not help.'),
|
||||||
|
spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see the opponent’s gestures. Destroys a monster.'),
|
||||||
|
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subject’s gestures and monsters cannot attack them. Destroys a monster.'),
|
||||||
|
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
|
||||||
|
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
|
||||||
|
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject\u2019s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
|
||||||
|
spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject\u2019s next enchantment (this turn or the next three) lasts for the rest of the duel.')
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SPELL_BY_ID: Record<SpellId, Spell> = Object.fromEntries(
|
||||||
|
SPELLS.map((s) => [s.id, s])
|
||||||
|
) as Record<SpellId, Spell>;
|
||||||
|
|
||||||
|
/** Enchantments that cancel one another when they hit the same being together. */
|
||||||
|
export const CONTROL_SPELLS: SpellId[] = [
|
||||||
|
'amnesia',
|
||||||
|
'confusion',
|
||||||
|
'charm_person',
|
||||||
|
'charm_monster',
|
||||||
|
'paralysis',
|
||||||
|
'fear'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Enchantments a permanency can extend for the rest of the duel. */
|
||||||
|
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
|
||||||
|
|
||||||
|
/** Spells that affect everyone rather than one subject. */
|
||||||
|
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
|
||||||
|
|
||||||
|
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
|
||||||
|
summon_goblin: 'goblin',
|
||||||
|
summon_ogre: 'ogre',
|
||||||
|
summon_troll: 'troll',
|
||||||
|
summon_giant: 'giant'
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MonsterKind = 'goblin' | 'ogre' | 'troll' | 'giant' | 'fire_elemental' | 'ice_elemental';
|
||||||
|
|
||||||
|
export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; attack: number }> = {
|
||||||
|
goblin: { name: 'Goblin', hp: 1, attack: 1 },
|
||||||
|
ogre: { name: 'Ogre', hp: 2, attack: 2 },
|
||||||
|
troll: { name: 'Troll', hp: 3, attack: 3 },
|
||||||
|
giant: { name: 'Giant', hp: 4, attack: 4 },
|
||||||
|
fire_elemental: { name: 'Fire elemental', hp: 3, attack: 3 },
|
||||||
|
ice_elemental: { name: 'Ice elemental', hp: 3, attack: 3 }
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isElemental(kind: MonsterKind): boolean {
|
||||||
|
return kind === 'fire_elemental' || kind === 'ice_elemental';
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import type { TurnGestures } from './gestures';
|
||||||
|
import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
|
||||||
|
|
||||||
|
export type WizardId = 'A' | 'B';
|
||||||
|
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */
|
||||||
|
export type TargetId = WizardId | string;
|
||||||
|
export const NOWHERE = 'nowhere';
|
||||||
|
|
||||||
|
export const MAX_HP = 15;
|
||||||
|
|
||||||
|
/** A main turn, the extra pair of gestures a hastened wizard makes, or a turn taken alone under time stop. */
|
||||||
|
export type Phase = 'main' | 'haste' | 'timestop';
|
||||||
|
|
||||||
|
/** Effects that shape a being's gestures or attacks on the following turn. */
|
||||||
|
export interface Constraints {
|
||||||
|
amnesia?: boolean;
|
||||||
|
confusion?: boolean;
|
||||||
|
/** A permanent confusion always produces the same slip. */
|
||||||
|
confusionFixed?: { hand: Hand; gesture: Gesture };
|
||||||
|
fear?: boolean;
|
||||||
|
paralysedHand?: Hand;
|
||||||
|
charmed?: { hand: Hand; by: WizardId };
|
||||||
|
/** A permanent charm repeats the first chosen gesture. */
|
||||||
|
charmedGesture?: Gesture;
|
||||||
|
/** Monsters: skip the next attack (paralysis). */
|
||||||
|
skipAttack?: boolean;
|
||||||
|
/** Monsters: attack the same target again (amnesia). */
|
||||||
|
forcedTarget?: TargetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryEntry extends TurnGestures {
|
||||||
|
turn: number;
|
||||||
|
phase: Phase;
|
||||||
|
/** Wizards who could not see these gestures when they were made. */
|
||||||
|
hiddenFrom: WizardId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastRecord {
|
||||||
|
turn: number;
|
||||||
|
/** Index into the wizard's history of the gesture that completed the spell; -1 for a released banked spell. */
|
||||||
|
index: number;
|
||||||
|
hand: Hand;
|
||||||
|
spellId: SpellId;
|
||||||
|
length: number;
|
||||||
|
target: TargetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankedSpell {
|
||||||
|
spellId: SpellId;
|
||||||
|
elemental?: 'fire' | 'ice';
|
||||||
|
chosenHand?: Hand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WizardState {
|
||||||
|
id: WizardId;
|
||||||
|
name: string;
|
||||||
|
hp: number;
|
||||||
|
alive: boolean;
|
||||||
|
surrendered: boolean;
|
||||||
|
history: HistoryEntry[];
|
||||||
|
casts: CastRecord[];
|
||||||
|
/** First turn index whose gestures may still be used in a spell (anti-spell). */
|
||||||
|
sequenceStart: number;
|
||||||
|
resistHeat: boolean;
|
||||||
|
resistCold: boolean;
|
||||||
|
protectionTurns: number;
|
||||||
|
blindTurns: number;
|
||||||
|
invisibleTurns: number;
|
||||||
|
diseaseTurns: number | null;
|
||||||
|
poisonTurns: number | null;
|
||||||
|
/** In force for the turn about to be played. */
|
||||||
|
constraints: Constraints;
|
||||||
|
/** Set during resolution, becomes `constraints` next turn. */
|
||||||
|
pending: Constraints;
|
||||||
|
/** Constraints made permanent by a permanency spell; reapplied every turn. */
|
||||||
|
permanent: Constraints;
|
||||||
|
hasteTurns: number;
|
||||||
|
/** Turns left in which the next spell cast will be banked. */
|
||||||
|
delayedTurns: number;
|
||||||
|
/** Turns left in which the next eligible enchantment becomes permanent. */
|
||||||
|
permanencyTurns: number;
|
||||||
|
banked: BankedSpell | null;
|
||||||
|
usedQuickLightning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Monster {
|
||||||
|
id: string;
|
||||||
|
kind: MonsterKind;
|
||||||
|
name: string;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
attack: number;
|
||||||
|
owner: WizardId;
|
||||||
|
resistHeat: boolean;
|
||||||
|
resistCold: boolean;
|
||||||
|
protectionTurns: number;
|
||||||
|
constraints: Constraints;
|
||||||
|
pending: Constraints;
|
||||||
|
createdTurn: number;
|
||||||
|
/** What it attacked last turn, shown in the UI and used for amnesia. */
|
||||||
|
lastTarget?: TargetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogKind = 'gesture' | 'spell' | 'damage' | 'death' | 'fizzle' | 'info';
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
turn: number;
|
||||||
|
kind: LogKind;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a reveal did to each wizard's health, for the result strip. */
|
||||||
|
export interface TurnSummary {
|
||||||
|
turn: number;
|
||||||
|
phase: Phase;
|
||||||
|
wizards: Record<WizardId, { before: number; after: number; effects: { label: string; delta: number }[] }>;
|
||||||
|
/** Everything worth knowing before the next decision, other than gestures and damage. */
|
||||||
|
events: string[];
|
||||||
|
/** Short explanations of rules that just mattered, each shown once a duel. */
|
||||||
|
lessons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Outcome {
|
||||||
|
winner: WizardId | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
turn: number;
|
||||||
|
wizards: Record<WizardId, WizardState>;
|
||||||
|
monsters: Monster[];
|
||||||
|
log: LogEntry[];
|
||||||
|
over: Outcome | null;
|
||||||
|
lastTurn: TurnSummary | null;
|
||||||
|
/** Keys of explanations already given this duel. */
|
||||||
|
lessonsShown: string[];
|
||||||
|
/** Wizards owed an extra turn alone, in order; the next resolution belongs to the first. */
|
||||||
|
timeStops: WizardId[];
|
||||||
|
nextMonsterId: number;
|
||||||
|
summonCounts: Partial<Record<MonsterKind, number>>;
|
||||||
|
rng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastChoice {
|
||||||
|
hand: Hand;
|
||||||
|
spellId: SpellId;
|
||||||
|
seqIndex?: number;
|
||||||
|
target?: TargetId;
|
||||||
|
elemental?: 'fire' | 'ice';
|
||||||
|
/** Paralysis and charm person: which of the subject's hands. */
|
||||||
|
chosenHand?: Hand;
|
||||||
|
/** Summons: what the new monster attacks this turn. */
|
||||||
|
monsterTarget?: TargetId;
|
||||||
|
/** Under a delayed effect: bank this spell rather than another cast this turn. */
|
||||||
|
bank?: boolean;
|
||||||
|
/** Under a permanency: make this enchantment permanent rather than another cast this turn. */
|
||||||
|
permanent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurnInput {
|
||||||
|
left: Gesture;
|
||||||
|
right: Gesture;
|
||||||
|
casts: CastChoice[];
|
||||||
|
/** The extra pair of gestures a hastened wizard makes. */
|
||||||
|
second?: { left: Gesture; right: Gesture; casts: CastChoice[]; stabTarget?: TargetId };
|
||||||
|
/** Loose the banked spell this turn. */
|
||||||
|
release?: { target?: TargetId; monsterTarget?: TargetId };
|
||||||
|
stabTarget?: TargetId;
|
||||||
|
monsterOrders?: Record<string, TargetId>;
|
||||||
|
/** When this wizard charmed the opponent last turn: the gesture the charmed hand makes. */
|
||||||
|
charmGesture?: Gesture;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enemyOf(id: WizardId): WizardId {
|
||||||
|
return id === 'A' ? 'B' : 'A';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWizard(id: WizardId, name: string): WizardState {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
hp: MAX_HP,
|
||||||
|
alive: true,
|
||||||
|
surrendered: false,
|
||||||
|
history: [],
|
||||||
|
casts: [],
|
||||||
|
sequenceStart: 0,
|
||||||
|
resistHeat: false,
|
||||||
|
resistCold: false,
|
||||||
|
protectionTurns: 0,
|
||||||
|
blindTurns: 0,
|
||||||
|
invisibleTurns: 0,
|
||||||
|
diseaseTurns: null,
|
||||||
|
poisonTurns: null,
|
||||||
|
constraints: {},
|
||||||
|
pending: {},
|
||||||
|
permanent: {},
|
||||||
|
hasteTurns: 0,
|
||||||
|
delayedTurns: 0,
|
||||||
|
permanencyTurns: 0,
|
||||||
|
banked: null,
|
||||||
|
usedQuickLightning: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGame(names: { A: string; B: string }, seed = Date.now()): GameState {
|
||||||
|
return {
|
||||||
|
turn: 0,
|
||||||
|
wizards: { A: makeWizard('A', names.A), B: makeWizard('B', names.B) },
|
||||||
|
monsters: [],
|
||||||
|
log: [],
|
||||||
|
over: null,
|
||||||
|
lastTurn: null,
|
||||||
|
lessonsShown: [],
|
||||||
|
timeStops: [],
|
||||||
|
nextMonsterId: 1,
|
||||||
|
summonCounts: {},
|
||||||
|
rng: seed >>> 0 || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mulberry32: small seeded generator so duels can be replayed in tests. */
|
||||||
|
export function nextRandom(state: GameState): number {
|
||||||
|
state.rng = (state.rng + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = state.rng;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWizardId(id: TargetId | undefined): id is WizardId {
|
||||||
|
return id === 'A' || id === 'B';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
|
||||||
|
return state.monsters.find((m) => m.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wizard who commands a being. */
|
||||||
|
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
|
||||||
|
if (isWizardId(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;
|
||||||
|
return findMonster(state, id)?.name ?? 'a vanished creature';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wizard's gestures as another wizard saw them: turns made while invisible,
|
||||||
|
* or while the viewer was blind, are unknown to the viewer.
|
||||||
|
*/
|
||||||
|
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
|
||||||
|
if (viewer === subject) return state.wizards[subject].history;
|
||||||
|
return state.wizards[subject].history.map((h) =>
|
||||||
|
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// The duel lives in the browser and is saved to local storage, so nothing is rendered on the
|
||||||
|
// server. Each route is prerendered as an empty shell that the client fills in.
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = true;
|
||||||
@@ -0,0 +1,541 @@
|
|||||||
|
<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 { duel, FOE, YOU, 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);
|
||||||
|
|
||||||
|
function startNew() {
|
||||||
|
if (duel.inProgress && !confirmingNew) {
|
||||||
|
confirmingNew = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirmingNew = false;
|
||||||
|
duel.newGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the duel whenever anything about it changes, and follow the window width.
|
||||||
|
$effect(() => {
|
||||||
|
duel.persist();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
const query = window.matchMedia('(max-width: 860px)');
|
||||||
|
const apply = () => (duel.compact = query.matches);
|
||||||
|
apply();
|
||||||
|
query.addEventListener('change', apply);
|
||||||
|
return () => query.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
|
||||||
|
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner === FOE));
|
||||||
|
const outcome = $derived(duel.state.over);
|
||||||
|
const won = $derived(outcome?.winner === YOU);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Waving Hands</title>
|
||||||
|
<meta name="description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="masthead">
|
||||||
|
<div>
|
||||||
|
<h1>Waving Hands</h1>
|
||||||
|
{#if duel.state.turn === 0 && !duel.timeStopped}
|
||||||
|
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>. Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>.</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" 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.timeStopped}
|
||||||
|
<!-- nobody else moves -->
|
||||||
|
{:else if duel.threats.length}
|
||||||
|
<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}
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
|
||||||
|
<select bind:value={duel.charmGesture}>
|
||||||
|
{#each MAGIC_GESTURES as g (g)}
|
||||||
|
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if duel.surrendering}
|
||||||
|
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
|
||||||
|
{/if}
|
||||||
|
{#if duel.conflict}
|
||||||
|
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
|
||||||
|
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="rail">
|
||||||
|
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
|
||||||
|
<WizardStatus wizard={duel.foe} monsters={foeMonsters} />
|
||||||
|
<SpellSheet {duel} />
|
||||||
|
<Chronicle log={duel.state.log} />
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<MobileBar {duel} />
|
||||||
|
|
||||||
|
<footer class="colophon">
|
||||||
|
<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders label {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orders label span {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
color: var(--blood);
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.haste-note {
|
||||||
|
color: var(--frost);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 1rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
background: var(--bone);
|
||||||
|
color: var(--slate-deep);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:hover:not(:disabled) {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--blood);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict.won h2 {
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict p {
|
||||||
|
margin: 0.3rem 0 0.8rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colophon {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem var(--gutter) 3rem;
|
||||||
|
color: var(--bone-faint);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 raw from '../../../docs/waving-hands-rules.txt?raw';
|
||||||
|
import { MONSTER_STATS, SPELLS, type SpellCategory } from '$lib/game/spells';
|
||||||
|
|
||||||
|
const groups: { category: SpellCategory; title: string; blurb: string }[] = [
|
||||||
|
{ category: 'protection', title: 'Protection', blurb: 'Usually cast on yourself. A shield is a single P, so a palm is never wasted.' },
|
||||||
|
{ category: 'summons', title: 'Summons', blurb: 'Creatures fight for whoever the spell is cast on, attack the same turn they appear, and keep attacking each turn until destroyed.' },
|
||||||
|
{ category: 'damage', title: 'Damage', blurb: 'A wizard can take 14 points; the fifteenth kills. Cures apply in the same turn as the damage.' },
|
||||||
|
{ category: 'enchantment', title: 'Enchantments', blurb: 'Most bind the subject on the following turn. Two of the mind-affecting kind landing at once cancel each other.' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const HEADINGS = [
|
||||||
|
'Introduction',
|
||||||
|
'The Turn',
|
||||||
|
'Gestures',
|
||||||
|
'Winning',
|
||||||
|
'Spells',
|
||||||
|
'Protection spells',
|
||||||
|
'Summons spells',
|
||||||
|
'Damaging Spells',
|
||||||
|
'Enchantments',
|
||||||
|
'Non-spells',
|
||||||
|
'Reference Sheet',
|
||||||
|
'Sample Game',
|
||||||
|
'Annotation'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Split the transcription at its headings so each part can be linked. */
|
||||||
|
const original = (() => {
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
const sections: { title: string; id: string; body: string }[] = [];
|
||||||
|
let current: { title: string; id: string; lines: string[] } | null = null;
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (HEADINGS.includes(trimmed)) {
|
||||||
|
if (current) sections.push({ title: current.title, id: current.id, body: current.lines.join('\n').trim() });
|
||||||
|
current = { title: trimmed, id: 'original-' + trimmed.toLowerCase().replace(/\s+/g, '-'), lines: [] };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^\s*(Spellcaster|Rules for the Spellcaster Game|Back to Main List)\s*$/.test(line)) continue;
|
||||||
|
if (current) current.lines.push(line);
|
||||||
|
}
|
||||||
|
if (current) sections.push({ title: current.title, id: current.id, body: current.lines.join('\n').trim() });
|
||||||
|
return sections;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const monsters = (['goblin', 'ogre', 'troll', 'giant', 'fire_elemental', 'ice_elemental'] as const).map((k) => MONSTER_STATS[k]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Waving Hands: the rules</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="rules">
|
||||||
|
<header>
|
||||||
|
<a class="back" href="/">Back to the duel</a>
|
||||||
|
<h1>How to duel</h1>
|
||||||
|
<p class="lede">Waving Hands is Richard Bartle's 1977 game of two wizards trading spells made of hand gestures. This page is a working reference; the original transcription is at the end.</p>
|
||||||
|
<nav aria-label="Sections">
|
||||||
|
<a href="#turn">A turn</a>
|
||||||
|
<a href="#gestures">Gestures</a>
|
||||||
|
<a href="#overlap">Overlapping spells</a>
|
||||||
|
<a href="#targets">Targets and timing</a>
|
||||||
|
<a href="#monsters">Monsters</a>
|
||||||
|
<a href="#winning">Winning</a>
|
||||||
|
<a href="#spells">Every spell</a>
|
||||||
|
<a href="#original">Original text</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="turn">
|
||||||
|
<h2>A turn</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Both wizards secretly choose a gesture for each hand. In this game you pick them, then choose which finished spells to cast and at whom.</li>
|
||||||
|
<li>Both reveal at once. Every spell, stab and monster attack of the turn takes effect together, so a shield you make this turn stops a missile made this turn.</li>
|
||||||
|
<li>Damage and healing are added up, monsters that lost all their hit points are removed, and enchantments cast this turn bind next turn.</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gestures">
|
||||||
|
<h2>Gestures</h2>
|
||||||
|
<p>Six magical gestures: <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit and <strong>C</strong> clap. A clap needs both hands; a clap with one hand is nothing. A hand may instead <strong>stab</strong> with the knife (one point of damage, one stab a turn) or do <strong>nothing</strong>. Neither belongs to any spell, so a hand that stabs or rests starts its next spell from scratch.</p>
|
||||||
|
<p>A spell is a run of gestures made on consecutive turns by one hand. In the notation, a bracketed lower-case letter such as <strong>(w</strong> means that gesture made with both hands at once.</p>
|
||||||
|
<p>Both palms at once is <strong>surrender</strong>. It is not a spell and it ends the duel.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="overlap">
|
||||||
|
<h2>Overlapping spells</h2>
|
||||||
|
<p>A gesture can belong to more than one spell as long as each spell's run is unbroken. F-F-F is Paralysis; follow it with S-S-D-D and the last five gestures, F-S-S-D-D, are a Fireball. Another F after F-F-F is a second Paralysis. Each gesture can finish only one spell, so if two spells would end on the same gesture you choose one. Any finished spell may be let go harmlessly, except the storms, which rage wherever they are released.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="targets">
|
||||||
|
<h2>Targets and timing</h2>
|
||||||
|
<p>Every spell has a subject. Protection and summons are usually cast on yourself; attacks and enchantments on your opponent; but any spell may be aimed at any wizard or monster, and a spell aimed at something that no longer exists is lost.</p>
|
||||||
|
<p>Because everything is simultaneous, a monster killed the turn it appears still attacks that turn, a Resist heat cast in the same turn as a Fireball saves you, and a Remove enchantment cast in the same turn as a Fireball leaves you burned. <strong>Counter-spell</strong> stops every other spell cast at its subject that turn, except Finger of death and Dispel magic. <strong>Dispel magic</strong> stops every spell on the field, ends every enchantment and unmakes every monster after it attacks.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="monsters">
|
||||||
|
<h2>Monsters</h2>
|
||||||
|
<p>A summoned creature obeys whoever it was summoned for, attacks any wizard or monster its master names, and is stopped by a shield on its target. Elementals attack everyone not resistant to their element, are destroyed by the opposite element, and merge if two of a kind meet.</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Creature</th><th>Hit points</th><th>Damage a turn</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{#each monsters as m (m.name)}
|
||||||
|
<tr><td>{m.name}</td><td>{m.hp}</td><td>{m.attack}</td></tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="winning">
|
||||||
|
<h2>Winning</h2>
|
||||||
|
<p>Each wizard has fifteen hit points and dies on losing them all. If both die in the same turn the duel is a draw. Surrender loses, unless the surrendering wizard's last gestures also killed the opponent.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="spells">
|
||||||
|
<h2>Every spell</h2>
|
||||||
|
{#each groups as g (g.category)}
|
||||||
|
<h3 id={'spells-' + g.category}>{g.title}</h3>
|
||||||
|
<p class="blurb">{g.blurb}</p>
|
||||||
|
<dl>
|
||||||
|
{#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)}
|
||||||
|
<div>
|
||||||
|
<dt><span class="name">{spell.name}</span> <span class="seq">{spell.sequences.join(' or ')}</span></dt>
|
||||||
|
<dd>{spell.summary}</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="original">
|
||||||
|
<h2>The original text</h2>
|
||||||
|
<p class="blurb">Bartle's rules as printed in the <em>Duel Purpose</em> fanzine, transcribed by Andrew Buchanan. Kept as the source for anything this page simplifies.</p>
|
||||||
|
<nav aria-label="Original sections" class="subnav">
|
||||||
|
{#each original as sec (sec.id)}
|
||||||
|
<a href={'#' + sec.id}>{sec.title}</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{#each original as sec (sec.id)}
|
||||||
|
<details id={sec.id}>
|
||||||
|
<summary>{sec.title}</summary>
|
||||||
|
<pre>{sec.body}</pre>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rules {
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem var(--gutter) 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--frost);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
font-weight: 300;
|
||||||
|
font-variation-settings: 'opsz' 144;
|
||||||
|
margin: 0.5rem 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
max-width: 38em;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 1.25rem 0 0.5rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p + p,
|
||||||
|
ol {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blurb {
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0.2rem 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.25rem 1.5rem 0.25rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl > div {
|
||||||
|
padding: 0.45rem 0;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
dt .name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt .seq {
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 0.5rem 0 0.5rem;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
sveltekit({
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) =>
|
||||||
|
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||||
|
},
|
||||||
|
// A static build: the duel runs entirely in the browser, so Caddy serves files and nothing else.
|
||||||
|
adapter: adapter({ fallback: 'index.html' })
|
||||||
|
})
|
||||||
|
],
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.ts']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user