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 WagonerandClaude Fable 5.1 committed 2026-09-22 15:33:29 -04:00
commit 865555e911
38 files changed
+8112

No files matched your search

+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 turn’s position.'
: ''
);
</script>
<fieldset class="hand">
<legend>{hand === 'left' ? 'Left hand' : 'Right hand'}{#if set === 'haste'}<span class="again">, again</span>{/if}</legend>
{#if forced}
<p class="locked"><span class="letter big">{glyph(forced, duel.gestureFor(set, other))}</span> {whyForced}</p>
{:else if charmed}
<p class="locked"><span class="letter big">?</span> Charmed: {duel.foe.name} chooses this hand’s gesture.</p>
{:else}
<div class="gestures" role="group" aria-label="{hand} hand gesture{set === 'haste' ? ', second pair' : ''}">
{#each GESTURES as g (g)}
<button
type="button"
class="gesture"
class:on={draft.gesture === g}
disabled={!duel.allowed[hand].includes(g)}
aria-pressed={draft.gesture === g}
onclick={() => duel.choose(set, hand, g)}
>
<GestureIcon gesture={g} {hand} />
<span class="letter">{glyph(g)}</span>
<span class="name">{GESTURE_SHORT[g]}</span>
</button>
{/each}
</div>
{#if duel.you.constraints.fear && !duel.timeStopped}
<p class="hint">Fear: no clap, digit, fingers or snap this turn.</p>
{/if}
{#if set === 'main' && duel.showPlans && duel.plans[hand].length === 0}
<p class="hint">Nothing under way. <button type="button" class="link" onclick={() => duel.openSheet()}>Pick a spell from the sheet</button> to plan with this hand.</p>
{:else if set === 'main' && duel.showPlans && duel.plans[hand].length > 0}
<ul class="plans" aria-label="{hand} hand, spells under way">
{#each plans as plan (plan.spell.id)}
<li class:pinned={plan.pinned}>
<button type="button" class="plan" title="Make the next gesture toward {plan.spell.name}" onclick={() => duel.follow(hand, plan)}>
<span class="seq">{#if plan.done.length}<span class="done">{tokensText(plan.done)}</span><span class="arrow">→</span>{/if}<span class="next">{tokensText(plan.remaining)}</span></span>
<span class="pname">{plan.spell.name}</span>
</button>
<button type="button" class="pin" aria-pressed={plan.pinned} title={plan.pinned ? 'Unpin' : 'Pin this spell'} onclick={() => duel.pin(hand, plan.spell.id)}>{plan.pinned ? '\u25c6' : '\u25c7'}</button>
</li>
{/each}
{#if hiddenPlans > 0 || morePlans}
<li class="more">
<button type="button" class="plan" onclick={() => (morePlans = !morePlans)}>{morePlans ? 'Fewer spells' : `More spells (${hiddenPlans})`}</button>
</li>
{/if}
</ul>
{/if}
{/if}
{#if options.length > 0}
<div class="cast">
<label>
<span>Finishes</span>
<select value={draft.spellId} onchange={(e) => duel.setSpell(set, hand, e.currentTarget.value as SpellId | '')}>
{#each options as c (c.spell.id + c.seqIndex)}
<option value={c.spell.id}>{c.spell.name} ({c.spell.sequences[c.seqIndex]})</option>
{/each}
<option value="">nothing, let it pass</option>
</select>
</label>
{#if spell}
<label>
<span>at</span>
<select bind:value={draft.target}>
{#each duel.targetsFor(spell) as t (t.id)}
<option value={t.id}>{t.label}</option>
{/each}
</select>
</label>
{#if spell.id === 'summon_elemental'}
<label>
<span>of</span>
<select bind:value={draft.elemental}>
<option value="fire">fire</option>
<option value="ice">ice</option>
</select>
</label>
{/if}
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
<label>
<span>their</span>
<select bind:value={draft.chosenHand}>
<option value="left">left hand</option>
<option value="right">right hand</option>
</select>
</label>
{/if}
<p class="summary">
{spell.summary}
{#if banking}<strong>This spell will be banked by your delayed effect, not cast.</strong>{/if}
{#if extending}<strong>Your permanency will make this enchantment last the whole duel.</strong>{/if}
</p>
{/if}
</div>
{/if}
</fieldset>
<style>
.hand {
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
legend {
font-style: italic;
font-size: 1.1rem;
padding: 0;
margin-bottom: 0.4rem;
}
.again {
color: var(--frost);
}
.gestures {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
}
.gesture {
background: var(--slate-raised);
border: 1px solid transparent;
border-radius: 4px;
padding: 0.3rem 0.2rem 0.25rem;
display: flex;
flex-direction: column;
align-items: center;
line-height: 1;
color: var(--bone);
transition: background 120ms ease;
}
.gesture:hover:not(:disabled) {
border-color: var(--rule-strong);
}
.gesture.on {
background: var(--ember);
color: var(--slate-deep);
}
.gesture:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.gesture .letter {
font-size: 1.25rem;
margin-top: 0.05rem;
}
.gesture .name {
font-size: 0.65rem;
margin-top: 0.2rem;
opacity: 0.85;
}
.locked {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--bone-dim);
background: var(--slate-raised);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.big {
font-size: 1.8rem;
color: var(--bone);
}
.cast {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
align-items: baseline;
}
.cast label {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.cast label span {
color: var(--bone-dim);
font-style: italic;
}
.plans {
list-style: none;
margin: 0.4rem 0 0;
padding: 0;
font-size: 0.82rem;
line-height: 1.4;
}
.plans li {
display: flex;
align-items: baseline;
gap: 0.3rem;
}
.plan {
flex: 1;
min-width: 0;
background: none;
border: 0;
padding: 0.1rem 0.2rem;
text-align: left;
display: flex;
gap: 0.6rem;
align-items: baseline;
border-radius: 3px;
}
.plan:hover {
background: var(--slate-raised);
}
.seq {
letter-spacing: 0.04em;
white-space: nowrap;
color: var(--bone-dim);
}
.seq .done {
color: var(--ember);
}
.seq .arrow {
color: var(--bone-faint);
margin: 0 0.35em;
}
.seq .next {
color: var(--bone);
}
.pname {
color: var(--bone-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
li.pinned .pname {
color: var(--frost);
}
li.more .plan {
color: var(--frost);
font-size: 0.8rem;
}
.pin {
background: none;
border: 0;
color: var(--bone-faint);
padding: 0 0.2rem;
font-size: 0.8rem;
}
li.pinned .pin {
color: var(--frost);
}
.summary,
.hint {
width: 100%;
font-size: 0.85rem;
color: var(--bone-dim);
margin-top: 0.3rem;
}
.link {
background: none;
border: 0;
padding: 0;
color: var(--frost);
text-decoration: underline;
font-size: inherit;
}
.summary strong {
display: block;
font-weight: 500;
color: var(--frost);
}
</style>