Files
waving-hands/src/lib/game/duel.svelte.ts
T
Eric WagonerandClaude Fable 5.1 81acbd2172 The target dropdown starts on the likeliest subject
One module decides where a spell most likely goes given the field (a
fireball at an ice elemental, charm monster at their strongest creature,
remove enchantment at the wizard only if enchanted); the bot and the
dropdown both use it. The dropdown lists that subject first and marks
creatures by whose they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:42:44 -04:00

670 lines
24 KiB
TypeScript

// 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<SetName, Record<Hand, HandDraft>> {
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<Hand, SpellId | null>;
sets: Record<SetName, Record<Hand, HandDraft>>;
stabTarget: TargetId;
monsterOrders: Record<string, TargetId>;
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<GameState>(createGame(pickNames()));
sets = $state<Record<SetName, Record<Hand, HandDraft>>>(blankSets());
stabTarget = $state<TargetId>(FOE);
monsterOrders = $state<Record<string, TargetId>>({});
/** Narrow screens get shorter lists and a sticky bar; the page sets this from a media query. */
compact = $state(false);
charmGesture = $state<Gesture>('P');
/** Where to loose the banked spell this turn; empty keeps it. */
release = $state<TargetId | ''>('');
/** Which cast to bank or make permanent when more than one qualifies. */
bankPick = $state<string>('');
permanentPick = $state<string>('');
/** Spells you have chosen to pursue with each hand. */
pins = $state<Record<Hand, SpellId | null>>({ 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<Highlight | null>(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<SetName[]>(this.hasted ? ['main', 'haste'] : ['main']);
allowed = $derived<Record<Hand, Gesture[]>>({
left: this.timeStopped ? GESTURES : allowedGestures(this.you, 'left'),
right: this.timeStopped ? GESTURES : allowedGestures(this.you, 'right')
});
forced = $derived<Record<Hand, Gesture | undefined>>({
left: this.timeStopped ? undefined : forcedGesture(this.you, 'left'),
right: this.timeStopped ? undefined : forcedGesture(this.you, 'right')
});
forcedSecond = $derived<Record<Hand, Gesture | undefined>>({
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<Hand | undefined>(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<Record<SetName, HistoryEntry[]>>({
main: [...this.you.history, this.entry('main')],
haste: [...this.you.history, this.entry('main'), this.entry('haste')]
});
private seqs = $derived<Record<SetName, Record<Hand, HandTurn[]>>>({
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<Record<SetName, Record<Hand, Completion[]>>>({
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<Record<SetName, Record<Hand, Completion | undefined>>>({
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<SpellId, Record<Hand, number>>;
});
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<Record<Hand, Plan[]>>({
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<SpellId, Plan>();
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<string, TargetId> = {};
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<string, TargetId> = {};
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();