diff --git a/.gitignore b/.gitignore index 1bbc136..3af846b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ node_modules # Output -.output -.vercel -.netlify -.wrangler /.svelte-kit /build diff --git a/README.md b/README.md index f9b08d3..e88c63d 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,19 @@ npm install npm run dev ``` -You duel a heuristic bot. Choose a gesture per hand, pick which completed spell (if any) each -hand casts and at whom, and reveal. Beneath each hand is a list of the spells that hand is -part-way through; selecting one makes its next gesture, and the diamond pins it. Every entry -in the spell sheet offers "Plan with left hand" and "Plan with right hand", showing what that -hand still has to make, so a duel can start from a chosen spell rather than a guessed gesture. -When a rule decides something surprising (a shield that stopped a goblin but not a wound -spell, a countered spell, a one-handed clap, a reused run of gestures) the result strip -explains it once per duel; the checkbox under the hands turns this off. Warnings -above the hands cite the opponent's gestures that could finish a hostile spell, and selecting -a warning lights those gestures up in the ledger. After each reveal a short strip shows what -changed each wizard's health and everything else worth knowing, with the full chronicle in -the sidebar. The rules are at `/rules`. +You duel a heuristic bot. Each turn, choose a gesture per hand, pick which completed spell +(if any) each hand casts and at whom, and reveal. The rules are at `/rules`. + +Around the two hands: + +- the ledger of every gesture both wizards have made, with completed spells named; +- beneath each hand, the spells it is part-way through, one click from the next gesture, with + a pin to keep one in view; every spell-sheet entry can also be planned with either hand; +- warnings citing the opponent's gestures that could finish a hostile spell, which light those + gestures up in the ledger when selected; +- after each reveal, a strip of what changed each wizard's health and everything else worth + knowing, and a one-time explanation when a rule decided something surprising; +- the full chronicle and the spell sheet in the sidebar. The duel is saved to the browser's local storage after every change, so it survives reading the rules, a refresh, or a phone putting the tab to sleep. Starting a new duel asks first if @@ -38,10 +39,12 @@ one is under way. Nothing is rendered on the server. - `src/lib/game/spells.ts` the spell book and monster stats - `src/lib/game/gestures.ts` sequence matching, threat detection, gesture-sharing conflicts +- `src/lib/game/state.ts` the duel's state and the seeded random source - `src/lib/game/resolve.ts` simultaneous turn resolution - `src/lib/game/bot.ts` the opponent -- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI -- `src/lib/components/` ledger, hand pickers, status, chronicle, spell sheet +- `src/lib/game/duel.svelte.ts` reactive store binding the engine to the UI, saved to local storage +- `src/lib/components/` the ledger, hand pickers, status panels, result strip, chronicle, spell sheet, and the sticky bar for phones +- `src/routes/rules/` the rules page, rendered from the spell book and the transcription in `docs/` ## Simplifications diff --git a/deploy/README.md b/deploy/README.md index 1b96769..17ec891 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,10 +12,10 @@ The smallest droplet is enough: `s-1vcpu-512mb-10gb` ($4 a month). The build is under half a megabyte and Caddy idles in about 30 MB of memory. Move up to `s-1vcpu-1gb` only if the site ever gains a server side. -The zero-cost alternative is a second site block in the wizwar droplet's -Caddyfile pointing at a second directory; the deploy script works unchanged -against that host. A separate droplet keeps the two games' uptime and -upgrades independent. +The zero-cost alternative is a second site block in an existing Caddy +server's configuration pointing at a second directory; the deploy script +works unchanged against that host. A droplet of its own keeps this site's +uptime and upgrades independent of anything else. ## Current production diff --git a/deploy/setup-droplet.sh b/deploy/setup-droplet.sh index 2249b72..594178e 100755 --- a/deploy/setup-droplet.sh +++ b/deploy/setup-droplet.sh @@ -4,8 +4,7 @@ # The argument is the Caddy site address line: one name, or several # separated by commas. Every name must already resolve to this droplet. # Waving Hands is a static site: Caddy serves the built files and terminates -# TLS. There is no application process, so nothing but Caddy and rsync is -# installed. +# TLS. There is no application process to install or supervise. set -euo pipefail HOST="${1:?usage: setup-droplet.sh }" @@ -28,8 +27,9 @@ chmod -R g+rX /opt/waving-hands # Caddy vhost: auto-TLS, security headers, static files. SvelteKit writes # each route as .html, so /rules is tried as /rules.html before # falling back to the app shell. Hashed assets under _app/immutable are -# cached for a year; everything else is revalidated so a deploy shows up -# on the next load. +# cached for a year; every other response is revalidated so a deploy shows +# up on the next load. The two header matchers are disjoint, so their order +# does not matter. cat > /etc/caddy/Caddyfile < 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) { diff --git a/src/lib/components/HandPicker.svelte b/src/lib/components/HandPicker.svelte index f08fa07..f0f1639 100644 --- a/src/lib/components/HandPicker.svelte +++ b/src/lib/components/HandPicker.svelte @@ -1,8 +1,8 @@ @@ -34,7 +34,7 @@ {#if forced}

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

{:else if charmed} -

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

+

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

{:else}
{#each GESTURES as g (g)} @@ -55,31 +55,33 @@ {#if duel.you.constraints.fear && !duel.timeStopped}

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

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

Nothing under way. to plan with this hand.

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

Nothing under way. to plan with this hand.

+ {:else} +
    + {#each plans as plan (plan.spell.id)} +
  • + + +
  • + {/each} + {#if hiddenPlans > 0 || morePlans} +
  • + +
  • + {/if} +
+ {/if} {/if} {/if} {#if options.length > 0}
-
- {duel.charmedHand === 'left' ? '?' : left === null ? '·' : glyph(left, right ?? '-')} - {duel.charmedHand === 'right' ? '?' : right === null ? '·' : glyph(right, left ?? '-')} + {#each HANDS as hand (hand)} + {duel.drawn('main', hand)} + {/each}
{#if open === spell.id}

{spell.summary}

@@ -137,11 +136,6 @@ border-color: var(--frost); } - .planbtn:disabled { - opacity: 0.4; - cursor: not-allowed; - } - .which { color: var(--frost); } diff --git a/src/lib/components/TurnSummary.svelte b/src/lib/components/TurnSummary.svelte index ed8f0b8..e154c86 100644 --- a/src/lib/components/TurnSummary.svelte +++ b/src/lib/components/TurnSummary.svelte @@ -31,14 +31,14 @@
{/each} - {#if summary.events?.length} + {#if summary.events.length}
    {#each summary.events as e, i (i)}
  • {e}
  • {/each}
{/if} - {#if duel.showLessons && summary.lessons?.length} + {#if duel.showLessons && summary.lessons.length}
    {#each summary.lessons as l, i (i)}
  • {l}
  • @@ -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; diff --git a/src/lib/game/bot.ts b/src/lib/game/bot.ts index d2140b2..572d391 100644 --- a/src/lib/game/bot.ts +++ b/src/lib/game/bot.ts @@ -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> = { 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, 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, 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 = {}; 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) { diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 1b7e729..99fb83e 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -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; diff --git a/src/lib/game/duel.test.ts b/src/lib/game/duel.test.ts index 7869c1d..e92b514 100644 --- a/src/lib/game/duel.test.ts +++ b/src/lib/game/duel.test.ts @@ -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'); - }); }); diff --git a/src/lib/game/engine.test.ts b/src/lib/game/engine.test.ts index 54ee0b6..fd050cf 100644 --- a/src/lib/game/engine.test.ts +++ b/src/lib/game/engine.test.ts @@ -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, b: Partial = {}): GameState { - return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } }); -} - -/** Feed gesture strings turn by turn; the caster casts every spell it completes on the final turn. */ -function runSequence( - state: GameState, - who: WizardId, - left: string, - right: string, - finalCasts: (turnIndex: number) => Partial -): GameState { - const L = left.split(''); - const R = right.split(''); - for (let i = 0; i < L.length; i++) { - const extra = i === L.length - 1 ? finalCasts(i) : {}; - const inp = { left: L[i] as Gesture, right: R[i] as Gesture, ...extra }; - state = who === 'A' ? play(state, inp) : play(state, {}, inp); - } - return state; -} +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 turn’s gestures and P+P is a surrender', () => { let s = createGame({ A: 'Black', B: 'White' }, 1); - s = runSequence(s, 'A', 'DPP', 'W--', () => ({ casts: [{ hand: 'left', spellId: 'amnesia' }] })); + 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); }); }); diff --git a/src/lib/game/gestures.ts b/src/lib/game/gestures.ts index fabedf6..ba51f3f 100644 --- a/src/lib/game/gestures.ts +++ b/src/lib/game/gestures.ts @@ -108,19 +108,16 @@ export function usedCells(completion: Completion, hand: Hand, turnCount: number) return cells; } +export function cellsOverlap(a: Set, b: Set): 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)); } diff --git a/src/lib/game/resolve.ts b/src/lib/game/resolve.ts index db80b87..0b6aa8b 100644 --- a/src/lib/game/resolve.ts +++ b/src/lib/game/resolve.ts @@ -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' && 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 { - 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 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(); const extendedNow = new Set(); for (const id of acting) { @@ -438,7 +439,7 @@ export function resolveTurn(previous: GameState, inputs: Record 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>(); for (const c of live()) { if (!CONTROL_SPELLS.includes(c.spell.id)) continue; @@ -586,7 +587,7 @@ export function resolveTurn(previous: GameState, inputs: Record(); const healing = new Map(); const slain = new Set(); @@ -794,7 +795,7 @@ export function resolveTurn(previous: GameState, inputs: Record { @@ -852,7 +853,7 @@ export function resolveTurn(previous: GameState, inputs: Record 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', '-']; 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. */ diff --git a/src/lib/game/simulation.test.ts b/src/lib/game/simulation.test.ts index 3ef3faa..d9b8b8e 100644 --- a/src/lib/game/simulation.test.ts +++ b/src/lib/game/simulation.test.ts @@ -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', () => { diff --git a/src/lib/game/spells.ts b/src/lib/game/spells.ts index 5153555..5dce555 100644 --- a/src/lib/game/spells.ts +++ b/src/lib/game/spells.ts @@ -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 subject’s gestures and monsters cannot attack them. Destroys a monster.'), spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'), spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'), - spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject\u2019s next spell (this turn or the next three) is banked, to be released whenever they choose.'), - spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject\u2019s next enchantment (this turn or the next three) lasts for the rest of the duel.') + spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject’s 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’s next enchantment (this turn or the next three) lasts for the rest of the duel.') ]; export const SPELL_BY_ID: Record = 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> = { - 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 = { @@ -203,3 +225,10 @@ export const MONSTER_STATS: Record> = { + summon_goblin: 'goblin', + summon_ogre: 'ogre', + summon_troll: 'troll', + summon_giant: 'giant' +}; diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts index 9e82b0c..a29a30e 100644 --- a/src/lib/game/state.ts +++ b/src/lib/game/state.ts @@ -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'; } diff --git a/src/lib/game/test-helpers.ts b/src/lib/game/test-helpers.ts new file mode 100644 index 0000000..40fae35 --- /dev/null +++ b/src/lib/game/test-helpers.ts @@ -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, b: Partial = {}): 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 = {} +): 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; + }; +} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c2aa6b1..18002aa 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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 @@

    Waving Hands

    {#if renaming}
    -