From 865555e9118eb686f4df783be6d97720f2513e1c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 22 Sep 2026 15:33:29 -0400 Subject: [PATCH] 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 --- .gitignore | 26 + .npmrc | 1 + README.md | 78 ++ deploy/README.md | 47 + deploy/deploy.sh | 22 + deploy/setup-droplet.sh | 64 + docs/waving-hands-rules.txt | 748 +++++++++++ package-lock.json | 1583 ++++++++++++++++++++++++ package.json | 26 + src/app.css | 94 ++ src/app.d.ts | 13 + src/app.html | 18 + src/lib/assets/favicon.svg | 21 + src/lib/components/Chronicle.svelte | 84 ++ src/lib/components/GestureIcon.svelte | 56 + src/lib/components/HandPicker.svelte | 325 +++++ src/lib/components/Ledger.svelte | 261 ++++ src/lib/components/MobileBar.svelte | 91 ++ src/lib/components/SpellSheet.svelte | 189 +++ src/lib/components/TurnSummary.svelte | 132 ++ src/lib/components/WizardStatus.svelte | 141 +++ src/lib/game/bot.ts | 502 ++++++++ src/lib/game/duel.svelte.ts | 621 ++++++++++ src/lib/game/engine.test.ts | 417 +++++++ src/lib/game/gestures.ts | 126 ++ src/lib/game/glyphs.ts | 20 + src/lib/game/resolve.ts | 1013 +++++++++++++++ src/lib/game/simulation.test.ts | 42 + src/lib/game/spells.ts | 205 +++ src/lib/game/state.ts | 260 ++++ src/lib/index.ts | 1 + src/routes/+layout.svelte | 12 + src/routes/+layout.ts | 4 + src/routes/+page.svelte | 541 ++++++++ src/routes/rules/+page.svelte | 285 +++++ static/robots.txt | 3 + tsconfig.json | 20 + vite.config.ts | 20 + 38 files changed, 8112 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 deploy/README.md create mode 100755 deploy/deploy.sh create mode 100755 deploy/setup-droplet.sh create mode 100644 docs/waving-hands-rules.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/components/Chronicle.svelte create mode 100644 src/lib/components/GestureIcon.svelte create mode 100644 src/lib/components/HandPicker.svelte create mode 100644 src/lib/components/Ledger.svelte create mode 100644 src/lib/components/MobileBar.svelte create mode 100644 src/lib/components/SpellSheet.svelte create mode 100644 src/lib/components/TurnSummary.svelte create mode 100644 src/lib/components/WizardStatus.svelte create mode 100644 src/lib/game/bot.ts create mode 100644 src/lib/game/duel.svelte.ts create mode 100644 src/lib/game/engine.test.ts create mode 100644 src/lib/game/gestures.ts create mode 100644 src/lib/game/glyphs.ts create mode 100644 src/lib/game/resolve.ts create mode 100644 src/lib/game/simulation.test.ts create mode 100644 src/lib/game/spells.ts create mode 100644 src/lib/game/state.ts create mode 100644 src/lib/index.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+layout.ts create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/rules/+page.svelte create mode 100644 static/robots.txt create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bbc136 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9b08d3 --- /dev/null +++ b/README.md @@ -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." diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..74320ee --- /dev/null +++ b/deploy/README.md @@ -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 --tag-name waving-hands --wait` +2. `scp deploy/setup-droplet.sh root@:/root/ && ssh root@ \ + "bash /root/setup-droplet.sh waving-hands..sslip.io"` +3. `deploy/deploy.sh ` + +The sslip.io hostname works with no DNS at all. To add a real name, point an +A record at the droplet and add the name to the first line of +/etc/caddy/Caddyfile (space separated), then `systemctl reload caddy`; Caddy +fetches the certificate on first request. + +## Everyday deploys + + deploy/deploy.sh + +Runs the type-check, the tests and the build locally, rsyncs `build/` to the +droplet keeping the previous week's hashed assets, and reloads Caddy. A +player mid-duel loses nothing: their game is in their own browser, and the +next page load picks up the new build. + +## Operations + +- Logs: `ssh root@ journalctl -u caddy -f`, access log in + /var/lib/caddy/access.log +- Nothing to back up on the server. Player duels are in their browsers. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..c768e8a --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Build locally and push the static site to the droplet. +# Usage: deploy/deploy.sh +set -euo pipefail +HOST="${1:?usage: deploy.sh }" + +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." diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh new file mode 100755 index 0000000..2f6ecbe --- /dev/null +++ b/deploy/setup-droplet.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# One-time droplet setup. Run ON the droplet as root: +# bash setup-droplet.sh waving-hands..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 }" + +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 .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 < from your machine" diff --git a/docs/waving-hands-rules.txt b/docs/waving-hands-rules.txt new file mode 100644 index 0000000..16bc0d2 --- /dev/null +++ b/docs/waving-hands-rules.txt @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b0b2465 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1583 @@ +{ + "name": "waving-hands", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "waving-hands", + "version": "0.0.1", + "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" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.8.tgz", + "integrity": "sha512-0yrRvyjiH1EWLIEPb0i26mEnOYsXgIjG77muq3LMJH/dpmp+ctFEEHF8jjGBQAdhwgrKfN74CohbJTMFIaJcMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c9a0858 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..e207cbe --- /dev/null +++ b/src/app.css @@ -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; + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -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 {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..85fe3b7 --- /dev/null +++ b/src/app.html @@ -0,0 +1,18 @@ + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..4f59ace --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1,21 @@ + + Waving Hands + + + + + + + diff --git a/src/lib/components/Chronicle.svelte b/src/lib/components/Chronicle.svelte new file mode 100644 index 0000000..30a2a89 --- /dev/null +++ b/src/lib/components/Chronicle.svelte @@ -0,0 +1,84 @@ + + +
+

Chronicle

+ {#if turns.length === 0} +

Nothing has happened yet. Choose a gesture for each hand and reveal them.

+ {/if} + {#each turns as [turn, entries] (turn)} +
+

Turn {turn + 1}

+
    + {#each entries as e, i (i)} +
  • {e.text}
  • + {/each} +
+
+ {/each} +
+ + diff --git a/src/lib/components/GestureIcon.svelte b/src/lib/components/GestureIcon.svelte new file mode 100644 index 0000000..3089553 --- /dev/null +++ b/src/lib/components/GestureIcon.svelte @@ -0,0 +1,56 @@ + + + + + + diff --git a/src/lib/components/HandPicker.svelte b/src/lib/components/HandPicker.svelte new file mode 100644 index 0000000..f08fa07 --- /dev/null +++ b/src/lib/components/HandPicker.svelte @@ -0,0 +1,325 @@ + + +
+ {hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}, again{/if} + + {#if forced} +

{glyph(forced, duel.gestureFor(set, other))} {whyForced}

+ {:else if charmed} +

? Charmed: {duel.foe.name} chooses this hand’s gesture.

+ {:else} +
+ {#each GESTURES as g (g)} + + {/each} +
+ {#if duel.you.constraints.fear && !duel.timeStopped} +

Fear: no clap, digit, fingers or snap this turn.

+ {/if} + {#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0} +

Nothing under way. to plan with this hand.

+ {:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0} +
    + {#each plans as plan (plan.spell.id)} +
  • + + +
  • + {/each} + {#if hiddenPlans > 0 || morePlans} +
  • + +
  • + {/if} +
+ {/if} + {/if} + + {#if options.length > 0} +
+ + {#if spell} + + {#if spell.id === 'summon_elemental'} + + {/if} + {#if spell.id === 'paralysis' || spell.id === 'charm_person'} + + {/if} +

+ {spell.summary} + {#if banking}This spell will be banked by your delayed effect, not cast.{/if} + {#if extending}Your permanency will make this enchantment last the whole duel.{/if} +

+ {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/Ledger.svelte b/src/lib/components/Ledger.svelte new file mode 100644 index 0000000..a2293dc --- /dev/null +++ b/src/lib/components/Ledger.svelte @@ -0,0 +1,261 @@ + + +
+ + + + + + + + + + + + + + + + + {#each rows as row (row.key)} + + + {#each [YOU, FOE] as const as who (who)} + {#each ['left', 'right'] as const as hand (hand)} + + {/each} + {/each} + + {/each} + {#if drafting} + + + {#each ['left', 'right'] as const as hand (hand)} + + {/each} + + + + {/if} + +
Turn{duel.you.name} (you){duel.foe.name}
leftrightleftright
{row.label}{#if row.note}{row.note}{/if} + {#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} + 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit> + {p.hidden ? '?' : glyph(p.entry[hand], other)} + {#if notes.length}{notes.join(', ')}{/if} + + {/each} +
{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}time stop{/if} + {#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]} + + + {charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))} + + {#if cast && duel.sets[set][hand].spellId}{cast.spell.name}{/if} + + {/each} + {#if !duel.timeStopped}?{/if}{#if !duel.timeStopped}?{/if}
+
+ + diff --git a/src/lib/components/MobileBar.svelte b/src/lib/components/MobileBar.svelte new file mode 100644 index 0000000..d65e00f --- /dev/null +++ b/src/lib/components/MobileBar.svelte @@ -0,0 +1,91 @@ + + +{#if !duel.state.over} +
+
+ {duel.you.name} {duel.you.hp}/{MAX_HP} + {duel.foe.name} {duel.foe.hp}/{MAX_HP} +
+
+ {duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')} + {duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')} +
+ +
+{/if} + + diff --git a/src/lib/components/SpellSheet.svelte b/src/lib/components/SpellSheet.svelte new file mode 100644 index 0000000..077ce7c --- /dev/null +++ b/src/lib/components/SpellSheet.svelte @@ -0,0 +1,189 @@ + + +
+ Spell sheet +

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. Full rules.

+ {#each groups as g (g.category)} +

{g.title}

+
    + {#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)} +
  • + + {#if open === spell.id} +

    {spell.summary}

    + {#if !duel.state.over} +
    + {#each HANDS as hand (hand)} + {@const plan = duel.planFor(hand, spell)} + + {/each} +
    + {/if} + {/if} +
  • + {/each} +
+ {/each} +
+ + diff --git a/src/lib/components/TurnSummary.svelte b/src/lib/components/TurnSummary.svelte new file mode 100644 index 0000000..b4ab4c9 --- /dev/null +++ b/src/lib/components/TurnSummary.svelte @@ -0,0 +1,132 @@ + + +{#if summary} +
+

{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}

+
+ {#each [YOU, FOE] as const as who (who)} + {@const w = summary.wizards[who]} +
+
{who === YOU ? 'You' : duel.foe.name}
+
+ {#if w.effects.length === 0} + untouched + {:else} + {#each w.effects as e, i (i)} + 0}>{e.label} {signed(e.delta)} + {/each} + {/if} + health {w.before}{#if w.before !== w.after} → {w.after}{/if} +
+
+ {/each} +
+ {#if summary.events?.length} +
    + {#each summary.events as e, i (i)} +
  • {e}
  • + {/each} +
+ {/if} + {#if duel.showLessons && summary.lessons?.length} +
    + {#each summary.lessons as l, i (i)} +
  • {l}
  • + {/each} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/WizardStatus.svelte b/src/lib/components/WizardStatus.svelte new file mode 100644 index 0000000..f8af908 --- /dev/null +++ b/src/lib/components/WizardStatus.svelte @@ -0,0 +1,141 @@ + + +
+

{wizard.name}{#if you} (you){/if}

+
+ + {wizard.hp} / {MAX_HP} +
+ {#if effects.length} +

{effects.join('; ')}.

+ {:else} +

Unenchanted.

+ {/if} + {#if monsters.length} +
    + {#each monsters as m (m.id)} +
  • + {m.name} + {m.hp}/{m.maxHp}, hits for {m.attack} + {#if m.constraints.skipAttack}paralysed{/if} +
  • + {/each} +
+ {/if} +
+ + diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts new file mode 100644 index 0000000..d2140b2 --- /dev/null +++ b/src/lib/game/bot.ts @@ -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> = { + 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; + /** 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 = { + 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, 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 = { + 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 = {}; + 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; +} diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts new file mode 100644 index 0000000..ef2b612 --- /dev/null +++ b/src/lib/game/duel.svelte.ts @@ -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> { + 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; + sets: Record>; + stabTarget: TargetId; + monsterOrders: Record; + 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(createGame(pickNames())); + sets = $state>>(blankSets()); + stabTarget = $state(FOE); + monsterOrders = $state>({}); + /** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */ + compact = $state(false); + charmGesture = $state('P'); + /** Where to loose the banked spell this turn; empty keeps it. */ + release = $state(''); + /** Which cast to bank or make permanent when more than one qualifies. */ + bankPick = $state(''); + permanentPick = $state(''); + /** Spells you have chosen to pursue with each hand. */ + pins = $state>({ 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(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(this.hasted ? ['main', 'haste'] : ['main']); + + allowed = $derived>({ + left: this.timeStopped ? GESTURES : allowedGestures(this.you, 'left'), + right: this.timeStopped ? GESTURES : allowedGestures(this.you, 'right') + }); + forced = $derived>({ + left: this.timeStopped ? undefined : forcedGesture(this.you, 'left'), + right: this.timeStopped ? undefined : forcedGesture(this.you, 'right') + }); + forcedSecond = $derived>({ + 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(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>({ + main: [...this.you.history, this.entry('main')], + haste: [...this.you.history, this.entry('main'), this.entry('haste')] + }); + private seqs = $derived>>({ + 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>>({ + 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>>({ + 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>; + }); + 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>({ + 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(); + 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 = {}; + 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 = {}; + 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(); diff --git a/src/lib/game/engine.test.ts b/src/lib/game/engine.test.ts new file mode 100644 index 0000000..54ee0b6 --- /dev/null +++ b/src/lib/game/engine.test.ts @@ -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, b: Partial = {}): 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 +): 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); + }); +}); diff --git a/src/lib/game/gestures.ts b/src/lib/game/gestures.ts new file mode 100644 index 0000000..fabedf6 --- /dev/null +++ b/src/lib/game/gestures.ts @@ -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 { + const tokens = completion.spell.tokens[completion.seqIndex]; + const cells = new Set(); + 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]; +} diff --git a/src/lib/game/glyphs.ts b/src/lib/game/glyphs.ts new file mode 100644 index 0000000..873eead --- /dev/null +++ b/src/lib/game/glyphs.ts @@ -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 = { + F: 'fingers', + P: 'palm', + S: 'snap', + W: 'wave', + D: 'digit', + C: 'clap', + '>': 'stab', + '-': 'nothing' +}; diff --git a/src/lib/game/resolve.ts b/src/lib/game/resolve.ts new file mode 100644 index 0000000..db80b87 --- /dev/null +++ b/src/lib/game/resolve.ts @@ -0,0 +1,1013 @@ +// Simultaneous turn resolution, following the precedence rules in +// docs/waving-hands-rules.txt: dispel magic first, then counter-spells and +// mirrors, banking and permanency, storm and elemental interplay, summons, +// enchantments, damage, monster attacks and stabs, and finally deaths and +// surrenders. A time stop resolves as a turn in which one wizard acts alone. + +import { completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures'; +import { + BLANKET_SPELLS, + CONTROL_SPELLS, + GESTURE_NAMES, + MAGIC_GESTURES, + MONSTER_STATS, + PERMANENCY_EXCLUDED, + SPELLS, + SPELL_BY_ID, + SUMMON_KIND, + isElemental, + type Gesture, + type Hand, + type MonsterKind, + type Spell +} from './spells'; +import { + MAX_HP, + NOWHERE, + beingName, + controllerOf, + enemyOf, + findMonster, + isWizardId, + nextRandom, + type CastChoice, + type Constraints, + type GameState, + type HistoryEntry, + type LogKind, + type Monster, + type Phase, + type TargetId, + type TurnInput, + type TurnSummary, + type WizardId, + type WizardState +} from './state'; + +interface ActiveCast { + caster: WizardId; + hand: Hand; + spell: Spell; + target: TargetId; + choice: CastChoice; + completion?: Completion; + /** History index of the completing gesture; -1 for a released banked spell. */ + index: number; + released?: boolean; + permanent?: boolean; + /** Why the spell did nothing, once decided. */ + nullified?: string; +} + +const IDS: WizardId[] = ['A', 'B']; + +export function paralysedForm(g: Gesture): Gesture { + if (g === 'C') return 'F'; + if (g === 'S') return 'D'; + if (g === 'W') return 'P'; + return g; +} + +export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S']; + +function describe(g: Gesture): string { + return GESTURE_NAMES[g]; +} + +function roman(n: number): string { + const numerals: [number, string][] = [ + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'] + ]; + let out = ''; + for (const [v, s] of numerals) { + while (n >= v) { + out += s; + n -= v; + } + } + return out; +} + +function clearEnchantments(w: WizardState): void { + w.resistHeat = false; + w.resistCold = false; + w.protectionTurns = 0; + w.blindTurns = 0; + w.invisibleTurns = 0; + w.diseaseTurns = null; + w.poisonTurns = null; + w.hasteTurns = 0; + w.delayedTurns = 0; + w.permanencyTurns = 0; + w.pending = {}; + w.permanent = {}; +} + +function clearMonsterEnchantments(m: Monster): void { + m.resistHeat = false; + m.resistCold = false; + m.protectionTurns = 0; + m.pending = {}; +} + +function permanencyEligible(spell: Spell): boolean { + return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id); +} + +/** + * Last turn's gestures as [first pair, second pair]: a wizard who was hastened + * repeats both pairs under amnesia, and each hand stays paralysed in its + * most recent position. + */ +export function previousPairs(w: WizardState): [HistoryEntry | undefined, HistoryEntry | undefined] { + const last = w.history[w.history.length - 1]; + if (!last) return [undefined, undefined]; + if (last.phase === 'haste') return [w.history[w.history.length - 2], last]; + return [last, last]; +} + +/** The longest run toward any spell the hand has going. */ +function longestRun(seq: HandTurn[], start: number): number { + let best = 0; + for (const spell of SPELLS) for (const tokens of spell.tokens) best = Math.max(best, progressToward(tokens, seq, start)); + return best; +} + +function tick(n: number): number { + return n === Infinity ? n : Math.max(0, n - 1); +} + +export function resolveTurn(previous: GameState, inputs: Record): GameState { + const state = structuredClone(previous); + if (state.over) return state; + const W = state.wizards; + const actor = state.timeStops.shift() ?? null; + const phase: Phase = actor ? 'timestop' : 'main'; + const acting: WizardId[] = actor ? [actor] : IDS; + /** Time-stop entries and log lines belong to the turn just played. */ + const turn = actor ? state.turn - 1 : state.turn; + const events: string[] = []; + const log = (text: string, kind: LogKind = 'info', surface = kind !== 'gesture' && kind !== 'damage') => { + state.log.push({ turn, kind, text }); + if (surface) events.push(text); + }; + const rng = () => nextRandom(state); + const pick = (items: T[]): T => items[Math.floor(rng() * items.length)]; + + const frozen = (id: WizardId) => !acting.includes(id); + const frozenBeing = (id: TargetId): boolean => { + const c = controllerOf(state, id); + return c !== undefined && frozen(c); + }; + const isBeing = (id: TargetId | undefined): id is TargetId => + id !== undefined && id !== NOWHERE && (isWizardId(id) ? W[id].alive : !!findMonster(state, id)); + const monsterOf = (id: TargetId) => findMonster(state, id); + /** Under time stop, those who cannot move have no resistance to anything. */ + const resists = (id: TargetId, type: 'heat' | 'cold'): boolean => { + if (frozenBeing(id)) return false; + const b = isWizardId(id) ? W[id] : monsterOf(id); + if (!b) return false; + return type === 'heat' ? b.resistHeat : b.resistCold; + }; + + const summary: TurnSummary = { + turn, + phase, + wizards: { + A: { before: W.A.hp, after: W.A.hp, effects: [] }, + B: { before: W.B.hp, after: W.B.hp, effects: [] } + }, + events, + lessons: [] + }; + const tidy = (why: string) => { + const stripped = why.replace(/^(a|an|the) /, ''); + return stripped.charAt(0).toUpperCase() + stripped.slice(1); + }; + const note = (id: TargetId, label: string, delta: number) => { + if (isWizardId(id)) summary.wizards[id].effects.push({ label, delta }); + }; + if (actor) log(`Time stands still. ${W[actor].name} alone can move.`, 'spell'); + /** Facts gathered along the way, turned into explanations at the end. */ + const facts = { + shieldBlocked: new Set(), + throughShield: [] as { target: TargetId; spell: string }[], + counterSmothered: 0, + fingerThroughCounter: false, + loneClap: [] as string[], + overlaps: [] as { name: string; spell: string; earlier: string }[], + broken: [] as { name: string; hand: Hand }[], + curedAndHurt: [] as { name: string; hurt: number; healed: number }[], + newMonsterAttacked: '' as string + }; + + // ---- 1. Gestures, after last turn's enchantments have their say ---- + const shape = (w: WizardState, c: Constraints, left: Gesture, right: Gesture, prev: HistoryEntry | undefined): [Gesture, Gesture] => { + if (c.amnesia && prev) { + log(`${w.name}, struck by amnesia, repeats the last gestures.`); + return [prev.left, prev.right]; + } + if (c.charmed) { + const g = c.charmedGesture ?? inputs[c.charmed.by].charmGesture ?? '-'; + if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g; + if (c.charmed.hand === 'left') left = g; + else right = g; + log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${describe(g)}.`); + } + if (c.paralysedHand && prev) { + const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]); + if (c.paralysedHand === 'left') left = g; + else right = g; + log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${describe(g)}.`); + } + if (c.fear) { + if (FEAR_BANNED.includes(left)) left = '-'; + if (FEAR_BANNED.includes(right)) right = '-'; + log(`${w.name} is too afraid to snap, point, clap or wriggle.`); + } + if (c.confusion) { + const slip = c.confusionFixed ?? { hand: rng() < 0.5 ? 'left' : 'right', gesture: pick(MAGIC_GESTURES) }; + if (w.permanent.confusion && !w.permanent.confusionFixed) w.permanent.confusionFixed = slip; + if (slip.hand === 'left') left = slip.gesture; + else right = slip.gesture; + log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${describe(slip.gesture)} instead.`); + } + if (left === '>' && right === '>') right = '-'; + return [left, right]; + }; + const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : describe(g)); + const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => { + const hiddenFrom: WizardId[] = []; + const foe = enemyOf(w.id); + if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe); + if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name); + for (const hand of ['left', 'right'] as Hand[]) { + const g = hand === 'left' ? left : right; + if (g !== '>' && g !== '-') continue; + const seq = handSequence(w.history, hand); + if (longestRun(seq, w.sequenceStart) >= 2) facts.broken.push({ name: w.name, hand }); + } + w.history.push({ left, right, turn, phase: entryPhase, hiddenFrom }); + if (left === 'P' && right === 'P') w.surrendered = true; + const prefix = entryPhase === 'haste' ? `${w.name}, hastened, adds` : `${w.name}:`; + log(`${prefix} left ${lone(left, right)}, right ${lone(right, left)}.`, 'gesture'); + }; + /** History indexes of the entries each acting wizard made this turn. */ + const entries: Record = { A: [], B: [] }; + for (const id of acting) { + const w = W[id]; + const inp = inputs[id]; + w.surrendered = false; + const c = phase === 'main' ? w.constraints : {}; + const [prevMain, prevHaste] = previousPairs(w); + const [l, r] = shape(w, c, inp.left, inp.right, prevMain); + record(w, l, r, phase); + entries[id].push(w.history.length - 1); + if (phase === 'main' && w.hasteTurns > 0) { + const second = inp.second ?? { left: '-', right: '-', casts: [] }; + const [l2, r2] = shape(w, c, second.left, second.right, prevHaste); + record(w, l2, r2, 'haste'); + entries[id].push(w.history.length - 1); + } + if (w.surrendered) log(`${w.name} offers both palms in surrender.`, 'death'); + } + + // ---- 2. Which completed spells are actually loosed ---- + const casts: ActiveCast[] = []; + const shielded = new Set(); + for (const id of acting) { + const w = W[id]; + const accepted: ActiveCast[] = []; + entries[id].forEach((index, half) => { + const upto = w.history.slice(0, index + 1); + const completions: Record = { + left: completedSpells(handSequence(upto, 'left'), w.sequenceStart), + right: completedSpells(handSequence(upto, 'right'), w.sequenceStart) + }; + const choices = half === 0 ? inputs[id].casts : (inputs[id].second?.casts ?? []); + for (const choice of choices) { + if (accepted.some((a) => a.index === index && a.hand === choice.hand)) continue; + const options = completions[choice.hand].filter((c) => c.spell.id === choice.spellId); + const completion = + options.find((c) => choice.seqIndex === undefined || c.seqIndex === choice.seqIndex) ?? options[0]; + if (!completion) continue; + if (completion.spell.id === 'lightning_bolt_quick' && w.usedQuickLightning) { + log(`${w.name}'s quick lightning bolt sputters: it has already been used this duel.`, 'fizzle'); + continue; + } + const cells = usedCells(completion, choice.hand, index + 1); + const clash = accepted.some((a) => { + if (!a.completion) return false; + for (const cell of usedCells(a.completion, a.hand, a.index + 1)) if (cells.has(cell)) return true; + return false; + }); + if (clash) { + log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle'); + continue; + } + let target = choice.target ?? (completion.spell.usualTarget === 'self' ? id : enemyOf(id)); + if (completion.spell.id === 'summon_elemental' && target === NOWHERE) target = id; + const earlier = w.casts.find( + (c) => c.hand === choice.hand && c.index >= 0 && c.index < index && c.index >= index - completion.length + 1 + ); + if (earlier) facts.overlaps.push({ name: w.name, spell: completion.spell.name, earlier: SPELL_BY_ID[earlier.spellId].name }); + accepted.push({ caster: id, hand: choice.hand, spell: completion.spell, target, choice, completion, index }); + w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, length: completion.length, target }); + const at = BLANKET_SPELLS.includes(completion.spell.id) || target === id ? '' : ` at ${beingName(state, target)}`; + log(`${w.name} casts ${completion.spell.name}${at} (${completion.spell.sequences[completion.seqIndex]}, ${choice.hand} hand).`, 'spell', false); + if (completion.spell.id === 'lightning_bolt_quick') { + w.usedQuickLightning = true; + log(`${w.name}'s quick lightning bolt is spent for this duel.`); + } + } + }); + const release = inputs[id].release; + if (release && w.banked) { + const spell = SPELL_BY_ID[w.banked.spellId]; + const target = release.target ?? (spell.usualTarget === 'self' ? id : enemyOf(id)); + accepted.push({ + caster: id, + hand: 'left', + spell, + target, + choice: { hand: 'left', spellId: spell.id, target, elemental: w.banked.elemental, chosenHand: w.banked.chosenHand, monsterTarget: release.monsterTarget }, + index: -1, + released: true + }); + w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, length: 0, target }); + w.banked = null; + const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`; + log(`${w.name} releases the banked ${spell.name}${at}.`, 'spell'); + } + casts.push(...accepted); + } + + for (const c of casts) { + if (!BLANKET_SPELLS.includes(c.spell.id) && c.target === NOWHERE) { + c.nullified = 'released harmlessly'; + log(`${W[c.caster].name} lets the ${c.spell.name} dissipate harmlessly.`, 'fizzle'); + } else if (!BLANKET_SPELLS.includes(c.spell.id) && !isBeing(c.target)) { + c.nullified = 'target gone'; + log(`${W[c.caster].name}'s ${c.spell.name} finds no target.`, 'fizzle'); + } + } + const live = () => casts.filter((c) => !c.nullified); + + const destroyedBeforeAttack = new Set(); + const destroyedAfterAttack = new Set(); + const orders = new Map(); + for (const m of state.monsters) { + orders.set(m.id, inputs[m.owner]?.monsterOrders?.[m.id] ?? m.constraints.forcedTarget ?? enemyOf(m.owner)); + } + + // ---- 3. Dispel magic overrides everything ---- + const countered = new Set(); + const dispels = live().filter((c) => c.spell.id === 'dispel_magic'); + if (dispels.length > 0) { + log('Dispel magic sweeps the field: every other spell fails, all enchantments end, and every monster will fall after this turn.', 'spell'); + for (const c of casts) if (!c.nullified && c.spell.id !== 'dispel_magic') c.nullified = 'dispelled'; + for (const d of dispels) shielded.add(d.target); + for (const id of IDS) clearEnchantments(W[id]); + for (const m of state.monsters) { + clearMonsterEnchantments(m); + destroyedAfterAttack.add(m.id); + } + } else { + // ---- 4. Counter-spells and magic mirrors ---- + for (const c of live()) if (c.spell.id === 'counter_spell') countered.add(c.target); + for (const c of live()) { + if (c.spell.id === 'magic_mirror' && countered.has(c.target)) { + c.nullified = 'countered'; + log(`The magic mirror on ${beingName(state, c.target)} is shattered by a counter-spell.`, 'fizzle'); + } + } + const mirrored = new Set(live().filter((c) => c.spell.id === 'magic_mirror').map((c) => c.target)); + for (const c of live()) { + if (c.spell.id === 'magic_mirror' || c.spell.id === 'counter_spell') continue; + if (BLANKET_SPELLS.includes(c.spell.id)) continue; + if (!mirrored.has(c.target)) continue; + const owner = controllerOf(state, c.target); + if (owner === c.caster || owner === undefined) continue; + const original = c.caster; + c.target = original; + c.caster = owner; + log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell'); + } + for (const c of live()) { + if (['dispel_magic', 'finger_of_death', 'counter_spell', 'magic_mirror'].includes(c.spell.id)) continue; + if (BLANKET_SPELLS.includes(c.spell.id)) continue; + if (countered.has(c.target)) { + c.nullified = 'countered'; + facts.counterSmothered += 1; + log(`A counter-spell on ${beingName(state, c.target)} smothers the ${c.spell.name}.`, 'fizzle'); + } + } + if (live().some((c) => c.spell.id === 'finger_of_death' && countered.has(c.target))) facts.fingerThroughCounter = true; + } + + // ---- 4b. Delayed effect banks a spell; permanency extends one ---- + const bankedNow = new Set(); + const extendedNow = new Set(); + for (const id of acting) { + const w = W[id]; + const own = () => live().filter((c) => c.caster === id && !c.released); + const delayed = w.delayedTurns > 0 || live().some((c) => c.spell.id === 'delayed_effect' && c.target === id); + if (delayed && !w.banked) { + const candidates = own().filter((c) => c.spell.id !== 'delayed_effect'); + const chosen = candidates.find((c) => c.choice.bank) ?? candidates[0]; + if (chosen) { + chosen.nullified = 'banked'; + w.banked = { spellId: chosen.spell.id, elemental: chosen.choice.elemental, chosenHand: chosen.choice.chosenHand }; + bankedNow.add(id); + log(`${w.name} banks the ${chosen.spell.name} to release later.`, 'spell'); + } + } + const extending = w.permanencyTurns > 0 || live().some((c) => c.spell.id === 'permanency' && c.target === id); + if (extending) { + const candidates = own().filter((c) => permanencyEligible(c.spell)); + const chosen = candidates.find((c) => c.choice.permanent) ?? candidates[0]; + if (chosen) { + chosen.permanent = true; + extendedNow.add(id); + log(`${w.name}'s ${chosen.spell.name} is made permanent.`, 'spell'); + } + } + } + + // ---- 5. Who is shielded this turn ---- + for (const c of live()) { + if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target); + } + for (const id of IDS) if (W[id].protectionTurns > 0) shielded.add(id); + for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id); + const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id); + + // ---- 6. Summons, then storms and elementals ---- + for (const c of live()) { + const kind: MonsterKind | undefined = + c.spell.id === 'summon_elemental' + ? c.choice.elemental === 'ice' + ? 'ice_elemental' + : 'fire_elemental' + : SUMMON_KIND[c.spell.id]; + if (!kind) continue; + const targetMonster = monsterOf(c.target); + if (targetMonster && isElemental(targetMonster.kind)) { + c.nullified = 'cast at an elemental'; + log(`Nothing can be summoned for an elemental; ${W[c.caster].name}'s ${c.spell.name} fails.`, 'fizzle'); + continue; + } + const controller = controllerOf(state, c.target); + if (!controller) continue; + const stats = MONSTER_STATS[kind]; + const n = (state.summonCounts[kind] ?? 0) + 1; + state.summonCounts[kind] = n; + const m: Monster = { + id: `m${state.nextMonsterId++}`, + kind, + name: n === 1 ? stats.name : `${stats.name} ${roman(n)}`, + hp: stats.hp, + maxHp: stats.hp, + attack: stats.attack, + owner: controller, + resistHeat: kind === 'fire_elemental', + resistCold: kind === 'ice_elemental', + protectionTurns: 0, + constraints: {}, + pending: {}, + createdTurn: turn + }; + state.monsters.push(m); + orders.set(m.id, c.choice.monsterTarget ?? enemyOf(controller)); + log(`A ${stats.name.toLowerCase()} answers ${W[controller].name}'s summons.`, 'spell'); + } + + let fireStorm = live().some((c) => c.spell.id === 'fire_storm'); + let iceStorm = live().some((c) => c.spell.id === 'ice_storm'); + const elementals = (kind: MonsterKind) => state.monsters.filter((m) => m.kind === kind && !destroyedBeforeAttack.has(m.id)); + const destroyBefore = (m: Monster, why: string) => { + destroyedBeforeAttack.add(m.id); + log(`${m.name} is ${why} before it can attack.`, 'death'); + }; + if (fireStorm && iceStorm) { + fireStorm = iceStorm = false; + log('The fire storm and the ice storm cancel each other; nothing is harmed.', 'fizzle'); + } + if (fireStorm && elementals('ice_elemental').length > 0) { + fireStorm = false; + for (const m of elementals('ice_elemental')) destroyBefore(m, 'melted by the fire storm, which it quenches'); + } + if (iceStorm && elementals('fire_elemental').length > 0) { + iceStorm = false; + for (const m of elementals('fire_elemental')) destroyBefore(m, 'snuffed by the ice storm, which it dissipates'); + } + if (fireStorm) for (const m of elementals('fire_elemental')) destroyBefore(m, 'engulfed by the fire storm'); + if (iceStorm) for (const m of elementals('ice_elemental')) destroyBefore(m, 'engulfed by the ice storm'); + const exemptFromIceStorm = new Set(); + for (const c of live()) { + const tm = monsterOf(c.target); + if (c.spell.id === 'fireball') { + if (iceStorm) { + c.nullified = 'quenched by the ice storm'; + exemptFromIceStorm.add(c.target); + log(`The fireball and the ice storm cancel around ${beingName(state, c.target)}, who is unharmed by either.`, 'fizzle'); + } else if (tm?.kind === 'ice_elemental') { + c.nullified = 'spent on the elemental'; + destroyBefore(tm, 'melted by the fireball'); + } else if (tm?.kind === 'fire_elemental') { + c.nullified = 'no effect on a fire elemental'; + log('The fireball washes harmlessly over the fire elemental.', 'fizzle'); + } + } + if (c.spell.id === 'resist_heat' && tm?.kind === 'fire_elemental') { + c.nullified = 'spent on the elemental'; + destroyBefore(tm, 'unmade by resist heat'); + } + if (c.spell.id === 'resist_cold' && tm?.kind === 'ice_elemental') { + c.nullified = 'spent on the elemental'; + destroyBefore(tm, 'unmade by resist cold'); + } + } + { + const fire = elementals('fire_elemental'); + const ice = elementals('ice_elemental'); + if (fire.length > 0 && ice.length > 0) { + for (const m of [...fire, ...ice]) destroyBefore(m, 'destroyed by its opposite'); + } + for (const kind of ['fire_elemental', 'ice_elemental'] as MonsterKind[]) { + const same = elementals(kind); + for (const extra of same.slice(1)) { + destroyedBeforeAttack.add(extra.id); + same[0].hp = same[0].maxHp; + log(`Two ${MONSTER_STATS[kind].name.toLowerCase()}s merge into one.`, 'info'); + } + } + } + + // ---- 7. Enchantments ---- + const controlByTarget = new Map>(); + for (const c of live()) { + if (!CONTROL_SPELLS.includes(c.spell.id)) continue; + if (!controlByTarget.has(c.target)) controlByTarget.set(c.target, new Set()); + controlByTarget.get(c.target)!.add(c.spell.id); + } + for (const [target, ids] of controlByTarget) { + if (ids.size < 2) continue; + for (const c of live()) if (c.target === target && ids.has(c.spell.id)) c.nullified = 'contradictory enchantments'; + log(`Contradictory enchantments on ${beingName(state, target)} cancel each other out.`, 'fizzle'); + } + for (const c of live()) { + const wiz = isWizardId(c.target) ? W[c.target] : undefined; + const mon = monsterOf(c.target); + const target = beingName(state, c.target); + const forever = c.permanent ? ' for the rest of the duel' : ''; + const duration = (n: number) => (c.permanent ? Infinity : n); + switch (c.spell.id) { + case 'amnesia': + if (wiz) { + wiz.pending.amnesia = true; + if (c.permanent) wiz.permanent.amnesia = true; + } else if (mon) mon.pending.forcedTarget = orders.get(mon.id); + break; + case 'confusion': + if (wiz) { + wiz.pending.confusion = true; + if (c.permanent) wiz.permanent.confusion = true; + } else if (mon) { + const candidates = [...IDS.filter((id) => W[id].alive), ...state.monsters.filter((o) => o.id !== mon.id).map((o) => o.id)]; + const t = pick(candidates as TargetId[]); + orders.set(mon.id, t); + log(`${mon.name} is confused and lunges at ${beingName(state, t)}.`); + } + break; + case 'charm_person': + if (wiz) { + const hand = c.choice.chosenHand ?? pick(['left', 'right'] as Hand[]); + wiz.pending.charmed = { hand, by: c.caster }; + if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster }; + log(`${wiz.name}'s ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`); + } else { + c.nullified = 'monsters cannot be charmed as people'; + log(`Charm person has no hold on ${target}.`, 'fizzle'); + } + break; + case 'charm_monster': + if (mon && !isElemental(mon.kind)) { + mon.owner = c.caster; + orders.set(mon.id, inputs[c.caster].monsterOrders?.[mon.id] ?? enemyOf(c.caster)); + log(`${mon.name} now serves ${W[c.caster].name}.`); + } else { + c.nullified = 'not a charmable monster'; + log(`Charm monster has no hold on ${target}.`, 'fizzle'); + } + break; + case 'paralysis': + if (wiz) { + const hand = wiz.constraints.paralysedHand ?? c.choice.chosenHand ?? 'left'; + wiz.pending.paralysedHand = hand; + if (c.permanent) wiz.permanent.paralysedHand = hand; + log(`${wiz.name}'s ${hand} hand will be paralysed${c.permanent ? ' from now on' : ' next turn'}.`); + } else if (mon && !isElemental(mon.kind)) { + mon.pending.skipAttack = true; + log(`${mon.name} will stand paralysed next turn.`); + } else { + c.nullified = 'elementals cannot be paralysed'; + } + break; + case 'fear': + if (wiz) { + wiz.pending.fear = true; + if (c.permanent) wiz.permanent.fear = true; + log(`${wiz.name} will be too afraid to snap, point, clap or wriggle${c.permanent ? ' from now on' : ' next turn'}.`); + } else c.nullified = 'monsters know no fear'; + break; + case 'anti_spell': + if (wiz) { + wiz.sequenceStart = wiz.history.length; + log(`${wiz.name}'s gestures so far are wiped from the weave; every spell must start afresh.`); + } + break; + case 'protection_from_evil': + if (wiz) wiz.protectionTurns = duration(4); + else if (mon) mon.protectionTurns = duration(4); + log(`${target} is protected from evil${c.permanent ? forever : ' for this turn and the next three'}.`); + break; + case 'resist_heat': + if (wiz) wiz.resistHeat = true; + else if (mon) mon.resistHeat = true; + log(`${target} now resists heat.`); + break; + case 'resist_cold': + if (wiz) wiz.resistCold = true; + else if (mon) mon.resistCold = true; + log(`${target} now resists cold.`); + break; + case 'disease': + if (wiz) { + wiz.diseaseTurns = 6; + log(`${wiz.name} sickens with a deadly disease: six turns to find a cure.`); + } else c.nullified = 'monsters do not sicken'; + break; + case 'poison': + if (wiz) { + wiz.poisonTurns = 6; + log(`${wiz.name} is poisoned: six turns to find a cure.`); + } else c.nullified = 'monsters do not sicken'; + break; + case 'blindness': + if (wiz) { + wiz.blindTurns = duration(4); + log(`${wiz.name} is struck blind${c.permanent ? forever : ' for three turns'}.`); + } else if (mon) destroyBefore(mon, 'blinded and unmade'); + break; + case 'invisibility': + if (wiz) { + wiz.invisibleTurns = duration(4); + log(`${wiz.name} fades from sight${c.permanent ? forever : ' for three turns'}.`); + } else if (mon) destroyBefore(mon, 'made invisible and unmade'); + break; + case 'haste': + if (wiz) { + wiz.hasteTurns = duration(4); + log(`${wiz.name} is hastened${c.permanent ? forever : ' for the next three turns'}: two pairs of gestures a turn.`); + } else c.nullified = 'monsters cannot be hastened'; + break; + case 'time_stop': + if (wiz) { + state.timeStops.push(wiz.id); + log(`${wiz.name} will act alone in a stopped moment.`); + } else c.nullified = 'only wizards can stop time'; + break; + case 'delayed_effect': + if (wiz) { + wiz.delayedTurns = bankedNow.has(wiz.id) ? 0 : duration(4); + if (!bankedNow.has(wiz.id)) log(`${wiz.name}'s next spell will be banked.`); + } else c.nullified = 'only wizards can delay a spell'; + break; + case 'permanency': + if (wiz) { + wiz.permanencyTurns = extendedNow.has(wiz.id) ? 0 : 4; + if (!extendedNow.has(wiz.id)) log(`${wiz.name}'s next enchantment will be permanent.`); + } else c.nullified = 'only a wizard can hold a permanency'; + break; + } + } + for (const id of bankedNow) W[id].delayedTurns = 0; + for (const id of extendedNow) W[id].permanencyTurns = 0; + for (const c of live()) { + if (c.spell.id !== 'remove_enchantment' && c.spell.id !== 'raise_dead') continue; + const wiz = isWizardId(c.target) ? W[c.target] : undefined; + const mon = monsterOf(c.target); + if (wiz) { + clearEnchantments(wiz); + log(`Every enchantment on ${wiz.name} is lifted.`); + } else if (mon && c.spell.id === 'remove_enchantment') { + destroyedAfterAttack.add(mon.id); + log(`${mon.name} will be unmade once it has attacked.`); + } + } + + // ---- 8. Damage and healing ---- + const damage = new Map(); + const healing = new Map(); + const slain = new Set(); + const hurt = (id: TargetId, n: number, why: string) => { + damage.set(id, (damage.get(id) ?? 0) + n); + note(id, tidy(why), -n); + log(`${beingName(state, id)} takes ${n} from ${why}.`, 'damage'); + }; + const heal = (id: TargetId, n: number, label: string) => { + healing.set(id, (healing.get(id) ?? 0) + n); + note(id, label, n); + }; + for (const c of live()) { + const t = c.target; + switch (c.spell.id) { + case 'missile': + if (isShielded(t)) { + facts.shieldBlocked.add(t); + log(`${beingName(state, t)}'s shield turns the missile aside.`, 'fizzle'); + } else hurt(t, 1, 'a missile'); + break; + case 'finger_of_death': + slain.add(t); + note(t, 'Finger of death', -(isWizardId(t) ? W[t].hp : 0)); + log(`The finger of death touches ${beingName(state, t)}.`, 'death'); + break; + case 'lightning_bolt': + case 'lightning_bolt_quick': + if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a lightning bolt' }); + hurt(t, 5, 'a lightning bolt'); + break; + case 'cause_light_wounds': + if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause light wounds' }); + hurt(t, 2, 'cause light wounds'); + break; + case 'cause_heavy_wounds': + if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'cause heavy wounds' }); + hurt(t, 3, 'cause heavy wounds'); + break; + case 'fireball': + if (resists(t, 'heat')) log(`${beingName(state, t)} shrugs off the fireball.`, 'fizzle'); + else { + if (isShielded(t)) facts.throughShield.push({ target: t, spell: 'a fireball' }); + hurt(t, 5, 'a fireball'); + } + break; + case 'cure_light_wounds': + heal(t, 1, 'Cure light wounds'); + break; + case 'cure_heavy_wounds': + heal(t, 2, 'Cure heavy wounds'); + if (isWizardId(t) && W[t].diseaseTurns !== null) { + W[t].diseaseTurns = null; + log(`${W[t].name}'s disease is cured.`); + } + break; + case 'raise_dead': + heal(t, 5, 'Raise dead'); + break; + } + } + const everyone = (): TargetId[] => [ + ...IDS.filter((id) => W[id].alive), + ...state.monsters.filter((m) => !destroyedBeforeAttack.has(m.id)).map((m) => m.id) + ]; + if (fireStorm) { + log('A fire storm rages across the field.', 'spell'); + for (const id of everyone()) { + if (resists(id, 'heat')) continue; + if (countered.has(id)) continue; + hurt(id, 5, 'the fire storm'); + } + } + if (iceStorm) { + log('An ice storm howls across the field.', 'spell'); + for (const id of everyone()) { + if (resists(id, 'cold')) continue; + if (countered.has(id) || exemptFromIceStorm.has(id)) continue; + hurt(id, 5, 'the ice storm'); + } + } + + // ---- 9. Monsters attack, including those slain this very turn ---- + for (const m of state.monsters) { + if (destroyedBeforeAttack.has(m.id)) continue; + if (frozen(m.owner)) continue; + if (m.constraints.skipAttack && phase === 'main') { + log(`${m.name} stands paralysed.`); + continue; + } + if (isElemental(m.kind)) { + const type = m.kind === 'fire_elemental' ? 'heat' : 'cold'; + for (const id of everyone()) { + if (id === m.id) continue; + const other = monsterOf(id); + if (other && isElemental(other.kind)) continue; + if (phase === 'main') { + if (resists(id, type)) continue; + if (isShielded(id)) continue; + if (isWizardId(id) && W[id].invisibleTurns > 0) continue; + } + hurt(id, m.attack, `the ${m.name.toLowerCase()}`); + } + continue; + } + const target = orders.get(m.id); + m.lastTarget = target; + if (!isBeing(target)) { + log(`${m.name} has nothing to attack.`); + continue; + } + if (isShielded(target)) { + facts.shieldBlocked.add(target); + log(`${m.name}'s blows glance off ${beingName(state, target)}'s shield.`, 'fizzle'); + } else if (isWizardId(target) && W[target].invisibleTurns > 0 && !frozen(target)) { + log(`${m.name} flails at the air where ${W[target].name} used to be.`, 'fizzle'); + } else { + if (m.createdTurn === turn && phase === 'main') facts.newMonsterAttacked = m.name; + hurt(target, m.attack, `the ${m.name.toLowerCase()}`); + } + } + + // ---- 10. Stabs ---- + for (const id of acting) { + const w = W[id]; + entries[id].forEach((index, half) => { + const g = w.history[index]; + if (g.left !== '>' && g.right !== '>') return; + const target = (half === 0 ? inputs[id].stabTarget : inputs[id].second?.stabTarget) ?? enemyOf(id); + if (target === id || !isBeing(target)) { + log(`${w.name} stabs at nothing.`, 'fizzle'); + } else if (isShielded(target)) { + facts.shieldBlocked.add(target); + log(`${w.name}'s knife skids off ${beingName(state, target)}'s shield.`, 'fizzle'); + } else { + hurt(target, 1, `${w.name}'s knife`); + } + }); + } + + // ---- 11. Apply the ledger ---- + for (const id of IDS) { + const w = W[id]; + if (!w.alive) continue; + const net = (healing.get(id) ?? 0) - (damage.get(id) ?? 0); + if ((healing.get(id) ?? 0) > 0 && (damage.get(id) ?? 0) > 0) { + facts.curedAndHurt.push({ name: w.name, hurt: damage.get(id) ?? 0, healed: healing.get(id) ?? 0 }); + } + w.hp = Math.min(MAX_HP, w.hp + net); + if (slain.has(id)) w.hp = 0; + if (w.hp <= 0) { + w.hp = 0; + w.alive = false; + log(`${w.name} falls dead.`, 'death'); + } + } + state.monsters = state.monsters.filter((m) => { + m.hp = Math.min(m.maxHp, m.hp + (healing.get(m.id) ?? 0) - (damage.get(m.id) ?? 0)); + if (slain.has(m.id)) m.hp = 0; + if (destroyedBeforeAttack.has(m.id)) return false; + if (destroyedAfterAttack.has(m.id)) { + log(`${m.name} is unmade.`, 'death'); + return false; + } + if (m.hp <= 0) { + log(`${m.name} is destroyed.`, 'death'); + return false; + } + return true; + }); + + // ---- 12. The turn ends ---- + if (phase === 'main') { + for (const id of IDS) { + const w = W[id]; + w.constraints = { ...w.pending, ...w.permanent }; + w.pending = {}; + w.protectionTurns = tick(w.protectionTurns); + w.blindTurns = tick(w.blindTurns); + w.invisibleTurns = tick(w.invisibleTurns); + w.hasteTurns = tick(w.hasteTurns); + w.delayedTurns = tick(w.delayedTurns); + w.permanencyTurns = tick(w.permanencyTurns); + if (w.alive && w.diseaseTurns !== null) { + w.diseaseTurns -= 1; + if (w.diseaseTurns <= 0) { + note(id, 'Disease', -w.hp); + w.alive = false; + w.hp = 0; + log(`${w.name} succumbs to the disease.`, 'death'); + } + } + if (w.alive && w.poisonTurns !== null) { + w.poisonTurns -= 1; + if (w.poisonTurns <= 0) { + note(id, 'Poison', -w.hp); + w.alive = false; + w.hp = 0; + log(`${w.name} succumbs to the poison.`, 'death'); + } + } + } + for (const m of state.monsters) { + m.constraints = m.pending; + m.pending = {}; + m.protectionTurns = tick(m.protectionTurns); + } + } else { + // A stopped moment: nothing runs down, but what was set in it carries into the next turn. + for (const id of IDS) { + const w = W[id]; + w.constraints = { ...w.constraints, ...w.pending, ...w.permanent }; + w.pending = {}; + } + for (const m of state.monsters) { + m.constraints = { ...m.constraints, ...m.pending }; + m.pending = {}; + } + } + + const a = W.A; + const b = W.B; + if (!a.alive && !b.alive) state.over = { winner: null, reason: 'Both wizards fall together. A posthumous draw.' }; + else if (!a.alive) state.over = { winner: 'B', reason: `${a.name} is dead. ${b.name} wins the duel.` }; + else if (!b.alive) state.over = { winner: 'A', reason: `${b.name} is dead. ${a.name} wins the duel.` }; + else if (a.surrendered && b.surrendered) state.over = { winner: null, reason: 'Both wizards surrender at once. A draw.' }; + else if (a.surrendered) state.over = { winner: 'B', reason: `${a.name} surrenders. ${b.name} wins the duel.` }; + else if (b.surrendered) state.over = { winner: 'A', reason: `${b.name} surrenders. ${a.name} wins the duel.` }; + if (state.over) { + log(state.over.reason, 'death'); + state.timeStops = []; + } + + for (const id of IDS) summary.wizards[id].after = W[id].hp; + const lessons: { key: string; text: string }[] = []; + const through = facts.throughShield.find((h) => facts.shieldBlocked.has(h.target)); + if (through) { + lessons.push({ + key: 'shield-scope', + text: `A shield stops creatures, missiles and stabs. It does not stop ${through.spell}, which is why ${beingName(state, through.target)} was hit anyway.` + }); + } else if (facts.throughShield.length > 0) { + lessons.push({ + key: 'shield-scope', + text: `A shield stops only creatures, missiles and stabs, so it did nothing against ${facts.throughShield[0].spell}. Counter-spell is the defence against other spells.` + }); + } + if (facts.fingerThroughCounter) { + lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' }); + } else if (facts.counterSmothered > 0) { + lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn and shields them as well. It cannot stop finger of death or dispel magic.' }); + } + if (facts.loneClap.length > 0) { + lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` }); + } + if (facts.overlaps.length > 0) { + const o = facts.overlaps[0]; + lessons.push({ key: 'overlap', text: `A gesture can serve more than one spell as long as each run is unbroken: ${o.name}'s ${o.spell} reused gestures from the earlier ${o.earlier}.` }); + } + if (facts.broken.length > 0) { + const b = facts.broken[0]; + lessons.push({ key: 'broken', text: `A stab or a rest belongs to no spell, so ${b.name}'s ${b.hand} hand has thrown away the run it had going and starts again.` }); + } + if (facts.curedAndHurt.length > 0) { + const c = facts.curedAndHurt[0]; + lessons.push({ key: 'cure-timing', text: `Cures land in the same turn as the damage: ${c.name} lost ${c.hurt} and regained ${c.healed} at once.` }); + } + if (facts.newMonsterAttacked) { + lessons.push({ key: 'monster-same-turn', text: `A creature attacks on the very turn it is summoned: ${facts.newMonsterAttacked} struck at once. A shield that turn is the only defence.` }); + } + for (const l of lessons) { + if (state.lessonsShown.includes(l.key) || summary.lessons.length >= 2) continue; + state.lessonsShown.push(l.key); + summary.lessons.push(l.text); + } + state.lastTurn = summary; + if (phase === 'main') state.turn += 1; + return state; +} + +/** Gestures a hand may choose this turn, given last turn's enchantments. */ +export function allowedGestures(w: WizardState, hand: Hand): Gesture[] { + const all: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-']; + if (w.constraints.amnesia) return []; + if (w.constraints.paralysedHand === hand) return []; + if (w.constraints.charmed?.hand === hand) return []; + if (w.constraints.fear) return all.filter((g) => !FEAR_BANNED.includes(g)); + return all; +} + +/** The gesture a hand is forced to make this turn, if any is known in advance. */ +export function forcedGesture(w: WizardState, hand: Hand, second = false): Gesture | undefined { + const [prevMain, prevHaste] = previousPairs(w); + const prev = second ? prevHaste : prevMain; + if (!prev) return undefined; + if (w.constraints.amnesia) return prev[hand]; + if (w.constraints.paralysedHand === hand) return paralysedForm(prev[hand]); + return undefined; +} diff --git a/src/lib/game/simulation.test.ts b/src/lib/game/simulation.test.ts new file mode 100644 index 0000000..3ef3faa --- /dev/null +++ b/src/lib/game/simulation.test.ts @@ -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); +}); diff --git a/src/lib/game/spells.ts b/src/lib/game/spells.ts new file mode 100644 index 0000000..5153555 --- /dev/null +++ b/src/lib/game/spells.ts @@ -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 = { + 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 = Object.fromEntries( + SPELLS.map((s) => [s.id, s]) +) as Record; + +/** 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> = { + 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 = { + 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'; +} diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts new file mode 100644 index 0000000..9e82b0c --- /dev/null +++ b/src/lib/game/state.ts @@ -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; + /** 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; + 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>; + 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; + /** 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 + ); +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..a50d726 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,12 @@ + + + + + + +{@render children()} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 0000000..2477701 --- /dev/null +++ b/src/routes/+layout.ts @@ -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; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..01516ee --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,541 @@ + + + + Waving Hands + + + +
+
+

Waving Hands

+ {#if duel.state.turn === 0 && !duel.timeStopped} +

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

+ {:else} +

You are {duel.you.name}, duelling {duel.foe.name}.

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

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

+
+ + +
+
+{/if} + +
+
+ + + + {#if outcome} +
+

{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}

+

{outcome.reason}

+ +
+ {:else} +
+ {#if duel.timeStopped} +

Time stands still. Take your extra turn.

+

{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.

+ {:else} +

Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}

+ {/if} + + {#if duel.timeStopped} + + {:else if duel.threats.length} +
+ {#if duel.threatsNow.length} +

Their hands could finish this turn:

+
    + {#each duel.threatsNow as t (t.hand + t.spell.id)} + {@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from} +
  • + +
  • + {/each} +
+ {/if} + {#if duel.threatsNext.length} +
+ Two gestures away ({duel.threatsNext.length}) +
    + {#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} +
  • + +
  • + {/each} +
+
+ {/if} +
+ {:else} +

Nothing hostile is one gesture away.

+ {/if} + +
+ + +
+
+ + +
+ {#if duel.hasted} +

You are hastened: a second pair of gestures follows the first, and both take effect together.

+
+ + +
+ {/if} + +
+ {#if duel.stabbing} + + {/if} + {#each duel.yourMonsters as m (m.id)} + + {/each} + {#if duel.bankedSpell} + + {/if} + {#if duel.bankCandidates.length > 1} + + {/if} + {#if duel.permanentCandidates.length > 1} + + {/if} + {#if duel.youCharmedFoe} + + {/if} +
+ + {#if duel.surrendering} +

Both palms at once is surrender. You will lose the duel.

+ {/if} + {#if duel.conflict} +

Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.

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

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

+
+ + diff --git a/src/routes/rules/+page.svelte b/src/routes/rules/+page.svelte new file mode 100644 index 0000000..3e10138 --- /dev/null +++ b/src/routes/rules/+page.svelte @@ -0,0 +1,285 @@ + + + + Waving Hands: the rules + + +
+
+ Back to the duel +

How to duel

+

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.

+ +
+ +
+

A turn

+
    +
  1. 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.
  2. +
  3. 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.
  4. +
  5. Damage and healing are added up, monsters that lost all their hit points are removed, and enchantments cast this turn bind next turn.
  6. +
+
+ +
+

Gestures

+

Six magical gestures: F wriggled fingers, P proffered palm, S snap, W wave, D pointed digit and C clap. A clap needs both hands; a clap with one hand is nothing. A hand may instead stab with the knife (one point of damage, one stab a turn) or do nothing. Neither belongs to any spell, so a hand that stabs or rests starts its next spell from scratch.

+

A spell is a run of gestures made on consecutive turns by one hand. In the notation, a bracketed lower-case letter such as (w means that gesture made with both hands at once.

+

Both palms at once is surrender. It is not a spell and it ends the duel.

+
+ +
+

Overlapping spells

+

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.

+
+ +
+

Targets and timing

+

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.

+

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. Counter-spell stops every other spell cast at its subject that turn, except Finger of death and Dispel magic. Dispel magic stops every spell on the field, ends every enchantment and unmakes every monster after it attacks.

+
+ +
+

Monsters

+

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.

+ + + + {#each monsters as m (m.name)} + + {/each} + +
CreatureHit pointsDamage a turn
{m.name}{m.hp}{m.attack}
+
+ +
+

Winning

+

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.

+
+ +
+

Every spell

+ {#each groups as g (g.category)} +

{g.title}

+

{g.blurb}

+
+ {#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)} +
+
{spell.name} {spell.sequences.join(' or ')}
+
{spell.summary}
+
+ {/each} +
+ {/each} +
+ +
+

The original text

+

Bartle's rules as printed in the Duel Purpose fanzine, transcribed by Andrew Buchanan. Kept as the source for anything this page simplifies.

+ + {#each original as sec (sec.id)} +
+ {sec.title} +
{sec.body}
+
+ {/each} +
+
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -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 +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7f80fcc --- /dev/null +++ b/vite.config.ts @@ -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'] + } +});