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>
235 lines
11 KiB
TypeScript
235 lines
11 KiB
TypeScript
// 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 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’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'
|
||
};
|