Credibility pass: one conflict check, one hostile list, one set of chrome

Duplicated logic and styling that had drifted apart is unified: the
gesture-sharing check, the hostile spell list, the enchanted test, the
plan limits, and the shared button, link, field and disabled styles.
Dead plumbing is gone: the always-true implemented flag, the unread
cast length, an unreachable guard, an impossible time-stop condition,
the unused sequence formatter and utility class, the template
placeholder. The counter-spell tests now put a counter-spell in front
of a spell, the test helpers live in one file, and the resolver's
sections are numbered in order. The deploy script's cache headers now
do what its comment says, and the README describes the screen rather
than listing features in the order they arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-09-22 16:37:54 -04:00
co-authored by Claude Fable 5.1
parent 527820ee95
commit 5de352db43
22 changed files with 415 additions and 485 deletions
+56 -3
View File
@@ -57,12 +57,14 @@ button {
button:focus-visible,
select:focus-visible,
input:focus-visible,
summary:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 2px;
}
select {
select,
input[type='text'] {
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
@@ -70,6 +72,53 @@ select {
max-width: 100%;
}
input[type='text'] {
color: var(--bone);
padding: 0.3rem 0.6rem;
}
button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
/* An action written as a word in running text. */
.link {
background: none;
border: 0;
padding: 0;
font-size: inherit;
color: var(--frost);
text-decoration: underline;
}
/* The one button that commits a turn. */
.reveal {
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;
}
/* An italic prompt beside a control, e.g. "at" before a target list. */
.field {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.field > span {
color: var(--bone-dim);
font-style: italic;
}
.letter {
font-weight: 600;
font-variation-settings: 'opsz' 144;
@@ -80,8 +129,12 @@ select {
color: var(--bone-dim);
}
.ember {
color: var(--ember);
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
@media (prefers-reduced-motion: reduce) {
+28 -51
View File
@@ -1,8 +1,8 @@
<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';
import GestureIcon from './GestureIcon.svelte';
let { duel, hand, set = 'main' }: { duel: Duel; hand: Hand; set?: SetName } = $props();
@@ -23,7 +23,7 @@
duel.you.constraints.amnesia
? 'Amnesia: you must repeat last turn.'
: duel.you.constraints.paralysedHand === hand
? 'Paralysed in last turns position.'
? "Paralysed in last turn's position."
: ''
);
</script>
@@ -34,7 +34,7 @@
{#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>
<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)}
@@ -55,31 +55,33 @@
{#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 set === 'main' && duel.showPlans}
{#if 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}
<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}
{#if options.length > 0}
<div class="cast">
<label>
<label class="field">
<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)}
@@ -89,7 +91,7 @@
</select>
</label>
{#if spell}
<label>
<label class="field">
<span>at</span>
<select bind:value={draft.target}>
{#each duel.targetsFor(spell) as t (t.id)}
@@ -98,7 +100,7 @@
</select>
</label>
{#if spell.id === 'summon_elemental'}
<label>
<label class="field">
<span>of</span>
<select bind:value={draft.elemental}>
<option value="fire">fire</option>
@@ -107,7 +109,7 @@
</label>
{/if}
{#if spell.id === 'paralysis' || spell.id === 'charm_person'}
<label>
<label class="field">
<span>their</span>
<select bind:value={draft.chosenHand}>
<option value="left">left hand</option>
@@ -172,11 +174,6 @@
color: var(--slate-deep);
}
.gesture:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.gesture .letter {
font-size: 1.25rem;
margin-top: 0.05rem;
@@ -211,17 +208,6 @@
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;
@@ -308,15 +294,6 @@
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;
+2 -12
View File
@@ -104,13 +104,10 @@
{#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 undecided = duel.chosenGesture(set, hand) === null || 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>
<span class="letter" class:placeholder={undecided}>{duel.drawn(set, hand)}</span>
{#if cast && duel.sets[set][hand].spellId}<span class="note">{cast.spell.name}</span>{/if}
</span>
{/each}
@@ -251,11 +248,4 @@
border-bottom: 2px solid var(--ember);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
+7 -16
View File
@@ -1,12 +1,9 @@
<script lang="ts">
import { FOE, YOU, type Duel } from '$lib/game/duel.svelte';
import { glyph } from '$lib/game/glyphs';
import type { Duel } from '$lib/game/duel.svelte';
import { HANDS } from '$lib/game/spells';
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}
@@ -16,8 +13,9 @@
<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>
{#each HANDS as hand (hand)}
<span class="letter" class:empty={duel.chosenGesture('main', hand) === null}>{duel.drawn('main', hand)}</span>
{/each}
</div>
<button type="button" class="reveal" disabled={!duel.ready} onclick={() => duel.reveal()}>
{duel.surrendering ? 'Surrender' : duel.timeStopped ? 'Act' : 'Reveal'}
@@ -30,6 +28,7 @@
display: none;
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.bar {
position: fixed;
@@ -77,15 +76,7 @@
}
.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;
font-size: 1rem;
}
</style>
+2 -8
View File
@@ -12,7 +12,6 @@
{ category: 'damage', title: 'Damage' },
{ category: 'enchantment', title: 'Enchantments' }
];
</script>
<details class="sheet" id="spell-sheet" bind:open={duel.sheetOpen}>
@@ -26,8 +25,8 @@
{@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>
<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>
@@ -137,11 +136,6 @@
border-color: var(--frost);
}
.planbtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.which {
color: var(--frost);
}
+3 -3
View File
@@ -31,14 +31,14 @@
</div>
{/each}
</dl>
{#if summary.events?.length}
{#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}
{#if duel.showLessons && summary.lessons.length}
<ul class="lessons">
{#each summary.lessons as l, i (i)}
<li>{l}</li>
@@ -58,7 +58,7 @@
font-size: 0.9rem;
}
/* On a phone, leave the last two ledger rows in view above the result. */
/* On a phone, leave the last two ledger rows in view above the result. Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.result {
scroll-margin-top: 9.5rem;
+23 -49
View File
@@ -5,6 +5,7 @@
// and each named opponent weights the spell book a little differently.
import {
cellsOverlap,
completableIn,
completedSpells,
handSequence,
@@ -15,10 +16,11 @@ import {
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 { GESTURES, HANDS, HOSTILE_SPELLS, MAGIC_GESTURES, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
import {
MAX_HP,
enemyOf,
isEnchanted,
visibleHistory,
type CastChoice,
type GameState,
@@ -27,27 +29,6 @@ import {
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,
@@ -122,16 +103,16 @@ function assess(state: GameState, me: WizardId, rng: () => number): Situation {
let missileNow = false;
let foeFirePlan = false;
let foeIcePlan = false;
for (const hand of ['left', 'right'] as Hand[]) {
for (const hand of HANDS) {
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
if (!HOSTILE.includes(c.spell.id)) continue;
if (!HOSTILE_SPELLS.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;
if (!HOSTILE_SPELLS.includes(c.spell.id)) continue;
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
}
for (const spell of SPELLS) {
@@ -166,8 +147,7 @@ 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;
const foeEnchanted = isEnchanted(foeW);
switch (spell.id) {
case 'fireball':
if (s.iceElemental) return 6;
@@ -208,7 +188,7 @@ function baseValue(s: Situation, spell: Spell): number {
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 hand of HANDS) {
for (const sp of SPELLS) {
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
}
@@ -296,10 +276,9 @@ function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned
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;
for (const tokens of spell.tokens) {
const k = progressToward(tokens, seq, start);
if (k === 0) return;
if (k === 0) continue;
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.
@@ -309,14 +288,14 @@ function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned
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 hand of HANDS) {
for (const completion of completions[hand]) {
const value = spellValue(s, completion.spell);
if (value > 0.25) options.push({ hand, completion, value });
@@ -327,11 +306,7 @@ function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCo
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;
if (chosen.some((c) => cellsOverlap(cells, usedCells(c.completion, c.hand, turnCount)))) continue;
chosen.push(o);
}
return chosen.map((o) => toChoice(s, o.hand, o.completion));
@@ -341,9 +316,9 @@ 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 hand of HANDS) {
for (const spell of SPELLS) {
if (!HOSTILE.includes(spell.id)) continue;
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
if (k > bestK) {
bestK = k;
@@ -375,8 +350,7 @@ function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
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;
choice.target = isEnchanted(foeW) || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
break;
}
case 'summon_elemental':
@@ -406,11 +380,11 @@ function charmGesture(s: Situation): Gesture {
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[]) {
for (const g of MAGIC_GESTURES) {
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
let score = 0;
for (const spell of SPELLS) {
if (!HOSTILE.includes(spell.id)) continue;
if (!HOSTILE_SPELLS.includes(spell.id)) continue;
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
}
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
@@ -428,11 +402,11 @@ interface PairChoice {
}
/** 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 {
function choosePair(s: Situation, history: TurnGestures[], 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', '>', '-'];
if (timeStopped) return GESTURES;
const forced = forcedGesture(meW, hand);
if (forced) return [forced];
const allowed = allowedGestures(meW, hand);
@@ -452,7 +426,7 @@ function choosePair(s: Situation, history: TurnGestures[], rng: () => number, ti
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;
score += s.rng() * 0.5;
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
}
}
@@ -471,7 +445,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
const foe = s.foe;
const timeStopped = state.timeStops[0] === me;
const first = choosePair(s, meW.history, rng, timeStopped);
const first = choosePair(s, meW.history, 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;
@@ -484,7 +458,7 @@ export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number
monsterOrders
};
if (meW.hasteTurns > 0 && !timeStopped) {
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], timeStopped);
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
}
if (meW.banked) {
+42 -50
View File
@@ -3,6 +3,7 @@
import { tick } from 'svelte';
import { chooseBotTurn } from './bot';
import {
cellsOverlap,
completableIn,
completedSpells,
handSequence,
@@ -11,19 +12,20 @@ import {
type Completion,
type HandTurn
} from './gestures';
import type { Token } from './spells';
import { glyph } from './glyphs';
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
import {
CONTROL_SPELLS,
GESTURES,
HANDS,
PERMANENCY_EXCLUDED,
HOSTILE_SPELLS,
SPELLS,
SPELL_BY_ID,
permanencyEligible,
type Gesture,
type Hand,
type Spell,
type SpellId
type SpellId,
type Token
} from './spells';
import {
NOWHERE,
@@ -40,34 +42,18 @@ import {
export const YOU: WizardId = 'A';
export const FOE: WizardId = 'B';
/** Narrow layouts: shorter lists and a sticky bar. The stylesheets in +page, MobileBar and TurnSummary use the same width. */
export const COMPACT_QUERY = '(max-width: 860px)';
/** How many spells under way to list beneath a hand, with and without a pinned one. */
const PLAN_LIMITS = { wide: 7, compact: 3, compactPinned: 4 };
/** 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;
@@ -177,7 +163,7 @@ function loadSaved(): SavedDuel | null {
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.
// Saves at this version may lack fields that came later; the engine expects them present.
saved.state.lessonsShown ??= [];
if (saved.state.lastTurn) {
saved.state.lastTurn.events ??= [];
@@ -245,8 +231,8 @@ export class Duel {
/**
* Choose each hand's default cast from its gestures. A hand bound by amnesia or
* paralysis can finish a spell without the player touching it, so this must run
* at the start of every turn, not only after a click.
* paralysis can finish a spell without the player touching it, so every turn
* starts here as well as every click.
*/
syncAll(): void {
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
@@ -316,8 +302,21 @@ export class Duel {
/** True when the bot's hand is yours to command this turn. */
youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped);
/** The gesture a hand will make: forced by an enchantment, or chosen, or nothing yet. */
chosenGesture(set: SetName, hand: Hand): Gesture | null {
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture;
}
gestureFor(set: SetName, hand: Hand): Gesture {
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
return this.chosenGesture(set, hand) ?? '-';
}
/** How a hand's gesture is written in the ledger and the sticky bar before the reveal. */
drawn(set: SetName, hand: Hand): string {
if (this.charmedHand === hand) return '?';
const g = this.chosenGesture(set, hand);
if (g === null) return '\u00b7';
return glyph(g, this.gestureFor(set, hand === 'left' ? 'right' : 'left'));
}
private entry(set: SetName): HistoryEntry {
@@ -374,9 +373,7 @@ export class Duel {
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;
}
for (let j = i + 1; j < cells.length; j++) if (cellsOverlap(cells[i], cells[j])) return true;
}
return false;
});
@@ -398,6 +395,7 @@ export class Duel {
return this.activeSets.some((set) => HANDS.some((h) => this.completions[set][h].some((c) => c.spell.id === id)));
}
summary = $derived(this.state.lastTurn);
foeVisible = $derived(visibleHistory(this.state, YOU, FOE));
/** Hostile spells the opponent could finish this turn or next, with the evidence. */
threats = $derived.by(() => {
@@ -406,7 +404,7 @@ export class Duel {
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 (!HOSTILE_SPELLS.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);
@@ -449,12 +447,13 @@ export class Duel {
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);
return all.slice(0, PLAN_LIMITS.wide);
}
/** How many plans to show before "More spells" on a narrow screen. */
/** How many plans to show before "More spells". */
planLimit(hand: Hand): number {
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
if (!this.compact) return PLAN_LIMITS.wide;
return this.pins[hand] ? PLAN_LIMITS.compactPinned : PLAN_LIMITS.compact;
}
pin(hand: Hand, id: SpellId): void {
@@ -466,14 +465,14 @@ export class Duel {
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)) {
if (this.handFree(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)) {
if (next.both && this.handFree(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);
this.syncAll();
}
setShowPlans(on: boolean): void {
@@ -509,8 +508,6 @@ export class Duel {
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));
@@ -536,12 +533,7 @@ export class Duel {
);
permanentCandidates = $derived(
this.permanencyActive
? this.planned.filter(
(p) =>
p.completion.spell.category === 'enchantment' &&
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
p !== this.willBank
)
? this.planned.filter((p) => permanencyEligible(p.completion.spell) && p !== this.willBank)
: []
);
willExtend = $derived(
@@ -562,7 +554,7 @@ export class Duel {
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);
this.syncAll();
}
/** Keep the selected spell valid for the current gestures; default to the longest completion. */
@@ -586,7 +578,7 @@ export class Duel {
hand: p.hand,
spellId: spell.id,
seqIndex: p.completion.seqIndex,
target: p.draft.target || (spell.usualTarget === 'self' ? YOU : FOE)
target: p.draft.target || defaultTarget(spell)
};
if (spell.id === 'summon_elemental') cast.elemental = p.draft.elemental;
if (spell.id === 'paralysis' || spell.id === 'charm_person') cast.chosenHand = p.draft.chosenHand;
+3 -25
View File
@@ -1,10 +1,8 @@
import { describe, expect, it } from 'vitest';
import { Duel } from './duel.svelte';
import { resolveTurn } from './resolve';
import { createGame, type GameState, type TurnInput } from './state';
import { createGame, type GameState } from './state';
import type { Gesture } from './spells';
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
import { play } from './test-helpers';
/** A is one gesture from a lightning bolt and has just been struck by amnesia. */
function amnesiaBeforeLightning(): GameState {
@@ -12,10 +10,7 @@ function amnesiaBeforeLightning(): GameState {
const left = 'DFFD';
const foe = '-DPP';
for (let i = 0; i < 4; i++) {
s = resolveTurn(s, {
A: { ...noop(), left: left[i] as Gesture },
B: { ...noop(), left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] }
});
s = play(s, { left: left[i] as Gesture }, { left: foe[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'amnesia' }] : [] });
}
expect(s.wizards.A.constraints.amnesia).toBe(true);
return s;
@@ -31,21 +26,4 @@ describe('the duel store', () => {
duel.reveal();
expect(duel.foe.hp).toBe(10);
});
it('chooses a default cast at the start of the next turn as well', () => {
let s = createGame({ A: 'You', B: 'Foe' }, 1);
// A's left hand made D-F-F-D; B's amnesia lands as A makes the fourth gesture.
const left = 'DFFD';
const foe = '-DPP';
for (let i = 0; i < 3; i++) {
s = resolveTurn(s, { A: { ...noop(), left: left[i] as Gesture }, B: { ...noop(), left: foe[i] as Gesture } });
}
const duel = new Duel(s);
duel.choose('main', 'left', 'D');
duel.choose('main', 'right', '-');
// The bot is replaced by a scripted foe for this test: resolve directly, then hand the store the result.
const next = resolveTurn(s, { A: { ...noop(), left: 'D' }, B: { ...noop(), left: 'P', casts: [{ hand: 'left', spellId: 'amnesia' }] } });
const fresh = new Duel(next);
expect(fresh.sets.main.left.spellId).toBe('lightning_bolt');
});
});
+54 -74
View File
@@ -3,36 +3,12 @@ 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;
}
import { createGame } from './state';
import { lcg, noop, play, runSequence } from './test-helpers';
describe('spell table', () => {
it('has 40 spells, all of them resolved', () => {
it('has the forty spells of the rules', () => {
expect(SPELLS).toHaveLength(40);
expect(SPELLS.filter((s) => !s.implemented)).toHaveLength(0);
});
it('parses two-handed tokens', () => {
@@ -97,30 +73,40 @@ describe('resolution', () => {
expect(s.wizards.B.hp).toBe(14);
});
it('a counter-spell stops a fireball but not a finger of death', () => {
it('a counter-spell smothers a fireball cast at its subject that turn', () => {
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');
// A's F-S-S-D-D and B's W-W-S finish on the same turn.
const left = 'FSSDD';
const foe = '--WWS';
for (let i = 0; i < 5; i++) {
const last = i === 4;
s = play(
s,
{ left: left[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'fireball' }] : [] },
{ left: foe[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'counter_spell' }] : [] }
);
}
expect(s.wizards.B.hp).toBe(15);
expect(s.log.some((l) => l.text.includes('smothers the Fireball'))).toBe(true);
});
it('counter-spell smothers a finger of death only via dispel magic', () => {
it('a counter-spell cannot stop a finger of death', () => {
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' });
const left = 'PWPFSSSD';
const foe = '-----WWS';
for (let i = 0; i < 8; i++) {
const last = i === 7;
s = play(
s,
{ left: left[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'finger_of_death' }] : [] },
{ left: foe[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'counter_spell' }] : [] }
);
}
expect(s.wizards.B.alive).toBe(false);
expect(s.over?.winner).toBe('A');
});
it('dispel magic is the one thing that stops a finger of death', () => {
let t = createGame({ A: 'Black', B: 'White' }, 1);
const bLeft = '----CDPW';
for (let i = 0; i < 8; i++) {
@@ -136,7 +122,7 @@ describe('resolution', () => {
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' }] }));
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' }] });
@@ -146,7 +132,7 @@ describe('resolution', () => {
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' }] }));
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' });
@@ -167,7 +153,7 @@ describe('resolution', () => {
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 = 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' });
});
@@ -184,8 +170,8 @@ describe('resolution', () => {
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' }] }));
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++) {
@@ -207,8 +193,8 @@ describe('resolution', () => {
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' }] }));
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);
@@ -226,15 +212,15 @@ describe('resolution', () => {
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' }] }));
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' }] }));
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' }] }));
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);
@@ -244,9 +230,9 @@ describe('resolution', () => {
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' }] }));
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' }] }));
s = runSequence(s, 'A', 'SD', '--', { casts: [{ hand: 'left', spellId: 'missile' }] });
expect(s.wizards.B.hp).toBe(15);
});
});
@@ -254,11 +240,7 @@ describe('resolution', () => {
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;
};
const rng = lcg(3);
for (let i = 0; i < 40 && !s.over; i++) {
const bot = chooseBotTurn(s, 'B', rng);
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
@@ -274,7 +256,7 @@ describe('bot', () => {
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' }] }));
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);
@@ -312,9 +294,9 @@ describe('haste, time stop, delayed effect and permanency', () => {
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' }] }));
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' }] }));
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);
@@ -342,22 +324,20 @@ describe('haste, time stop, delayed effect and permanency', () => {
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' }] }));
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' }] }));
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 = 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++) {
@@ -401,7 +381,7 @@ describe('lessons', () => {
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 = 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([]);
});
@@ -410,8 +390,8 @@ describe('lessons', () => {
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' }] }));
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);
});
});
+6 -9
View File
@@ -108,19 +108,16 @@ export function usedCells(completion: Completion, hand: Hand, turnCount: number)
return cells;
}
export function cellsOverlap(a: Set<string>, b: Set<string>): boolean {
for (const cell of b) if (a.has(cell)) return true;
return false;
}
/** 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];
return cellsOverlap(usedCells(a.completion, a.hand, turnCount), usedCells(b.completion, b.hand, turnCount));
}
+39 -39
View File
@@ -4,22 +4,25 @@
// enchantments, damage, monster attacks and stabs, and finally deaths and
// surrenders. A time stop resolves as a turn in which one wizard acts alone.
import { completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
import { cellsOverlap, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures';
import {
BLANKET_SPELLS,
CONTROL_SPELLS,
GESTURES,
GESTURE_NAMES,
HANDS,
MAGIC_GESTURES,
MONSTER_STATS,
PERMANENCY_EXCLUDED,
SPELLS,
SPELL_BY_ID,
SUMMON_KIND,
isElemental,
permanencyEligible,
type Gesture,
type Hand,
type MonsterKind,
type Spell
type Spell,
type SpellId
} from './spells';
import {
MAX_HP,
@@ -70,9 +73,8 @@ export function paralysedForm(g: Gesture): Gesture {
export const FEAR_BANNED: Gesture[] = ['C', 'D', 'F', 'S'];
function describe(g: Gesture): string {
return GESTURE_NAMES[g];
}
/** A counter-spell cannot touch these. */
const UNCOUNTERABLE: SpellId[] = ['finger_of_death', 'counter_spell', 'magic_mirror'];
function roman(n: number): string {
const numerals: [number, string][] = [
@@ -114,10 +116,6 @@ function clearMonsterEnchantments(m: Monster): void {
m.pending = {};
}
function permanencyEligible(spell: Spell): boolean {
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
}
/**
* Last turn's gestures as [first pair, second pair]: a wizard who was hastened
* repeats both pairs under amnesia, and each hand stays paralysed in its
@@ -202,7 +200,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
overlaps: [] as { name: string; spell: string; earlier: string }[],
broken: [] as { name: string; hand: Hand }[],
curedAndHurt: [] as { name: string; hurt: number; healed: number }[],
newMonsterAttacked: '' as string
newMonsterAttacked: ''
};
// ---- 1. Gestures, after last turn's enchantments have their say ----
@@ -216,13 +214,13 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (w.permanent.charmed && !w.permanent.charmedGesture) w.permanent.charmedGesture = g;
if (c.charmed.hand === 'left') left = g;
else right = g;
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${describe(g)}.`);
log(`${W[c.charmed.by].name} charms ${w.name}'s ${c.charmed.hand} hand into a ${GESTURE_NAMES[g]}.`);
}
if (c.paralysedHand && prev) {
const g = paralysedForm(w.history[w.history.length - 1][c.paralysedHand]);
if (c.paralysedHand === 'left') left = g;
else right = g;
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${describe(g)}.`);
log(`${w.name}'s ${c.paralysedHand} hand is paralysed into a ${GESTURE_NAMES[g]}.`);
}
if (c.fear) {
if (FEAR_BANNED.includes(left)) left = '-';
@@ -234,18 +232,18 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (w.permanent.confusion && !w.permanent.confusionFixed) w.permanent.confusionFixed = slip;
if (slip.hand === 'left') left = slip.gesture;
else right = slip.gesture;
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${describe(slip.gesture)} instead.`);
log(`Confused, ${w.name}'s ${slip.hand} hand makes a ${GESTURE_NAMES[slip.gesture]} instead.`);
}
if (left === '>' && right === '>') right = '-';
return [left, right];
};
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : describe(g));
const lone = (g: Gesture, o: Gesture) => (g === 'C' && o !== 'C' ? 'clap with one hand (nothing)' : GESTURE_NAMES[g]);
const record = (w: WizardState, left: Gesture, right: Gesture, entryPhase: Phase) => {
const hiddenFrom: WizardId[] = [];
const foe = enemyOf(w.id);
if (w.invisibleTurns > 0 || W[foe].blindTurns > 0 || phase === 'timestop') hiddenFrom.push(foe);
if ((left === 'C') !== (right === 'C')) facts.loneClap.push(w.name);
for (const hand of ['left', 'right'] as Hand[]) {
for (const hand of HANDS) {
const g = hand === 'left' ? left : right;
if (g !== '>' && g !== '-') continue;
const seq = handSequence(w.history, hand);
@@ -300,11 +298,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
continue;
}
const cells = usedCells(completion, choice.hand, index + 1);
const clash = accepted.some((a) => {
if (!a.completion) return false;
for (const cell of usedCells(a.completion, a.hand, a.index + 1)) if (cells.has(cell)) return true;
return false;
});
const clash = accepted.some((a) => !!a.completion && cellsOverlap(cells, usedCells(a.completion, a.hand, a.index + 1)));
if (clash) {
log(`${w.name} cannot use the same gesture for two spells; ${completion.spell.name} is dropped.`, 'fizzle');
continue;
@@ -316,7 +310,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
);
if (earlier) facts.overlaps.push({ name: w.name, spell: completion.spell.name, earlier: SPELL_BY_ID[earlier.spellId].name });
accepted.push({ caster: id, hand: choice.hand, spell: completion.spell, target, choice, completion, index });
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, length: completion.length, target });
w.casts.push({ turn, index, hand: choice.hand, spellId: completion.spell.id, target });
const at = BLANKET_SPELLS.includes(completion.spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
log(`${w.name} casts ${completion.spell.name}${at} (${completion.spell.sequences[completion.seqIndex]}, ${choice.hand} hand).`, 'spell', false);
if (completion.spell.id === 'lightning_bolt_quick') {
@@ -338,7 +332,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
index: -1,
released: true
});
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, length: 0, target });
w.casts.push({ turn, index: -1, hand: 'left', spellId: spell.id, target });
w.banked = null;
const at = BLANKET_SPELLS.includes(spell.id) || target === id ? '' : ` at ${beingName(state, target)}`;
log(`${w.name} releases the banked ${spell.name}${at}.`, 'spell');
@@ -398,18 +392,25 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
log(`${W[owner].name}'s magic mirror turns the ${c.spell.name} back on ${W[original].name}.`, 'spell');
}
for (const c of live()) {
if (['dispel_magic', 'finger_of_death', 'counter_spell', 'magic_mirror'].includes(c.spell.id)) continue;
if (UNCOUNTERABLE.includes(c.spell.id)) continue;
if (BLANKET_SPELLS.includes(c.spell.id)) continue;
if (countered.has(c.target)) {
c.nullified = 'countered';
facts.counterSmothered += 1;
log(`A counter-spell on ${beingName(state, c.target)} smothers the ${c.spell.name}.`, 'fizzle');
const subject = beingName(state, c.target);
const own = c.caster === c.target;
log(
c.spell.id === 'shield'
? `A counter-spell on ${subject} smothers the Shield, and shields ${subject} itself.`
: `A counter-spell on ${subject} smothers ${own ? 'their own ' : 'the '}${c.spell.name}.`,
'fizzle'
);
}
}
if (live().some((c) => c.spell.id === 'finger_of_death' && countered.has(c.target))) facts.fingerThroughCounter = true;
}
// ---- 4b. Delayed effect banks a spell; permanency extends one ----
// ---- 5. Delayed effect banks a spell; permanency extends one ----
const bankedNow = new Set<WizardId>();
const extendedNow = new Set<WizardId>();
for (const id of acting) {
@@ -438,7 +439,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 5. Who is shielded this turn ----
// ---- 6. Who is shielded this turn ----
for (const c of live()) {
if (['shield', 'counter_spell', 'dispel_magic', 'protection_from_evil'].includes(c.spell.id)) shielded.add(c.target);
}
@@ -446,7 +447,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
for (const m of state.monsters) if (m.protectionTurns > 0) shielded.add(m.id);
const isShielded = (id: TargetId) => shielded.has(id) && !frozenBeing(id);
// ---- 6. Summons, then storms and elementals ----
// ---- 7. Summons, then storms and elementals ----
for (const c of live()) {
const kind: MonsterKind | undefined =
c.spell.id === 'summon_elemental'
@@ -548,7 +549,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 7. Enchantments ----
// ---- 8. Enchantments ----
const controlByTarget = new Map<TargetId, Set<string>>();
for (const c of live()) {
if (!CONTROL_SPELLS.includes(c.spell.id)) continue;
@@ -586,7 +587,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
break;
case 'charm_person':
if (wiz) {
const hand = c.choice.chosenHand ?? pick(['left', 'right'] as Hand[]);
const hand = c.choice.chosenHand ?? pick(HANDS);
wiz.pending.charmed = { hand, by: c.caster };
if (c.permanent) wiz.permanent.charmed = { hand, by: c.caster };
log(`${wiz.name}'s ${hand} hand will obey ${W[c.caster].name}${c.permanent ? ' from now on' : ' next turn'}.`);
@@ -711,7 +712,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 8. Damage and healing ----
// ---- 9. Damage and healing ----
const damage = new Map<TargetId, number>();
const healing = new Map<TargetId, number>();
const slain = new Set<TargetId>();
@@ -794,7 +795,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 9. Monsters attack, including those slain this very turn ----
// ---- 10. Monsters attack, including those slain this very turn ----
for (const m of state.monsters) {
if (destroyedBeforeAttack.has(m.id)) continue;
if (frozen(m.owner)) continue;
@@ -834,7 +835,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
}
}
// ---- 10. Stabs ----
// ---- 11. Stabs ----
for (const id of acting) {
const w = W[id];
entries[id].forEach((index, half) => {
@@ -852,7 +853,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
});
}
// ---- 11. Apply the ledger ----
// ---- 12. Apply the ledger ----
for (const id of IDS) {
const w = W[id];
if (!w.alive) continue;
@@ -883,7 +884,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
return true;
});
// ---- 12. The turn ends ----
// ---- 13. The turn ends ----
if (phase === 'main') {
for (const id of IDS) {
const w = W[id];
@@ -962,7 +963,7 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
if (facts.fingerThroughCounter) {
lessons.push({ key: 'counter-finger', text: 'A counter-spell cannot stop finger of death. Only dispel magic can, or an anti-spell before the eight gestures are finished.' });
} else if (facts.counterSmothered > 0) {
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn and shields them as well. It cannot stop finger of death or dispel magic.' });
lessons.push({ key: 'counter-scope', text: 'A counter-spell stops every other spell cast at its subject that turn, even the subject\'s own, and shields them as well. It cannot stop finger of death or dispel magic.' });
}
if (facts.loneClap.length > 0) {
lessons.push({ key: 'lone-clap', text: `A clap needs both hands at once. ${facts.loneClap[0]}'s single-handed clap counts as nothing, and that hand's spells start from scratch.` });
@@ -994,12 +995,11 @@ export function resolveTurn(previous: GameState, inputs: Record<WizardId, TurnIn
/** Gestures a hand may choose this turn, given last turn's enchantments. */
export function allowedGestures(w: WizardState, hand: Hand): Gesture[] {
const all: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
if (w.constraints.amnesia) return [];
if (w.constraints.paralysedHand === hand) return [];
if (w.constraints.charmed?.hand === hand) return [];
if (w.constraints.fear) return all.filter((g) => !FEAR_BANNED.includes(g));
return all;
if (w.constraints.fear) return GESTURES.filter((g) => !FEAR_BANNED.includes(g));
return GESTURES;
}
/** The gesture a hand is forced to make this turn, if any is known in advance. */
+1 -8
View File
@@ -2,14 +2,7 @@ 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;
};
}
import { lcg } from './test-helpers';
describe('bot versus bot', () => {
it('sixty duels end without errors and by death, not surrender', () => {
+45 -16
View File
@@ -81,8 +81,6 @@ export interface Spell {
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[] {
@@ -100,8 +98,7 @@ function spell(
category: SpellCategory,
sequences: string[],
usualTarget: 'self' | 'enemy',
summary: string,
implemented = true
summary: string
): Spell {
return {
id,
@@ -110,8 +107,7 @@ function spell(
sequences,
tokens: sequences.map(parseSequence),
usualTarget,
summary,
implemented
summary
};
}
@@ -158,8 +154,8 @@ export const SPELLS: Spell[] = [
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.')
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subjects 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 subjects 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(
@@ -176,19 +172,45 @@ export const CONTROL_SPELLS: SpellId[] = [
'fear'
];
/** Enchantments a permanency can extend for the rest of the duel. */
/** Enchantments a permanency cannot extend. */
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
export function permanencyEligible(spell: Spell): boolean {
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
}
/** Spells worth warning about, or defending against, when the other wizard's hands approach them. */
export const HOSTILE_SPELLS: SpellId[] = [
'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',
'disease',
'poison',
'blindness',
'remove_enchantment',
'summon_goblin',
'summon_ogre',
'summon_troll',
'summon_giant',
'summon_elemental'
];
/** 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 }> = {
@@ -203,3 +225,10 @@ export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; atta
export function isElemental(kind: MonsterKind): boolean {
return kind === 'fire_elemental' || kind === 'ice_elemental';
}
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
summon_goblin: 'goblin',
summon_ogre: 'ogre',
summon_troll: 'troll',
summon_giant: 'giant'
};
+5 -1
View File
@@ -41,7 +41,6 @@ export interface CastRecord {
index: number;
hand: Hand;
spellId: SpellId;
length: number;
target: TargetId;
}
@@ -171,6 +170,11 @@ export interface TurnInput {
charmGesture?: Gesture;
}
/** Under any lasting enchantment a remove enchantment or dispel magic would strip. */
export function isEnchanted(w: WizardState): boolean {
return w.resistHeat || w.resistCold || w.protectionTurns > 0 || w.invisibleTurns > 0 || w.hasteTurns > 0;
}
export function enemyOf(id: WizardId): WizardId {
return id === 'A' ? 'B' : 'A';
}
+34
View File
@@ -0,0 +1,34 @@
import { resolveTurn } from './resolve';
import type { Gesture } from './spells';
import type { GameState, TurnInput, WizardId } from './state';
export const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
/** One turn with partial inputs for each wizard; anything unspecified is a rest. */
export function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
}
/** Feed one wizard gesture strings turn by turn while the other rests, casting on the final turn. */
export function runSequence(
state: GameState,
who: WizardId,
left: string,
right: string,
finalCasts: Partial<TurnInput> = {}
): GameState {
for (let i = 0; i < left.length; i++) {
const input = { left: left[i] as Gesture, right: right[i] as Gesture, ...(i === left.length - 1 ? finalCasts : {}) };
state = who === 'A' ? play(state, input) : play(state, {}, input);
}
return state;
}
/** A small linear congruential generator, so bot decisions in tests are repeatable. */
export function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
+36 -92
View File
@@ -6,7 +6,7 @@
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, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { COMPACT_QUERY, duel, FOE, YOU, NAME_MAX, castKey, tokensText } from '$lib/game/duel.svelte';
import { GESTURE_SHORT, glyph } from '$lib/game/glyphs';
import { MAGIC_GESTURES } from '$lib/game/spells';
@@ -34,12 +34,12 @@
duel.newGame();
}
// Save the duel whenever anything about it changes, and follow the window width.
$effect(() => {
duel.persist();
});
// The store's idea of a narrow screen must agree with the stylesheets' breakpoint.
$effect(() => {
const query = window.matchMedia('(max-width: 860px)');
const query = window.matchMedia(COMPACT_QUERY);
const apply = () => (duel.compact = query.matches);
apply();
query.addEventListener('change', apply);
@@ -60,7 +60,7 @@
<h1>Waving Hands</h1>
{#if renaming}
<form class="rename" onsubmit={saveName}>
<label>
<label class="field">
<span>Your name</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="text" bind:value={nameDraft} maxlength={NAME_MAX} autofocus autocomplete="nickname" placeholder="Leave empty for a drawn name" />
@@ -71,7 +71,7 @@
{:else}
<p class="standfirst">
You are <em>{duel.you.name}</em><button type="button" class="link" onclick={startRename}>change name</button>, duelling <em>{duel.foe.name}</em>.
{#if duel.state.turn === 0 && !duel.timeStopped}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
{#if duel.state.turn === 0}Each turn, both wizards choose one gesture per hand and reveal them together. The right run of gestures on one hand is a spell.{/if}
</p>
{/if}
</div>
@@ -111,9 +111,7 @@
<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}
{#if duel.threats.length && !duel.timeStopped}
<div class="threats">
{#if duel.threatsNow.length}
<p>Their hands could finish this turn:</p>
@@ -132,23 +130,23 @@
{/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>
<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}
{:else if !duel.timeStopped}
<p class="threats muted">Nothing hostile is one gesture away.</p>
{/if}
@@ -176,7 +174,7 @@
<div class="orders">
{#if duel.stabbing}
<label>
<label class="field">
<span>Stab</span>
<select bind:value={duel.stabTarget}>
<option value={FOE}>{duel.foe.name}</option>
@@ -187,7 +185,7 @@
</label>
{/if}
{#each duel.yourMonsters as m (m.id)}
<label>
<label class="field">
<span>{m.name} attacks</span>
<select bind:value={duel.monsterOrders[m.id]}>
<option value={FOE}>{duel.foe.name}</option>
@@ -198,7 +196,7 @@
</label>
{/each}
{#if duel.bankedSpell}
<label>
<label class="field">
<span>Release the banked {duel.bankedSpell.name.toLowerCase()}</span>
<select bind:value={duel.release}>
<option value="">not yet</option>
@@ -209,7 +207,7 @@
</label>
{/if}
{#if duel.bankCandidates.length > 1}
<label>
<label class="field">
<span>Bank</span>
<select bind:value={duel.bankPick}>
{#each duel.bankCandidates as p (castKey(p.set, p.hand))}
@@ -219,7 +217,7 @@
</label>
{/if}
{#if duel.permanentCandidates.length > 1}
<label>
<label class="field">
<span>Make permanent</span>
<select bind:value={duel.permanentPick}>
{#each duel.permanentCandidates as p (castKey(p.set, p.hand))}
@@ -229,7 +227,7 @@
</label>
{/if}
{#if duel.youCharmedFoe}
<label>
<label class="field">
<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)}
@@ -296,15 +294,9 @@
color: var(--bone);
}
.link {
background: none;
border: 0;
padding: 0;
.standfirst .link {
margin-left: 0.5em;
font-size: 0.8rem;
color: var(--frost);
text-decoration: underline;
vertical-align: baseline;
}
.rename {
@@ -315,29 +307,8 @@
margin-top: 0.4rem;
}
.rename label {
display: inline-flex;
align-items: baseline;
gap: 0.5rem;
color: var(--bone-dim);
font-style: italic;
}
.rename input {
font: inherit;
font-style: normal;
color: var(--bone);
background: var(--slate-deep);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.3rem 0.6rem;
width: 14em;
max-width: 100%;
}
.rename input:focus-visible {
outline: 2px solid var(--frost);
outline-offset: 1px;
}
.actions {
@@ -378,12 +349,6 @@
flex-wrap: wrap;
}
.reveal.small {
margin: 0;
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
details.next summary {
cursor: pointer;
list-style: none;
@@ -491,10 +456,6 @@
color: var(--bone-dim);
}
.colophon a {
color: var(--frost);
}
.hands {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -508,17 +469,6 @@
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;
@@ -530,24 +480,13 @@
margin: 1rem 0 0.6rem;
}
.reveal {
.move > .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;
.reveal.small {
padding: 0.4rem 0.9rem;
font-size: 1rem;
}
.verdict {
@@ -577,6 +516,11 @@
font-size: 0.85rem;
}
.colophon a {
color: var(--frost);
}
/* Must match COMPACT_QUERY in duel.svelte.ts. */
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);