Files
waving-hands/src/lib/game/spells.ts
T
Eric WagonerandClaude Opus 5.5 7317096c70 Words that fit a table of any size
The rules page, the offline page, the guide, two spell descriptions and
the share description no longer assume a duel has exactly two wizards:
every wizard chooses, attacks go at an opponent, the last wizard
standing wins, and blindness and invisibility speak of other wizards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
2026-10-10 19:00:42 -04:00

235 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
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
): Spell {
return {
id,
name,
category,
sequences,
tokens: sequences.map(parseSequence),
usualTarget,
summary
};
}
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', 'Every other spell cast at the subject this turn fails, and the subject is shielded. Cast it on yourself to stop what the others cast at you. 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', 'A fever takes the subject, who dies at the end of the fifth turn after this one 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 other wizards’ gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns other wizards 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’s 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’s 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 cannot extend. */
export const PERMANENCY_EXCLUDED: SpellId[] = ['anti_spell', 'disease', 'poison', 'time_stop', 'permanency'];
export function permanencyEligible(spell: Spell): boolean {
return spell.category === 'enchantment' && !PERMANENCY_EXCLUDED.includes(spell.id);
}
/** Spells worth warning about, or defending against, when the other wizard's hands approach them. */
export const HOSTILE_SPELLS: SpellId[] = [
'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',
'disease',
'poison',
'blindness',
'remove_enchantment',
'summon_goblin',
'summon_ogre',
'summon_troll',
'summon_giant',
'summon_elemental'
];
/** Spells that affect everyone rather than one subject. */
export const BLANKET_SPELLS: SpellId[] = ['fire_storm', 'ice_storm'];
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';
}
export const SUMMON_KIND: Partial<Record<SpellId, MonsterKind>> = {
summon_goblin: 'goblin',
summon_ogre: 'ogre',
summon_troll: 'troll',
summon_giant: 'giant'
};