Waving Hands: a browser duel after Bartle's 1977 game

SvelteKit 5 single-player version against a heuristic bot. The engine
resolves all forty spells simultaneously as the rules describe, the
ledger of gestures is the interface, and the duel is saved in the
browser. Tester feedback rounds added per-hand planning, threat
evidence, a result strip with one-time rule explanations, a phone
layout and a structured rules page. Deploys as a static site behind
Caddy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 15:33:29 -04:00
co-authored by Claude Fable 5.1
commit 865555e911
38 changed files with 8112 additions and 0 deletions
+94
View File
@@ -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;
}
}
+13
View File
@@ -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 {};
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<title>Waving Hands</title>
<!--
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
The bitmap borders were removed and the contours converted to SVG with lightly
rounded corners. The original poses and left/right mirroring are retained.
Original notice (Spellcast README):
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
distributed, and modified, as long as this copyright notice is retained.
The source code and any derivative works may not be sold for profit without
the permission of Andrew Plotkin and Richard Bartle.
-->
<rect width="48" height="48" rx="10" fill="#121a20" />
<g transform="translate(3 3) scale(.84)">
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45Z" fill="#f0a53a" fill-rule="evenodd" />
<path d="m22 3 2 4m9-4-1 4m5 3 5-2m-3 9h6m-7 6 3 3" fill="none" stroke="#e9e2d2" stroke-width="2.2" stroke-linecap="round" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
import type { LogEntry } from '$lib/game/state';
let { log }: { log: LogEntry[] } = $props();
const turns = $derived.by(() => {
const groups = new Map<number, LogEntry[]>();
for (const entry of log) {
if (!groups.has(entry.turn)) groups.set(entry.turn, []);
groups.get(entry.turn)!.push(entry);
}
return [...groups.entries()].reverse();
});
</script>
<section class="chronicle">
<h2>Chronicle</h2>
{#if turns.length === 0}
<p class="muted">Nothing has happened yet. Choose a gesture for each hand and reveal them.</p>
{/if}
{#each turns as [turn, entries] (turn)}
<article>
<h3>Turn {turn + 1}</h3>
<ul>
{#each entries as e, i (i)}
<li class={e.kind}>{e.text}</li>
{/each}
</ul>
</article>
{/each}
</section>
<style>
.chronicle {
padding: 0.75rem 0;
max-height: 44vh;
overflow-y: auto;
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
}
h3 {
font-size: 0.8rem;
color: var(--bone-faint);
margin: 0.6rem 0 0.2rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.15rem;
}
li.gesture {
color: var(--bone-dim);
}
li.spell {
color: var(--frost);
}
li.damage {
color: var(--ember);
}
li.death {
color: var(--blood);
}
li.fizzle {
color: var(--bone-dim);
font-style: italic;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import type { Gesture, Hand } from '$lib/game/spells';
let { gesture, hand = 'left' }: { gesture: Gesture; hand?: Hand } = $props();
</script>
<!--
Gesture silhouettes adapted from Andrew Plotkin's Spellcast for X Windows,
bitmaps/bf.bm, bp.bm, bs.bm, bw.bm, bd.bm, bc.bm, and bk.bm.
The bitmap borders were removed and the contours converted to SVG with lightly
rounded corners. The original poses and left/right mirroring are retained.
Original notice (Spellcast README):
This implementation is by Andrew Plotkin (ap1i+@andrew.cmu.edu).
It is copyright 1993 by Andrew Plotkin. The source code may be freely copied,
distributed, and modified, as long as this copyright notice is retained.
The source code and any derivative works may not be sold for profit without
the permission of Andrew Plotkin and Richard Bartle.
-->
<svg
class="icon"
viewBox="0 0 48 48"
width="36"
height="36"
fill="currentColor"
fill-rule="evenodd"
aria-hidden="true"
focusable="false"
>
<g transform={hand === 'right' ? 'translate(48 0) scale(-1 1)' : undefined}>
{#if gesture === 'F'}
<path d="M4 46.4L4 45.6Q4 45 4.47 44.63L8.53 41.37Q9 41 9 40.4L9 39.6Q9 39 8.46 38.73L7.6 38.3Q7 38 7 37.33L7 33.3Q7 33 6.7 33L6.3 33Q6 33 6 32.7L6 23.2Q6 22 6.7 21.02L10.65 15.49Q11 15 11.6 15L12.4 15Q13 15 13 15.6L13 17.1Q13 18 12.36 18.64L10.85 20.15Q10 21 10 22.2L10 24.7Q10 25 10.3 25L10.7 25Q11 25 11.09 24.72L12.79 19.64Q13 19 13.6 18.7L14.4 18.3Q15 18 15.16 17.35L15.79 14.82Q16 14 16.6 13.4L17.58 12.42Q18 12 18.6 12L19.4 12Q20 12 20.33 11.5L21.47 9.79Q22 9 22.9 9.3L24.1 9.7Q25 10 24.38 10.72L19.78 16.09Q19 17 18.8 18.18L18.05 22.7Q18 23 17.7 23L17.3 23Q17 23 17 23.3L17 23.7Q17 24 17.3 24L18.4 24Q19 24 19.21 23.44L21.58 17.12Q22 16 22.85 15.15L28.15 9.85Q29 9 30.2 9L32.4 9Q33 9 33 9.6L33 10.4Q33 11 32.58 11.42L26.85 17.15Q26 18 26 19.2L26 22.33Q26 23 26.6 23.3L27.4 23.7Q28 24 28.47 23.53L29.4 22.6Q30 22 30.21 21.18L30.71 19.16Q31 18 31.85 17.15L35.15 13.85Q36 13 37.18 12.8L41.41 12.1Q42 12 42 12.6L42 13.4Q42 14 41.58 14.42L40.42 15.58Q40 16 39.4 16L38.6 16Q38 16 38 16.6L38 17.4Q38 18 37.46 18.27L36.54 18.73Q36 19 36 19.6L36 20.4Q36 21 35.58 21.42L34.6 22.4Q34 23 34 23.85L34 26.7Q34 27 33.7 27L33.3 27Q33 27 33 27.3L33 32.4Q33 33 33.6 33L34.4 33Q35 33 35.42 32.58L38.58 29.42Q39 29 39 28.4L39 27.6Q39 27 39.6 27L41.1 27Q42 27 42 27.9L42 30.8Q42 32 41.22 32.91L36.39 38.54Q36 39 35.4 39L34.6 39Q34 39 33.67 39.5L32.6 41.1Q32 42 31.01 42.43L25.55 44.76Q25 45 25 45.6L25 46.4Q25 47 24.4 47L4.6 47Q4 47 4 46.4Z" />
{:else if gesture === 'P'}
<path d="M10 35.7L10 21.3Q10 21 10.3 21L10.7 21Q11 21 11 20.7L11 17.67Q11 17 11.6 16.7L12.62 16.19Q13 16 13.3 16.3L13.7 16.7Q14 17 14 17.42L14 25.7Q14 26 14.3 26L14.7 26Q15 26 15.02 25.7L15.95 8.95Q16 8 16.9 7.7L18.43 7.19Q19 7 19 7.6L19 8.7Q19 9 19.3 9L19.7 9Q20 9 20 9.3L20 22.33Q20 23 20.6 22.7L21.4 22.3Q22 22 22 21.33L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 3.7L23 2.6Q23 2 23.6 2L24.58 2Q25 2 25.3 2.3L25.7 2.7Q26 3 26 3.42L26 20.33Q26 21 26.6 21.3L27.4 21.7Q28 22 28 21.33L28 6.3Q28 6 28.3 6L28.7 6Q29 6 29 5.7L29 4.6Q29 4 29.57 4.19L31.1 4.7Q32 5 32.04 5.95L32.95 29.8Q33 31 33.96 30.28L36.04 28.72Q37 28 37.47 26.9L39.76 21.55Q40 21 40.6 21L41.4 21Q42 21 42 21.6L42 27.7Q42 28 41.7 28L41.3 28Q41 28 40.95 28.3L40.2 32.82Q40 34 39.15 34.85L36.85 37.15Q36 38 35.53 39.1L33.24 44.45Q33 45 32.4 45L31.6 45Q31 45 31 45.6L31 46.4Q31 47 30.4 47L15.85 47Q15 47 14.4 46.4L13.6 45.6Q13 45 12.83 44.17L12.06 40.29Q12 40 11.7 40L11.3 40Q11 40 11 39.7L11 36.3Q11 36 10.7 36L10.3 36Q10 36 10 35.7Z" />
{:else if gesture === 'S'}
<path d="M6 31.33L6 27.3Q6 27 6.3 27L6.7 27Q7 27 7.07 26.71L7.71 24.16Q8 23 8.85 22.15L15.58 15.42Q16 15 16.6 15L17.4 15Q18 15 18.42 14.58L20.36 12.64Q21 12 21.9 12L23.15 12Q24 12 24.6 11.4L25.4 10.6Q26 10 26.85 10L28.1 10Q29 10 29.28 10.85L29.7 12.1Q30 13 30.9 13.3L32.1 13.7Q33 14 32.87 14.94L31.17 26.81Q31 28 31.96 28.72L34.28 30.46Q35 31 35 31.9L35 33.7Q35 34 35.3 34L35.7 34Q36 34 36 34.3L36 41.7Q36 42 35.7 42L35.3 42Q35 42 34.93 42.29L34.1 45.59Q34 46 33.7 46.3L33.3 46.7Q33 47 32.58 47L21.6 47Q21 47 21 46.4L21 45.6Q21 45 20.4 45L19.6 45Q19 45 19 44.4L19 43.6Q19 43 18.58 42.58L15.42 39.42Q15 39 14.4 39L13.6 39Q13 39 12.64 38.52L10.36 35.48Q10 35 9.4 35L8.6 35Q8 35 8 34.4L8 33.6Q8 33 7.46 32.73L6.6 32.3Q6 32 6 31.33ZM24.6 18.45L18.4 25.55Q18 26 18 26.6L18 27.4Q18 28 18.58 27.85L20.84 27.29Q22 27 22.85 26.15L25.15 23.85Q26 23 26.24 21.82L26.88 18.59Q27 18 26.4 18L25.6 18Q25 18 24.6 18.45ZM20.7 2.7L20.3 2.3Q20 2 20.3 2.3L20.7 2.7Q21 3 20.7 2.7ZM21.7 3.7L21.3 3.3Q21 3 21.3 3.3L21.7 3.7Q22 4 21.7 3.7ZM22 5.7L22 4.3Q22 4 22.3 4L22.7 4Q23 4 23 4.3L23 5.7Q23 6 22.7 6L22.3 6Q22 6 22 5.7ZM23.7 6.7L23.3 6.3Q23 6 23.3 6.3L23.7 6.7Q24 7 23.7 6.7ZM32 6.7L32 5.6Q32 5 32.33 4.5L33.4 2.9Q34 2 33.79 3.06L33.06 6.71Q33 7 32.7 7L32.3 7Q32 7 32 6.7ZM36.79 10.79L36.3 10.3Q36 10 36.4 9.87L41.2 8.27Q42 8 41.4 8.6L40.6 9.4Q40 10 39.15 10L37.3 10Q37 10 37 10.3L37 10.7Q37 11 36.79 10.79ZM37.7 22.7L37.3 22.3Q37 22 37.3 22.3L37.7 22.7Q38 23 37.7 22.7ZM38.7 23.7L38.3 23.3Q38 23 38.3 23.3L38.7 23.7Q39 24 38.7 23.7ZM39 16.7L39 16.3Q39 16 39.3 16L45.7 16Q46 16 46 16.3L46 16.7Q46 17 45.7 17L39.3 17Q39 17 39 16.7ZM39.7 24.7L39.3 24.3Q39 24 39.3 24.3L39.7 24.7Q40 25 39.7 24.7ZM40.7 25.7L40.3 25.3Q40 25 40.3 25.3L40.7 25.7Q41 26 40.7 25.7Z" />
{:else if gesture === 'W'}
<path d="M9 28.1L9 26.9Q9 26 9.64 25.36L15.58 19.42Q16 19 16 18.4L16 17.6Q16 17 16.42 16.58L17.4 15.6Q18 15 18.8 14.73L22.86 13.38Q24 13 24.85 12.15L27.15 9.85Q28 9 29.2 9L31.05 9Q32 9 32.3 9.9L32.81 11.43Q33 12 32.4 12L31.6 12Q31 12 30.67 12.5L29.33 14.5Q29 15 28.4 15L27.6 15Q27 15 26.58 15.42L22.85 19.15Q22 20 21.62 21.14L20.38 24.86Q20 26 20.75 26.94L23.63 30.53Q24 31 24.6 31L25.4 31Q26 31 26.37 31.47L29.63 35.53Q30 36 30.6 36L31.4 36Q32 36 32.42 36.42L34.15 38.15Q35 39 35.2 40.18L35.86 44.16Q36 45 35.4 45.6L34.6 46.4Q34 47 33.15 47L18.6 47Q18 47 18 46.4L18 45.6Q18 45 18.54 44.73L19.4 44.3Q20 44 20 43.33L20 40.2Q20 39 19.11 38.19L9.67 29.61Q9 29 9 28.1Z" />
{:else if gesture === 'D'}
<path d="M9 37.8L9 28.67Q9 28 9.6 27.7L10.46 27.27Q11 27 11 26.4L11 25.6Q11 25 11.6 25L13.1 25Q14 25 14.28 24.15L14.72 22.85Q15 22 15.9 22L17.4 22Q18 22 18 21.4L18 20.6Q18 20 18.6 20L21.15 20Q22 20 22.6 19.4L23.4 18.6Q24 18 24.32 17.21L29.78 3.56Q30 3 30.6 3L31.4 3Q32 3 32.1 3.59L32.82 7.93Q33 9 32.4 9.9L31.6 11.1Q31 12 31 13.08L31 15.7Q31 16 30.7 16L30.3 16Q30 16 30 16.3L30 22.8Q30 24 31.18 23.76L34.41 23.12Q35 23 35 23.6L35 24.4Q35 25 34.67 25.5L33.6 27.1Q33 28 33 29.08L33 33.7Q33 34 32.7 34L32.3 34Q32 34 31.91 34.28L30.34 38.97Q30 40 29.1 40.6L27.5 41.67Q27 42 27 42.6L27 43.4Q27 44 26.43 44.19L24.57 44.81Q24 45 24 45.6L24 46.4Q24 47 23.4 47L10.85 47Q10 47 10.6 46.4L11.4 45.6Q12 45 12 44.15L12 42.9Q12 42 11.36 41.36L9.85 39.85Q9 39 9 37.8Z" />
{:else if gesture === 'C'}
<path d="M19 46.7L19 46.3Q19 46 19.28 45.91L21.1 45.3Q22 45 22.3 44.1L22.79 42.64Q23 42 23.6 41.7L24.4 41.3Q25 41 25 40.33L25 38.3Q25 38 25.3 38L25.7 38Q26 38 26 37.7L26 27.3Q26 27 25.7 27L25.3 27Q25 27 25.03 26.7L25.97 17.3Q26 17 26.3 17L26.7 17Q27 17 27.06 16.71L27.94 12.29Q28 12 28.3 12L28.7 12Q29 12 29 11.7L29 9.67Q29 9 29.6 8.7L30.4 8.3Q31 8 31.13 7.34L31.88 3.59Q32 3 32.6 3L33.4 3Q34 3 34.12 3.59L34.79 6.94Q35 8 34.4 8.9L33.6 10.1Q33 11 33 12.08L33 14.7Q33 15 32.7 15L32.3 15Q32 15 32 15.3L32 26.7Q32 27 32.3 27L32.7 27Q33 27 33 27.3L33 31.15Q33 32 33.6 32.6L34.4 33.4Q35 34 35 34.85L35 36.7Q35 37 35.3 37L35.7 37Q36 37 35.95 37.3L35.2 41.82Q35 43 34.28 43.96L32.72 46.04Q32 47 30.8 47L19.3 47Q19 47 19 46.7Z" />
{:else if gesture === '>'}
<path d="M10 46.7L10 46.3Q10 46 10.17 45.75L13.33 41Q14 40 14.09 38.8L14.98 27.3Q15 27 15.3 27L15.7 27Q16 27 16 26.7L16 22.3Q16 22 16.3 22L16.7 22Q17 22 17 21.7L17 17.3Q17 17 16.7 17L16.3 17Q16 17 15.94 16.71L13.2 2.06Q13 1 13.6 1.9L14.4 3.1Q15 4 15 5.08L15 8.7Q15 9 15.3 9L15.7 9Q16 9 16.05 9.3L17.79 18.82Q18 20 18.85 19.15L20.58 17.42Q21 17 21.6 17L22.4 17Q23 17 23 16.4L23 15.6Q23 15 22.46 14.73L21.54 14.27Q21 14 21 13.4L21 12.6Q21 12 20.64 11.52L18.54 8.72Q18 8 18 7.1L18 5.85Q18 5 17.4 4.4L16.42 3.42Q16 3 16 2.4L16 1.3Q16 1 16.3 1L16.7 1Q17 1 17.13 1.27L23.49 14.91Q24 16 25.16 16.29L27.18 16.79Q28 17 28.6 17.6L29.4 18.4Q30 19 30 19.85L30 22.4Q30 23 30.6 23L31.4 23Q32 23 32.42 23.42L33.4 24.4Q34 25 33.79 25.82L33.16 28.35Q33 29 33.6 29.3L34.4 29.7Q35 30 34.87 30.66L34.13 34.34Q34 35 34.6 35.3L35.4 35.7Q36 36 36 36.67L36 38.8Q36 40 35.3 40.98L31.7 46.02Q31 47 29.8 47L10.3 47Q10 47 10 46.7Z" />
{:else}
<!-- Spellcast left this tile blank; the letter row already shows a dash. -->
{/if}
</g>
</svg>
<style>
.icon {
display: block;
flex: none;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script lang="ts">
import { castKey, tokensText, type Duel, type SetName } from '$lib/game/duel.svelte';
import GestureIcon from './GestureIcon.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { GESTURES, type Hand, type SpellId } from '$lib/game/spells';
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
const draft = $derived(duel.sets[set][hand]);
const forced = $derived(duel.forcedFor(set, hand));
const charmed = $derived(duel.charmedHand === hand);
const options = $derived(duel.completions[set][hand]);
const chosen = $derived(duel.chosen[set][hand]);
const spell = $derived(chosen?.spell);
const other = $derived<Hand>(hand === 'left' ? 'right' : 'left');
const key = $derived(castKey(set, hand));
const banking = $derived(duel.willBank && castKey(duel.willBank.set, duel.willBank.hand) === key);
const extending = $derived(duel.willExtend && castKey(duel.willExtend.set, duel.willExtend.hand) === key);
let morePlans = $state(false);
const plans = $derived(morePlans ? duel.plans[hand] : duel.plans[hand].slice(0, duel.planLimit(hand)));
const hiddenPlans = $derived(duel.plans[hand].length - plans.length);
const whyForced = $derived(
duel.you.constraints.amnesia
? 'Amnesia: you must repeat last turn.'
: duel.you.constraints.paralysedHand === hand
? 'Paralysed in last turns position.'
: ''
);
</script>
<fieldset class="hand">
<legend>{hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
{#if forced}
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
{:else if charmed}
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hands gesture.</p>
{:else}
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
{#each GESTURES as g (g)}
<button
type="button"
class="gesture"
class:on={draft.gesture === g}
disabled={!duel.allowed[hand].includes(g)}
aria-pressed={draft.gesture === g}
onclick={() => duel.choose(set, hand, g)}
>
<GestureIcon gesture={g} {hand} />
<span class="letter">{glyph(g)}</span>
<span class="name">{GESTURE_SHORT[g]}</span>
</button>
{/each}
</div>
{#if duel.you.constraints.fear && !duel.timeStopped}
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
{/if}
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
<ul class="plans" aria-label="{hand} hand, spells under way">
{#each plans as plan (plan.spell.id)}
<li class:pinned={plan.pinned}>
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow"></span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
<span class="pname">{plan.spell.name}</span>
</button>
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
</li>
{/each}
{#if hiddenPlans > 0 || morePlans}
<li class="more">
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
</li>
{/if}
</ul>
{/if}
{/if}
{#if options.length > 0}
<div class="cast">
<label>
<span>Finishes</span>
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
{#each options as c (c.spell.id + c.seqIndex)}
<option value={c.spell.id}>{c.spell.name} ({c.spell.sequences[c.seqIndex]})</option>
{/each}
<option value="">nothing, let it pass</option>
</select>
</label>
{#if spell}
<label>
<span>at</span>
<select bind:value={draft.target}>
{#each duel.targetsFor(spell) as t (t.id)}
<option value={t.id}>{t.label}</option>
{/each}
</select>
</label>
{#if spell.id === 'summon_elemental'}
<label>
<span>of</span>
<select bind:value={draft.elemental}>
<option value="fire">fire</option>
<option value="ice">ice</option>
</select>
</label>
{/if}
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
<label>
<span>their</span>
<select bind:value={draft.chosenHand}>
<option value="left">left hand</option>
<option value="right">right hand</option>
</select>
</label>
{/if}
<p class="summary">
{spell.summary}
{#if banking}<strong>This spell will be banked by your delayed effect, not cast.</strong>{/if}
{#if extending}<strong>Your permanency will make this enchantment last the whole duel.</strong>{/if}
</p>
{/if}
</div>
{/if}
</fieldset>
<style>
.hand {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
legend {
font-style: italic;
font-size: 1.1rem;
padding: 0;
margin-bottom: 0.4rem;
}
.again {
color: var(--frost);
}
.gestures {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
}
.gesture {
background: var(--slate-raised);
border: 1px solid transparent;
border-radius: 4px;
padding: 0.3rem 0.2rem 0.25rem;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
color: var(--bone);
transition: background 120ms ease;
}
.gesture:hover:not(:disabled) {
border-color: var(--rule-strong);
}
.gesture.on {
background: var(--ember);
color: var(--slate-deep);
}
.gesture:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.gesture .letter {
font-size: 1.25rem;
margin-top: 0.05rem;
}
.gesture .name {
font-size: 0.65rem;
margin-top: 0.2rem;
opacity: 0.85;
}
.locked {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--bone-dim);
background: var(--slate-raised);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.big {
font-size: 1.8rem;
color: var(--bone);
}
.cast {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
}
.cast label {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.cast label span {
color: var(--bone-dim);
font-style: italic;
}
.plans {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
font-size: 0.82rem;
line-height: 1.4;
}
.plans li {
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.plan {
flex: 1;
min-width: 0;
background: none;
border: 0;
padding: 0.1rem 0.2rem;
text-align: left;
display: flex;
gap: 0.6rem;
align-items: baseline;
border-radius: 3px;
}
.plan:hover {
background: var(--slate-raised);
}
.seq {
letter-spacing: 0.04em;
white-space: nowrap;
color: var(--bone-dim);
}
.seq .done {
color: var(--ember);
}
.seq .arrow {
color: var(--bone-faint);
margin: 0 0.35em;
}
.seq .next {
color: var(--bone);
}
.pname {
color: var(--bone-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
li.pinned .pname {
color: var(--frost);
}
li.more .plan {
color: var(--frost);
font-size: 0.8rem;
}
.pin {
background: none;
border: 0;
color: var(--bone-faint);
padding: 0 0.2rem;
font-size: 0.8rem;
}
li.pinned .pin {
color: var(--frost);
}
.summary,
.hint {
width: 100%;
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.3rem;
}
.link {
background: none;
border: 0;
padding: 0;
color: var(--frost);
text-decoration: underline;
font-size: inherit;
}
.summary strong {
display: block;
font-weight: 500;
color: var(--frost);
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import { SPELL_BY_ID, type Hand } from '$lib/game/spells';
import type { HistoryEntry, WizardId } from '$lib/game/state';
let { duel }: { duel: Duel } = $props();
let scroller: HTMLDivElement | undefined = $state();
interface Placed {
entry: HistoryEntry;
index: number;
hidden: boolean;
}
interface Row {
key: string;
label: string;
note?: string;
cells: Record<WizardId, Placed[]>;
}
function placed(who: WizardId, turn: number, phases: string[]): Placed[] {
const history = who === YOU ? duel.you.history : duel.foeVisible;
const raw = duel.state.wizards[who].history;
const out: Placed[] = [];
history.forEach((entry, index) => {
if (entry.turn === turn && phases.includes(entry.phase)) {
out.push({ entry, index, hidden: raw[index].hiddenFrom.includes(YOU) });
}
});
return out;
}
const rows = $derived.by(() => {
const out: Row[] = [];
for (let t = 0; t < duel.state.turn; t++) {
out.push({ key: `t${t}`, label: String(t + 1), cells: { A: placed(YOU, t, ['main', 'haste']), B: placed(FOE, t, ['main', 'haste']) } });
for (const who of [YOU, FOE] as WizardId[]) {
for (const p of placed(who, t, ['timestop'])) {
out.push({ key: `t${t}-${who}-${p.index}`, label: '', note: 'time stop', cells: { A: who === YOU ? [p] : [], B: who === FOE ? [p] : [] } });
}
}
}
return out;
});
const drafting = $derived(!duel.state.over);
const turns = $derived(duel.you.history.length + duel.foe.history.length);
function notesFor(who: WizardId, index: number, hand: Hand): string[] {
return duel.state.wizards[who].casts
.filter((c) => c.index === index && c.hand === hand)
.map((c) => SPELL_BY_ID[c.spellId].name);
}
$effect(() => {
void turns;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
</script>
<div class="scroller" bind:this={scroller}>
<table class="ledger">
<thead>
<tr>
<th class="turn" scope="col"><span class="visually-hidden">Turn</span></th>
<th class="you" colspan="2" scope="colgroup">{duel.you.name} (you)</th>
<th class="foe" colspan="2" scope="colgroup">{duel.foe.name}</th>
</tr>
<tr class="hands">
<th></th>
<th scope="col">left</th>
<th scope="col">right</th>
<th scope="col">left</th>
<th scope="col">right</th>
</tr>
</thead>
<tbody>
{#each rows as row (row.key)}
<tr class:stopped={!!row.note}>
<td class="turn">{row.label}{#if row.note}<span class="rownote">{row.note}</span>{/if}</td>
{#each [YOU, FOE] as const as who (who)}
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell" class:you={who === YOU} class:foe={who === FOE}>
{#each row.cells[who] as p (p.index)}
{@const lit = who === FOE && duel.highlight?.hand === hand && p.index >= duel.highlight.from && p.index <= duel.highlight.to}
{@const notes = notesFor(who, p.index, hand)}
{@const other = hand === 'left' ? p.entry.right : p.entry.left}
<span class="entry" class:cast={notes.length > 0} class:hidden={p.hidden} class:haste={p.entry.phase === 'haste'} class:lit>
<span class="letter">{p.hidden ? '?' : glyph(p.entry[hand], other)}</span>
{#if notes.length}<span class="note">{notes.join(', ')}</span>{/if}
</span>
{/each}
</td>
{/each}
{/each}
</tr>
{/each}
{#if drafting}
<tr class="draft" class:stopped={duel.timeStopped}>
<td class="turn">{duel.timeStopped ? '' : duel.turnNumber}{#if duel.timeStopped}<span class="rownote">time stop</span>{/if}</td>
{#each ['left', 'right'] as const as hand (hand)}
<td class="cell you">
{#each duel.activeSets as set (set)}
{@const chosen = duel.forcedFor(set, hand) ?? duel.sets[set][hand].gesture}
{@const charmed = duel.charmedHand === hand}
{@const cast = duel.chosen[set][hand]}
<span class="entry" class:cast={!!cast} class:haste={set === 'haste'}>
<span class="letter" class:placeholder={chosen === null || charmed}>
{charmed ? '?' : chosen === null ? '·' : glyph(chosen, duel.gestureFor(set, hand === 'left' ? 'right' : 'left'))}
</span>
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
</span>
{/each}
</td>
{/each}
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
<td class="cell foe">{#if !duel.timeStopped}<span class="letter placeholder">?</span>{/if}</td>
</tr>
{/if}
</tbody>
</table>
</div>
<style>
.scroller {
max-height: clamp(170px, 30vh, 400px);
overflow-y: auto;
border-top: 1px solid var(--rule-strong);
border-bottom: 1px solid var(--rule-strong);
}
.ledger {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
thead th {
position: sticky;
top: 0;
background: var(--slate-deep);
font-weight: 500;
text-align: center;
padding: 0.5rem 0 0.15rem;
font-size: 1.05rem;
z-index: 1;
}
thead th.you,
thead th.foe {
font-style: italic;
font-size: 1.15rem;
}
thead tr.hands th {
top: 2.2rem;
font-size: 0.8rem;
font-weight: 400;
color: var(--bone-dim);
padding: 0 0 0.35rem;
border-bottom: 1px solid var(--rule-strong);
}
th.turn,
td.turn {
width: 2.9rem;
color: var(--bone-faint);
text-align: right;
padding-right: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
vertical-align: top;
padding-top: 0.6rem;
}
.rownote {
display: block;
font-size: 0.6rem;
font-style: italic;
line-height: 1.1;
color: var(--frost);
}
td.cell {
text-align: center;
vertical-align: top;
padding: 0.35rem 0.2rem 0.3rem;
border-bottom: 1px solid var(--rule);
line-height: 1.1;
}
tr.stopped td.cell {
background: rgba(140, 200, 224, 0.06);
}
td.cell.you:nth-child(3) {
border-right: 1px solid var(--rule-strong);
}
.entry {
display: block;
}
.entry.haste .letter {
font-size: clamp(1.1rem, 2.4vw, 1.4rem);
color: var(--frost);
}
.letter {
display: block;
font-size: clamp(1.6rem, 3.5vw, 2.1rem);
color: var(--bone);
}
.entry.cast .letter {
color: var(--ember);
}
.entry.hidden .letter {
color: var(--bone-faint);
}
.entry.lit {
background: rgba(140, 200, 224, 0.18);
outline: 1px solid var(--frost);
border-radius: 3px;
}
.entry.lit .letter {
color: var(--frost);
}
.placeholder {
color: var(--bone-faint);
}
.note {
display: block;
font-size: 0.72rem;
font-style: italic;
color: var(--ember);
line-height: 1.15;
margin-top: 0.15rem;
}
tr.draft td.cell {
background: var(--slate);
border-bottom: 2px solid var(--ember);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import { MAX_HP } from '$lib/game/state';
let { duel }: { duel: Duel } = $props();
const left = $derived(duel.forcedFor('main', 'left') ?? duel.sets.main.left.gesture);
const right = $derived(duel.forcedFor('main', 'right') ?? duel.sets.main.right.gesture);
</script>
{#if !duel.state.over}
<div class="bar">
<div class="health">
<span><em>{duel.you.name}</em> {duel.you.hp}/{MAX_HP}</span>
<span><em>{duel.foe.name}</em> {duel.foe.hp}/{MAX_HP}</span>
</div>
<div class="gestures" aria-label="Chosen gestures">
<span class="letter" class:empty={left === null}>{duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')}</span>
<span class="letter" class:empty={right === null}>{duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')}</span>
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
</button>
</div>
{/if}
<style>
.bar {
display: none;
}
@media (max-width: 860px) {
.bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 16px calc(0.5rem + env(safe-area-inset-bottom));
background: var(--slate);
border-top: 1px solid var(--rule-strong);
}
}
.health {
display: flex;
flex-direction: column;
font-size: 0.8rem;
color: var(--bone-dim);
font-variant-numeric: tabular-nums;
line-height: 1.3;
}
.health em {
color: var(--bone);
}
.gestures {
display: flex;
gap: 0.5rem;
}
.letter {
font-size: 1.6rem;
color: var(--ember);
min-width: 1.2em;
text-align: center;
}
.letter.empty {
color: var(--bone-faint);
}
.reveal {
background: var(--bone);
color: var(--slate-deep);
border: 0;
border-radius: 4px;
padding: 0.5rem 1rem;
font-weight: 500;
}
.reveal:disabled {
opacity: 0.35;
}
</style>
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
import { tokensText, type Duel } from '$lib/game/duel.svelte';
import { HANDS, SPELLS, type SpellCategory } from '$lib/game/spells';
let { duel }: { duel: Duel } = $props();
let open = $state<string | null>(null);
const groups: { category: SpellCategory; title: string }[] = [
{ category: 'protection', title: 'Protection' },
{ category: 'summons', title: 'Summons' },
{ category: 'damage', title: 'Damage' },
{ category: 'enchantment', title: 'Enchantments' }
];
</script>
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
<summary>Spell sheet</summary>
<p class="muted intro">Gestures you have already made light up; select a spell to read what it does. A bracketed lower-case letter, such as (w, is that gesture made with both hands at once; C, the clap, is always two-handed. Surrender is both palms at once: never a spell. <a href="/rules">Full rules</a>.</p>
{#each groups as g (g.category)}
<h3>{g.title}</h3>
<ul>
{#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)}
{@const k = Math.max(duel.progress[spell.id].left, duel.progress[spell.id].right)}
{@const done = duel.completedNow(spell.id)}
<li class:done class:open={open === spell.id}>
<button type="button" class="row" aria-expanded={open === spell.id} onclick={() => (open = open === spell.id ? null : spell.id)}>
<span class="seq">{#each spell.sequences as seq, si (seq)}{#if si > 0}<span class="or">&nbsp;or&nbsp;</span>{/if}{#each seq.split('-') as part, i (i)}{#if i > 0}<span class="dash">-</span>{/if}<span class="tok" class:lit={done || i < k}>{part}</span>{/each}{/each}</span>
<span class="name">{spell.name}</span>
</button>
{#if open === spell.id}
<p class="about">{spell.summary}</p>
{#if !duel.state.over}
<div class="planwith">
{#each HANDS as hand (hand)}
{@const plan = duel.planFor(hand, spell)}
<button
type="button"
class="planbtn"
disabled={!duel.handFree(hand)}
title={duel.handFree(hand) ? `Pin ${spell.name} to the ${hand} hand and make its next gesture` : `The ${hand} hand is not yours to move this turn`}
onclick={() => duel.follow(hand, plan)}
>
<span class="which">Plan with {hand} hand</span>
<span class="rest">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span> then {/if}{tokensText(plan.remaining)} <span class="count">({plan.remaining.length} more)</span></span>
</button>
{/each}
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/each}
</details>
<style>
.sheet {
padding: 0.75rem 0;
border-bottom: 1px solid var(--rule);
}
summary {
font-size: 1.1rem;
font-style: italic;
cursor: pointer;
}
.intro {
font-size: 0.85rem;
margin: 0.4rem 0 0.2rem;
}
h3 {
font-size: 0.8rem;
color: var(--bone-faint);
margin: 0.7rem 0 0.2rem;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
font-size: 0.9rem;
}
.row {
width: 100%;
display: grid;
grid-template-columns: minmax(9.5rem, auto) 1fr;
gap: 0.75rem;
background: none;
border: 0;
padding: 0.12rem 0.2rem;
text-align: left;
border-radius: 3px;
}
.row:hover {
background: var(--slate-raised);
}
li.open .row {
background: var(--slate);
}
.about {
font-size: 0.85rem;
color: var(--bone-dim);
padding: 0.2rem 0.2rem 0.4rem;
}
.planwith {
display: grid;
gap: 0.3rem;
padding: 0 0.2rem 0.6rem;
}
.planbtn {
background: var(--slate-raised);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.3rem 0.5rem;
text-align: left;
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.6rem;
align-items: baseline;
font-size: 0.85rem;
}
.planbtn:hover:not(:disabled) {
border-color: var(--frost);
}
.planbtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.which {
color: var(--frost);
}
.rest {
letter-spacing: 0.04em;
}
.rest .done {
color: var(--ember);
}
.count {
color: var(--bone-dim);
letter-spacing: 0;
}
a {
color: var(--frost);
}
.seq {
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
white-space: nowrap;
}
.dash,
.or {
color: var(--bone-faint);
}
.tok {
color: var(--bone-dim);
}
.tok.lit {
color: var(--ember);
font-weight: 600;
}
li.done .name {
color: var(--ember);
}
</style>
+132
View File
@@ -0,0 +1,132 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
let { duel }: { duel: Duel } = $props();
const summary = $derived(duel.summary);
function signed(n: number): string {
return n > 0 ? `+${n}` : String(n);
}
</script>
{#if summary}
<section class="result" aria-label="What the last reveal did">
<h2>{summary.phase === 'timestop' ? 'In the stopped moment' : `Turn ${summary.turn + 1}`}</h2>
<dl>
{#each [YOU, FOE] as const as who (who)}
{@const w = summary.wizards[who]}
<div>
<dt>{who === YOU ? 'You' : duel.foe.name}</dt>
<dd>
{#if w.effects.length === 0}
<span class="muted">untouched</span>
{:else}
{#each w.effects as e, i (i)}
<span class="effect" class:hurt={e.delta < 0} class:healed={e.delta > 0}>{e.label} {signed(e.delta)}</span>
{/each}
{/if}
<span class="hp" class:changed={w.before !== w.after}>health {w.before}{#if w.before !== w.after} {w.after}{/if}</span>
</dd>
</div>
{/each}
</dl>
{#if summary.events?.length}
<ul class="events">
{#each summary.events as e, i (i)}
<li>{e}</li>
{/each}
</ul>
{/if}
{#if duel.showLessons && summary.lessons?.length}
<ul class="lessons">
{#each summary.lessons as l, i (i)}
<li>{l}</li>
{/each}
</ul>
{/if}
</section>
{/if}
<style>
.result {
margin: 0.5rem 0 0;
padding: 0.4rem 0.8rem;
background: var(--slate);
border-left: 2px solid var(--frost);
font-size: 0.9rem;
}
h2 {
font-size: 0.8rem;
color: var(--bone-faint);
margin-bottom: 0.15rem;
}
dl {
margin: 0;
display: grid;
gap: 0.15rem;
}
dl > div {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.6rem;
align-items: baseline;
}
dt {
font-style: italic;
min-width: 5.5em;
color: var(--bone-dim);
}
dd {
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.75rem;
}
.effect.hurt {
color: var(--ember);
}
.effect.healed {
color: var(--frost);
}
.hp {
color: var(--bone-dim);
}
.hp.changed {
color: var(--bone);
}
.lessons {
list-style: none;
margin: 0.4rem 0 0;
padding: 0.4rem 0 0.1rem 0.7rem;
border-left: 2px solid var(--ember);
color: var(--bone);
font-size: 0.85rem;
font-style: italic;
line-height: 1.45;
}
.lessons li + li {
margin-top: 0.3rem;
}
.events {
list-style: none;
margin: 0.35rem 0 0;
padding: 0.35rem 0 0;
border-top: 1px solid var(--rule);
color: var(--bone-dim);
font-size: 0.85rem;
line-height: 1.4;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { SPELL_BY_ID } from '$lib/game/spells';
import { MAX_HP, type Monster, type WizardState } from '$lib/game/state';
let { wizard, monsters, you = false }: { wizard: WizardState; monsters: Monster[]; you?: boolean } = $props();
const more = (n: number) => (n === Infinity ? 'for good' : `${n} more`);
const effects = $derived.by(() => {
const out: string[] = [];
if (wizard.resistHeat) out.push('resists heat');
if (wizard.resistCold) out.push('resists cold');
if (wizard.protectionTurns > 0) out.push(`protected from evil, ${more(wizard.protectionTurns)}`);
if (wizard.blindTurns > 0) out.push(`blind, ${more(wizard.blindTurns)}`);
if (wizard.invisibleTurns > 0) out.push(`invisible, ${more(wizard.invisibleTurns)}`);
if (wizard.hasteTurns > 0) out.push(`hastened, ${more(wizard.hasteTurns)}`);
if (wizard.banked) out.push(`holding a banked ${SPELL_BY_ID[wizard.banked.spellId].name.toLowerCase()}`);
else if (wizard.delayedTurns > 0) out.push(`next spell will be banked, ${more(wizard.delayedTurns)}`);
if (wizard.permanencyTurns > 0) out.push(`next enchantment will be permanent, ${more(wizard.permanencyTurns)}`);
if (wizard.diseaseTurns !== null) out.push(`diseased, dies in ${wizard.diseaseTurns}`);
if (wizard.poisonTurns !== null) out.push(`poisoned, dies in ${wizard.poisonTurns}`);
const c = wizard.constraints;
if (c.amnesia) out.push(wizard.permanent.amnesia ? 'amnesia for good' : 'amnesia this turn');
if (c.confusion) out.push(wizard.permanent.confusion ? 'confused for good' : 'confused this turn');
if (c.fear) out.push(wizard.permanent.fear ? 'afraid for good' : 'afraid this turn');
if (c.paralysedHand) out.push(`${c.paralysedHand} hand paralysed${wizard.permanent.paralysedHand ? ' for good' : ''}`);
if (c.charmed) out.push(`${c.charmed.hand} hand charmed${wizard.permanent.charmed ? ' for good' : ''}`);
if (wizard.usedQuickLightning) out.push('quick lightning spent');
return out;
});
</script>
<section class="wizard" class:you class:dead={!wizard.alive}>
<h2>{wizard.name}{#if you}<span class="tag"> (you)</span>{/if}</h2>
<div class="health">
<div class="pips" role="img" aria-label="{wizard.hp} of {MAX_HP} hit points">
{#each { length: MAX_HP } as _, i (i)}
<span class="pip" class:lost={i >= wizard.hp}></span>
{/each}
</div>
<span class="hp">{wizard.hp} / {MAX_HP}</span>
</div>
{#if effects.length}
<p class="effects">{effects.join('; ')}.</p>
{:else}
<p class="effects muted">Unenchanted.</p>
{/if}
{#if monsters.length}
<ul class="monsters">
{#each monsters as m (m.id)}
<li>
<span class="mname">{m.name}</span>
<span class="mhp">{m.hp}/{m.maxHp}, hits for {m.attack}</span>
{#if m.constraints.skipAttack}<span class="muted">paralysed</span>{/if}
</li>
{/each}
</ul>
{/if}
</section>
<style>
.wizard {
padding: 0.75rem 0;
border-bottom: 1px solid var(--rule);
}
.wizard.dead h2 {
text-decoration: line-through;
color: var(--bone-dim);
}
h2 {
font-size: 1.3rem;
font-style: italic;
}
.tag {
margin-left: 0.35em;
font-size: 0.9rem;
color: var(--bone-dim);
font-style: normal;
}
.health {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0.4rem 0 0.35rem;
}
.pips {
display: flex;
gap: 3px;
flex: 1;
}
.hp {
font-variant-numeric: tabular-nums;
color: var(--bone-dim);
font-size: 0.9rem;
white-space: nowrap;
}
.pip {
width: 100%;
max-width: 16px;
height: 10px;
background: var(--bone);
border-radius: 1px;
}
.pip.lost {
background: transparent;
box-shadow: inset 0 0 0 1px var(--bone-faint);
}
.effects {
font-size: 0.9rem;
}
.monsters {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
font-size: 0.9rem;
}
.monsters li {
display: flex;
gap: 0.6rem;
align-items: baseline;
}
.mname {
color: var(--ember);
}
.mhp {
color: var(--bone-dim);
}
</style>
+502
View File
@@ -0,0 +1,502 @@
// A heuristic opponent. Each turn it scores every pair of gestures by the
// spells they finish, the spells they move toward, and what the human's
// visible gestures threaten, then casts the most valuable spells it completed.
// Spells it has cast recently are discounted so it does not fall into a rut,
// and each named opponent weights the spell book a little differently.
import {
completableIn,
completedSpells,
handSequence,
progressToward,
usedCells,
type Completion,
type HandTurn,
type TurnGestures
} from './gestures';
import { allowedGestures, forcedGesture } from './resolve';
import { CONTROL_SPELLS, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
import {
MAX_HP,
enemyOf,
visibleHistory,
type CastChoice,
type GameState,
type TargetId,
type TurnInput,
type WizardId
} from './state';
const HOSTILE: SpellId[] = [
'missile',
'finger_of_death',
'lightning_bolt',
'lightning_bolt_quick',
'cause_light_wounds',
'cause_heavy_wounds',
'fireball',
'fire_storm',
'ice_storm',
...CONTROL_SPELLS,
'anti_spell',
'disease',
'poison',
'blindness',
'summon_goblin',
'summon_ogre',
'summon_troll',
'summon_giant',
'summon_elemental'
];
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
missile: 1,
finger_of_death: 10,
lightning_bolt: 5,
lightning_bolt_quick: 5,
cause_light_wounds: 2,
cause_heavy_wounds: 3,
fireball: 5,
fire_storm: 5,
ice_storm: 5,
amnesia: 2,
confusion: 1.5,
charm_person: 2.5,
charm_monster: 3,
paralysis: 2.5,
fear: 1.5,
anti_spell: 2,
disease: 4,
poison: 4,
blindness: 3,
summon_goblin: 2,
summon_ogre: 3,
summon_troll: 4,
summon_giant: 5,
summon_elemental: 4
};
/** Defensive spells whose worth is purely situational, so repeating them is fine. */
const NO_REPEAT_DISCOUNT: SpellId[] = ['shield', 'counter_spell', 'magic_mirror', 'dispel_magic', 'cure_light_wounds', 'cure_heavy_wounds'];
interface Situation {
state: GameState;
me: WizardId;
foe: WizardId;
foeSeq: Record<Hand, HandTurn[]>;
/** Hostile spells the foe could finish this very turn, by value. */
threatNow: number;
threatNowCounterable: number;
/** Hostile spells the foe could finish next turn. */
threatNext: number;
foeMonsterDamage: number;
missileOrMonsterNow: boolean;
foeFirePlan: boolean;
foeIcePlan: boolean;
iceElemental: boolean;
fireElemental: boolean;
rng: () => number;
}
/** A stable 0..1 number from a string, so each opponent has its own leanings. */
function hash01(text: string): number {
let h = 2166136261;
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return ((h >>> 0) % 1000) / 1000;
}
function assess(state: GameState, me: WizardId, rng: () => number): Situation {
const foe = enemyOf(me);
const foeW = state.wizards[foe];
const hist = visibleHistory(state, me, foe);
const foeSeq: Record<Hand, HandTurn[]> = {
left: handSequence(hist, 'left'),
right: handSequence(hist, 'right')
};
let threatNow = 0;
let threatNowCounterable = 0;
let threatNext = 0;
let missileNow = false;
let foeFirePlan = false;
let foeIcePlan = false;
for (const hand of ['left', 'right'] as Hand[]) {
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
if (!HOSTILE.includes(c.spell.id)) continue;
const v = THREAT_VALUE[c.spell.id] ?? 1;
threatNow = Math.max(threatNow, v);
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
if (c.spell.id === 'missile') missileNow = true;
}
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
if (!HOSTILE.includes(c.spell.id)) continue;
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
}
for (const spell of SPELLS) {
const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart);
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
}
}
const foeMonsterDamage = state.monsters
.filter((m) => m.owner === foe && !isElemental(m.kind))
.reduce((sum, m) => sum + m.attack, 0);
return {
state,
me,
foe,
foeSeq,
threatNow,
threatNowCounterable,
threatNext,
foeMonsterDamage,
missileOrMonsterNow: missileNow || foeMonsterDamage > 0,
foeFirePlan,
foeIcePlan,
iceElemental: state.monsters.some((m) => m.kind === 'ice_elemental'),
fireElemental: state.monsters.some((m) => m.kind === 'fire_elemental'),
rng
};
}
/** How much the bot would like to finish this spell right now, before its leanings and recent habits. */
function baseValue(s: Situation, spell: Spell): number {
const meW = s.state.wizards[s.me];
const foeW = s.state.wizards[s.foe];
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
const foeEnchanted =
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
switch (spell.id) {
case 'fireball':
if (s.iceElemental) return 6;
return foeW.resistHeat ? 0.2 : 5;
case 'lightning_bolt':
return 5;
case 'lightning_bolt_quick':
return meW.usedQuickLightning ? 0 : 5.2;
case 'cause_heavy_wounds':
return 3.2;
case 'cause_light_wounds':
return 2.2;
case 'missile':
return 1.1;
case 'finger_of_death':
return 9;
case 'summon_goblin':
return 2.2;
case 'summon_ogre':
return 3.2;
case 'summon_troll':
return 4;
case 'summon_giant':
return 4.6;
case 'summon_elemental':
return meW.resistHeat || meW.resistCold ? 4.5 : 0.8;
case 'paralysis':
return 2.4;
case 'amnesia':
return 2;
case 'confusion':
return 1.6;
case 'fear':
return 1.8;
case 'charm_person':
return 2.6;
case 'charm_monster':
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
case 'anti_spell': {
let longest = 0;
for (const hand of ['left', 'right'] as Hand[]) {
for (const sp of SPELLS) {
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
}
}
return 1 + longest * 0.6;
}
case 'disease':
return 3.5;
case 'poison':
return 3.8;
case 'blindness':
return 3;
case 'invisibility':
return 2.5 + s.foeMonsterDamage;
case 'haste':
return meW.hasteTurns > 0 ? 0 : 3.5;
case 'time_stop':
return 3;
case 'delayed_effect':
return meW.banked || meW.delayedTurns > 0 ? 0 : 1.5;
case 'permanency':
return meW.permanencyTurns > 0 ? 0 : 2.5;
case 'shield':
if (meW.protectionTurns > 0) return 0;
if (s.foeMonsterDamage > 0) return 1.5 + s.foeMonsterDamage;
if (s.missileOrMonsterNow) return 1.4;
return 0.3;
case 'counter_spell':
return Math.max(0.4, s.threatNowCounterable);
case 'magic_mirror':
return s.threatNowCounterable >= 3 ? s.threatNowCounterable : 0.3;
case 'dispel_magic':
return Math.max(0.5, s.foeMonsterDamage + (foeEnchanted ? 2 : 0) + (s.threatNow >= 5 ? s.threatNow : 0));
case 'remove_enchantment':
return foeEnchanted ? 2.5 : foeMonsters.length > 0 ? 1 + s.foeMonsterDamage : 0.3;
case 'protection_from_evil':
return meW.protectionTurns > 1 ? 0 : 2 + s.foeMonsterDamage;
case 'resist_heat':
if (s.fireElemental) return 4;
return s.foeFirePlan && !meW.resistHeat ? 3.5 : meW.resistHeat ? 0 : 0.6;
case 'resist_cold':
if (s.iceElemental) return 4;
return s.foeIcePlan && !meW.resistCold ? 3.5 : meW.resistCold ? 0 : 0.5;
case 'cure_light_wounds':
return meW.hp < MAX_HP ? 1 : 0;
case 'cure_heavy_wounds':
return meW.hp < MAX_HP - 1 ? 2 : meW.diseaseTurns !== null ? 5 : 0;
case 'raise_dead':
return meW.hp <= MAX_HP - 5 ? 4 : 0;
case 'fire_storm':
return meW.resistHeat && !foeW.resistHeat ? 5 : 0;
case 'ice_storm':
return meW.resistCold && !foeW.resistCold ? 5 : 0;
default:
return 0;
}
}
function spellValue(s: Situation, spell: Spell): number {
let v = baseValue(s, spell);
if (v <= 0) return 0;
const meW = s.state.wizards[s.me];
// Leanings: each opponent favours some spells over others.
v *= 0.7 + 0.6 * hash01(meW.name + spell.id);
// Habit: a spell cast in the last few turns is worth less again.
if (!NO_REPEAT_DISCOUNT.includes(spell.id)) {
const recent = meW.casts.filter((c) => c.spellId === spell.id && c.turn >= s.state.turn - 4).length;
v /= 1 + recent;
}
return v;
}
interface HandEval {
score: number;
planned?: SpellId;
}
function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned?: SpellId): HandEval {
let best = 0;
for (const c of completedSpells(seq, start)) {
best = Math.max(best, spellValue(s, c.spell));
}
let progress = 0;
let planned: SpellId | undefined;
for (const spell of SPELLS) {
const value = spellValue(s, spell);
if (value <= 0) continue;
spell.tokens.forEach((tokens, i) => {
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
const k = progressToward(tokens, seq, start);
if (k === 0) return;
let v = value * Math.pow(k / tokens.length, 1.3);
if (k >= 2) v += 0.3;
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
if (spell.id === 'counter_spell' && k === tokens.length - 1) v = Math.max(v, s.threatNext * 0.8);
if (spell.id === otherPlanned) v *= 0.4;
if (v > progress) {
progress = v;
planned = spell.id;
}
});
}
return { score: best + progress * 0.9, planned };
}
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
const options: { hand: Hand; completion: Completion; value: number }[] = [];
for (const hand of ['left', 'right'] as Hand[]) {
for (const completion of completions[hand]) {
const value = spellValue(s, completion.spell);
if (value > 0.25) options.push({ hand, completion, value });
}
}
options.sort((a, b) => b.value - a.value);
const chosen: typeof options = [];
for (const o of options) {
if (chosen.some((c) => c.hand === o.hand)) continue;
const cells = usedCells(o.completion, o.hand, turnCount);
const clash = chosen.some((c) => {
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
return false;
});
if (clash) continue;
chosen.push(o);
}
return chosen.map((o) => toChoice(s, o.hand, o.completion));
}
function foeDangerousHand(s: Situation): Hand {
const foeW = s.state.wizards[s.foe];
let bestHand: Hand = 'left';
let bestK = -1;
for (const hand of ['left', 'right'] as Hand[]) {
for (const spell of SPELLS) {
if (!HOSTILE.includes(spell.id)) continue;
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
if (k > bestK) {
bestK = k;
bestHand = hand;
}
}
}
return bestHand;
}
function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
const meW = s.state.wizards[s.me];
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
const iceEl = s.state.monsters.find((m) => m.kind === 'ice_elemental');
const fireEl = s.state.monsters.find((m) => m.kind === 'fire_elemental');
switch (spell.id) {
case 'fireball':
choice.target = iceEl ? iceEl.id : s.foe;
break;
case 'resist_cold':
choice.target = iceEl ? iceEl.id : s.me;
break;
case 'resist_heat':
choice.target = fireEl ? fireEl.id : s.me;
break;
case 'charm_monster':
choice.target = strongestFoeMonster?.id ?? s.foe;
break;
case 'remove_enchantment': {
const foeW = s.state.wizards[s.foe];
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
break;
}
case 'summon_elemental':
choice.target = s.me;
choice.elemental = meW.resistCold ? 'ice' : 'fire';
break;
case 'paralysis':
case 'charm_person':
choice.target = s.foe;
choice.chosenHand = foeDangerousHand(s);
break;
default:
choice.target = spell.usualTarget === 'self' ? s.me : s.foe;
}
if (spell.category === 'summons') choice.monsterTarget = s.foe;
}
function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice {
const choice: CastChoice = { hand, spellId: completion.spell.id, seqIndex: completion.seqIndex };
targetFor(s, completion.spell, choice);
return choice;
}
/** The gesture that most disrupts a charmed hand's sequences. */
function charmGesture(s: Situation): Gesture {
const foeW = s.state.wizards[s.foe];
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
let best: Gesture = 'P';
let bestScore = Infinity;
for (const g of ['C', 'D', 'F', 'P', 'S', 'W'] as Gesture[]) {
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
let score = 0;
for (const spell of SPELLS) {
if (!HOSTILE.includes(spell.id)) continue;
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
}
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
bestScore = score;
best = g;
}
}
return best;
}
interface PairChoice {
left: Gesture;
right: Gesture;
casts: CastChoice[];
}
/** Pick one pair of gestures given the history so far, and what to cast with them. */
function choosePair(s: Situation, history: TurnGestures[], rng: () => number, timeStopped: boolean): PairChoice {
const meW = s.state.wizards[s.me];
const foe = s.foe;
const optionsFor = (hand: Hand): Gesture[] => {
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
const forced = forcedGesture(meW, hand);
if (forced) return [forced];
const allowed = allowedGestures(meW, hand);
return allowed.length ? allowed : ['-'];
};
const start = meW.sequenceStart;
let bestPair: { left: Gesture; right: Gesture; score: number } | undefined;
for (const left of optionsFor('left')) {
for (const right of optionsFor('right')) {
const next = [...history, { left, right }];
const seqL = handSequence(next, 'left');
const seqR = handSequence(next, 'right');
const evalL = evaluateHand(s, seqL, start);
const evalR = evaluateHand(s, seqR, start, evalL.planned);
let score = evalL.score + evalR.score;
if (left === 'P' && right === 'P') score -= 100;
if (left === '>' && right === '>') score -= 50;
if (left === '-' && right === '-') score -= 3;
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15;
score += rng() * 0.5;
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
}
}
const pair = bestPair ?? { left: '-' as Gesture, right: '-' as Gesture, score: 0 };
const next = [...history, { left: pair.left, right: pair.right }];
const completions: Record<Hand, Completion[]> = {
left: completedSpells(handSequence(next, 'left'), start),
right: completedSpells(handSequence(next, 'right'), start)
};
return { left: pair.left, right: pair.right, casts: bestCasts(s, completions, next.length) };
}
export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number = Math.random): TurnInput {
const s = assess(state, me, rng);
const meW = state.wizards[me];
const foe = s.foe;
const timeStopped = state.timeStops[0] === me;
const first = choosePair(s, meW.history, rng, timeStopped);
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
const monsterOrders: Record<string, TargetId> = {};
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
const input: TurnInput = {
left: first.left,
right: first.right,
casts: first.casts,
stabTarget: weakFoeMonster?.id ?? foe,
monsterOrders
};
if (meW.hasteTurns > 0 && !timeStopped) {
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
}
if (meW.banked) {
// Loose a banked spell as soon as it is worth anything; keep a shield for a rainy day.
const spell = SPELL_BY_ID[meW.banked.spellId];
const worth = baseValue(s, spell);
if (worth > 0.5 && (spell.id !== 'shield' || s.missileOrMonsterNow)) {
const choice: CastChoice = { hand: 'left', spellId: spell.id };
targetFor(s, spell, choice);
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
}
}
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s);
return input;
}
+621
View File
@@ -0,0 +1,621 @@
// Reactive wrapper around the engine for a duel of one person against the bot.
import { chooseBotTurn } from './bot';
import {
completableIn,
completedSpells,
handSequence,
progressToward,
usedCells,
type Completion,
type HandTurn
} from './gestures';
import type { Token } from './spells';
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
import {
CONTROL_SPELLS,
GESTURES,
HANDS,
PERMANENCY_EXCLUDED,
SPELLS,
SPELL_BY_ID,
type Gesture,
type Hand,
type Spell,
type SpellId
} from './spells';
import {
NOWHERE,
createGame,
visibleHistory,
type CastChoice,
type GameState,
type HistoryEntry,
type TargetId,
type TurnInput,
type WizardId
} from './state';
export const YOU: WizardId = 'A';
export const FOE: WizardId = 'B';
/** The first pair of gestures each turn, and the extra pair a hastened wizard makes. */
export type SetName = 'main' | 'haste';
export const SET_NAMES: SetName[] = ['main', 'haste'];
const NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
const HOSTILE: SpellId[] = [
'missile',
'finger_of_death',
'lightning_bolt',
'lightning_bolt_quick',
'cause_light_wounds',
'cause_heavy_wounds',
'fireball',
'fire_storm',
'ice_storm',
...CONTROL_SPELLS,
'anti_spell',
'disease',
'poison',
'blindness',
'remove_enchantment',
'summon_goblin',
'summon_ogre',
'summon_troll',
'summon_giant',
'summon_elemental'
];
export interface HandDraft {
gesture: Gesture | null;
spellId: SpellId | '';
target: TargetId | '';
elemental: 'fire' | 'ice';
chosenHand: Hand;
}
export interface PlannedCast {
set: SetName;
hand: Hand;
completion: Completion;
draft: HandDraft;
/** Length of the history the completing gesture sits at the end of. */
turnCount: number;
}
function defaultTarget(spell: Spell | undefined): TargetId | '' {
if (!spell) return '';
return spell.usualTarget === 'self' ? YOU : FOE;
}
function blankDraft(): HandDraft {
return { gesture: null, spellId: '', target: '', elemental: 'fire', chosenHand: 'left' };
}
function blankSets(): Record<SetName, Record<Hand, HandDraft>> {
return { main: { left: blankDraft(), right: blankDraft() }, haste: { left: blankDraft(), right: blankDraft() } };
}
function pickNames(): { A: string; B: string } {
const pool = [...NAMES];
const a = pool.splice(Math.floor(Math.random() * pool.length), 1)[0];
const b = pool[Math.floor(Math.random() * pool.length)];
return { A: a, B: b };
}
export function castKey(set: SetName, hand: Hand): string {
return `${set}:${hand}`;
}
/** A spell a hand is part-way through, and what would finish it. */
export interface Plan {
spell: Spell;
seqIndex: number;
done: Token[];
remaining: Token[];
pinned: boolean;
}
/** A spell the opponent's hand could finish soon, with the gestures that say so. */
export interface Threat {
hand: Hand;
spell: Spell;
done: Token[];
remaining: Token[];
/** History indexes of the gestures already made. */
from: number;
to: number;
soon: boolean;
}
export interface Highlight {
hand: Hand;
from: number;
to: number;
}
/** A token in Bartle's notation: a bracketed lower-case letter is a two-handed gesture. */
export function tokenText(t: Token): string {
return t.both && t.gesture !== 'C' ? `(${t.gesture.toLowerCase()}` : t.gesture;
}
export function tokensText(tokens: Token[]): string {
return tokens.map(tokenText).join('-');
}
const SAVE_KEY = 'wh:duel';
const SAVE_VERSION = 2;
interface SavedDuel {
v: number;
state: GameState;
pins: Record<Hand, SpellId | null>;
sets: Record<SetName, Record<Hand, HandDraft>>;
stabTarget: TargetId;
monsterOrders: Record<string, TargetId>;
release: TargetId | '';
}
function loadSaved(): SavedDuel | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return null;
const saved = JSON.parse(raw) as SavedDuel;
if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null;
// Fields added since the save was written.
saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= [];
saved.state.lastTurn.lessons ??= [];
}
return saved;
} catch {
return null;
}
}
function savePreference(key: string, on: boolean): void {
try {
localStorage.setItem(key, on ? '1' : '0');
} catch {
// Preferences are a convenience only.
}
}
function readPreference(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v === '1';
} catch {
return fallback;
}
}
export class Duel {
state = $state<GameState>(createGame(pickNames()));
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
stabTarget = $state<TargetId>(FOE);
monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false);
charmGesture = $state<Gesture>('P');
/** Where to loose the banked spell this turn; empty keeps it. */
release = $state<TargetId | ''>('');
/** Which cast to bank or make permanent when more than one qualifies. */
bankPick = $state<string>('');
permanentPick = $state<string>('');
/** Spells you have chosen to pursue with each hand. */
pins = $state<Record<Hand, SpellId | null>>({ left: null, right: null });
showPlans = $state(readPreference('wh:plans', true));
showLessons = $state(readPreference('wh:lessons', true));
/** The spell sheet's open state, so a hint under a hand can open it. */
sheetOpen = $state(false);
/** Opponent gestures to light up in the ledger. */
highlight = $state<Highlight | null>(null);
constructor() {
const saved = typeof localStorage === 'undefined' ? null : loadSaved();
if (saved) {
this.state = saved.state;
this.pins = saved.pins ?? { left: null, right: null };
this.sets = saved.sets ?? blankSets();
this.stabTarget = saved.stabTarget ?? FOE;
this.monsterOrders = saved.monsterOrders ?? {};
this.release = saved.release ?? '';
}
}
/** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */
persist(): void {
const bundle: SavedDuel = {
v: SAVE_VERSION,
state: $state.snapshot(this.state),
pins: $state.snapshot(this.pins),
sets: $state.snapshot(this.sets),
stabTarget: this.stabTarget,
monsterOrders: $state.snapshot(this.monsterOrders),
release: this.release
};
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(bundle));
} catch {
// Storage may be full or blocked; the duel still plays in memory.
}
}
/** A duel worth asking about before it is thrown away. */
inProgress = $derived(this.state.turn > 0 && !this.state.over);
you = $derived(this.state.wizards[YOU]);
foe = $derived(this.state.wizards[FOE]);
turnNumber = $derived(this.state.turn + 1);
/** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */
timeStopped = $derived(this.state.timeStops[0] === YOU);
hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped);
activeSets = $derived<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
allowed = $derived<Record<Hand, Gesture[]>>({
left: this.timeStopped ? GESTURES : allowedGestures(this.you, 'left'),
right: this.timeStopped ? GESTURES : allowedGestures(this.you, 'right')
});
forced = $derived<Record<Hand, Gesture | undefined>>({
left: this.timeStopped ? undefined : forcedGesture(this.you, 'left'),
right: this.timeStopped ? undefined : forcedGesture(this.you, 'right')
});
forcedSecond = $derived<Record<Hand, Gesture | undefined>>({
left: this.timeStopped ? undefined : forcedGesture(this.you, 'left', true),
right: this.timeStopped ? undefined : forcedGesture(this.you, 'right', true)
});
forcedFor(set: SetName, hand: Hand): Gesture | undefined {
return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand];
}
charmedHand = $derived<Hand | undefined>(this.timeStopped ? undefined : this.you.constraints.charmed?.hand);
/** True when the bot's hand is yours to command this turn. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
gestureFor(set: SetName, hand: Hand): Gesture {
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
}
private entry(set: SetName): HistoryEntry {
return {
left: this.gestureFor(set, 'left'),
right: this.gestureFor(set, 'right'),
turn: this.state.turn,
phase: set === 'haste' ? 'haste' : this.timeStopped ? 'timestop' : 'main',
hiddenFrom: []
};
}
/** Your history with this turn's drafts appended, so spells light up as you choose. */
previews = $derived<Record<SetName, HistoryEntry[]>>({
main: [...this.you.history, this.entry('main')],
haste: [...this.you.history, this.entry('main'), this.entry('haste')]
});
private seqs = $derived<Record<SetName, Record<Hand, HandTurn[]>>>({
main: { left: handSequence(this.previews.main, 'left'), right: handSequence(this.previews.main, 'right') },
haste: { left: handSequence(this.previews.haste, 'left'), right: handSequence(this.previews.haste, 'right') }
});
completions = $derived<Record<SetName, Record<Hand, Completion[]>>>({
main: {
left: completedSpells(this.seqs.main.left, this.you.sequenceStart),
right: completedSpells(this.seqs.main.right, this.you.sequenceStart)
},
haste: {
left: completedSpells(this.seqs.haste.left, this.you.sequenceStart),
right: completedSpells(this.seqs.haste.right, this.you.sequenceStart)
}
});
chosen = $derived<Record<SetName, Record<Hand, Completion | undefined>>>({
main: {
left: this.completions.main.left.find((c) => c.spell.id === this.sets.main.left.spellId),
right: this.completions.main.right.find((c) => c.spell.id === this.sets.main.right.spellId)
},
haste: {
left: this.completions.haste.left.find((c) => c.spell.id === this.sets.haste.left.spellId),
right: this.completions.haste.right.find((c) => c.spell.id === this.sets.haste.right.spellId)
}
});
/** Every spell you have chosen to cast this turn, across hands and sets. */
planned = $derived.by(() => {
const out: PlannedCast[] = [];
for (const set of this.activeSets) {
for (const hand of HANDS) {
const completion = this.chosen[set][hand];
if (!completion) continue;
out.push({ set, hand, completion, draft: this.sets[set][hand], turnCount: this.previews[set].length });
}
}
return out;
});
conflict = $derived.by(() => {
const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount));
for (let i = 0; i < cells.length; i++) {
for (let j = i + 1; j < cells.length; j++) {
for (const cell of cells[i]) if (cells[j].has(cell)) return true;
}
}
return false;
});
/** Progress of each hand toward each spell, for the reference sheet. */
progress = $derived.by(() => {
const seq = this.seqs[this.hasted ? 'haste' : 'main'];
return Object.fromEntries(
SPELLS.map((s) => [
s.id,
{
left: Math.max(...s.tokens.map((t) => progressToward(t, seq.left, this.you.sequenceStart))),
right: Math.max(...s.tokens.map((t) => progressToward(t, seq.right, this.you.sequenceStart)))
}
])
) as Record<SpellId, Record<Hand, number>>;
});
completedNow(id: SpellId): boolean {
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
}
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
threats = $derived.by(() => {
const out: Threat[] = [];
for (const hand of HANDS) {
const seq = handSequence(this.foeVisible, hand);
for (const turnsAway of [1, 2]) {
for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) {
if (!HOSTILE.includes(c.spell.id)) continue;
if (c.spell.id === 'lightning_bolt_quick' && this.foe.usedQuickLightning) continue;
const tokens = c.spell.tokens[c.seqIndex];
const done = tokens.slice(0, tokens.length - turnsAway);
if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue;
out.push({ hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 });
}
}
}
return out.sort((a, b) => Number(b.soon) - Number(a.soon) || b.done.length - a.done.length);
});
threatsNow = $derived(this.threats.filter((t) => t.soon));
threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6));
toggleHighlight(t: Threat): void {
const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to;
this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to };
}
/** Spells each hand is part-way through, based on the gestures already on the ledger. */
plans = $derived<Record<Hand, Plan[]>>({
left: this.plansFor('left'),
right: this.plansFor('right')
});
private plansFor(hand: Hand): Plan[] {
const seq = handSequence(this.you.history, hand);
const start = this.you.sequenceStart;
const pinned = this.pins[hand];
const best = new Map<SpellId, Plan>();
for (const spell of SPELLS) {
if (spell.id === 'lightning_bolt_quick' && this.you.usedQuickLightning) continue;
spell.tokens.forEach((tokens, seqIndex) => {
const k = progressToward(tokens, seq, start);
if (k === 0 && spell.id !== pinned) return;
const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: spell.id === pinned };
const prior = best.get(spell.id);
if (!prior || plan.remaining.length < prior.remaining.length) best.set(spell.id, plan);
});
}
const all = [...best.values()].sort(
(a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name)
);
return all.slice(0, pinned ? 7 : 6);
}
/** How many plans to show before "More spells" on a narrow screen. */
planLimit(hand: Hand): number {
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
}
pin(hand: Hand, id: SpellId): void {
this.pins[hand] = this.pins[hand] === id ? null : id;
}
/** Pin a plan and make its next gesture, with the other hand too if the gesture needs both. */
follow(hand: Hand, plan: Plan): void {
this.pins[hand] = plan.spell.id;
const next = plan.remaining[0];
if (!next) return;
if (!this.forcedFor('main', hand) && this.charmedHand !== hand && this.allowed[hand].includes(next.gesture)) {
this.sets.main[hand].gesture = next.gesture;
}
const other: Hand = hand === 'left' ? 'right' : 'left';
if (next.both && !this.forcedFor('main', other) && this.charmedHand !== other && this.allowed[other].includes(next.gesture)) {
this.sets.main[other].gesture = next.gesture;
}
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
}
setShowPlans(on: boolean): void {
this.showPlans = on;
savePreference('wh:plans', on);
}
setShowLessons(on: boolean): void {
this.showLessons = on;
savePreference('wh:lessons', on);
}
/** The shortest way for a hand to finish a spell from where its gestures stand. */
planFor(hand: Hand, spell: Spell): Plan {
const seq = handSequence(this.you.history, hand);
const start = this.you.sequenceStart;
let best: Plan | undefined;
spell.tokens.forEach((tokens, seqIndex) => {
const k = progressToward(tokens, seq, start);
const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: this.pins[hand] === spell.id };
if (!best || plan.remaining.length < best.remaining.length) best = plan;
});
return best!;
}
/** Whether a hand can be pointed at a new plan this turn. */
handFree(hand: Hand): boolean {
return !this.forcedFor('main', hand) && this.charmedHand !== hand;
}
openSheet(): void {
this.sheetOpen = true;
document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
summary = $derived(this.state.lastTurn);
surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P'));
stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>')));
yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU));
ready = $derived(
!this.state.over &&
this.activeSets.every((set) =>
HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null)
) &&
!this.conflict
);
/** A delayed effect is waiting for a spell to bank, counting one cast this turn. */
bankingActive = $derived(
!this.you.banked &&
(this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || YOU) === YOU))
);
bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []);
willBank = $derived(
this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0]
);
permanencyActive = $derived(
this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || YOU) === YOU)
);
permanentCandidates = $derived(
this.permanencyActive
? this.planned.filter(
(p) =>
p.completion.spell.category === 'enchantment' &&
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
p !== this.willBank
)
: []
);
willExtend = $derived(
this.permanentCandidates.find((p) => castKey(p.set, p.hand) === this.permanentPick) ?? this.permanentCandidates[0]
);
bankedSpell = $derived(this.you.banked ? SPELL_BY_ID[this.you.banked.spellId] : undefined);
targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] {
const list: { id: TargetId; label: string }[] = [
{ id: YOU, label: 'yourself' },
{ id: FOE, label: this.foe.name }
];
for (const m of this.state.monsters) list.push({ id: m.id, label: m.name });
if (spell && spell.id !== 'fire_storm' && spell.id !== 'ice_storm') list.push({ id: NOWHERE, label: 'nowhere (let it fade)' });
return list;
}
choose(set: SetName, hand: Hand, gesture: Gesture): void {
const draft = this.sets[set][hand];
draft.gesture = draft.gesture === gesture ? null : gesture;
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
}
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
syncSpell(set: SetName, hand: Hand): void {
const draft = this.sets[set][hand];
const options = this.completions[set][hand];
if (!options.some((c) => c.spell.id === draft.spellId)) {
draft.spellId = options[0]?.spell.id ?? '';
draft.target = defaultTarget(options[0]?.spell);
}
}
setSpell(set: SetName, hand: Hand, id: SpellId | ''): void {
this.sets[set][hand].spellId = id;
this.sets[set][hand].target = defaultTarget(id ? SPELL_BY_ID[id] : undefined);
}
private castFor(p: PlannedCast): CastChoice {
const spell = p.completion.spell;
const cast: CastChoice = {
hand: p.hand,
spellId: spell.id,
seqIndex: p.completion.seqIndex,
target: p.draft.target || (spell.usualTarget === 'self' ? YOU : FOE)
};
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
if (spell.category === 'summons') cast.monsterTarget = FOE;
if (p === this.willBank) cast.bank = true;
if (p === this.willExtend) cast.permanent = true;
return cast;
}
reveal(): void {
if (!this.ready) return;
const orders: Record<string, TargetId> = {};
for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE;
const yours: TurnInput = {
left: this.sets.main.left.gesture ?? '-',
right: this.sets.main.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)),
stabTarget: this.stabTarget,
monsterOrders: orders
};
if (this.hasted) {
yours.second = {
left: this.sets.haste.left.gesture ?? '-',
right: this.sets.haste.right.gesture ?? '-',
casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)),
stabTarget: this.stabTarget
};
}
if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE };
if (this.youCharmedFoe) yours.charmGesture = this.charmGesture;
// The engine clones plain data; hand it a snapshot rather than the reactive proxy.
let plain = $state.snapshot(this.state);
const theirs = chooseBotTurn(plain, FOE);
plain = resolveTurn(plain, { A: yours, B: theirs });
// The bot takes any stopped moments it is owed straight away.
while (!plain.over && plain.timeStops[0] === FOE) {
plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) });
}
this.state = plain;
for (const h of HANDS) {
const pinned = this.pins[h];
if (pinned && this.planned.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null;
}
this.resetDrafts();
}
private resetDrafts(): void {
this.sets = blankSets();
this.stabTarget = FOE;
// Monsters keep attacking what they attacked last, until told otherwise or the target is gone.
const orders: Record<string, TargetId> = {};
for (const m of this.state.monsters) {
if (m.owner !== YOU) continue;
const last = m.lastTarget;
const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE);
orders[m.id] = valid && last ? last : FOE;
}
this.monsterOrders = orders;
this.release = '';
this.bankPick = '';
this.permanentPick = '';
this.highlight = null;
}
newGame(): void {
this.state = createGame(pickNames());
this.pins = { left: null, right: null };
this.resetDrafts();
}
}
/** The one duel this browser is playing; it survives moving between pages. */
export const duel = new Duel();
+417
View File
@@ -0,0 +1,417 @@
import { describe, expect, it } from 'vitest';
import { chooseBotTurn } from './bot';
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
import { resolveTurn } from './resolve';
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
import { createGame, type GameState, type TurnInput, type WizardId } from './state';
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
}
/** Feed gesture strings turn by turn; the caster casts every spell it completes on the final turn. */
function runSequence(
state: GameState,
who: WizardId,
left: string,
right: string,
finalCasts: (turnIndex: number) => Partial<TurnInput>
): GameState {
const L = left.split('');
const R = right.split('');
for (let i = 0; i < L.length; i++) {
const extra = i === L.length - 1 ? finalCasts(i) : {};
const inp = { left: L[i] as Gesture, right: R[i] as Gesture, ...extra };
state = who === 'A' ? play(state, inp) : play(state, {}, inp);
}
return state;
}
describe('spell table', () => {
it('has 40 spells, all of them resolved', () => {
expect(SPELLS).toHaveLength(40);
expect(SPELLS.filter((s) => !s.implemented)).toHaveLength(0);
});
it('parses two-handed tokens', () => {
expect(parseSequence('P-P-(w-(s')).toEqual([
{ gesture: 'P', both: false },
{ gesture: 'P', both: false },
{ gesture: 'W', both: true },
{ gesture: 'S', both: true }
]);
expect(parseSequence('W-D-D-C')[3]).toEqual({ gesture: 'C', both: true });
});
});
describe('gesture matching', () => {
const seq = (own: string, other = '') =>
own.split('').map((g, i) => ({ own: g as Gesture, other: (other[i] ?? '-') as Gesture }));
it('finds every spell the tail completes, longest first', () => {
const names = completedSpells(seq('PWPFSSSD'), 0).map((c) => c.spell.id);
expect(names).toEqual(['finger_of_death', 'missile']);
});
it('lets one gesture stream carry overlapping spells', () => {
expect(completedSpells(seq('FFF'), 0).map((c) => c.spell.id)).toEqual(['paralysis']);
expect(completedSpells(seq('FFFSSDD'), 0).map((c) => c.spell.id)).toEqual(['fireball']);
});
it('requires the other hand for two-handed tokens', () => {
expect(completedSpells(seq('PPWS', '--WS'), 0).map((c) => c.spell.id)).toContain('invisibility');
expect(completedSpells(seq('PPWS', '--W-'), 0).map((c) => c.spell.id)).not.toContain('invisibility');
expect(completedSpells(seq('WDDC', '---C'), 0).map((c) => c.spell.id)).toContain('lightning_bolt_quick');
expect(completedSpells(seq('WDDC', '----'), 0).map((c) => c.spell.id)).not.toContain('lightning_bolt_quick');
});
it('honours an anti-spell cut-off', () => {
expect(completedSpells(seq('SD'), 1)).toHaveLength(0);
expect(completedSpells(seq('SD'), 0).map((c) => c.spell.id)).toEqual(['missile']);
});
it('reports spells one gesture away and progress toward them', () => {
const soon = completableIn(seq('FSSD'), 0, 1).map((c) => c.spell.id);
expect(soon).toContain('fireball');
expect(progressToward(SPELL_BY_ID.fireball.tokens[0], seq('FSS'), 0)).toBe(3);
});
it('flags two spells that share a gesture', () => {
const history = ['PW', 'PW', 'WW', 'SS'].map((g) => ({ left: g[0] as Gesture, right: g[1] as Gesture }));
const inv = completedSpells(handSequence(history, 'left'), 0).find((c) => c.spell.id === 'invisibility')!;
const counter = completedSpells(handSequence(history, 'right'), 0).find((c) => c.spell.id === 'counter_spell')!;
expect(castsConflict({ completion: inv, hand: 'left' }, { completion: counter, hand: 'right' }, 4)).toBe(true);
});
});
describe('resolution', () => {
it('a missile does one damage unless shielded', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
s = play(s, { left: 'S' });
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(14);
s = play(s, { left: 'S' });
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] }, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
expect(s.wizards.B.hp).toBe(14);
});
it('a counter-spell stops a fireball but not a finger of death', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
s = runSequence(s, 'A', 'FSSDD', 'PWPFS', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
expect(s.wizards.B.hp).toBe(10);
s = play(s, {}, { left: 'W' });
s = play(s, { right: 'S' }, { left: 'W' });
s = play(s, { right: 'S' }, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'finger_of_death' }] }, { left: 'W' });
expect(s.wizards.B.alive).toBe(true);
s = play(s, {}, { left: 'W' });
s = play(s, {}, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
expect(s.wizards.B.hp).toBe(10);
// Now the real finger of death lands through a counter-spell.
let t = createGame({ A: 'Black', B: 'White' }, 1);
t = runSequence(t, 'A', 'PWPFSSSD', '--------', () => ({ casts: [{ hand: 'left', spellId: 'finger_of_death' }] }));
expect(t.wizards.B.alive).toBe(false);
expect(t.over?.winner).toBe('A');
});
it('counter-spell smothers a finger of death only via dispel magic', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
for (let i = 0; i < 7; i++) s = play(s, { left: 'PWPFSSS'[i] as Gesture }, { left: 'C', right: 'C' });
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'finger_of_death' }] }, { left: 'D' });
expect(s.wizards.B.alive).toBe(false);
let t = createGame({ A: 'Black', B: 'White' }, 1);
const bLeft = '----CDPW';
for (let i = 0; i < 8; i++) {
const last = i === 7;
t = play(
t,
{ left: 'PWPFSSSD'[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'finger_of_death' }] : [] },
{ left: bLeft[i] as Gesture, right: bLeft[i] === 'C' ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'dispel_magic' }] : [] }
);
}
expect(t.wizards.B.alive).toBe(true);
});
it('a summoned goblin attacks the same turn and dies to a stab', () => {
let s = createGame({ A: 'Black', B: 'White' }, 1);
s = runSequence(s, 'A', 'SFW', '---', () => ({ casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] }));
expect(s.monsters).toHaveLength(1);
expect(s.wizards.B.hp).toBe(14);
s = play(s, {}, { left: '>', right: 'P', stabTarget: 'm1', casts: [{ hand: 'right', spellId: 'shield' }] });
expect(s.monsters).toHaveLength(0);
expect(s.wizards.B.hp).toBe(14);
});
it('amnesia forces last turns 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);
});
});
+126
View File
@@ -0,0 +1,126 @@
// Gesture sequence matching: which spells a hand has completed, which it is
// working toward, and whether two chosen spells would reuse the same gesture.
import { SPELLS, type Gesture, type Hand, type Spell, type Token } from './spells';
/** One turn of gestures as seen from one hand. */
export interface HandTurn {
own: Gesture;
other: Gesture;
}
export interface TurnGestures {
left: Gesture;
right: Gesture;
}
export interface Completion {
spell: Spell;
seqIndex: number;
/** Number of turns the sequence spans. */
length: number;
}
export function handSequence(history: TurnGestures[], hand: Hand): HandTurn[] {
return history.map((t) =>
hand === 'left' ? { own: t.left, other: t.right } : { own: t.right, other: t.left }
);
}
export function tokenMatches(token: Token, turn: HandTurn): boolean {
if (token.both) return turn.own === token.gesture && turn.other === token.gesture;
return turn.own === token.gesture;
}
/**
* True when the first `count` tokens of a spell sequence match the most
* recent gestures of the hand, and none of those gestures fall before
* `start` (the first usable turn after an anti-spell).
*/
export function prefixMatchesTail(
tokens: Token[],
count: number,
seq: HandTurn[],
start: number
): boolean {
if (count === 0) return true;
const from = seq.length - count;
if (from < start) return false;
for (let i = 0; i < count; i++) {
if (!tokenMatches(tokens[i], seq[from + i])) return false;
}
return true;
}
/** Every spell the hand has just finished, longest first. */
export function completedSpells(seq: HandTurn[], start: number): Completion[] {
const out: Completion[] = [];
for (const spell of SPELLS) {
spell.tokens.forEach((tokens, seqIndex) => {
if (prefixMatchesTail(tokens, tokens.length, seq, start)) {
out.push({ spell, seqIndex, length: tokens.length });
}
});
}
return out.sort((a, b) => b.length - a.length);
}
/**
* Spells the hand could finish `turnsAway` turns from now if it keeps to the
* sequence. With turnsAway = 1 these are the spells one more gesture completes.
*/
export function completableIn(
seq: HandTurn[],
start: number,
turnsAway: number
): { spell: Spell; seqIndex: number; remaining: Token[] }[] {
const out: { spell: Spell; seqIndex: number; remaining: Token[] }[] = [];
for (const spell of SPELLS) {
spell.tokens.forEach((tokens, seqIndex) => {
const done = tokens.length - turnsAway;
if (done < 1) return;
if (prefixMatchesTail(tokens, done, seq, start)) {
out.push({ spell, seqIndex, remaining: tokens.slice(done) });
}
});
}
return out;
}
/** Longest prefix of `tokens` the hand's latest gestures satisfy (strictly shorter than the spell). */
export function progressToward(tokens: Token[], seq: HandTurn[], start: number): number {
for (let k = tokens.length - 1; k >= 1; k--) {
if (prefixMatchesTail(tokens, k, seq, start)) return k;
}
return 0;
}
/** The (hand, turn) cells a completed spell consumes; two-handed tokens use both hands. */
export function usedCells(completion: Completion, hand: Hand, turnCount: number): Set<string> {
const tokens = completion.spell.tokens[completion.seqIndex];
const cells = new Set<string>();
const from = turnCount - tokens.length;
tokens.forEach((token, i) => {
const turn = from + i;
cells.add(`${hand}:${turn}`);
if (token.both) cells.add(`${hand === 'left' ? 'right' : 'left'}:${turn}`);
});
return cells;
}
/** No gesture may complete more than one spell. */
export function castsConflict(
a: { completion: Completion; hand: Hand },
b: { completion: Completion; hand: Hand },
turnCount: number
): boolean {
const cellsA = usedCells(a.completion, a.hand, turnCount);
for (const cell of usedCells(b.completion, b.hand, turnCount)) {
if (cellsA.has(cell)) return true;
}
return false;
}
export function formatSequence(spell: Spell, seqIndex = 0): string {
return spell.sequences[seqIndex];
}
+20
View File
@@ -0,0 +1,20 @@
import type { Gesture } from './spells';
/** How a gesture is written in the ledger. A clap needs both hands; alone it is nothing. */
export function glyph(g: Gesture, other: Gesture = 'C'): string {
if (g === '>') return '†';
if (g === '-') return '';
if (g === 'C' && other !== 'C') return '(C)';
return g;
}
export const GESTURE_SHORT: Record<Gesture, string> = {
F: 'fingers',
P: 'palm',
S: 'snap',
W: 'wave',
D: 'digit',
C: 'clap',
'>': 'stab',
'-': 'nothing'
};
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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);
});
+205
View File
@@ -0,0 +1,205 @@
// The spell book for Waving Hands (Richard Bartle, 1977), as transcribed in
// docs/waving-hands-rules.txt. Gesture notation: F fingers, P palm, S snap,
// W wave, D digit point, C clap (always both hands). A lower-case letter in
// parentheses, e.g. "(w", means both hands make that gesture at once.
export type Gesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C' | '>' | '-';
export type MagicGesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C';
export type Hand = 'left' | 'right';
export const HANDS: Hand[] = ['left', 'right'];
export const GESTURES: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
export const MAGIC_GESTURES: MagicGesture[] = ['F', 'P', 'S', 'W', 'D', 'C'];
export const GESTURE_NAMES: Record<Gesture, string> = {
F: 'wriggled fingers',
P: 'proffered palm',
S: 'snap',
W: 'wave',
D: 'pointed digit',
C: 'clap',
'>': 'stab',
'-': 'nothing'
};
export interface Token {
gesture: MagicGesture;
/** Both hands must make this gesture on the same turn. */
both: boolean;
}
export type SpellCategory = 'protection' | 'summons' | 'damage' | 'enchantment';
export type SpellId =
| 'shield'
| 'remove_enchantment'
| 'magic_mirror'
| 'counter_spell'
| 'dispel_magic'
| 'raise_dead'
| 'cure_light_wounds'
| 'cure_heavy_wounds'
| 'summon_goblin'
| 'summon_ogre'
| 'summon_troll'
| 'summon_giant'
| 'summon_elemental'
| 'missile'
| 'finger_of_death'
| 'lightning_bolt'
| 'lightning_bolt_quick'
| 'cause_light_wounds'
| 'cause_heavy_wounds'
| 'fireball'
| 'fire_storm'
| 'ice_storm'
| 'amnesia'
| 'confusion'
| 'charm_person'
| 'charm_monster'
| 'paralysis'
| 'fear'
| 'anti_spell'
| 'protection_from_evil'
| 'resist_heat'
| 'resist_cold'
| 'disease'
| 'poison'
| 'blindness'
| 'invisibility'
| 'haste'
| 'time_stop'
| 'delayed_effect'
| 'permanency';
export interface Spell {
id: SpellId;
name: string;
category: SpellCategory;
/** Gesture sequences that complete the spell, in Bartle's notation. */
sequences: string[];
tokens: Token[][];
usualTarget: 'self' | 'enemy';
summary: string;
/** False for spells the engine recognises but does not yet resolve. */
implemented: boolean;
}
export function parseSequence(sequence: string): Token[] {
return sequence.split('-').map((part) => {
if (part.startsWith('(')) {
return { gesture: part[1].toUpperCase() as MagicGesture, both: true };
}
return { gesture: part as MagicGesture, both: part === 'C' };
});
}
function spell(
id: SpellId,
name: string,
category: SpellCategory,
sequences: string[],
usualTarget: 'self' | 'enemy',
summary: string,
implemented = true
): Spell {
return {
id,
name,
category,
sequences,
tokens: sequences.map(parseSequence),
usualTarget,
summary,
implemented
};
}
export const SPELLS: Spell[] = [
// Protection
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Other spells cast at the subject this turn fail, and the subject is shielded. Cannot stop finger of death or dispel magic.'),
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),
spell('cure_heavy_wounds', 'Cure heavy wounds', 'protection', ['D-F-P-W'], 'self', 'Heals 2 damage and cures disease.'),
// Summons
spell('summon_goblin', 'Summon goblin', 'summons', ['S-F-W'], 'self', 'A goblin (1 hit point, 1 damage a turn) fights for the subject.'),
spell('summon_ogre', 'Summon ogre', 'summons', ['P-S-F-W'], 'self', 'An ogre (2 hit points, 2 damage a turn) fights for the subject.'),
spell('summon_troll', 'Summon troll', 'summons', ['F-P-S-F-W'], 'self', 'A troll (3 hit points, 3 damage a turn) fights for the subject.'),
spell('summon_giant', 'Summon giant', 'summons', ['W-F-P-S-F-W'], 'self', 'A giant (4 hit points, 4 damage a turn) fights for the subject.'),
spell('summon_elemental', 'Summon elemental', 'summons', ['C-S-W-W-S'], 'self', 'A fire or ice elemental (3 hit points) savages everyone not resistant to it for 3 a turn.'),
// Damage
spell('missile', 'Missile', 'damage', ['S-D'], 'enemy', '1 damage. Stopped by a shield.'),
spell('finger_of_death', 'Finger of death', 'damage', ['P-W-P-F-S-S-S-D'], 'enemy', 'Kills the subject outright. Only dispel magic can stop it.'),
spell('lightning_bolt', 'Lightning bolt', 'damage', ['D-F-F-D-D'], 'enemy', '5 damage. Shields do not help.'),
spell('lightning_bolt_quick', 'Lightning bolt (quick)', 'damage', ['W-D-D-C'], 'enemy', '5 damage. Each wizard may use this short form only once a duel.'),
spell('cause_light_wounds', 'Cause light wounds', 'damage', ['W-F-P'], 'enemy', '2 damage. Shields do not help.'),
spell('cause_heavy_wounds', 'Cause heavy wounds', 'damage', ['W-P-F-D'], 'enemy', '3 damage. Shields do not help.'),
spell('fireball', 'Fireball', 'damage', ['F-S-S-D-D'], 'enemy', '5 damage unless the subject resists heat. Destroys an ice elemental.'),
spell('fire_storm', 'Fire storm', 'damage', ['S-W-W-C'], 'enemy', '5 damage to everyone not resistant to heat, the caster included. Cancels with an ice storm or ice elemental.'),
spell('ice_storm', 'Ice storm', 'damage', ['W-S-S-C'], 'enemy', '5 damage to everyone not resistant to cold, the caster included. Cancels with a fire storm or fire elemental.'),
// Enchantments
spell('amnesia', 'Amnesia', 'enchantment', ['D-P-P'], 'enemy', 'Next turn the subject must repeat this turns gestures exactly.'),
spell('confusion', 'Confusion', 'enchantment', ['D-S-F'], 'enemy', 'Next turn one of the subjects 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 subjects 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 subjects 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 subjects 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 opponents gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subjects gestures and monsters cannot attack them. Destroys a monster.'),
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject\u2019s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject\u2019s next enchantment (this turn or the next three) lasts for the rest of the duel.')
];
export const SPELL_BY_ID: Record<SpellId, Spell> = Object.fromEntries(
SPELLS.map((s) => [s.id, s])
) as Record<SpellId, Spell>;
/** Enchantments that cancel one another when they hit the same being together. */
export const CONTROL_SPELLS: SpellId[] = [
'amnesia',
'confusion',
'charm_person',
'charm_monster',
'paralysis',
'fear'
];
/** Enchantments a permanency can extend for the rest of the duel. */
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
/** Spells that affect everyone rather than one subject. */
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
summon_goblin: 'goblin',
summon_ogre: 'ogre',
summon_troll: 'troll',
summon_giant: 'giant'
};
export type MonsterKind = 'goblin' | 'ogre' | 'troll' | 'giant' | 'fire_elemental' | 'ice_elemental';
export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; attack: number }> = {
goblin: { name: 'Goblin', hp: 1, attack: 1 },
ogre: { name: 'Ogre', hp: 2, attack: 2 },
troll: { name: 'Troll', hp: 3, attack: 3 },
giant: { name: 'Giant', hp: 4, attack: 4 },
fire_elemental: { name: 'Fire elemental', hp: 3, attack: 3 },
ice_elemental: { name: 'Ice elemental', hp: 3, attack: 3 }
};
export function isElemental(kind: MonsterKind): boolean {
return kind === 'fire_elemental' || kind === 'ice_elemental';
}
+260
View File
@@ -0,0 +1,260 @@
import type { TurnGestures } from './gestures';
import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
export type WizardId = 'A' | 'B';
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */
export type TargetId = WizardId | string;
export const NOWHERE = 'nowhere';
export const MAX_HP = 15;
/** A main turn, the extra pair of gestures a hastened wizard makes, or a turn taken alone under time stop. */
export type Phase = 'main' | 'haste' | 'timestop';
/** Effects that shape a being's gestures or attacks on the following turn. */
export interface Constraints {
amnesia?: boolean;
confusion?: boolean;
/** A permanent confusion always produces the same slip. */
confusionFixed?: { hand: Hand; gesture: Gesture };
fear?: boolean;
paralysedHand?: Hand;
charmed?: { hand: Hand; by: WizardId };
/** A permanent charm repeats the first chosen gesture. */
charmedGesture?: Gesture;
/** Monsters: skip the next attack (paralysis). */
skipAttack?: boolean;
/** Monsters: attack the same target again (amnesia). */
forcedTarget?: TargetId;
}
export interface HistoryEntry extends TurnGestures {
turn: number;
phase: Phase;
/** Wizards who could not see these gestures when they were made. */
hiddenFrom: WizardId[];
}
export interface CastRecord {
turn: number;
/** Index into the wizard's history of the gesture that completed the spell; -1 for a released banked spell. */
index: number;
hand: Hand;
spellId: SpellId;
length: number;
target: TargetId;
}
export interface BankedSpell {
spellId: SpellId;
elemental?: 'fire' | 'ice';
chosenHand?: Hand;
}
export interface WizardState {
id: WizardId;
name: string;
hp: number;
alive: boolean;
surrendered: boolean;
history: HistoryEntry[];
casts: CastRecord[];
/** First turn index whose gestures may still be used in a spell (anti-spell). */
sequenceStart: number;
resistHeat: boolean;
resistCold: boolean;
protectionTurns: number;
blindTurns: number;
invisibleTurns: number;
diseaseTurns: number | null;
poisonTurns: number | null;
/** In force for the turn about to be played. */
constraints: Constraints;
/** Set during resolution, becomes `constraints` next turn. */
pending: Constraints;
/** Constraints made permanent by a permanency spell; reapplied every turn. */
permanent: Constraints;
hasteTurns: number;
/** Turns left in which the next spell cast will be banked. */
delayedTurns: number;
/** Turns left in which the next eligible enchantment becomes permanent. */
permanencyTurns: number;
banked: BankedSpell | null;
usedQuickLightning: boolean;
}
export interface Monster {
id: string;
kind: MonsterKind;
name: string;
hp: number;
maxHp: number;
attack: number;
owner: WizardId;
resistHeat: boolean;
resistCold: boolean;
protectionTurns: number;
constraints: Constraints;
pending: Constraints;
createdTurn: number;
/** What it attacked last turn, shown in the UI and used for amnesia. */
lastTarget?: TargetId;
}
export type LogKind = 'gesture' | 'spell' | 'damage' | 'death' | 'fizzle' | 'info';
export interface LogEntry {
turn: number;
kind: LogKind;
text: string;
}
/** What a reveal did to each wizard's health, for the result strip. */
export interface TurnSummary {
turn: number;
phase: Phase;
wizards: Record<WizardId, { before: number; after: number; effects: { label: string; delta: number }[] }>;
/** Everything worth knowing before the next decision, other than gestures and damage. */
events: string[];
/** Short explanations of rules that just mattered, each shown once a duel. */
lessons: string[];
}
export interface Outcome {
winner: WizardId | null;
reason: string;
}
export interface GameState {
turn: number;
wizards: Record<WizardId, WizardState>;
monsters: Monster[];
log: LogEntry[];
over: Outcome | null;
lastTurn: TurnSummary | null;
/** Keys of explanations already given this duel. */
lessonsShown: string[];
/** Wizards owed an extra turn alone, in order; the next resolution belongs to the first. */
timeStops: WizardId[];
nextMonsterId: number;
summonCounts: Partial<Record<MonsterKind, number>>;
rng: number;
}
export interface CastChoice {
hand: Hand;
spellId: SpellId;
seqIndex?: number;
target?: TargetId;
elemental?: 'fire' | 'ice';
/** Paralysis and charm person: which of the subject's hands. */
chosenHand?: Hand;
/** Summons: what the new monster attacks this turn. */
monsterTarget?: TargetId;
/** Under a delayed effect: bank this spell rather than another cast this turn. */
bank?: boolean;
/** Under a permanency: make this enchantment permanent rather than another cast this turn. */
permanent?: boolean;
}
export interface TurnInput {
left: Gesture;
right: Gesture;
casts: CastChoice[];
/** The extra pair of gestures a hastened wizard makes. */
second?: { left: Gesture; right: Gesture; casts: CastChoice[]; stabTarget?: TargetId };
/** Loose the banked spell this turn. */
release?: { target?: TargetId; monsterTarget?: TargetId };
stabTarget?: TargetId;
monsterOrders?: Record<string, TargetId>;
/** When this wizard charmed the opponent last turn: the gesture the charmed hand makes. */
charmGesture?: Gesture;
}
export function enemyOf(id: WizardId): WizardId {
return id === 'A' ? 'B' : 'A';
}
function makeWizard(id: WizardId, name: string): WizardState {
return {
id,
name,
hp: MAX_HP,
alive: true,
surrendered: false,
history: [],
casts: [],
sequenceStart: 0,
resistHeat: false,
resistCold: false,
protectionTurns: 0,
blindTurns: 0,
invisibleTurns: 0,
diseaseTurns: null,
poisonTurns: null,
constraints: {},
pending: {},
permanent: {},
hasteTurns: 0,
delayedTurns: 0,
permanencyTurns: 0,
banked: null,
usedQuickLightning: false
};
}
export function createGame(names: { A: string; B: string }, seed = Date.now()): GameState {
return {
turn: 0,
wizards: { A: makeWizard('A', names.A), B: makeWizard('B', names.B) },
monsters: [],
log: [],
over: null,
lastTurn: null,
lessonsShown: [],
timeStops: [],
nextMonsterId: 1,
summonCounts: {},
rng: seed >>> 0 || 1
};
}
/** Mulberry32: small seeded generator so duels can be replayed in tests. */
export function nextRandom(state: GameState): number {
state.rng = (state.rng + 0x6d2b79f5) >>> 0;
let t = state.rng;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function isWizardId(id: TargetId | undefined): id is WizardId {
return id === 'A' || id === 'B';
}
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
return state.monsters.find((m) => m.id === id);
}
/** The wizard who commands a being. */
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
if (isWizardId(id)) return id;
return findMonster(state, id)?.owner;
}
export function beingName(state: GameState, id: TargetId | undefined): string {
if (id === undefined || id === NOWHERE) return 'nowhere';
if (isWizardId(id)) return state.wizards[id].name;
return findMonster(state, id)?.name ?? 'a vanished creature';
}
/**
* A wizard's gestures as another wizard saw them: turns made while invisible,
* or while the viewer was blind, are unknown to the viewer.
*/
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
if (viewer === subject) return state.wizards[subject].history;
return state.wizards[subject].history.map((h) =>
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h
);
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children()}
+4
View File
@@ -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;
+541
View File
@@ -0,0 +1,541 @@
<script lang="ts">
import Chronicle from '$lib/components/Chronicle.svelte';
import HandPicker from '$lib/components/HandPicker.svelte';
import Ledger from '$lib/components/Ledger.svelte';
import MobileBar from '$lib/components/MobileBar.svelte';
import SpellSheet from '$lib/components/SpellSheet.svelte';
import TurnSummary from '$lib/components/TurnSummary.svelte';
import WizardStatus from '$lib/components/WizardStatus.svelte';
import { duel, FOE, YOU, castKey, tokensText } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
let confirmingNew = $state(false);
function startNew() {
if (duel.inProgress && !confirmingNew) {
confirmingNew = true;
return;
}
confirmingNew = false;
duel.newGame();
}
// Save the duel whenever anything about it changes, and follow the window width.
$effect(() => {
duel.persist();
});
$effect(() => {
const query = window.matchMedia('(max-width: 860px)');
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
return () => query.removeEventListener('change', apply);
});
const foeMonsters = $derived(duel.state.monsters.filter((m) => m.owner === FOE));
const outcome = $derived(duel.state.over);
const won = $derived(outcome?.winner === YOU);
</script>
<svelte:head>
<title>Waving Hands</title>
<meta name="description" content="A duel of gestures against a rival wizard, after Richard Bartle's 1977 pencil-and-paper game." />
</svelte:head>
<header class="masthead">
<div>
<h1>Waving Hands</h1>
{#if duel.state.turn === 0 && !duel.timeStopped}
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>. Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.</p>
{:else}
<p class="standfirst">You are <em>{duel.you.name}</em>, duelling <em>{duel.foe.name}</em>.</p>
{/if}
</div>
<nav class="actions">
<a class="quiet" href="/rules">Rules</a>
<button type="button" class="quiet" onclick={startNew}>New duel</button>
</nav>
</header>
{#if confirmingNew}
<div class="confirm" role="alertdialog" aria-labelledby="confirm-heading">
<p id="confirm-heading">Abandon the duel against {duel.foe.name}? Turn {duel.turnNumber} will be lost.</p>
<div class="choices">
<button type="button" class="reveal small" onclick={startNew}>Start a new duel</button>
<button type="button" class="quiet" onclick={() => (confirmingNew = false)}>Keep playing</button>
</div>
</div>
{/if}
<main class="board">
<section class="play">
<Ledger {duel} />
<TurnSummary {duel} />
{#if outcome}
<div class="verdict" class:won>
<h2>{won ? 'You win.' : outcome.winner === null ? 'A draw.' : 'You lose.'}</h2>
<p>{outcome.reason}</p>
<button type="button" class="reveal" onclick={() => duel.newGame()}>Duel again</button>
</div>
{:else}
<section class="move" aria-labelledby="move-heading">
{#if duel.timeStopped}
<h2 id="move-heading">Time stands still. Take your extra turn.</h2>
<p class="threats"><span class="muted">{duel.foe.name} cannot move, see this, or resist anything you do. Last turn's enchantments do not bind you.</span></p>
{:else}
<h2 id="move-heading">Turn {duel.turnNumber}: write your gestures{#if duel.hasted}, twice{/if}</h2>
{/if}
{#if duel.timeStopped}
<!-- nobody else moves -->
{:else if duel.threats.length}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
<ul>
{#each duel.threatsNow as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from}
<li>
<button type="button" class="threat soon" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)}<strong>{tokensText(t.remaining)}</strong></span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if duel.threatsNext.length}
<details class="next" open={!duel.compact}>
<summary class="muted">Two gestures away ({duel.threatsNext.length})</summary>
<ul>
{#each duel.threatsNext as t (t.hand + t.spell.id)}
{@const active = duel.highlight?.hand === t.hand && duel.highlight.from === t.from && duel.highlight.to === t.to}
<li>
<button type="button" class="threat" class:active aria-pressed={active} onclick={() => duel.toggleHighlight(t)}>
<span class="thand">{t.hand}</span>
<span class="tseq">{tokensText(t.done)}{tokensText(t.remaining)}</span>
<span class="tname">{t.spell.name}</span>
</button>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{:else}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
<div class="hands">
<HandPicker {duel} hand="left" />
<HandPicker {duel} hand="right" />
</div>
<div class="prefs">
<label class="pref">
<input type="checkbox" checked={duel.showPlans} onchange={(e) => duel.setShowPlans(e.currentTarget.checked)} />
Show spells under way beneath each hand
</label>
<label class="pref">
<input type="checkbox" checked={duel.showLessons} onchange={(e) => duel.setShowLessons(e.currentTarget.checked)} />
Explain rules as they come up
</label>
</div>
{#if duel.hasted}
<p class="haste-note">You are hastened: a second pair of gestures follows the first, and both take effect together.</p>
<div class="hands">
<HandPicker {duel} hand="left" set="haste" />
<HandPicker {duel} hand="right" set="haste" />
</div>
{/if}
<div class="orders">
{#if duel.stabbing}
<label>
<span>Stab</span>
<select bind:value={duel.stabTarget}>
<option value={FOE}>{duel.foe.name}</option>
{#each foeMonsters as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label>
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
<option value={FOE}>{duel.foe.name}</option>
{#each foeMonsters as fm (fm.id)}
<option value={fm.id}>{fm.name}</option>
{/each}
</select>
</label>
{/each}
{#if duel.bankedSpell}
<label>
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
{#each duel.targetsFor(duel.bankedSpell) as t (t.id)}
<option value={t.id}>at {t.label}</option>
{/each}
</select>
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label>
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label>
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
<option value={castKey(p.set, p.hand)}>{p.completion.spell.name} ({p.hand}{p.set === 'haste' ? ', second pair' : ''})</option>
{/each}
</select>
</label>
{/if}
{#if duel.youCharmedFoe}
<label>
<span>{duel.foe.name}'s charmed {duel.foe.constraints.charmed?.hand} hand makes</span>
<select bind:value={duel.charmGesture}>
{#each MAGIC_GESTURES as g (g)}
<option value={g}>{glyph(g)} {GESTURE_SHORT[g]}</option>
{/each}
</select>
</label>
{/if}
</div>
{#if duel.surrendering}
<p class="warning">Both palms at once is surrender. You will lose the duel.</p>
{/if}
{#if duel.conflict}
<p class="warning">Those two spells share a gesture. A gesture can finish only one spell; let one of them pass.</p>
{/if}
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act in the stopped moment' : 'Reveal gestures'}
</button>
</section>
{/if}
</section>
<aside class="rail">
<WizardStatus wizard={duel.you} monsters={duel.yourMonsters} you />
<WizardStatus wizard={duel.foe} monsters={foeMonsters} />
<SpellSheet {duel} />
<Chronicle log={duel.state.log} />
</aside>
</main>
<MobileBar {duel} />
<footer class="colophon">
<p>After Richard Bartle's <em>Waving Hands</em> (1977), also known as Spellbinder and Spellcaster. <a href="/rules">Read the full rules</a>.</p>
</footer>
<style>
.masthead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem var(--gutter) 0.6rem;
max-width: 1280px;
margin: 0 auto;
}
h1 {
font-size: clamp(2rem, 4vw, 2.7rem);
font-weight: 300;
font-variation-settings: 'opsz' 144;
letter-spacing: -0.01em;
}
.standfirst {
max-width: 38em;
color: var(--bone-dim);
margin-top: 0.4rem;
}
.standfirst em {
color: var(--bone);
}
.actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.4rem 0.9rem;
white-space: nowrap;
color: var(--bone);
text-decoration: none;
line-height: 1.55;
}
.quiet:hover {
border-color: var(--bone);
}
.confirm {
max-width: 1280px;
margin: 0 auto 0.75rem;
padding: 0.75rem var(--gutter);
}
.confirm p {
color: var(--blood);
margin-bottom: 0.5rem;
}
.choices {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.reveal.small {
margin: 0;
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
details.next summary {
cursor: pointer;
list-style: none;
}
details.next summary::before {
content: '\25B8 ';
color: var(--bone-faint);
}
details.next[open] summary::before {
content: '\25BE ';
}
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
.play {
min-width: 0;
}
.rail {
min-width: 0;
}
.move {
padding: 0.6rem 0 1.5rem;
}
.move h2 {
font-size: 1.3rem;
margin-bottom: 0.3rem;
}
.threats {
font-size: 0.9rem;
margin-bottom: 0.7rem;
}
.threats ul {
list-style: none;
margin: 0.15rem 0 0.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.5rem;
}
.threat {
background: var(--slate);
border: 1px solid transparent;
border-radius: 3px;
padding: 0.15rem 0.5rem;
display: inline-flex;
gap: 0.5rem;
align-items: baseline;
color: var(--bone-dim);
}
.threat.soon {
color: var(--bone);
}
.threat:hover,
.threat.active {
border-color: var(--frost);
}
.threat.active {
background: rgba(140, 200, 224, 0.12);
}
.thand {
font-style: italic;
font-size: 0.8rem;
}
.tseq {
letter-spacing: 0.04em;
}
.tseq strong {
font-weight: 600;
color: var(--ember);
}
.prefs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1.5rem;
margin-top: 0.7rem;
}
.pref {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.85rem;
color: var(--bone-dim);
}
.colophon a {
color: var(--frost);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem 2rem;
}
.orders {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 0.9rem;
}
.orders label {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.orders label span {
color: var(--bone-dim);
font-style: italic;
}
.warning {
color: var(--blood);
margin-top: 0.8rem;
}
.haste-note {
color: var(--frost);
font-size: 0.95rem;
margin: 1rem 0 0.6rem;
}
.reveal {
margin-top: 0.8rem;
background: var(--bone);
color: var(--slate-deep);
border: 0;
border-radius: 4px;
padding: 0.6rem 1.4rem;
font-size: 1.05rem;
font-weight: 500;
}
.reveal:hover:not(:disabled) {
background: #fff;
}
.reveal:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.verdict {
padding: 1.5rem 0;
}
.verdict h2 {
font-size: 2rem;
font-style: italic;
color: var(--blood);
}
.verdict.won h2 {
color: var(--ember);
}
.verdict p {
margin: 0.3rem 0 0.8rem;
color: var(--bone-dim);
}
.colophon {
max-width: 1280px;
margin: 0 auto;
padding: 2rem var(--gutter) 3rem;
color: var(--bone-faint);
font-size: 0.85rem;
}
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
.colophon {
padding-bottom: 6rem;
}
/* The sticky bar carries the reveal button on phones. */
.move > .reveal {
display: none;
}
.rail {
order: 2;
border-top: 1px solid var(--rule-strong);
margin-top: 1rem;
}
.hands {
grid-template-columns: 1fr;
}
.masthead {
flex-direction: column;
}
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<script lang="ts">
import raw from '../../../docs/waving-hands-rules.txt?raw';
import { MONSTER_STATS, SPELLS, type SpellCategory } from '$lib/game/spells';
const groups: { category: SpellCategory; title: string; blurb: string }[] = [
{ category: 'protection', title: 'Protection', blurb: 'Usually cast on yourself. A shield is a single P, so a palm is never wasted.' },
{ category: 'summons', title: 'Summons', blurb: 'Creatures fight for whoever the spell is cast on, attack the same turn they appear, and keep attacking each turn until destroyed.' },
{ category: 'damage', title: 'Damage', blurb: 'A wizard can take 14 points; the fifteenth kills. Cures apply in the same turn as the damage.' },
{ category: 'enchantment', title: 'Enchantments', blurb: 'Most bind the subject on the following turn. Two of the mind-affecting kind landing at once cancel each other.' }
];
const HEADINGS = [
'Introduction',
'The Turn',
'Gestures',
'Winning',
'Spells',
'Protection spells',
'Summons spells',
'Damaging Spells',
'Enchantments',
'Non-spells',
'Reference Sheet',
'Sample Game',
'Annotation'
];
/** Split the transcription at its headings so each part can be linked. */
const original = (() => {
const lines = raw.split('\n');
const sections: { title: string; id: string; body: string }[] = [];
let current: { title: string; id: string; lines: string[] } | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (HEADINGS.includes(trimmed)) {
if (current) sections.push({ title: current.title, id: current.id, body: current.lines.join('\n').trim() });
current = { title: trimmed, id: 'original-' + trimmed.toLowerCase().replace(/\s+/g, '-'), lines: [] };
continue;
}
if (/^\s*(Spellcaster|Rules for the Spellcaster Game|Back to Main List)\s*$/.test(line)) continue;
if (current) current.lines.push(line);
}
if (current) sections.push({ title: current.title, id: current.id, body: current.lines.join('\n').trim() });
return sections;
})();
const monsters = (['goblin', 'ogre', 'troll', 'giant', 'fire_elemental', 'ice_elemental'] as const).map((k) => MONSTER_STATS[k]);
</script>
<svelte:head>
<title>Waving Hands: the rules</title>
</svelte:head>
<main class="rules">
<header>
<a class="back" href="/">Back to the duel</a>
<h1>How to duel</h1>
<p class="lede">Waving Hands is Richard Bartle's 1977 game of two wizards trading spells made of hand gestures. This page is a working reference; the original transcription is at the end.</p>
<nav aria-label="Sections">
<a href="#turn">A turn</a>
<a href="#gestures">Gestures</a>
<a href="#overlap">Overlapping spells</a>
<a href="#targets">Targets and timing</a>
<a href="#monsters">Monsters</a>
<a href="#winning">Winning</a>
<a href="#spells">Every spell</a>
<a href="#original">Original text</a>
</nav>
</header>
<section id="turn">
<h2>A turn</h2>
<ol>
<li>Both wizards secretly choose a gesture for each hand. In this game you pick them, then choose which finished spells to cast and at whom.</li>
<li>Both reveal at once. Every spell, stab and monster attack of the turn takes effect together, so a shield you make this turn stops a missile made this turn.</li>
<li>Damage and healing are added up, monsters that lost all their hit points are removed, and enchantments cast this turn bind next turn.</li>
</ol>
</section>
<section id="gestures">
<h2>Gestures</h2>
<p>Six magical gestures: <strong>F</strong> wriggled fingers, <strong>P</strong> proffered palm, <strong>S</strong> snap, <strong>W</strong> wave, <strong>D</strong> pointed digit and <strong>C</strong> clap. A clap needs both hands; a clap with one hand is nothing. A hand may instead <strong>stab</strong> with the knife (one point of damage, one stab a turn) or do <strong>nothing</strong>. Neither belongs to any spell, so a hand that stabs or rests starts its next spell from scratch.</p>
<p>A spell is a run of gestures made on consecutive turns by one hand. In the notation, a bracketed lower-case letter such as <strong>(w</strong> means that gesture made with both hands at once.</p>
<p>Both palms at once is <strong>surrender</strong>. It is not a spell and it ends the duel.</p>
</section>
<section id="overlap">
<h2>Overlapping spells</h2>
<p>A gesture can belong to more than one spell as long as each spell's run is unbroken. F-F-F is Paralysis; follow it with S-S-D-D and the last five gestures, F-S-S-D-D, are a Fireball. Another F after F-F-F is a second Paralysis. Each gesture can finish only one spell, so if two spells would end on the same gesture you choose one. Any finished spell may be let go harmlessly, except the storms, which rage wherever they are released.</p>
</section>
<section id="targets">
<h2>Targets and timing</h2>
<p>Every spell has a subject. Protection and summons are usually cast on yourself; attacks and enchantments on your opponent; but any spell may be aimed at any wizard or monster, and a spell aimed at something that no longer exists is lost.</p>
<p>Because everything is simultaneous, a monster killed the turn it appears still attacks that turn, a Resist heat cast in the same turn as a Fireball saves you, and a Remove enchantment cast in the same turn as a Fireball leaves you burned. <strong>Counter-spell</strong> stops every other spell cast at its subject that turn, except Finger of death and Dispel magic. <strong>Dispel magic</strong> stops every spell on the field, ends every enchantment and unmakes every monster after it attacks.</p>
</section>
<section id="monsters">
<h2>Monsters</h2>
<p>A summoned creature obeys whoever it was summoned for, attacks any wizard or monster its master names, and is stopped by a shield on its target. Elementals attack everyone not resistant to their element, are destroyed by the opposite element, and merge if two of a kind meet.</p>
<table>
<thead><tr><th>Creature</th><th>Hit points</th><th>Damage a turn</th></tr></thead>
<tbody>
{#each monsters as m (m.name)}
<tr><td>{m.name}</td><td>{m.hp}</td><td>{m.attack}</td></tr>
{/each}
</tbody>
</table>
</section>
<section id="winning">
<h2>Winning</h2>
<p>Each wizard has fifteen hit points and dies on losing them all. If both die in the same turn the duel is a draw. Surrender loses, unless the surrendering wizard's last gestures also killed the opponent.</p>
</section>
<section id="spells">
<h2>Every spell</h2>
{#each groups as g (g.category)}
<h3 id={'spells-' + g.category}>{g.title}</h3>
<p class="blurb">{g.blurb}</p>
<dl>
{#each SPELLS.filter((s) => s.category === g.category) as spell (spell.id)}
<div>
<dt><span class="name">{spell.name}</span> <span class="seq">{spell.sequences.join(' or ')}</span></dt>
<dd>{spell.summary}</dd>
</div>
{/each}
</dl>
{/each}
</section>
<section id="original">
<h2>The original text</h2>
<p class="blurb">Bartle's rules as printed in the <em>Duel Purpose</em> fanzine, transcribed by Andrew Buchanan. Kept as the source for anything this page simplifies.</p>
<nav aria-label="Original sections" class="subnav">
{#each original as sec (sec.id)}
<a href={'#' + sec.id}>{sec.title}</a>
{/each}
</nav>
{#each original as sec (sec.id)}
<details id={sec.id}>
<summary>{sec.title}</summary>
<pre>{sec.body}</pre>
</details>
{/each}
</section>
</main>
<style>
.rules {
max-width: 44rem;
margin: 0 auto;
padding: 1.5rem var(--gutter) 4rem;
}
a {
color: var(--frost);
}
.back {
font-size: 0.95rem;
}
h1 {
font-size: 2.4rem;
font-weight: 300;
font-variation-settings: 'opsz' 144;
margin: 0.5rem 0 0.3rem;
}
.lede {
color: var(--bone-dim);
max-width: 38em;
}
nav {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 1rem;
margin: 1rem 0 0.5rem;
font-size: 0.95rem;
}
section {
padding: 1.25rem 0 0.5rem;
border-top: 1px solid var(--rule);
margin-top: 1rem;
}
h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
h3 {
font-size: 1.15rem;
font-style: italic;
margin-top: 1.25rem;
}
p + p,
ol {
margin-top: 0.6rem;
}
ol {
padding-left: 1.4rem;
}
li + li {
margin-top: 0.3rem;
}
.blurb {
color: var(--bone-dim);
font-size: 0.95rem;
margin: 0.2rem 0 0.6rem;
}
table {
border-collapse: collapse;
margin-top: 0.75rem;
font-size: 0.95rem;
}
th,
td {
text-align: left;
padding: 0.25rem 1.5rem 0.25rem 0;
border-bottom: 1px solid var(--rule);
}
th {
font-weight: 500;
color: var(--bone-dim);
}
dl {
margin: 0;
}
dl > div {
padding: 0.45rem 0;
border-bottom: 1px solid var(--rule);
}
dt .name {
font-weight: 500;
}
dt .seq {
margin-left: 0.6rem;
letter-spacing: 0.05em;
color: var(--ember);
}
dd {
margin: 0.15rem 0 0;
color: var(--bone-dim);
font-size: 0.95rem;
}
.subnav {
font-size: 0.9rem;
}
details {
border-bottom: 1px solid var(--rule);
padding: 0.4rem 0;
}
summary {
cursor: pointer;
font-style: italic;
}
pre {
font-family: var(--font);
font-size: 0.95rem;
line-height: 1.55;
white-space: pre-wrap;
margin: 0.5rem 0 0.5rem;
color: var(--bone-dim);
}
</style>