// Reactive wrapper around the engine for a duel of one person against the bot. import { tick } from 'svelte'; import { chooseBotTurn } from './bot'; import { cellsOverlap, completableIn, completedSpells, handSequence, progressToward, usedCells, type Completion, type HandTurn } from './gestures'; import { glyph } from './glyphs'; import { allowedGestures, forcedGesture, resolveTurn } from './resolve'; import { likelyTarget } from './targets'; import { GESTURES, HANDS, HOSTILE_SPELLS, SPELLS, SPELL_BY_ID, permanencyEligible, type Gesture, type Hand, type Spell, type SpellId, type Token } from './spells'; import { NOWHERE, createGame, visibleHistory, type CastChoice, type GameState, type HistoryEntry, type TargetId, type TurnInput, type WizardId } from './state'; export const YOU: WizardId = 'A'; export const FOE: WizardId = 'B'; /** 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']; export interface HandDraft { gesture: Gesture | null; spellId: SpellId | ''; target: TargetId | ''; elemental: 'fire' | 'ice'; chosenHand: Hand; } export interface PlannedCast { set: SetName; hand: Hand; completion: Completion; draft: HandDraft; /** Length of the history the completing gesture sits at the end of. */ turnCount: number; } function blankDraft(): HandDraft { return { gesture: null, spellId: '', target: '', elemental: 'fire', chosenHand: 'left' }; } function blankSets(): Record> { return { main: { left: blankDraft(), right: blankDraft() }, haste: { left: blankDraft(), right: blankDraft() } }; } const NAME_KEY = 'wh:name'; export const NAME_MAX = 24; function readName(): string { try { return (localStorage.getItem(NAME_KEY) ?? '').trim().slice(0, NAME_MAX); } catch { return ''; } } /** The player's chosen name if they have one, and an opponent who is not called the same. */ function pickNames(player = readName()): { A: string; B: string } { const pool = NAMES.filter((n) => n.toLowerCase() !== player.toLowerCase()); const a = player || pool.splice(Math.floor(Math.random() * pool.length), 1)[0]; const b = pool[Math.floor(Math.random() * pool.length)]; return { A: a, B: b }; } export function castKey(set: SetName, hand: Hand): string { return `${set}:${hand}`; } /** A spell a hand is part-way through, and what would finish it. */ export interface Plan { spell: Spell; seqIndex: number; done: Token[]; remaining: Token[]; pinned: boolean; } /** A spell the opponent's hand could finish soon, with the gestures that say so. */ export interface Threat { hand: Hand; spell: Spell; done: Token[]; remaining: Token[]; /** History indexes of the gestures already made. */ from: number; to: number; soon: boolean; } export interface Highlight { hand: Hand; from: number; to: number; } /** A token in Bartle's notation: a bracketed lower-case letter is a two-handed gesture. */ export function tokenText(t: Token): string { return t.both && t.gesture !== 'C' ? `(${t.gesture.toLowerCase()}` : t.gesture; } export function tokensText(tokens: Token[]): string { return tokens.map(tokenText).join('-'); } const SAVE_KEY = 'wh:duel'; const SAVE_VERSION = 2; interface SavedDuel { v: number; state: GameState; pins: Record; sets: Record>; stabTarget: TargetId; monsterOrders: Record; release: TargetId | ''; } function loadSaved(): SavedDuel | null { try { const raw = localStorage.getItem(SAVE_KEY); if (!raw) return null; const saved = JSON.parse(raw) as SavedDuel; if (saved.v !== SAVE_VERSION || !saved.state?.wizards?.A || !saved.state?.wizards?.B) return null; // 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 ??= []; saved.state.lastTurn.lessons ??= []; } return saved; } catch { return null; } } function savePreference(key: string, on: boolean): void { try { localStorage.setItem(key, on ? '1' : '0'); } catch { // Preferences are a convenience only. } } function readPreference(key: string, fallback: boolean): boolean { try { const v = localStorage.getItem(key); return v === null ? fallback : v === '1'; } catch { return fallback; } } export class Duel { state = $state(createGame(pickNames())); sets = $state>>(blankSets()); stabTarget = $state(FOE); monsterOrders = $state>({}); /** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */ compact = $state(false); charmGesture = $state('P'); /** Where to loose the banked spell this turn; empty keeps it. */ release = $state(''); /** Which cast to bank or make permanent when more than one qualifies. */ bankPick = $state(''); permanentPick = $state(''); /** Spells you have chosen to pursue with each hand. */ pins = $state>({ left: null, right: null }); showPlans = $state(readPreference('wh:plans', true)); showLessons = $state(readPreference('wh:lessons', true)); /** The spell sheet's open state, so a hint under a hand can open it. */ sheetOpen = $state(false); /** Opponent gestures to light up in the ledger. */ highlight = $state(null); constructor(initial?: GameState) { const saved = typeof localStorage === 'undefined' ? null : loadSaved(); if (initial) { this.state = initial; } else if (saved) { this.state = saved.state; this.pins = saved.pins ?? { left: null, right: null }; this.sets = saved.sets ?? blankSets(); this.stabTarget = saved.stabTarget ?? FOE; this.monsterOrders = saved.monsterOrders ?? {}; this.release = saved.release ?? ''; } this.syncAll(); } /** * 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 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); } /** Rename the player for this duel and every duel after it. An empty name goes back to a drawn one. */ setName(raw: string): void { const name = raw.trim().slice(0, NAME_MAX); try { if (name) localStorage.setItem(NAME_KEY, name); else localStorage.removeItem(NAME_KEY); } catch { // The name still applies to this duel. } const next = name || pickNames('').A; if (next.toLowerCase() === this.foe.name.toLowerCase()) { this.state.wizards[FOE].name = pickNames(next).B; } this.state.wizards[YOU].name = next; } /** Write the duel and the half-written turn to local storage. Reads everything it saves, so an effect can track it. */ persist(): void { const bundle: SavedDuel = { v: SAVE_VERSION, state: $state.snapshot(this.state), pins: $state.snapshot(this.pins), sets: $state.snapshot(this.sets), stabTarget: this.stabTarget, monsterOrders: $state.snapshot(this.monsterOrders), release: this.release }; try { localStorage.setItem(SAVE_KEY, JSON.stringify(bundle)); } catch { // Storage may be full or blocked; the duel still plays in memory. } } /** A duel worth asking about before it is thrown away. */ inProgress = $derived(this.state.turn > 0 && !this.state.over); you = $derived(this.state.wizards[YOU]); foe = $derived(this.state.wizards[FOE]); turnNumber = $derived(this.state.turn + 1); /** Your extra turn under time stop: nobody else moves and last turn's enchantments do not bind. */ timeStopped = $derived(this.state.timeStops[0] === YOU); hasted = $derived(this.you.hasteTurns > 0 && !this.timeStopped); activeSets = $derived(this.hasted ? ['main', 'haste'] : ['main']); allowed = $derived>({ left: this.timeStopped ? GESTURES : allowedGestures(this.you, 'left'), right: this.timeStopped ? GESTURES : allowedGestures(this.you, 'right') }); forced = $derived>({ left: this.timeStopped ? undefined : forcedGesture(this.you, 'left'), right: this.timeStopped ? undefined : forcedGesture(this.you, 'right') }); forcedSecond = $derived>({ left: this.timeStopped ? undefined : forcedGesture(this.you, 'left', true), right: this.timeStopped ? undefined : forcedGesture(this.you, 'right', true) }); forcedFor(set: SetName, hand: Hand): Gesture | undefined { return set === 'haste' ? this.forcedSecond[hand] : this.forced[hand]; } charmedHand = $derived(this.timeStopped ? undefined : this.you.constraints.charmed?.hand); /** True when the bot's hand is yours to command this turn. */ youCharmedFoe = $derived(this.foe.constraints.charmed?.by === YOU && !this.timeStopped); /** 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.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 { return { left: this.gestureFor(set, 'left'), right: this.gestureFor(set, 'right'), turn: this.state.turn, phase: set === 'haste' ? 'haste' : this.timeStopped ? 'timestop' : 'main', hiddenFrom: [] }; } /** Your history with this turn's drafts appended, so spells light up as you choose. */ previews = $derived>({ main: [...this.you.history, this.entry('main')], haste: [...this.you.history, this.entry('main'), this.entry('haste')] }); private seqs = $derived>>({ main: { left: handSequence(this.previews.main, 'left'), right: handSequence(this.previews.main, 'right') }, haste: { left: handSequence(this.previews.haste, 'left'), right: handSequence(this.previews.haste, 'right') } }); completions = $derived>>({ main: { left: completedSpells(this.seqs.main.left, this.you.sequenceStart), right: completedSpells(this.seqs.main.right, this.you.sequenceStart) }, haste: { left: completedSpells(this.seqs.haste.left, this.you.sequenceStart), right: completedSpells(this.seqs.haste.right, this.you.sequenceStart) } }); chosen = $derived>>({ main: { left: this.completions.main.left.find((c) => c.spell.id === this.sets.main.left.spellId), right: this.completions.main.right.find((c) => c.spell.id === this.sets.main.right.spellId) }, haste: { left: this.completions.haste.left.find((c) => c.spell.id === this.sets.haste.left.spellId), right: this.completions.haste.right.find((c) => c.spell.id === this.sets.haste.right.spellId) } }); /** Every spell you have chosen to cast this turn, across hands and sets. */ planned = $derived.by(() => { const out: PlannedCast[] = []; for (const set of this.activeSets) { for (const hand of HANDS) { const completion = this.chosen[set][hand]; if (!completion) continue; out.push({ set, hand, completion, draft: this.sets[set][hand], turnCount: this.previews[set].length }); } } return out; }); conflict = $derived.by(() => { const cells = this.planned.map((p) => usedCells(p.completion, p.hand, p.turnCount)); for (let i = 0; i < cells.length; i++) { for (let j = i + 1; j < cells.length; j++) if (cellsOverlap(cells[i], cells[j])) return true; } return false; }); /** Progress of each hand toward each spell, for the reference sheet. */ progress = $derived.by(() => { const seq = this.seqs[this.hasted ? 'haste' : 'main']; return Object.fromEntries( SPELLS.map((s) => [ s.id, { left: Math.max(...s.tokens.map((t) => progressToward(t, seq.left, this.you.sequenceStart))), right: Math.max(...s.tokens.map((t) => progressToward(t, seq.right, this.you.sequenceStart))) } ]) ) as Record>; }); completedNow(id: SpellId): boolean { 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(() => { const out: Threat[] = []; for (const hand of HANDS) { const seq = handSequence(this.foeVisible, hand); for (const turnsAway of [1, 2]) { for (const c of completableIn(seq, this.foe.sequenceStart, turnsAway)) { if (!HOSTILE_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); if (out.some((t) => t.hand === hand && t.spell.id === c.spell.id)) continue; out.push({ hand, spell: c.spell, done, remaining: c.remaining, from: seq.length - done.length, to: seq.length - 1, soon: turnsAway === 1 }); } } } return out.sort((a, b) => Number(b.soon) - Number(a.soon) || b.done.length - a.done.length); }); threatsNow = $derived(this.threats.filter((t) => t.soon)); threatsNext = $derived(this.threats.filter((t) => !t.soon).slice(0, 6)); toggleHighlight(t: Threat): void { const same = this.highlight && this.highlight.hand === t.hand && this.highlight.from === t.from && this.highlight.to === t.to; this.highlight = same ? null : { hand: t.hand, from: t.from, to: t.to }; } /** Spells each hand is part-way through, based on the gestures already on the ledger. */ plans = $derived>({ left: this.plansFor('left'), right: this.plansFor('right') }); private plansFor(hand: Hand): Plan[] { const seq = handSequence(this.you.history, hand); const start = this.you.sequenceStart; const pinned = this.pins[hand]; const best = new Map(); for (const spell of SPELLS) { if (spell.id === 'lightning_bolt_quick' && this.you.usedQuickLightning) continue; spell.tokens.forEach((tokens, seqIndex) => { const k = progressToward(tokens, seq, start); if (k === 0 && spell.id !== pinned) return; const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: spell.id === pinned }; const prior = best.get(spell.id); if (!prior || plan.remaining.length < prior.remaining.length) best.set(spell.id, plan); }); } const all = [...best.values()].sort( (a, b) => Number(b.pinned) - Number(a.pinned) || a.remaining.length - b.remaining.length || b.done.length - a.done.length || a.spell.name.localeCompare(b.spell.name) ); return all.slice(0, PLAN_LIMITS.wide); } /** How many plans to show before "More spells". */ planLimit(hand: Hand): number { if (!this.compact) return PLAN_LIMITS.wide; return this.pins[hand] ? PLAN_LIMITS.compactPinned : PLAN_LIMITS.compact; } pin(hand: Hand, id: SpellId): void { this.pins[hand] = this.pins[hand] === id ? null : id; } /** Pin a plan and make its next gesture, with the other hand too if the gesture needs both. */ follow(hand: Hand, plan: Plan): void { this.pins[hand] = plan.spell.id; const next = plan.remaining[0]; if (!next) return; if (this.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.handFree(other) && this.allowed[other].includes(next.gesture)) { this.sets.main[other].gesture = next.gesture; } this.syncAll(); } setShowPlans(on: boolean): void { this.showPlans = on; savePreference('wh:plans', on); } setShowLessons(on: boolean): void { this.showLessons = on; savePreference('wh:lessons', on); } /** The shortest way for a hand to finish a spell from where its gestures stand. */ planFor(hand: Hand, spell: Spell): Plan { const seq = handSequence(this.you.history, hand); const start = this.you.sequenceStart; let best: Plan | undefined; spell.tokens.forEach((tokens, seqIndex) => { const k = progressToward(tokens, seq, start); const plan: Plan = { spell, seqIndex, done: tokens.slice(0, k), remaining: tokens.slice(k), pinned: this.pins[hand] === spell.id }; if (!best || plan.remaining.length < best.remaining.length) best = plan; }); return best!; } /** Whether a hand can be pointed at a new plan this turn. */ handFree(hand: Hand): boolean { return !this.forcedFor('main', hand) && this.charmedHand !== hand; } openSheet(): void { this.sheetOpen = true; document.getElementById('spell-sheet')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } surrendering = $derived(this.activeSets.some((set) => this.gestureFor(set, 'left') === 'P' && this.gestureFor(set, 'right') === 'P')); stabbing = $derived(this.activeSets.some((set) => HANDS.some((h) => this.gestureFor(set, h) === '>'))); yourMonsters = $derived(this.state.monsters.filter((m) => m.owner === YOU)); ready = $derived( !this.state.over && this.activeSets.every((set) => HANDS.every((h) => this.forced[h] || this.charmedHand === h || this.sets[set][h].gesture !== null) ) && !this.conflict ); /** A delayed effect is waiting for a spell to bank, counting one cast this turn. */ bankingActive = $derived( !this.you.banked && (this.you.delayedTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'delayed_effect' && (p.draft.target || YOU) === YOU)) ); bankCandidates = $derived(this.bankingActive ? this.planned.filter((p) => p.completion.spell.id !== 'delayed_effect') : []); willBank = $derived( this.bankCandidates.find((p) => castKey(p.set, p.hand) === this.bankPick) ?? this.bankCandidates[0] ); permanencyActive = $derived( this.you.permanencyTurns > 0 || this.planned.some((p) => p.completion.spell.id === 'permanency' && (p.draft.target || YOU) === YOU) ); permanentCandidates = $derived( this.permanencyActive ? this.planned.filter((p) => permanencyEligible(p.completion.spell) && p !== this.willBank) : [] ); willExtend = $derived( this.permanentCandidates.find((p) => castKey(p.set, p.hand) === this.permanentPick) ?? this.permanentCandidates[0] ); bankedSpell = $derived(this.you.banked ? SPELL_BY_ID[this.you.banked.spellId] : undefined); /** Where a spell goes unless the player says otherwise. */ defaultTarget(spell: Spell | undefined): TargetId | '' { return spell ? likelyTarget(this.state, YOU, spell) : ''; } /** Every possible subject, the likeliest first, creatures marked by whose they are. */ targetsFor(spell: Spell | undefined): { id: TargetId; label: string }[] { const list: { id: TargetId; label: string }[] = [ { id: YOU, label: 'yourself' }, { id: FOE, label: this.foe.name } ]; for (const m of this.state.monsters) { list.push({ id: m.id, label: `${m.name} (${m.owner === YOU ? 'yours' : `${this.foe.name}'s`})` }); } const likely = this.defaultTarget(spell); list.sort((a, b) => Number(b.id === likely) - Number(a.id === likely)); if (spell && spell.id !== 'fire_storm' && spell.id !== 'ice_storm') list.push({ id: NOWHERE, label: 'nowhere (let it fade)' }); return list; } choose(set: SetName, hand: Hand, gesture: Gesture): void { const draft = this.sets[set][hand]; draft.gesture = draft.gesture === gesture ? null : gesture; this.syncAll(); } /** Keep the selected spell valid for the current gestures; default to the longest completion. */ syncSpell(set: SetName, hand: Hand): void { const draft = this.sets[set][hand]; const options = this.completions[set][hand]; if (!options.some((c) => c.spell.id === draft.spellId)) { draft.spellId = options[0]?.spell.id ?? ''; draft.target = this.defaultTarget(options[0]?.spell); } } setSpell(set: SetName, hand: Hand, id: SpellId | ''): void { this.sets[set][hand].spellId = id; this.sets[set][hand].target = this.defaultTarget(id ? SPELL_BY_ID[id] : undefined); } private castFor(p: PlannedCast): CastChoice { const spell = p.completion.spell; const cast: CastChoice = { hand: p.hand, spellId: spell.id, seqIndex: p.completion.seqIndex, target: p.draft.target || this.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; if (spell.category === 'summons') cast.monsterTarget = FOE; if (p === this.willBank) cast.bank = true; if (p === this.willExtend) cast.permanent = true; return cast; } reveal(): void { if (!this.ready) return; const orders: Record = {}; for (const m of this.yourMonsters) orders[m.id] = this.monsterOrders[m.id] ?? FOE; const yours: TurnInput = { left: this.sets.main.left.gesture ?? '-', right: this.sets.main.right.gesture ?? '-', casts: this.planned.filter((p) => p.set === 'main').map((p) => this.castFor(p)), stabTarget: this.stabTarget, monsterOrders: orders }; if (this.hasted) { yours.second = { left: this.sets.haste.left.gesture ?? '-', right: this.sets.haste.right.gesture ?? '-', casts: this.planned.filter((p) => p.set === 'haste').map((p) => this.castFor(p)), stabTarget: this.stabTarget }; } if (this.release && this.you.banked) yours.release = { target: this.release, monsterTarget: FOE }; if (this.youCharmedFoe) yours.charmGesture = this.charmGesture; // The engine clones plain data; hand it a snapshot rather than the reactive proxy. let plain = $state.snapshot(this.state); const theirs = chooseBotTurn(plain, FOE); plain = resolveTurn(plain, { A: yours, B: theirs }); // The bot takes any stopped moments it is owed straight away. while (!plain.over && plain.timeStops[0] === FOE) { plain = resolveTurn(plain, { A: yours, B: chooseBotTurn(plain, FOE) }); } this.state = plain; for (const h of HANDS) { const pinned = this.pins[h]; if (pinned && this.planned.some((p) => p.hand === h && p.completion.spell.id === pinned)) this.pins[h] = null; } this.resetDrafts(); // On a phone the hands sit far below the ledger; bring the result into view. if (this.compact) { void tick().then(() => { const target = document.getElementById(this.state.over ? 'verdict' : 'turn-result'); const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; target?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' }); }); } } private resetDrafts(): void { this.sets = blankSets(); this.syncAll(); this.stabTarget = FOE; // Monsters keep attacking what they attacked last, until told otherwise or the target is gone. const orders: Record = {}; for (const m of this.state.monsters) { if (m.owner !== YOU) continue; const last = m.lastTarget; const valid = last === FOE || this.state.monsters.some((o) => o.id === last && o.owner === FOE); orders[m.id] = valid && last ? last : FOE; } this.monsterOrders = orders; this.release = ''; this.bankPick = ''; this.permanentPick = ''; this.highlight = null; } newGame(): void { this.state = createGame(pickNames()); this.pins = { left: null, right: null }; this.resetDrafts(); } } /** The one duel this browser is playing; it survives moving between pages. */ export const duel = new Duel();