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
+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>