Files
waving-hands/src/lib/game/spells.ts
T
Eric WagonerandClaude Fable 5.1 5de352db43 Credibility pass: one conflict check, one hostile list, one set of chrome
Duplicated logic and styling that had drifted apart is unified: the
gesture-sharing check, the hostile spell list, the enchanted test, the
plan limits, and the shared button, link, field and disabled styles.
Dead plumbing is gone: the always-true implemented flag, the unread
cast length, an unreachable guard, an impossible time-stop condition,
the unused sequence formatter and utility class, the template
placeholder. The counter-spell tests now put a counter-spell in front
of a spell, the test helpers live in one file, and the resolver's
sections are numbered in order. The deploy script's cache headers now
do what its comment says, and the README describes the screen rather
than listing features in the order they arrived.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 16:37:54 -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', '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 turns gestures exactly.'),
spell('confusion', 'Confusion', 'enchantment', ['D-S-F'], 'enemy', 'Next turn one of the subjects 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 subjects 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 subjects 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 subjects 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 opponents gestures. Destroys a monster.'),
spell('invisibility', 'Invisibility', 'enchantment', ['P-P-(w-(s'], 'self', 'For three turns the opponent cannot see the subjects 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 subjects 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 subjects 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'
};