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:
commit
865555e911
38 files changed
+8112
No files matched your search
@@ -0,0 +1,502 @@
|
||||
// A heuristic opponent. Each turn it scores every pair of gestures by the
|
||||
// spells they finish, the spells they move toward, and what the human's
|
||||
// visible gestures threaten, then casts the most valuable spells it completed.
|
||||
// Spells it has cast recently are discounted so it does not fall into a rut,
|
||||
// and each named opponent weights the spell book a little differently.
|
||||
|
||||
import {
|
||||
completableIn,
|
||||
completedSpells,
|
||||
handSequence,
|
||||
progressToward,
|
||||
usedCells,
|
||||
type Completion,
|
||||
type HandTurn,
|
||||
type TurnGestures
|
||||
} from './gestures';
|
||||
import { allowedGestures, forcedGesture } from './resolve';
|
||||
import { CONTROL_SPELLS, SPELLS, SPELL_BY_ID, isElemental, type Gesture, type Hand, type Spell, type SpellId } from './spells';
|
||||
import {
|
||||
MAX_HP,
|
||||
enemyOf,
|
||||
visibleHistory,
|
||||
type CastChoice,
|
||||
type GameState,
|
||||
type TargetId,
|
||||
type TurnInput,
|
||||
type WizardId
|
||||
} from './state';
|
||||
|
||||
const HOSTILE: SpellId[] = [
|
||||
'missile',
|
||||
'finger_of_death',
|
||||
'lightning_bolt',
|
||||
'lightning_bolt_quick',
|
||||
'cause_light_wounds',
|
||||
'cause_heavy_wounds',
|
||||
'fireball',
|
||||
'fire_storm',
|
||||
'ice_storm',
|
||||
...CONTROL_SPELLS,
|
||||
'anti_spell',
|
||||
'disease',
|
||||
'poison',
|
||||
'blindness',
|
||||
'summon_goblin',
|
||||
'summon_ogre',
|
||||
'summon_troll',
|
||||
'summon_giant',
|
||||
'summon_elemental'
|
||||
];
|
||||
|
||||
const THREAT_VALUE: Partial<Record<SpellId, number>> = {
|
||||
missile: 1,
|
||||
finger_of_death: 10,
|
||||
lightning_bolt: 5,
|
||||
lightning_bolt_quick: 5,
|
||||
cause_light_wounds: 2,
|
||||
cause_heavy_wounds: 3,
|
||||
fireball: 5,
|
||||
fire_storm: 5,
|
||||
ice_storm: 5,
|
||||
amnesia: 2,
|
||||
confusion: 1.5,
|
||||
charm_person: 2.5,
|
||||
charm_monster: 3,
|
||||
paralysis: 2.5,
|
||||
fear: 1.5,
|
||||
anti_spell: 2,
|
||||
disease: 4,
|
||||
poison: 4,
|
||||
blindness: 3,
|
||||
summon_goblin: 2,
|
||||
summon_ogre: 3,
|
||||
summon_troll: 4,
|
||||
summon_giant: 5,
|
||||
summon_elemental: 4
|
||||
};
|
||||
|
||||
/** Defensive spells whose worth is purely situational, so repeating them is fine. */
|
||||
const NO_REPEAT_DISCOUNT: SpellId[] = ['shield', 'counter_spell', 'magic_mirror', 'dispel_magic', 'cure_light_wounds', 'cure_heavy_wounds'];
|
||||
|
||||
interface Situation {
|
||||
state: GameState;
|
||||
me: WizardId;
|
||||
foe: WizardId;
|
||||
foeSeq: Record<Hand, HandTurn[]>;
|
||||
/** Hostile spells the foe could finish this very turn, by value. */
|
||||
threatNow: number;
|
||||
threatNowCounterable: number;
|
||||
/** Hostile spells the foe could finish next turn. */
|
||||
threatNext: number;
|
||||
foeMonsterDamage: number;
|
||||
missileOrMonsterNow: boolean;
|
||||
foeFirePlan: boolean;
|
||||
foeIcePlan: boolean;
|
||||
iceElemental: boolean;
|
||||
fireElemental: boolean;
|
||||
rng: () => number;
|
||||
}
|
||||
|
||||
/** A stable 0..1 number from a string, so each opponent has its own leanings. */
|
||||
function hash01(text: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return ((h >>> 0) % 1000) / 1000;
|
||||
}
|
||||
|
||||
function assess(state: GameState, me: WizardId, rng: () => number): Situation {
|
||||
const foe = enemyOf(me);
|
||||
const foeW = state.wizards[foe];
|
||||
const hist = visibleHistory(state, me, foe);
|
||||
const foeSeq: Record<Hand, HandTurn[]> = {
|
||||
left: handSequence(hist, 'left'),
|
||||
right: handSequence(hist, 'right')
|
||||
};
|
||||
let threatNow = 0;
|
||||
let threatNowCounterable = 0;
|
||||
let threatNext = 0;
|
||||
let missileNow = false;
|
||||
let foeFirePlan = false;
|
||||
let foeIcePlan = false;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 1)) {
|
||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
||||
const v = THREAT_VALUE[c.spell.id] ?? 1;
|
||||
threatNow = Math.max(threatNow, v);
|
||||
if (c.spell.id !== 'finger_of_death') threatNowCounterable = Math.max(threatNowCounterable, v);
|
||||
if (c.spell.id === 'missile') missileNow = true;
|
||||
}
|
||||
for (const c of completableIn(foeSeq[hand], foeW.sequenceStart, 2)) {
|
||||
if (!HOSTILE.includes(c.spell.id)) continue;
|
||||
threatNext = Math.max(threatNext, THREAT_VALUE[c.spell.id] ?? 1);
|
||||
}
|
||||
for (const spell of SPELLS) {
|
||||
const k = progressToward(spell.tokens[0], foeSeq[hand], foeW.sequenceStart);
|
||||
if (k >= 2 && (spell.id === 'fireball' || spell.id === 'fire_storm')) foeFirePlan = true;
|
||||
if (k >= 2 && spell.id === 'ice_storm') foeIcePlan = true;
|
||||
}
|
||||
}
|
||||
const foeMonsterDamage = state.monsters
|
||||
.filter((m) => m.owner === foe && !isElemental(m.kind))
|
||||
.reduce((sum, m) => sum + m.attack, 0);
|
||||
return {
|
||||
state,
|
||||
me,
|
||||
foe,
|
||||
foeSeq,
|
||||
threatNow,
|
||||
threatNowCounterable,
|
||||
threatNext,
|
||||
foeMonsterDamage,
|
||||
missileOrMonsterNow: missileNow || foeMonsterDamage > 0,
|
||||
foeFirePlan,
|
||||
foeIcePlan,
|
||||
iceElemental: state.monsters.some((m) => m.kind === 'ice_elemental'),
|
||||
fireElemental: state.monsters.some((m) => m.kind === 'fire_elemental'),
|
||||
rng
|
||||
};
|
||||
}
|
||||
|
||||
/** How much the bot would like to finish this spell right now, before its leanings and recent habits. */
|
||||
function baseValue(s: Situation, spell: Spell): number {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||
const foeEnchanted =
|
||||
foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
if (s.iceElemental) return 6;
|
||||
return foeW.resistHeat ? 0.2 : 5;
|
||||
case 'lightning_bolt':
|
||||
return 5;
|
||||
case 'lightning_bolt_quick':
|
||||
return meW.usedQuickLightning ? 0 : 5.2;
|
||||
case 'cause_heavy_wounds':
|
||||
return 3.2;
|
||||
case 'cause_light_wounds':
|
||||
return 2.2;
|
||||
case 'missile':
|
||||
return 1.1;
|
||||
case 'finger_of_death':
|
||||
return 9;
|
||||
case 'summon_goblin':
|
||||
return 2.2;
|
||||
case 'summon_ogre':
|
||||
return 3.2;
|
||||
case 'summon_troll':
|
||||
return 4;
|
||||
case 'summon_giant':
|
||||
return 4.6;
|
||||
case 'summon_elemental':
|
||||
return meW.resistHeat || meW.resistCold ? 4.5 : 0.8;
|
||||
case 'paralysis':
|
||||
return 2.4;
|
||||
case 'amnesia':
|
||||
return 2;
|
||||
case 'confusion':
|
||||
return 1.6;
|
||||
case 'fear':
|
||||
return 1.8;
|
||||
case 'charm_person':
|
||||
return 2.6;
|
||||
case 'charm_monster':
|
||||
return foeMonsters.some((m) => !isElemental(m.kind)) ? 3.5 : 0;
|
||||
case 'anti_spell': {
|
||||
let longest = 0;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const sp of SPELLS) {
|
||||
longest = Math.max(longest, progressToward(sp.tokens[0], s.foeSeq[hand], foeW.sequenceStart));
|
||||
}
|
||||
}
|
||||
return 1 + longest * 0.6;
|
||||
}
|
||||
case 'disease':
|
||||
return 3.5;
|
||||
case 'poison':
|
||||
return 3.8;
|
||||
case 'blindness':
|
||||
return 3;
|
||||
case 'invisibility':
|
||||
return 2.5 + s.foeMonsterDamage;
|
||||
case 'haste':
|
||||
return meW.hasteTurns > 0 ? 0 : 3.5;
|
||||
case 'time_stop':
|
||||
return 3;
|
||||
case 'delayed_effect':
|
||||
return meW.banked || meW.delayedTurns > 0 ? 0 : 1.5;
|
||||
case 'permanency':
|
||||
return meW.permanencyTurns > 0 ? 0 : 2.5;
|
||||
case 'shield':
|
||||
if (meW.protectionTurns > 0) return 0;
|
||||
if (s.foeMonsterDamage > 0) return 1.5 + s.foeMonsterDamage;
|
||||
if (s.missileOrMonsterNow) return 1.4;
|
||||
return 0.3;
|
||||
case 'counter_spell':
|
||||
return Math.max(0.4, s.threatNowCounterable);
|
||||
case 'magic_mirror':
|
||||
return s.threatNowCounterable >= 3 ? s.threatNowCounterable : 0.3;
|
||||
case 'dispel_magic':
|
||||
return Math.max(0.5, s.foeMonsterDamage + (foeEnchanted ? 2 : 0) + (s.threatNow >= 5 ? s.threatNow : 0));
|
||||
case 'remove_enchantment':
|
||||
return foeEnchanted ? 2.5 : foeMonsters.length > 0 ? 1 + s.foeMonsterDamage : 0.3;
|
||||
case 'protection_from_evil':
|
||||
return meW.protectionTurns > 1 ? 0 : 2 + s.foeMonsterDamage;
|
||||
case 'resist_heat':
|
||||
if (s.fireElemental) return 4;
|
||||
return s.foeFirePlan && !meW.resistHeat ? 3.5 : meW.resistHeat ? 0 : 0.6;
|
||||
case 'resist_cold':
|
||||
if (s.iceElemental) return 4;
|
||||
return s.foeIcePlan && !meW.resistCold ? 3.5 : meW.resistCold ? 0 : 0.5;
|
||||
case 'cure_light_wounds':
|
||||
return meW.hp < MAX_HP ? 1 : 0;
|
||||
case 'cure_heavy_wounds':
|
||||
return meW.hp < MAX_HP - 1 ? 2 : meW.diseaseTurns !== null ? 5 : 0;
|
||||
case 'raise_dead':
|
||||
return meW.hp <= MAX_HP - 5 ? 4 : 0;
|
||||
case 'fire_storm':
|
||||
return meW.resistHeat && !foeW.resistHeat ? 5 : 0;
|
||||
case 'ice_storm':
|
||||
return meW.resistCold && !foeW.resistCold ? 5 : 0;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function spellValue(s: Situation, spell: Spell): number {
|
||||
let v = baseValue(s, spell);
|
||||
if (v <= 0) return 0;
|
||||
const meW = s.state.wizards[s.me];
|
||||
// Leanings: each opponent favours some spells over others.
|
||||
v *= 0.7 + 0.6 * hash01(meW.name + spell.id);
|
||||
// Habit: a spell cast in the last few turns is worth less again.
|
||||
if (!NO_REPEAT_DISCOUNT.includes(spell.id)) {
|
||||
const recent = meW.casts.filter((c) => c.spellId === spell.id && c.turn >= s.state.turn - 4).length;
|
||||
v /= 1 + recent;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
interface HandEval {
|
||||
score: number;
|
||||
planned?: SpellId;
|
||||
}
|
||||
|
||||
function evaluateHand(s: Situation, seq: HandTurn[], start: number, otherPlanned?: SpellId): HandEval {
|
||||
let best = 0;
|
||||
for (const c of completedSpells(seq, start)) {
|
||||
best = Math.max(best, spellValue(s, c.spell));
|
||||
}
|
||||
let progress = 0;
|
||||
let planned: SpellId | undefined;
|
||||
for (const spell of SPELLS) {
|
||||
const value = spellValue(s, spell);
|
||||
if (value <= 0) continue;
|
||||
spell.tokens.forEach((tokens, i) => {
|
||||
if (spell.id === 'lightning_bolt_quick' && i > 0) return;
|
||||
const k = progressToward(tokens, seq, start);
|
||||
if (k === 0) return;
|
||||
let v = value * Math.pow(k / tokens.length, 1.3);
|
||||
if (k >= 2) v += 0.3;
|
||||
// Being one gesture from a counter-spell is worth a lot when a blow is coming next turn.
|
||||
if (spell.id === 'counter_spell' && k === tokens.length - 1) v = Math.max(v, s.threatNext * 0.8);
|
||||
if (spell.id === otherPlanned) v *= 0.4;
|
||||
if (v > progress) {
|
||||
progress = v;
|
||||
planned = spell.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { score: best + progress * 0.9, planned };
|
||||
}
|
||||
|
||||
function bestCasts(s: Situation, completions: Record<Hand, Completion[]>, turnCount: number): CastChoice[] {
|
||||
const options: { hand: Hand; completion: Completion; value: number }[] = [];
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const completion of completions[hand]) {
|
||||
const value = spellValue(s, completion.spell);
|
||||
if (value > 0.25) options.push({ hand, completion, value });
|
||||
}
|
||||
}
|
||||
options.sort((a, b) => b.value - a.value);
|
||||
const chosen: typeof options = [];
|
||||
for (const o of options) {
|
||||
if (chosen.some((c) => c.hand === o.hand)) continue;
|
||||
const cells = usedCells(o.completion, o.hand, turnCount);
|
||||
const clash = chosen.some((c) => {
|
||||
for (const cell of usedCells(c.completion, c.hand, turnCount)) if (cells.has(cell)) return true;
|
||||
return false;
|
||||
});
|
||||
if (clash) continue;
|
||||
chosen.push(o);
|
||||
}
|
||||
return chosen.map((o) => toChoice(s, o.hand, o.completion));
|
||||
}
|
||||
|
||||
function foeDangerousHand(s: Situation): Hand {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
let bestHand: Hand = 'left';
|
||||
let bestK = -1;
|
||||
for (const hand of ['left', 'right'] as Hand[]) {
|
||||
for (const spell of SPELLS) {
|
||||
if (!HOSTILE.includes(spell.id)) continue;
|
||||
const k = progressToward(spell.tokens[0], s.foeSeq[hand], foeW.sequenceStart);
|
||||
if (k > bestK) {
|
||||
bestK = k;
|
||||
bestHand = hand;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestHand;
|
||||
}
|
||||
|
||||
function targetFor(s: Situation, spell: Spell, choice: CastChoice): void {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foeMonsters = s.state.monsters.filter((m) => m.owner === s.foe);
|
||||
const strongestFoeMonster = [...foeMonsters].sort((a, b) => b.attack - a.attack)[0];
|
||||
const iceEl = s.state.monsters.find((m) => m.kind === 'ice_elemental');
|
||||
const fireEl = s.state.monsters.find((m) => m.kind === 'fire_elemental');
|
||||
switch (spell.id) {
|
||||
case 'fireball':
|
||||
choice.target = iceEl ? iceEl.id : s.foe;
|
||||
break;
|
||||
case 'resist_cold':
|
||||
choice.target = iceEl ? iceEl.id : s.me;
|
||||
break;
|
||||
case 'resist_heat':
|
||||
choice.target = fireEl ? fireEl.id : s.me;
|
||||
break;
|
||||
case 'charm_monster':
|
||||
choice.target = strongestFoeMonster?.id ?? s.foe;
|
||||
break;
|
||||
case 'remove_enchantment': {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const enchanted = foeW.resistHeat || foeW.resistCold || foeW.protectionTurns > 0 || foeW.invisibleTurns > 0 || foeW.hasteTurns > 0;
|
||||
choice.target = enchanted || !strongestFoeMonster ? s.foe : strongestFoeMonster.id;
|
||||
break;
|
||||
}
|
||||
case 'summon_elemental':
|
||||
choice.target = s.me;
|
||||
choice.elemental = meW.resistCold ? 'ice' : 'fire';
|
||||
break;
|
||||
case 'paralysis':
|
||||
case 'charm_person':
|
||||
choice.target = s.foe;
|
||||
choice.chosenHand = foeDangerousHand(s);
|
||||
break;
|
||||
default:
|
||||
choice.target = spell.usualTarget === 'self' ? s.me : s.foe;
|
||||
}
|
||||
if (spell.category === 'summons') choice.monsterTarget = s.foe;
|
||||
}
|
||||
|
||||
function toChoice(s: Situation, hand: Hand, completion: Completion): CastChoice {
|
||||
const choice: CastChoice = { hand, spellId: completion.spell.id, seqIndex: completion.seqIndex };
|
||||
targetFor(s, completion.spell, choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
/** The gesture that most disrupts a charmed hand's sequences. */
|
||||
function charmGesture(s: Situation): Gesture {
|
||||
const foeW = s.state.wizards[s.foe];
|
||||
const charmedHand = foeW.constraints.charmed?.hand ?? 'left';
|
||||
let best: Gesture = 'P';
|
||||
let bestScore = Infinity;
|
||||
for (const g of ['C', 'D', 'F', 'P', 'S', 'W'] as Gesture[]) {
|
||||
const seq = [...s.foeSeq[charmedHand], { own: g, other: '-' as Gesture }];
|
||||
let score = 0;
|
||||
for (const spell of SPELLS) {
|
||||
if (!HOSTILE.includes(spell.id)) continue;
|
||||
score += progressToward(spell.tokens[0], seq, foeW.sequenceStart);
|
||||
}
|
||||
if (score < bestScore || (score === bestScore && s.rng() < 0.5)) {
|
||||
bestScore = score;
|
||||
best = g;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
interface PairChoice {
|
||||
left: Gesture;
|
||||
right: Gesture;
|
||||
casts: CastChoice[];
|
||||
}
|
||||
|
||||
/** Pick one pair of gestures given the history so far, and what to cast with them. */
|
||||
function choosePair(s: Situation, history: TurnGestures[], rng: () => number, timeStopped: boolean): PairChoice {
|
||||
const meW = s.state.wizards[s.me];
|
||||
const foe = s.foe;
|
||||
const optionsFor = (hand: Hand): Gesture[] => {
|
||||
if (timeStopped) return ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||
const forced = forcedGesture(meW, hand);
|
||||
if (forced) return [forced];
|
||||
const allowed = allowedGestures(meW, hand);
|
||||
return allowed.length ? allowed : ['-'];
|
||||
};
|
||||
const start = meW.sequenceStart;
|
||||
let bestPair: { left: Gesture; right: Gesture; score: number } | undefined;
|
||||
for (const left of optionsFor('left')) {
|
||||
for (const right of optionsFor('right')) {
|
||||
const next = [...history, { left, right }];
|
||||
const seqL = handSequence(next, 'left');
|
||||
const seqR = handSequence(next, 'right');
|
||||
const evalL = evaluateHand(s, seqL, start);
|
||||
const evalR = evaluateHand(s, seqR, start, evalL.planned);
|
||||
let score = evalL.score + evalR.score;
|
||||
if (left === 'P' && right === 'P') score -= 100;
|
||||
if (left === '>' && right === '>') score -= 50;
|
||||
if (left === '-' && right === '-') score -= 3;
|
||||
if (left === '>' || right === '>') score += s.state.monsters.some((m) => m.owner === foe && m.hp === 1) ? 1.5 : 0.15;
|
||||
score += rng() * 0.5;
|
||||
if (!bestPair || score > bestPair.score) bestPair = { left, right, score };
|
||||
}
|
||||
}
|
||||
const pair = bestPair ?? { left: '-' as Gesture, right: '-' as Gesture, score: 0 };
|
||||
const next = [...history, { left: pair.left, right: pair.right }];
|
||||
const completions: Record<Hand, Completion[]> = {
|
||||
left: completedSpells(handSequence(next, 'left'), start),
|
||||
right: completedSpells(handSequence(next, 'right'), start)
|
||||
};
|
||||
return { left: pair.left, right: pair.right, casts: bestCasts(s, completions, next.length) };
|
||||
}
|
||||
|
||||
export function chooseBotTurn(state: GameState, me: WizardId, rng: () => number = Math.random): TurnInput {
|
||||
const s = assess(state, me, rng);
|
||||
const meW = state.wizards[me];
|
||||
const foe = s.foe;
|
||||
const timeStopped = state.timeStops[0] === me;
|
||||
|
||||
const first = choosePair(s, meW.history, rng, timeStopped);
|
||||
const weakFoeMonster = state.monsters.find((m) => m.owner === foe && m.hp === 1);
|
||||
const monsterOrders: Record<string, TargetId> = {};
|
||||
for (const m of state.monsters) if (m.owner === me) monsterOrders[m.id] = foe;
|
||||
|
||||
const input: TurnInput = {
|
||||
left: first.left,
|
||||
right: first.right,
|
||||
casts: first.casts,
|
||||
stabTarget: weakFoeMonster?.id ?? foe,
|
||||
monsterOrders
|
||||
};
|
||||
if (meW.hasteTurns > 0 && !timeStopped) {
|
||||
const second = choosePair(s, [...meW.history, { left: first.left, right: first.right }], rng, timeStopped);
|
||||
input.second = { left: second.left, right: second.right, casts: second.casts, stabTarget: input.stabTarget };
|
||||
}
|
||||
if (meW.banked) {
|
||||
// Loose a banked spell as soon as it is worth anything; keep a shield for a rainy day.
|
||||
const spell = SPELL_BY_ID[meW.banked.spellId];
|
||||
const worth = baseValue(s, spell);
|
||||
if (worth > 0.5 && (spell.id !== 'shield' || s.missileOrMonsterNow)) {
|
||||
const choice: CastChoice = { hand: 'left', spellId: spell.id };
|
||||
targetFor(s, spell, choice);
|
||||
input.release = { target: choice.target, monsterTarget: choice.monsterTarget };
|
||||
}
|
||||
}
|
||||
if (state.wizards[foe].constraints.charmed?.by === me) input.charmGesture = charmGesture(s);
|
||||
return input;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,417 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chooseBotTurn } from './bot';
|
||||
import { completedSpells, handSequence, castsConflict, completableIn, progressToward } from './gestures';
|
||||
import { resolveTurn } from './resolve';
|
||||
import { SPELLS, SPELL_BY_ID, parseSequence, type Gesture } from './spells';
|
||||
import { createGame, type GameState, type TurnInput, type WizardId } from './state';
|
||||
|
||||
const noop = (): TurnInput => ({ left: '-', right: '-', casts: [] });
|
||||
|
||||
function play(state: GameState, a: Partial<TurnInput>, b: Partial<TurnInput> = {}): GameState {
|
||||
return resolveTurn(state, { A: { ...noop(), ...a }, B: { ...noop(), ...b } });
|
||||
}
|
||||
|
||||
/** Feed gesture strings turn by turn; the caster casts every spell it completes on the final turn. */
|
||||
function runSequence(
|
||||
state: GameState,
|
||||
who: WizardId,
|
||||
left: string,
|
||||
right: string,
|
||||
finalCasts: (turnIndex: number) => Partial<TurnInput>
|
||||
): GameState {
|
||||
const L = left.split('');
|
||||
const R = right.split('');
|
||||
for (let i = 0; i < L.length; i++) {
|
||||
const extra = i === L.length - 1 ? finalCasts(i) : {};
|
||||
const inp = { left: L[i] as Gesture, right: R[i] as Gesture, ...extra };
|
||||
state = who === 'A' ? play(state, inp) : play(state, {}, inp);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
describe('spell table', () => {
|
||||
it('has 40 spells, all of them resolved', () => {
|
||||
expect(SPELLS).toHaveLength(40);
|
||||
expect(SPELLS.filter((s) => !s.implemented)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses two-handed tokens', () => {
|
||||
expect(parseSequence('P-P-(w-(s')).toEqual([
|
||||
{ gesture: 'P', both: false },
|
||||
{ gesture: 'P', both: false },
|
||||
{ gesture: 'W', both: true },
|
||||
{ gesture: 'S', both: true }
|
||||
]);
|
||||
expect(parseSequence('W-D-D-C')[3]).toEqual({ gesture: 'C', both: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gesture matching', () => {
|
||||
const seq = (own: string, other = '') =>
|
||||
own.split('').map((g, i) => ({ own: g as Gesture, other: (other[i] ?? '-') as Gesture }));
|
||||
|
||||
it('finds every spell the tail completes, longest first', () => {
|
||||
const names = completedSpells(seq('PWPFSSSD'), 0).map((c) => c.spell.id);
|
||||
expect(names).toEqual(['finger_of_death', 'missile']);
|
||||
});
|
||||
|
||||
it('lets one gesture stream carry overlapping spells', () => {
|
||||
expect(completedSpells(seq('FFF'), 0).map((c) => c.spell.id)).toEqual(['paralysis']);
|
||||
expect(completedSpells(seq('FFFSSDD'), 0).map((c) => c.spell.id)).toEqual(['fireball']);
|
||||
});
|
||||
|
||||
it('requires the other hand for two-handed tokens', () => {
|
||||
expect(completedSpells(seq('PPWS', '--WS'), 0).map((c) => c.spell.id)).toContain('invisibility');
|
||||
expect(completedSpells(seq('PPWS', '--W-'), 0).map((c) => c.spell.id)).not.toContain('invisibility');
|
||||
expect(completedSpells(seq('WDDC', '---C'), 0).map((c) => c.spell.id)).toContain('lightning_bolt_quick');
|
||||
expect(completedSpells(seq('WDDC', '----'), 0).map((c) => c.spell.id)).not.toContain('lightning_bolt_quick');
|
||||
});
|
||||
|
||||
it('honours an anti-spell cut-off', () => {
|
||||
expect(completedSpells(seq('SD'), 1)).toHaveLength(0);
|
||||
expect(completedSpells(seq('SD'), 0).map((c) => c.spell.id)).toEqual(['missile']);
|
||||
});
|
||||
|
||||
it('reports spells one gesture away and progress toward them', () => {
|
||||
const soon = completableIn(seq('FSSD'), 0, 1).map((c) => c.spell.id);
|
||||
expect(soon).toContain('fireball');
|
||||
expect(progressToward(SPELL_BY_ID.fireball.tokens[0], seq('FSS'), 0)).toBe(3);
|
||||
});
|
||||
|
||||
it('flags two spells that share a gesture', () => {
|
||||
const history = ['PW', 'PW', 'WW', 'SS'].map((g) => ({ left: g[0] as Gesture, right: g[1] as Gesture }));
|
||||
const inv = completedSpells(handSequence(history, 'left'), 0).find((c) => c.spell.id === 'invisibility')!;
|
||||
const counter = completedSpells(handSequence(history, 'right'), 0).find((c) => c.spell.id === 'counter_spell')!;
|
||||
expect(castsConflict({ completion: inv, hand: 'left' }, { completion: counter, hand: 'right' }, 4)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolution', () => {
|
||||
it('a missile does one damage unless shielded', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = play(s, { left: 'S' });
|
||||
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] });
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
s = play(s, { left: 'S' });
|
||||
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'missile' }] }, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
});
|
||||
|
||||
it('a counter-spell stops a fireball but not a finger of death', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'FSSDD', 'PWPFS', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
s = play(s, {}, { left: 'W' });
|
||||
s = play(s, { right: 'S' }, { left: 'W' });
|
||||
s = play(s, { right: 'S' }, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
||||
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'finger_of_death' }] }, { left: 'W' });
|
||||
expect(s.wizards.B.alive).toBe(true);
|
||||
s = play(s, {}, { left: 'W' });
|
||||
s = play(s, {}, { left: 'S', casts: [{ hand: 'left', spellId: 'counter_spell' }] });
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
// Now the real finger of death lands through a counter-spell.
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
t = runSequence(t, 'A', 'PWPFSSSD', '--------', () => ({ casts: [{ hand: 'left', spellId: 'finger_of_death' }] }));
|
||||
expect(t.wizards.B.alive).toBe(false);
|
||||
expect(t.over?.winner).toBe('A');
|
||||
});
|
||||
|
||||
it('counter-spell smothers a finger of death only via dispel magic', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
for (let i = 0; i < 7; i++) s = play(s, { left: 'PWPFSSS'[i] as Gesture }, { left: 'C', right: 'C' });
|
||||
s = play(s, { left: 'D', casts: [{ hand: 'left', spellId: 'finger_of_death' }] }, { left: 'D' });
|
||||
expect(s.wizards.B.alive).toBe(false);
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
const bLeft = '----CDPW';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const last = i === 7;
|
||||
t = play(
|
||||
t,
|
||||
{ left: 'PWPFSSSD'[i] as Gesture, casts: last ? [{ hand: 'left', spellId: 'finger_of_death' }] : [] },
|
||||
{ left: bLeft[i] as Gesture, right: bLeft[i] === 'C' ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'dispel_magic' }] : [] }
|
||||
);
|
||||
}
|
||||
expect(t.wizards.B.alive).toBe(true);
|
||||
});
|
||||
|
||||
it('a summoned goblin attacks the same turn and dies to a stab', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'SFW', '---', () => ({ casts: [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }] }));
|
||||
expect(s.monsters).toHaveLength(1);
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
s = play(s, {}, { left: '>', right: 'P', stabTarget: 'm1', casts: [{ hand: 'right', spellId: 'shield' }] });
|
||||
expect(s.monsters).toHaveLength(0);
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
});
|
||||
|
||||
it('amnesia forces last turn’s gestures and P+P is a surrender', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'DPP', 'W--', () => ({ casts: [{ hand: 'left', spellId: 'amnesia' }] }));
|
||||
// B was idle, so amnesia makes B repeat nothing with both hands.
|
||||
expect(s.wizards.B.constraints.amnesia).toBe(true);
|
||||
s = play(s, {}, { left: 'F', right: 'F' });
|
||||
const last = s.wizards.B.history.at(-1)!;
|
||||
expect([last.left, last.right]).toEqual(['-', '-']);
|
||||
s = play(s, { left: 'P', right: 'P' });
|
||||
expect(s.over?.winner).toBe('B');
|
||||
});
|
||||
|
||||
it('paralysis freezes a hand with C, S, W transposed', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = play(s, { left: 'F' }, { left: 'W' });
|
||||
s = play(s, { left: 'F' }, { left: 'W' });
|
||||
s = play(s, { left: 'F', casts: [{ hand: 'left', spellId: 'paralysis', chosenHand: 'left' }] }, { left: 'W' });
|
||||
s = play(s, {}, { left: 'S' });
|
||||
expect(s.wizards.B.history.at(-1)!.left).toBe('P');
|
||||
});
|
||||
|
||||
it('fear bans C, D, F and S next turn', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
||||
s = play(s, {}, { left: 'D', right: 'W' });
|
||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||
});
|
||||
|
||||
it('anti-spell wipes earlier gestures from the weave', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = play(s, { left: 'S' }, { left: 'F' });
|
||||
s = play(s, { left: 'P' }, { left: 'S' });
|
||||
s = play(s, { left: 'F', casts: [{ hand: 'left', spellId: 'anti_spell' }] }, { left: 'S' });
|
||||
s = play(s, {}, { left: 'D' });
|
||||
s = play(s, {}, { left: 'D', casts: [{ hand: 'left', spellId: 'fireball' }] });
|
||||
expect(s.wizards.A.hp).toBe(15);
|
||||
});
|
||||
|
||||
it('resist heat makes a fireball harmless and a magic mirror reflects it', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'B', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
||||
s = runSequence(s, 'A', 'FSSDD', '-----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const g = 'FSSDD'[i] as Gesture;
|
||||
const mirror = i >= 3;
|
||||
t = play(
|
||||
t,
|
||||
{ left: g, casts: i === 4 ? [{ hand: 'left', spellId: 'fireball' }] : [] },
|
||||
{
|
||||
left: i === 3 ? 'C' : mirror ? 'W' : '-',
|
||||
right: i === 3 ? 'C' : mirror ? 'W' : '-',
|
||||
casts: i === 4 ? [{ hand: 'left', spellId: 'magic_mirror' }] : []
|
||||
}
|
||||
);
|
||||
}
|
||||
expect(t.wizards.B.hp).toBe(15);
|
||||
expect(t.wizards.A.hp).toBe(10);
|
||||
});
|
||||
|
||||
it('fire storm and ice storm cancel; fire storm spares the heat-resistant', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'WWFP', '----', () => ({ casts: [{ hand: 'left', spellId: 'resist_heat' }] }));
|
||||
s = runSequence(s, 'A', 'SWWC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'fire_storm' }] }));
|
||||
expect(s.wizards.A.hp).toBe(15);
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
let t = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const last = i === 3;
|
||||
t = play(
|
||||
t,
|
||||
{ left: 'SWWC'[i] as Gesture, right: last ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'fire_storm' }] : [] },
|
||||
{ left: 'WSSC'[i] as Gesture, right: last ? 'C' : '-', casts: last ? [{ hand: 'left', spellId: 'ice_storm' }] : [] }
|
||||
);
|
||||
}
|
||||
expect(t.wizards.A.hp).toBe(15);
|
||||
expect(t.wizards.B.hp).toBe(15);
|
||||
});
|
||||
|
||||
it('the quick lightning bolt works once per duel', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
s = runSequence(s, 'A', 'WDDC', '---C', () => ({ casts: [{ hand: 'left', spellId: 'lightning_bolt_quick' }] }));
|
||||
expect(s.wizards.B.hp).toBe(10);
|
||||
});
|
||||
|
||||
it('disease kills at the end of the sixth turn unless cured', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'DSFFFC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'disease' }] }));
|
||||
expect(s.wizards.B.diseaseTurns).toBe(5);
|
||||
for (let i = 0; i < 4; i++) s = play(s, {});
|
||||
expect(s.wizards.B.alive).toBe(true);
|
||||
s = play(s, {});
|
||||
expect(s.wizards.B.alive).toBe(false);
|
||||
});
|
||||
|
||||
it('protection from evil covers four turns and dispel magic ends it', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'B', 'WWP', '---', () => ({ casts: [{ hand: 'left', spellId: 'protection_from_evil' }] }));
|
||||
expect(s.wizards.B.protectionTurns).toBe(3);
|
||||
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot', () => {
|
||||
it('never surrenders by accident and only casts spells it completed', () => {
|
||||
let s = createGame({ A: 'You', B: 'Bot' }, 7);
|
||||
let rngSeed = 3;
|
||||
const rng = () => {
|
||||
rngSeed = (rngSeed * 1664525 + 1013904223) >>> 0;
|
||||
return rngSeed / 4294967296;
|
||||
};
|
||||
for (let i = 0; i < 40 && !s.over; i++) {
|
||||
const bot = chooseBotTurn(s, 'B', rng);
|
||||
expect(bot.left === 'P' && bot.right === 'P').toBe(false);
|
||||
const before = s.log.length;
|
||||
s = resolveTurn(s, { A: { ...noop(), left: 'W', right: 'P' }, B: bot });
|
||||
const fizzles = s.log.slice(before).filter((l) => l.text.includes('cannot use the same gesture'));
|
||||
expect(fizzles).toHaveLength(0);
|
||||
}
|
||||
expect(s.wizards.A.hp).toBeLessThan(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('haste, time stop, delayed effect and permanency', () => {
|
||||
it('a hastened wizard makes two pairs of gestures a turn and can cast from both', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'PWPWWC', '-----C', () => ({ casts: [{ hand: 'left', spellId: 'haste' }] }));
|
||||
expect(s.wizards.A.hasteTurns).toBe(3);
|
||||
s = play(s, { left: 'S', right: 'S', second: { left: 'D', right: 'D', casts: [{ hand: 'left', spellId: 'missile' }, { hand: 'right', spellId: 'missile' }] } });
|
||||
expect(s.wizards.A.history).toHaveLength(8);
|
||||
expect(s.wizards.A.history.at(-1)?.phase).toBe('haste');
|
||||
expect(s.wizards.B.hp).toBe(13);
|
||||
expect(s.wizards.B.history).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('time stop grants a turn alone in which shields and resistances count for nothing', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
// B's protection from evil lands one turn before A's time stop, so it covers the stopped moment and the turn after.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
s = play(
|
||||
s,
|
||||
{ left: 'SPPC'[i] as Gesture, right: i === 3 ? 'C' : '-', casts: i === 3 ? [{ hand: 'left', spellId: 'time_stop' }] : [] },
|
||||
{ left: '-WWP'[i] as Gesture, casts: i === 3 ? [{ hand: 'left', spellId: 'protection_from_evil' }] : [] }
|
||||
);
|
||||
}
|
||||
expect(s.timeStops).toEqual(['A']);
|
||||
expect(s.wizards.B.protectionTurns).toBe(3);
|
||||
const turnBefore = s.turn;
|
||||
const bHistory = s.wizards.B.history.length;
|
||||
s = play(s, { left: '>', right: 'S', stabTarget: 'B' }, { left: 'P', right: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||
expect(s.turn).toBe(turnBefore);
|
||||
expect(s.timeStops).toEqual([]);
|
||||
expect(s.wizards.B.history).toHaveLength(bHistory);
|
||||
expect(s.wizards.B.surrendered).toBe(false);
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
expect(s.wizards.A.history.at(-1)).toMatchObject({ phase: 'timestop', hiddenFrom: ['B'] });
|
||||
// The snap made in the stopped moment still counts toward a missile.
|
||||
s = play(s, { right: 'D', casts: [{ hand: 'right', spellId: 'missile' }] });
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
expect(s.log.some((l) => l.text.includes('shield turns the missile aside'))).toBe(true);
|
||||
});
|
||||
|
||||
it('delayed effect banks the next spell until it is released', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'DWSSSP', '------', () => ({ casts: [{ hand: 'left', spellId: 'delayed_effect' }] }));
|
||||
expect(s.wizards.A.delayedTurns).toBe(3);
|
||||
s = runSequence(s, 'A', 'SD', '--', () => ({ casts: [{ hand: 'left', spellId: 'missile' }] }));
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
||||
expect(s.wizards.A.delayedTurns).toBe(0);
|
||||
s = play(s, { release: { target: 'B' } });
|
||||
expect(s.wizards.B.hp).toBe(14);
|
||||
expect(s.wizards.A.banked).toBeNull();
|
||||
});
|
||||
|
||||
it('a spell cast with the delayed effect on the same turn is banked, not the delayed effect itself', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
const left = 'DWSSSP';
|
||||
const right = '----SD';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const last = i === 5;
|
||||
s = play(s, {
|
||||
left: left[i] as Gesture,
|
||||
right: right[i] as Gesture,
|
||||
casts: last ? [{ hand: 'left', spellId: 'delayed_effect' }, { hand: 'right', spellId: 'missile' }] : []
|
||||
});
|
||||
}
|
||||
expect(s.wizards.B.hp).toBe(15);
|
||||
expect(s.wizards.A.banked?.spellId).toBe('missile');
|
||||
expect(s.wizards.A.delayedTurns).toBe(0);
|
||||
});
|
||||
|
||||
it('permanency makes the next enchantment last for the rest of the duel', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = runSequence(s, 'A', 'SPFPSDW', '-------', () => ({ casts: [{ hand: 'left', spellId: 'permanency' }] }));
|
||||
expect(s.wizards.A.permanencyTurns).toBe(3);
|
||||
s = runSequence(s, 'A', 'SWD', '---', () => ({ casts: [{ hand: 'left', spellId: 'fear' }] }));
|
||||
expect(s.wizards.A.permanencyTurns).toBe(0);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
s = play(s, {}, { left: 'F', right: 'W' });
|
||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: '-', right: 'W' });
|
||||
}
|
||||
// Only a dispel magic can lift it now: B cannot point a digit to cast remove enchantment.
|
||||
s = runSequence(s, 'A', 'CDPW', 'C---', () => ({ casts: [{ hand: 'left', spellId: 'dispel_magic' }] }));
|
||||
s = play(s, {}, { left: 'F', right: 'W' });
|
||||
expect(s.wizards.B.history.at(-1)).toMatchObject({ left: 'F', right: 'W' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('haste under amnesia', () => {
|
||||
it('repeats both of last turn\u2019s pairs', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
s = play(
|
||||
s,
|
||||
{ left: 'PWPWWC'[i] as Gesture, right: i === 5 ? 'C' : '-', casts: i === 5 ? [{ hand: 'left', spellId: 'haste' }] : [] },
|
||||
{ left: i === 5 ? 'D' : '-' }
|
||||
);
|
||||
}
|
||||
expect(s.wizards.A.hasteTurns).toBe(3);
|
||||
s = play(s, { left: 'W', right: 'W', second: { left: 'D', right: 'D', casts: [] } }, { left: 'P' });
|
||||
// The amnesia lands on the turn A makes S-F and then, hastened, W-D.
|
||||
s = play(s, { left: 'S', right: 'F', second: { left: 'W', right: 'D', casts: [] } }, { left: 'P', casts: [{ hand: 'left', spellId: 'amnesia' }] });
|
||||
expect(s.wizards.A.constraints.amnesia).toBe(true);
|
||||
expect(s.wizards.A.hasteTurns).toBe(1);
|
||||
s = play(s, { left: 'C', right: 'C', second: { left: 'C', right: 'C', casts: [] } });
|
||||
const last = s.wizards.A.history.slice(-2);
|
||||
expect(last.map((h) => h.left + h.right)).toEqual(['SF', 'WD']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lessons', () => {
|
||||
it('explains a shield that blocked a creature but not cause light wounds, once per duel', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
// A summons a goblin on turn 3 and lands cause light wounds on turn 3 too; B shields on turn 3.
|
||||
const left = 'SFW';
|
||||
const right = 'WFP';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const last = i === 2;
|
||||
s = play(
|
||||
s,
|
||||
{
|
||||
left: left[i] as Gesture,
|
||||
right: right[i] as Gesture,
|
||||
casts: last ? [{ hand: 'left', spellId: 'summon_goblin', monsterTarget: 'B' }, { hand: 'right', spellId: 'cause_light_wounds' }] : []
|
||||
},
|
||||
{ left: last ? 'P' : '-', casts: last ? [{ hand: 'left', spellId: 'shield' }] : [] }
|
||||
);
|
||||
}
|
||||
expect(s.wizards.B.hp).toBe(13);
|
||||
expect(s.lastTurn?.lessons.some((l) => l.includes('does not stop cause light wounds'))).toBe(true);
|
||||
expect(s.lastTurn?.lessons.some((l) => l.includes('attacks on the very turn'))).toBe(false);
|
||||
// The same situation again teaches nothing new.
|
||||
s = runSequence(s, 'A', 'WFP', '---', () => ({ casts: [{ hand: 'left', spellId: 'cause_light_wounds' }] }));
|
||||
s = play(s, {}, { left: 'P', casts: [{ hand: 'left', spellId: 'shield' }] });
|
||||
expect(s.lastTurn?.lessons).toEqual([]);
|
||||
});
|
||||
|
||||
it('notices a single-handed clap and a reused run', () => {
|
||||
let s = createGame({ A: 'Black', B: 'White' }, 1);
|
||||
s = play(s, { left: 'C' });
|
||||
expect(s.lastTurn?.lessons[0]).toContain('clap needs both hands');
|
||||
s = runSequence(s, 'A', 'FFF', '---', () => ({ casts: [{ hand: 'left', spellId: 'paralysis' }] }));
|
||||
s = runSequence(s, 'A', 'SSDD', '----', () => ({ casts: [{ hand: 'left', spellId: 'fireball' }] }));
|
||||
expect(s.lastTurn?.lessons.some((l) => l.includes('reused gestures from the earlier Paralysis'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// Gesture sequence matching: which spells a hand has completed, which it is
|
||||
// working toward, and whether two chosen spells would reuse the same gesture.
|
||||
|
||||
import { SPELLS, type Gesture, type Hand, type Spell, type Token } from './spells';
|
||||
|
||||
/** One turn of gestures as seen from one hand. */
|
||||
export interface HandTurn {
|
||||
own: Gesture;
|
||||
other: Gesture;
|
||||
}
|
||||
|
||||
export interface TurnGestures {
|
||||
left: Gesture;
|
||||
right: Gesture;
|
||||
}
|
||||
|
||||
export interface Completion {
|
||||
spell: Spell;
|
||||
seqIndex: number;
|
||||
/** Number of turns the sequence spans. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
export function handSequence(history: TurnGestures[], hand: Hand): HandTurn[] {
|
||||
return history.map((t) =>
|
||||
hand === 'left' ? { own: t.left, other: t.right } : { own: t.right, other: t.left }
|
||||
);
|
||||
}
|
||||
|
||||
export function tokenMatches(token: Token, turn: HandTurn): boolean {
|
||||
if (token.both) return turn.own === token.gesture && turn.other === token.gesture;
|
||||
return turn.own === token.gesture;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the first `count` tokens of a spell sequence match the most
|
||||
* recent gestures of the hand, and none of those gestures fall before
|
||||
* `start` (the first usable turn after an anti-spell).
|
||||
*/
|
||||
export function prefixMatchesTail(
|
||||
tokens: Token[],
|
||||
count: number,
|
||||
seq: HandTurn[],
|
||||
start: number
|
||||
): boolean {
|
||||
if (count === 0) return true;
|
||||
const from = seq.length - count;
|
||||
if (from < start) return false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (!tokenMatches(tokens[i], seq[from + i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Every spell the hand has just finished, longest first. */
|
||||
export function completedSpells(seq: HandTurn[], start: number): Completion[] {
|
||||
const out: Completion[] = [];
|
||||
for (const spell of SPELLS) {
|
||||
spell.tokens.forEach((tokens, seqIndex) => {
|
||||
if (prefixMatchesTail(tokens, tokens.length, seq, start)) {
|
||||
out.push({ spell, seqIndex, length: tokens.length });
|
||||
}
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spells the hand could finish `turnsAway` turns from now if it keeps to the
|
||||
* sequence. With turnsAway = 1 these are the spells one more gesture completes.
|
||||
*/
|
||||
export function completableIn(
|
||||
seq: HandTurn[],
|
||||
start: number,
|
||||
turnsAway: number
|
||||
): { spell: Spell; seqIndex: number; remaining: Token[] }[] {
|
||||
const out: { spell: Spell; seqIndex: number; remaining: Token[] }[] = [];
|
||||
for (const spell of SPELLS) {
|
||||
spell.tokens.forEach((tokens, seqIndex) => {
|
||||
const done = tokens.length - turnsAway;
|
||||
if (done < 1) return;
|
||||
if (prefixMatchesTail(tokens, done, seq, start)) {
|
||||
out.push({ spell, seqIndex, remaining: tokens.slice(done) });
|
||||
}
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Longest prefix of `tokens` the hand's latest gestures satisfy (strictly shorter than the spell). */
|
||||
export function progressToward(tokens: Token[], seq: HandTurn[], start: number): number {
|
||||
for (let k = tokens.length - 1; k >= 1; k--) {
|
||||
if (prefixMatchesTail(tokens, k, seq, start)) return k;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The (hand, turn) cells a completed spell consumes; two-handed tokens use both hands. */
|
||||
export function usedCells(completion: Completion, hand: Hand, turnCount: number): Set<string> {
|
||||
const tokens = completion.spell.tokens[completion.seqIndex];
|
||||
const cells = new Set<string>();
|
||||
const from = turnCount - tokens.length;
|
||||
tokens.forEach((token, i) => {
|
||||
const turn = from + i;
|
||||
cells.add(`${hand}:${turn}`);
|
||||
if (token.both) cells.add(`${hand === 'left' ? 'right' : 'left'}:${turn}`);
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
|
||||
/** No gesture may complete more than one spell. */
|
||||
export function castsConflict(
|
||||
a: { completion: Completion; hand: Hand },
|
||||
b: { completion: Completion; hand: Hand },
|
||||
turnCount: number
|
||||
): boolean {
|
||||
const cellsA = usedCells(a.completion, a.hand, turnCount);
|
||||
for (const cell of usedCells(b.completion, b.hand, turnCount)) {
|
||||
if (cellsA.has(cell)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function formatSequence(spell: Spell, seqIndex = 0): string {
|
||||
return spell.sequences[seqIndex];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Gesture } from './spells';
|
||||
|
||||
/** How a gesture is written in the ledger. A clap needs both hands; alone it is nothing. */
|
||||
export function glyph(g: Gesture, other: Gesture = 'C'): string {
|
||||
if (g === '>') return '†';
|
||||
if (g === '-') return '–';
|
||||
if (g === 'C' && other !== 'C') return '(C)';
|
||||
return g;
|
||||
}
|
||||
|
||||
export const GESTURE_SHORT: Record<Gesture, string> = {
|
||||
F: 'fingers',
|
||||
P: 'palm',
|
||||
S: 'snap',
|
||||
W: 'wave',
|
||||
D: 'digit',
|
||||
C: 'clap',
|
||||
'>': 'stab',
|
||||
'-': 'nothing'
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { chooseBotTurn } from './bot';
|
||||
import { resolveTurn } from './resolve';
|
||||
import { createGame } from './state';
|
||||
|
||||
function lcg(seed: number) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
describe('bot versus bot', () => {
|
||||
it('sixty duels end without errors and by death, not surrender', () => {
|
||||
let finished = 0;
|
||||
let byDeath = 0;
|
||||
let totalTurns = 0;
|
||||
for (let seed = 1; seed <= 60; seed++) {
|
||||
let state = createGame({ A: 'Black', B: 'White' }, seed);
|
||||
const rngA = lcg(seed * 7 + 1);
|
||||
const rngB = lcg(seed * 13 + 5);
|
||||
for (let turn = 0; turn < 80 && !state.over; turn++) {
|
||||
const a = chooseBotTurn(state, 'A', rngA);
|
||||
const b = chooseBotTurn(state, 'B', rngB);
|
||||
state = resolveTurn(state, { A: a, B: b });
|
||||
}
|
||||
if (state.over) {
|
||||
finished++;
|
||||
totalTurns += state.turn;
|
||||
if (!state.wizards.A.alive || !state.wizards.B.alive) byDeath++;
|
||||
}
|
||||
for (const w of Object.values(state.wizards)) {
|
||||
expect(w.hp).toBeGreaterThanOrEqual(0);
|
||||
expect(w.hp).toBeLessThanOrEqual(15);
|
||||
}
|
||||
}
|
||||
expect(finished).toBeGreaterThan(45);
|
||||
expect(byDeath).toBe(finished);
|
||||
expect(totalTurns / finished).toBeLessThan(40);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// The spell book for Waving Hands (Richard Bartle, 1977), as transcribed in
|
||||
// docs/waving-hands-rules.txt. Gesture notation: F fingers, P palm, S snap,
|
||||
// W wave, D digit point, C clap (always both hands). A lower-case letter in
|
||||
// parentheses, e.g. "(w", means both hands make that gesture at once.
|
||||
|
||||
export type Gesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C' | '>' | '-';
|
||||
export type MagicGesture = 'F' | 'P' | 'S' | 'W' | 'D' | 'C';
|
||||
export type Hand = 'left' | 'right';
|
||||
|
||||
export const HANDS: Hand[] = ['left', 'right'];
|
||||
export const GESTURES: Gesture[] = ['F', 'P', 'S', 'W', 'D', 'C', '>', '-'];
|
||||
export const MAGIC_GESTURES: MagicGesture[] = ['F', 'P', 'S', 'W', 'D', 'C'];
|
||||
|
||||
export const GESTURE_NAMES: Record<Gesture, string> = {
|
||||
F: 'wriggled fingers',
|
||||
P: 'proffered palm',
|
||||
S: 'snap',
|
||||
W: 'wave',
|
||||
D: 'pointed digit',
|
||||
C: 'clap',
|
||||
'>': 'stab',
|
||||
'-': 'nothing'
|
||||
};
|
||||
|
||||
export interface Token {
|
||||
gesture: MagicGesture;
|
||||
/** Both hands must make this gesture on the same turn. */
|
||||
both: boolean;
|
||||
}
|
||||
|
||||
export type SpellCategory = 'protection' | 'summons' | 'damage' | 'enchantment';
|
||||
|
||||
export type SpellId =
|
||||
| 'shield'
|
||||
| 'remove_enchantment'
|
||||
| 'magic_mirror'
|
||||
| 'counter_spell'
|
||||
| 'dispel_magic'
|
||||
| 'raise_dead'
|
||||
| 'cure_light_wounds'
|
||||
| 'cure_heavy_wounds'
|
||||
| 'summon_goblin'
|
||||
| 'summon_ogre'
|
||||
| 'summon_troll'
|
||||
| 'summon_giant'
|
||||
| 'summon_elemental'
|
||||
| 'missile'
|
||||
| 'finger_of_death'
|
||||
| 'lightning_bolt'
|
||||
| 'lightning_bolt_quick'
|
||||
| 'cause_light_wounds'
|
||||
| 'cause_heavy_wounds'
|
||||
| 'fireball'
|
||||
| 'fire_storm'
|
||||
| 'ice_storm'
|
||||
| 'amnesia'
|
||||
| 'confusion'
|
||||
| 'charm_person'
|
||||
| 'charm_monster'
|
||||
| 'paralysis'
|
||||
| 'fear'
|
||||
| 'anti_spell'
|
||||
| 'protection_from_evil'
|
||||
| 'resist_heat'
|
||||
| 'resist_cold'
|
||||
| 'disease'
|
||||
| 'poison'
|
||||
| 'blindness'
|
||||
| 'invisibility'
|
||||
| 'haste'
|
||||
| 'time_stop'
|
||||
| 'delayed_effect'
|
||||
| 'permanency';
|
||||
|
||||
export interface Spell {
|
||||
id: SpellId;
|
||||
name: string;
|
||||
category: SpellCategory;
|
||||
/** Gesture sequences that complete the spell, in Bartle's notation. */
|
||||
sequences: string[];
|
||||
tokens: Token[][];
|
||||
usualTarget: 'self' | 'enemy';
|
||||
summary: string;
|
||||
/** False for spells the engine recognises but does not yet resolve. */
|
||||
implemented: boolean;
|
||||
}
|
||||
|
||||
export function parseSequence(sequence: string): Token[] {
|
||||
return sequence.split('-').map((part) => {
|
||||
if (part.startsWith('(')) {
|
||||
return { gesture: part[1].toUpperCase() as MagicGesture, both: true };
|
||||
}
|
||||
return { gesture: part as MagicGesture, both: part === 'C' };
|
||||
});
|
||||
}
|
||||
|
||||
function spell(
|
||||
id: SpellId,
|
||||
name: string,
|
||||
category: SpellCategory,
|
||||
sequences: string[],
|
||||
usualTarget: 'self' | 'enemy',
|
||||
summary: string,
|
||||
implemented = true
|
||||
): Spell {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
sequences,
|
||||
tokens: sequences.map(parseSequence),
|
||||
usualTarget,
|
||||
summary,
|
||||
implemented
|
||||
};
|
||||
}
|
||||
|
||||
export const SPELLS: Spell[] = [
|
||||
// Protection
|
||||
spell('shield', 'Shield', 'protection', ['P'], 'self', 'Blocks monsters, missiles and stabs against the subject this turn.'),
|
||||
spell('remove_enchantment', 'Remove enchantment', 'protection', ['P-D-W-P'], 'enemy', 'Ends every enchantment on the subject. Destroys a monster after it attacks.'),
|
||||
spell('magic_mirror', 'Magic mirror', 'protection', ['C-(w'], 'self', 'Spells cast at the subject this turn rebound on their caster. Undone by a counter-spell.'),
|
||||
spell('counter_spell', 'Counter-spell', 'protection', ['W-P-P', 'W-W-S'], 'self', 'Other spells cast at the subject this turn fail, and the subject is shielded. Cannot stop finger of death or dispel magic.'),
|
||||
spell('dispel_magic', 'Dispel magic', 'protection', ['C-D-P-W'], 'self', 'Every other spell this turn fails, all enchantments end, all monsters die after attacking. Shields the subject.'),
|
||||
spell('raise_dead', 'Raise dead', 'protection', ['D-W-W-F-W-C'], 'self', 'Heals 5 damage and removes all enchantments from the subject.'),
|
||||
spell('cure_light_wounds', 'Cure light wounds', 'protection', ['D-F-W'], 'self', 'Heals 1 damage.'),
|
||||
spell('cure_heavy_wounds', 'Cure heavy wounds', 'protection', ['D-F-P-W'], 'self', 'Heals 2 damage and cures disease.'),
|
||||
// Summons
|
||||
spell('summon_goblin', 'Summon goblin', 'summons', ['S-F-W'], 'self', 'A goblin (1 hit point, 1 damage a turn) fights for the subject.'),
|
||||
spell('summon_ogre', 'Summon ogre', 'summons', ['P-S-F-W'], 'self', 'An ogre (2 hit points, 2 damage a turn) fights for the subject.'),
|
||||
spell('summon_troll', 'Summon troll', 'summons', ['F-P-S-F-W'], 'self', 'A troll (3 hit points, 3 damage a turn) fights for the subject.'),
|
||||
spell('summon_giant', 'Summon giant', 'summons', ['W-F-P-S-F-W'], 'self', 'A giant (4 hit points, 4 damage a turn) fights for the subject.'),
|
||||
spell('summon_elemental', 'Summon elemental', 'summons', ['C-S-W-W-S'], 'self', 'A fire or ice elemental (3 hit points) savages everyone not resistant to it for 3 a turn.'),
|
||||
// Damage
|
||||
spell('missile', 'Missile', 'damage', ['S-D'], 'enemy', '1 damage. Stopped by a shield.'),
|
||||
spell('finger_of_death', 'Finger of death', 'damage', ['P-W-P-F-S-S-S-D'], 'enemy', 'Kills the subject outright. Only dispel magic can stop it.'),
|
||||
spell('lightning_bolt', 'Lightning bolt', 'damage', ['D-F-F-D-D'], 'enemy', '5 damage. Shields do not help.'),
|
||||
spell('lightning_bolt_quick', 'Lightning bolt (quick)', 'damage', ['W-D-D-C'], 'enemy', '5 damage. Each wizard may use this short form only once a duel.'),
|
||||
spell('cause_light_wounds', 'Cause light wounds', 'damage', ['W-F-P'], 'enemy', '2 damage. Shields do not help.'),
|
||||
spell('cause_heavy_wounds', 'Cause heavy wounds', 'damage', ['W-P-F-D'], 'enemy', '3 damage. Shields do not help.'),
|
||||
spell('fireball', 'Fireball', 'damage', ['F-S-S-D-D'], 'enemy', '5 damage unless the subject resists heat. Destroys an ice elemental.'),
|
||||
spell('fire_storm', 'Fire storm', 'damage', ['S-W-W-C'], 'enemy', '5 damage to everyone not resistant to heat, the caster included. Cancels with an ice storm or ice elemental.'),
|
||||
spell('ice_storm', 'Ice storm', 'damage', ['W-S-S-C'], 'enemy', '5 damage to everyone not resistant to cold, the caster included. Cancels with a fire storm or fire elemental.'),
|
||||
// Enchantments
|
||||
spell('amnesia', 'Amnesia', 'enchantment', ['D-P-P'], 'enemy', 'Next turn the subject must repeat this turn’s gestures exactly.'),
|
||||
spell('confusion', 'Confusion', 'enchantment', ['D-S-F'], 'enemy', 'Next turn one of the subject’s gestures is replaced at random.'),
|
||||
spell('charm_person', 'Charm person', 'enchantment', ['P-S-D-F'], 'enemy', 'Next turn the caster chooses what one of the subject’s hands does.'),
|
||||
spell('charm_monster', 'Charm monster', 'enchantment', ['P-S-D-D'], 'enemy', 'The caster takes control of the target monster from this turn on.'),
|
||||
spell('paralysis', 'Paralysis', 'enchantment', ['F-F-F'], 'enemy', 'Next turn one of the subject’s hands is frozen in its current gesture (C becomes F, S becomes D, W becomes P). A monster misses its next attack.'),
|
||||
spell('fear', 'Fear', 'enchantment', ['S-W-D'], 'enemy', 'Next turn the subject cannot make a C, D, F or S gesture.'),
|
||||
spell('anti_spell', 'Anti-spell', 'enchantment', ['S-P-F'], 'enemy', 'The subject’s gestures so far can no longer be used in a spell. Every spell must start again.'),
|
||||
spell('protection_from_evil', 'Protection from evil', 'enchantment', ['W-W-P'], 'self', 'Shields the subject this turn and for the next three.'),
|
||||
spell('resist_heat', 'Resist heat', 'enchantment', ['W-W-F-P'], 'self', 'The subject ignores fireballs, fire storms and fire elementals until dispelled. Destroys a fire elemental.'),
|
||||
spell('resist_cold', 'Resist cold', 'enchantment', ['S-S-F-P'], 'self', 'The subject ignores ice storms and ice elementals until dispelled. Destroys an ice elemental.'),
|
||||
spell('disease', 'Disease', 'enchantment', ['D-S-F-F-F-C'], 'enemy', 'The subject dies at the end of the sixth turn unless cured by cure heavy wounds, remove enchantment or dispel magic.'),
|
||||
spell('poison', 'Poison', 'enchantment', ['D-W-W-F-W-D'], 'enemy', 'As disease, but cure heavy wounds does not help.'),
|
||||
spell('blindness', 'Blindness', 'enchantment', ['D-W-F-F-(d'], 'enemy', 'For three turns the subject cannot see the opponent’s gestures. Destroys a monster.'),
|
||||
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subject’s gestures and monsters cannot attack them. Destroys a monster.'),
|
||||
spell('haste', 'Haste', 'enchantment', ['P-W-P-W-W-C'], 'self', 'For the next three turns the subject makes two pairs of gestures a turn, both taking effect together.'),
|
||||
spell('time_stop', 'Time stop', 'enchantment', ['S-P-P-C'], 'self', 'The subject at once takes an extra turn nobody else can see or resist.'),
|
||||
spell('delayed_effect', 'Delayed effect', 'enchantment', ['D-W-S-S-S-P'], 'self', 'The subject\u2019s next spell (this turn or the next three) is banked, to be released whenever they choose.'),
|
||||
spell('permanency', 'Permanency', 'enchantment', ['S-P-F-P-S-D-W'], 'self', 'The subject\u2019s next enchantment (this turn or the next three) lasts for the rest of the duel.')
|
||||
];
|
||||
|
||||
export const SPELL_BY_ID: Record<SpellId, Spell> = Object.fromEntries(
|
||||
SPELLS.map((s) => [s.id, s])
|
||||
) as Record<SpellId, Spell>;
|
||||
|
||||
/** Enchantments that cancel one another when they hit the same being together. */
|
||||
export const CONTROL_SPELLS: SpellId[] = [
|
||||
'amnesia',
|
||||
'confusion',
|
||||
'charm_person',
|
||||
'charm_monster',
|
||||
'paralysis',
|
||||
'fear'
|
||||
];
|
||||
|
||||
/** Enchantments a permanency can extend for the rest of the duel. */
|
||||
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
|
||||
|
||||
/** Spells that affect everyone rather than one subject. */
|
||||
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
|
||||
|
||||
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
|
||||
summon_goblin: 'goblin',
|
||||
summon_ogre: 'ogre',
|
||||
summon_troll: 'troll',
|
||||
summon_giant: 'giant'
|
||||
};
|
||||
|
||||
export type MonsterKind = 'goblin' | 'ogre' | 'troll' | 'giant' | 'fire_elemental' | 'ice_elemental';
|
||||
|
||||
export const MONSTER_STATS: Record<MonsterKind, { name: string; hp: number; attack: number }> = {
|
||||
goblin: { name: 'Goblin', hp: 1, attack: 1 },
|
||||
ogre: { name: 'Ogre', hp: 2, attack: 2 },
|
||||
troll: { name: 'Troll', hp: 3, attack: 3 },
|
||||
giant: { name: 'Giant', hp: 4, attack: 4 },
|
||||
fire_elemental: { name: 'Fire elemental', hp: 3, attack: 3 },
|
||||
ice_elemental: { name: 'Ice elemental', hp: 3, attack: 3 }
|
||||
};
|
||||
|
||||
export function isElemental(kind: MonsterKind): boolean {
|
||||
return kind === 'fire_elemental' || kind === 'ice_elemental';
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { TurnGestures } from './gestures';
|
||||
import type { Gesture, Hand, MonsterKind, SpellId } from './spells';
|
||||
|
||||
export type WizardId = 'A' | 'B';
|
||||
/** A wizard, a monster id, or nowhere (a spell released harmlessly). */
|
||||
export type TargetId = WizardId | string;
|
||||
export const NOWHERE = 'nowhere';
|
||||
|
||||
export const MAX_HP = 15;
|
||||
|
||||
/** A main turn, the extra pair of gestures a hastened wizard makes, or a turn taken alone under time stop. */
|
||||
export type Phase = 'main' | 'haste' | 'timestop';
|
||||
|
||||
/** Effects that shape a being's gestures or attacks on the following turn. */
|
||||
export interface Constraints {
|
||||
amnesia?: boolean;
|
||||
confusion?: boolean;
|
||||
/** A permanent confusion always produces the same slip. */
|
||||
confusionFixed?: { hand: Hand; gesture: Gesture };
|
||||
fear?: boolean;
|
||||
paralysedHand?: Hand;
|
||||
charmed?: { hand: Hand; by: WizardId };
|
||||
/** A permanent charm repeats the first chosen gesture. */
|
||||
charmedGesture?: Gesture;
|
||||
/** Monsters: skip the next attack (paralysis). */
|
||||
skipAttack?: boolean;
|
||||
/** Monsters: attack the same target again (amnesia). */
|
||||
forcedTarget?: TargetId;
|
||||
}
|
||||
|
||||
export interface HistoryEntry extends TurnGestures {
|
||||
turn: number;
|
||||
phase: Phase;
|
||||
/** Wizards who could not see these gestures when they were made. */
|
||||
hiddenFrom: WizardId[];
|
||||
}
|
||||
|
||||
export interface CastRecord {
|
||||
turn: number;
|
||||
/** Index into the wizard's history of the gesture that completed the spell; -1 for a released banked spell. */
|
||||
index: number;
|
||||
hand: Hand;
|
||||
spellId: SpellId;
|
||||
length: number;
|
||||
target: TargetId;
|
||||
}
|
||||
|
||||
export interface BankedSpell {
|
||||
spellId: SpellId;
|
||||
elemental?: 'fire' | 'ice';
|
||||
chosenHand?: Hand;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
id: WizardId;
|
||||
name: string;
|
||||
hp: number;
|
||||
alive: boolean;
|
||||
surrendered: boolean;
|
||||
history: HistoryEntry[];
|
||||
casts: CastRecord[];
|
||||
/** First turn index whose gestures may still be used in a spell (anti-spell). */
|
||||
sequenceStart: number;
|
||||
resistHeat: boolean;
|
||||
resistCold: boolean;
|
||||
protectionTurns: number;
|
||||
blindTurns: number;
|
||||
invisibleTurns: number;
|
||||
diseaseTurns: number | null;
|
||||
poisonTurns: number | null;
|
||||
/** In force for the turn about to be played. */
|
||||
constraints: Constraints;
|
||||
/** Set during resolution, becomes `constraints` next turn. */
|
||||
pending: Constraints;
|
||||
/** Constraints made permanent by a permanency spell; reapplied every turn. */
|
||||
permanent: Constraints;
|
||||
hasteTurns: number;
|
||||
/** Turns left in which the next spell cast will be banked. */
|
||||
delayedTurns: number;
|
||||
/** Turns left in which the next eligible enchantment becomes permanent. */
|
||||
permanencyTurns: number;
|
||||
banked: BankedSpell | null;
|
||||
usedQuickLightning: boolean;
|
||||
}
|
||||
|
||||
export interface Monster {
|
||||
id: string;
|
||||
kind: MonsterKind;
|
||||
name: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
attack: number;
|
||||
owner: WizardId;
|
||||
resistHeat: boolean;
|
||||
resistCold: boolean;
|
||||
protectionTurns: number;
|
||||
constraints: Constraints;
|
||||
pending: Constraints;
|
||||
createdTurn: number;
|
||||
/** What it attacked last turn, shown in the UI and used for amnesia. */
|
||||
lastTarget?: TargetId;
|
||||
}
|
||||
|
||||
export type LogKind = 'gesture' | 'spell' | 'damage' | 'death' | 'fizzle' | 'info';
|
||||
|
||||
export interface LogEntry {
|
||||
turn: number;
|
||||
kind: LogKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** What a reveal did to each wizard's health, for the result strip. */
|
||||
export interface TurnSummary {
|
||||
turn: number;
|
||||
phase: Phase;
|
||||
wizards: Record<WizardId, { before: number; after: number; effects: { label: string; delta: number }[] }>;
|
||||
/** Everything worth knowing before the next decision, other than gestures and damage. */
|
||||
events: string[];
|
||||
/** Short explanations of rules that just mattered, each shown once a duel. */
|
||||
lessons: string[];
|
||||
}
|
||||
|
||||
export interface Outcome {
|
||||
winner: WizardId | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface GameState {
|
||||
turn: number;
|
||||
wizards: Record<WizardId, WizardState>;
|
||||
monsters: Monster[];
|
||||
log: LogEntry[];
|
||||
over: Outcome | null;
|
||||
lastTurn: TurnSummary | null;
|
||||
/** Keys of explanations already given this duel. */
|
||||
lessonsShown: string[];
|
||||
/** Wizards owed an extra turn alone, in order; the next resolution belongs to the first. */
|
||||
timeStops: WizardId[];
|
||||
nextMonsterId: number;
|
||||
summonCounts: Partial<Record<MonsterKind, number>>;
|
||||
rng: number;
|
||||
}
|
||||
|
||||
export interface CastChoice {
|
||||
hand: Hand;
|
||||
spellId: SpellId;
|
||||
seqIndex?: number;
|
||||
target?: TargetId;
|
||||
elemental?: 'fire' | 'ice';
|
||||
/** Paralysis and charm person: which of the subject's hands. */
|
||||
chosenHand?: Hand;
|
||||
/** Summons: what the new monster attacks this turn. */
|
||||
monsterTarget?: TargetId;
|
||||
/** Under a delayed effect: bank this spell rather than another cast this turn. */
|
||||
bank?: boolean;
|
||||
/** Under a permanency: make this enchantment permanent rather than another cast this turn. */
|
||||
permanent?: boolean;
|
||||
}
|
||||
|
||||
export interface TurnInput {
|
||||
left: Gesture;
|
||||
right: Gesture;
|
||||
casts: CastChoice[];
|
||||
/** The extra pair of gestures a hastened wizard makes. */
|
||||
second?: { left: Gesture; right: Gesture; casts: CastChoice[]; stabTarget?: TargetId };
|
||||
/** Loose the banked spell this turn. */
|
||||
release?: { target?: TargetId; monsterTarget?: TargetId };
|
||||
stabTarget?: TargetId;
|
||||
monsterOrders?: Record<string, TargetId>;
|
||||
/** When this wizard charmed the opponent last turn: the gesture the charmed hand makes. */
|
||||
charmGesture?: Gesture;
|
||||
}
|
||||
|
||||
export function enemyOf(id: WizardId): WizardId {
|
||||
return id === 'A' ? 'B' : 'A';
|
||||
}
|
||||
|
||||
function makeWizard(id: WizardId, name: string): WizardState {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
hp: MAX_HP,
|
||||
alive: true,
|
||||
surrendered: false,
|
||||
history: [],
|
||||
casts: [],
|
||||
sequenceStart: 0,
|
||||
resistHeat: false,
|
||||
resistCold: false,
|
||||
protectionTurns: 0,
|
||||
blindTurns: 0,
|
||||
invisibleTurns: 0,
|
||||
diseaseTurns: null,
|
||||
poisonTurns: null,
|
||||
constraints: {},
|
||||
pending: {},
|
||||
permanent: {},
|
||||
hasteTurns: 0,
|
||||
delayedTurns: 0,
|
||||
permanencyTurns: 0,
|
||||
banked: null,
|
||||
usedQuickLightning: false
|
||||
};
|
||||
}
|
||||
|
||||
export function createGame(names: { A: string; B: string }, seed = Date.now()): GameState {
|
||||
return {
|
||||
turn: 0,
|
||||
wizards: { A: makeWizard('A', names.A), B: makeWizard('B', names.B) },
|
||||
monsters: [],
|
||||
log: [],
|
||||
over: null,
|
||||
lastTurn: null,
|
||||
lessonsShown: [],
|
||||
timeStops: [],
|
||||
nextMonsterId: 1,
|
||||
summonCounts: {},
|
||||
rng: seed >>> 0 || 1
|
||||
};
|
||||
}
|
||||
|
||||
/** Mulberry32: small seeded generator so duels can be replayed in tests. */
|
||||
export function nextRandom(state: GameState): number {
|
||||
state.rng = (state.rng + 0x6d2b79f5) >>> 0;
|
||||
let t = state.rng;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
export function isWizardId(id: TargetId | undefined): id is WizardId {
|
||||
return id === 'A' || id === 'B';
|
||||
}
|
||||
|
||||
export function findMonster(state: GameState, id: TargetId | undefined): Monster | undefined {
|
||||
return state.monsters.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
/** The wizard who commands a being. */
|
||||
export function controllerOf(state: GameState, id: TargetId): WizardId | undefined {
|
||||
if (isWizardId(id)) return id;
|
||||
return findMonster(state, id)?.owner;
|
||||
}
|
||||
|
||||
export function beingName(state: GameState, id: TargetId | undefined): string {
|
||||
if (id === undefined || id === NOWHERE) return 'nowhere';
|
||||
if (isWizardId(id)) return state.wizards[id].name;
|
||||
return findMonster(state, id)?.name ?? 'a vanished creature';
|
||||
}
|
||||
|
||||
/**
|
||||
* A wizard's gestures as another wizard saw them: turns made while invisible,
|
||||
* or while the viewer was blind, are unknown to the viewer.
|
||||
*/
|
||||
export function visibleHistory(state: GameState, viewer: WizardId, subject: WizardId): HistoryEntry[] {
|
||||
if (viewer === subject) return state.wizards[subject].history;
|
||||
return state.wizards[subject].history.map((h) =>
|
||||
h.hiddenFrom.includes(viewer) ? { ...h, left: '-', right: '-' } : h
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user