Waving Hands: a browser duel after Bartle's 1977 game
SvelteKit 5 single-player version against a heuristic bot. The engine resolves all forty spells simultaneously as the rules describe, the ledger of gestures is the interface, and the duel is saved in the browser. Tester feedback rounds added per-hand planning, threat evidence, a result strip with one-time rule explanations, a phone layout and a structured rules page. Deploys as a static site behind Caddy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,621 @@
|
||||
// Reactive wrapper around the engine for a duel of one person against the bot.
|
||||
|
||||
import { chooseBotTurn } from './bot';
|
||||
import {
|
||||
completableIn,
|
||||
completedSpells,
|
||||
handSequence,
|
||||
progressToward,
|
||||
usedCells,
|
||||
type Completion,
|
||||
type HandTurn
|
||||
} from './gestures';
|
||||
import type { Token } from './spells';
|
||||
import { allowedGestures, forcedGesture, resolveTurn } from './resolve';
|
||||
import {
|
||||
CONTROL_SPELLS,
|
||||
GESTURES,
|
||||
HANDS,
|
||||
PERMANENCY_EXCLUDED,
|
||||
SPELLS,
|
||||
SPELL_BY_ID,
|
||||
type Gesture,
|
||||
type Hand,
|
||||
type Spell,
|
||||
type SpellId
|
||||
} 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';
|
||||
|
||||
/** 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;
|
||||
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 defaultTarget(spell: Spell | undefined): TargetId | '' {
|
||||
if (!spell) return '';
|
||||
return spell.usualTarget === 'self' ? YOU : FOE;
|
||||
}
|
||||
|
||||
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() } };
|
||||
}
|
||||
|
||||
function pickNames(): { A: string; B: string } {
|
||||
const pool = [...NAMES];
|
||||
const a = 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;
|
||||
// Fields added since the save was written.
|
||||
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() {
|
||||
const saved = typeof localStorage === 'undefined' ? null : loadSaved();
|
||||
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 ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
gestureFor(set: SetName, hand: Hand): Gesture {
|
||||
return this.forcedFor(set, hand) ?? this.sets[set][hand].gesture ?? '-';
|
||||
}
|
||||
|
||||
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++) {
|
||||
for (const cell of cells[i]) if (cells[j].has(cell)) 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)));
|
||||
}
|
||||
|
||||
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.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, pinned ? 7 : 6);
|
||||
}
|
||||
|
||||
/** How many plans to show before "More spells" on a narrow screen. */
|
||||
planLimit(hand: Hand): number {
|
||||
return this.compact ? (this.pins[hand] ? 4 : 3) : 7;
|
||||
}
|
||||
|
||||
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.forcedFor('main', hand) && this.charmedHand !== 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)) {
|
||||
this.sets.main[other].gesture = next.gesture;
|
||||
}
|
||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
|
||||
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));
|
||||
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) =>
|
||||
p.completion.spell.category === 'enchantment' &&
|
||||
!PERMANENCY_EXCLUDED.includes(p.completion.spell.id) &&
|
||||
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);
|
||||
|
||||
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 });
|
||||
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;
|
||||
for (const s of SET_NAMES) for (const h of HANDS) this.syncSpell(s, h);
|
||||
}
|
||||
|
||||
/** 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 = defaultTarget(options[0]?.spell);
|
||||
}
|
||||
}
|
||||
|
||||
setSpell(set: SetName, hand: Hand, id: SpellId | ''): void {
|
||||
this.sets[set][hand].spellId = id;
|
||||
this.sets[set][hand].target = 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 || (spell.usualTarget === 'self' ? YOU : FOE)
|
||||
};
|
||||
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();
|
||||
}
|
||||
|
||||
private resetDrafts(): void {
|
||||
this.sets = blankSets();
|
||||
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();
|
||||
Reference in New Issue
Block a user