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>
670 lines
24 KiB
TypeScript
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();
|