Repository navigation
fix(magic): normalize spell-database keys so kebab-case lookups resolve #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,13 +11,23 @@ export const SPELL_DATABASE: Map<string, Spell> = new Map(); | |
| // Input type for spell registration (ritual defaults to false) | ||
| type SpellInput = Omit<Spell, 'ritual'> & { ritual?: boolean }; | ||
|
|
||
| // Helper to add spell to database | ||
| /** | ||
| * Normalize a spell name/id for lookup. | ||
| * Accepts "Fire Bolt", "fire-bolt", "fire_bolt", "FIREBOLT" — all collapse to "firebolt". | ||
| */ | ||
| function normalizeSpellKey(name: string): string { | ||
| return name.toLowerCase().replace(/[\s\-_]+/g, ''); | ||
| } | ||
|
|
||
| // Helper to add spell to database — registers under both the spaced name | ||
| // and the kebab-case id so all reasonable callers can find the spell. | ||
| function registerSpell(input: SpellInput): void { | ||
| const spell: Spell = { | ||
| ...input, | ||
| ritual: input.ritual ?? false | ||
| }; | ||
| SPELL_DATABASE.set(spell.name.toLowerCase(), spell); | ||
| SPELL_DATABASE.set(normalizeSpellKey(spell.name), spell); | ||
| SPELL_DATABASE.set(normalizeSpellKey(spell.id), spell); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding a second Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| // ============================================================================ | ||
|
|
@@ -229,10 +239,79 @@ registerSpell({ | |
| autoHit: false | ||
| }); | ||
|
|
||
| registerSpell({ | ||
| id: 'healing-word', | ||
| name: 'Healing Word', | ||
| level: 1, | ||
| school: 'evocation', | ||
| castingTime: 'bonus_action', | ||
| range: 60, | ||
| components: { verbal: true, somatic: false, material: false }, | ||
| duration: 'Instantaneous', | ||
| concentration: false, | ||
| description: 'A creature of your choice within range regains 1d4 + your spellcasting ability modifier.', | ||
| higherLevels: 'Healing increases by 1d4 for each slot level above 1st.', | ||
| classes: ['bard', 'cleric', 'druid'], | ||
| targetType: 'creature', | ||
| effects: [{ | ||
| type: 'healing', | ||
| dice: '1d4', | ||
| upcastBonus: { dice: '1d4', perLevel: 1 } | ||
| }], | ||
| autoHit: false | ||
| }); | ||
|
|
||
| // ============================================================================ | ||
| // 2ND LEVEL SPELLS | ||
| // ============================================================================ | ||
|
|
||
| registerSpell({ | ||
| id: 'scorching-ray', | ||
| name: 'Scorching Ray', | ||
| level: 2, | ||
| school: 'evocation', | ||
| castingTime: 'action', | ||
| range: 120, | ||
| components: { verbal: true, somatic: true, material: false }, | ||
| duration: 'Instantaneous', | ||
| concentration: false, | ||
| description: 'You create three rays of fire and hurl them at targets within range. Each ray deals 2d6 fire on a hit.', | ||
| higherLevels: 'You create one additional ray for each slot level above 2nd.', | ||
| classes: ['sorcerer', 'wizard'], | ||
| targetType: 'creature', | ||
| effects: [{ | ||
| type: 'damage', | ||
| dice: '6d6', | ||
| damageType: 'fire', | ||
| saveType: 'none', | ||
| upcastBonus: { dice: '2d6', perLevel: 1 } | ||
| }], | ||
| autoHit: false | ||
| }); | ||
|
|
||
| registerSpell({ | ||
| id: 'web', | ||
| name: 'Web', | ||
| level: 2, | ||
| school: 'conjuration', | ||
| castingTime: 'action', | ||
| range: 60, | ||
| components: { verbal: true, somatic: true, material: true, materialDescription: 'a bit of spiderweb' }, | ||
| duration: 'Concentration, up to 1 hour', | ||
| concentration: true, | ||
| description: 'You conjure a mass of thick, sticky webbing in a 20-foot cube. Creatures starting their turn in the webs or entering them on their turn must make a Dexterity saving throw or become restrained.', | ||
| classes: ['sorcerer', 'wizard'], | ||
| targetType: 'area', | ||
| areaOfEffect: { shape: 'cube', size: 20 }, | ||
| effects: [{ | ||
| type: 'debuff', | ||
| saveType: 'dexterity', | ||
| saveEffect: 'none', | ||
| conditions: ['RESTRAINED'] | ||
| }], | ||
| autoHit: false | ||
| }); | ||
|
Comment on lines
+242
to
+313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
for spell in healing-word scorching-ray web; do
echo "=== $spell ==="
rg -n -C2 --glob 'tests/**/*.ts' "\"$spell\"|'$spell'" tests || true
doneRepository: Mnehmos/mnehmos.rpg.mcp Length of output: 1487 🏁 Script executed: # Search for cast or casting related tests
find tests -name '*.test.ts' -o -name '*.spec.ts' | head -20Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 766 🏁 Script executed: # Search for spell-casting or cast-related test patterns
rg -l 'cast|spell.*effect|apply.*spell' tests/ --type ts | head -20Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 547 🏁 Script executed: # Search for these specific spell references in all test files, not just by name
rg -n 'healing-word|scorching-ray' tests/ --type ts -B2 -A5Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 899 🏁 Script executed: # Check test file structure - how many test files exist
find tests -name '*.test.ts' -o -name '*.spec.ts' | wc -lRepository: Mnehmos/mnehmos.rpg.mcp Length of output: 70 🏁 Script executed: # Check the contents of spellcasting.test.ts
cat -n tests/server/spellcasting.test.ts | head -100Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 4488 🏁 Script executed: # Search specifically in spellcasting.test.ts for references to our three spells
rg -n 'healing-word|scorching-ray|web' tests/server/spellcasting.test.ts -B3 -A3Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 49 🏁 Script executed: # Check what spells are actually tested in spellcasting.test.ts
rg -n "id:.*'|spell.*id" tests/server/spellcasting.test.ts | head -30Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 2970 🏁 Script executed: # Search all test files for these spell IDs in cast/casting contexts
rg -n 'spellId.*healing-word|spellId.*scorching-ray|spellId.*web|"healing-word"|"scorching-ray"' tests/ --type ts -B2 -A2Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 49 🏁 Script executed: # Check the full spell-database.test.ts to understand what's actually being tested
wc -l tests/engine/magic/spell-database.test.tsRepository: Mnehmos/mnehmos.rpg.mcp Length of output: 111 🏁 Script executed: # Get the context around the spell lookup tests in spell-database.test.ts
sed -n '20,60p' tests/engine/magic/spell-database.test.tsRepository: Mnehmos/mnehmos.rpg.mcp Length of output: 2071 🏁 Script executed: # Search for cast-related test descriptions across all test files
rg -n "it.*cast|describe.*cast|it.*spell.*effect|it.*healing|it.*damage" tests/ --type ts | grep -i "healing-word\|scorching-ray\|web" || echo "No cast tests found for these spells"Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 170 🏁 Script executed: # Check if there are any references to issue `#41` in the codebase
rg -n "issue.*41|#41" . --type ts --type md -B2 -A2Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 1144 🏁 Script executed: # View the entire spell-database.test.ts file to see all tests
cat -n tests/engine/magic/spell-database.test.tsRepository: Mnehmos/mnehmos.rpg.mcp Length of output: 3725 🏁 Script executed: # Search for any cast action tests that might use spell IDs generically
rg -n "castSpell|executeSpell|executeAction.*spell|spellAction" tests/ --type ts | head -20Repository: Mnehmos/mnehmos.rpg.mcp Length of output: 2523 Add cast-path tests for the three newly registered spells. The three spells— 🤖 Prompt for AI Agents |
||
|
|
||
| registerSpell({ | ||
| id: 'hold-person', | ||
| name: 'Hold Person', | ||
|
|
@@ -571,31 +650,40 @@ registerSpell({ | |
| // ============================================================================ | ||
|
|
||
| /** | ||
| * Get spell by name (case-insensitive) | ||
| * Get spell by name or id (case-insensitive, ignores spaces/hyphens/underscores) | ||
| */ | ||
| export function getSpell(name: string): Spell | undefined { | ||
| return SPELL_DATABASE.get(name.toLowerCase()); | ||
| return SPELL_DATABASE.get(normalizeSpellKey(name)); | ||
| } | ||
|
|
||
| /** | ||
| * Check if spell exists in database | ||
| */ | ||
| export function spellExists(name: string): boolean { | ||
| return SPELL_DATABASE.has(name.toLowerCase()); | ||
| return SPELL_DATABASE.has(normalizeSpellKey(name)); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| /** | ||
| * Iterate the deduplicated spell set. Each spell may live under multiple | ||
| * keys (name + id), so iterating SPELL_DATABASE.values() directly would | ||
| * yield duplicates. | ||
| */ | ||
| function uniqueSpells(): Spell[] { | ||
| return Array.from(new Set(SPELL_DATABASE.values())); | ||
| } | ||
|
|
||
| /** | ||
| * Get all spells of a specific level | ||
| */ | ||
| export function getSpellsByLevel(level: number): Spell[] { | ||
| return Array.from(SPELL_DATABASE.values()).filter(s => s.level === level); | ||
| return uniqueSpells().filter(s => s.level === level); | ||
| } | ||
|
|
||
| /** | ||
| * Get all spells available to a class | ||
| */ | ||
| export function getSpellsForClass(characterClass: SpellcastingClass): Spell[] { | ||
| return Array.from(SPELL_DATABASE.values()).filter(s => | ||
| return uniqueSpells().filter(s => | ||
| s.classes.includes(characterClass) | ||
| ); | ||
| } | ||
|
|
@@ -665,5 +753,6 @@ export function calculateUpcastDice(spell: Spell, slotLevel: number): string { | |
| return `${totalCount}d${diceSize}${modifier}`; | ||
| } | ||
|
|
||
| // Export total spell count for tests | ||
| export const SPELL_COUNT = SPELL_DATABASE.size; | ||
| // Export total unique-spell count for tests (each spell may be indexed under | ||
| // multiple keys, so dedupe before counting). | ||
| export const SPELL_COUNT = new Set(SPELL_DATABASE.values()).size; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * Spell-database lookup regression tests. | ||
| * Issue #41: spells were registered under "name.toLowerCase()" (e.g. "fire bolt") | ||
| * but callers passed kebab-case ids (e.g. "fire-bolt"), so getSpell returned undefined | ||
| * for nearly every common spell despite them existing in the database. | ||
| */ | ||
|
|
||
| import { | ||
| getSpell, | ||
| spellExists, | ||
| getSpellsByLevel, | ||
| getSpellsForClass, | ||
| SPELL_DATABASE, | ||
| SPELL_COUNT | ||
| } from '../../../src/engine/magic/spell-database'; | ||
|
|
||
| describe('spell-database key normalization', () => { | ||
| const synonyms: Array<[string, string[]]> = [ | ||
| ['Fire Bolt', ['fire-bolt', 'fire bolt', 'firebolt', 'FIRE-BOLT', 'Fire_Bolt']], | ||
| ['Magic Missile', ['magic-missile', 'magic missile', 'magicmissile']], | ||
| ['Sacred Flame', ['sacred-flame', 'sacred flame', 'SACREDFLAME']], | ||
| ['Eldritch Blast', ['eldritch-blast', 'eldritch blast']], | ||
| ['Cure Wounds', ['cure-wounds', 'cure wounds']], | ||
| ['Hold Person', ['hold-person', 'hold person']], | ||
| ['Spiritual Weapon', ['spiritual-weapon', 'spiritual weapon']], | ||
| ['Bless', ['bless', 'BLESS']], | ||
| // Issue #41 acceptance — must resolve via either canonical name or kebab id. | ||
| ['Scorching Ray', ['scorching-ray', 'scorching ray']], | ||
| ['Healing Word', ['healing-word', 'healing word']], | ||
| ['Web', ['web', 'WEB']] | ||
| ]; | ||
|
|
||
| for (const [canonical, variants] of synonyms) { | ||
| it(`resolves "${canonical}" via every variant: ${variants.join(', ')}`, () => { | ||
| const expected = getSpell(canonical); | ||
| expect(expected, `canonical lookup of "${canonical}" failed`).toBeDefined(); | ||
| for (const v of variants) { | ||
| expect(getSpell(v), `variant "${v}" did not resolve`).toBe(expected); | ||
| expect(spellExists(v), `spellExists("${v}") returned false`).toBe(true); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| it('returns undefined for an actually-unknown spell', () => { | ||
| expect(getSpell('totally-made-up-spell-xyz')).toBeUndefined(); | ||
| expect(spellExists('totally-made-up-spell-xyz')).toBe(false); | ||
| }); | ||
|
|
||
| it('SPELL_COUNT counts unique spells, not key aliases', () => { | ||
| const uniqueSpells = new Set(SPELL_DATABASE.values()).size; | ||
| expect(SPELL_COUNT).toBe(uniqueSpells); | ||
| }); | ||
| }); | ||
|
|
||
| describe('spell-database iteration helpers (no duplicates)', () => { | ||
| // After dual-key registration, iterating SPELL_DATABASE.values() directly | ||
| // would yield each spell twice. The helpers must dedupe. | ||
| it('getSpellsByLevel returns unique spells per level', () => { | ||
| for (const level of [0, 1, 2, 3]) { | ||
| const list = getSpellsByLevel(level); | ||
| const ids = list.map((s) => s.id); | ||
| expect(new Set(ids).size).toBe(ids.length); | ||
| } | ||
| }); | ||
|
|
||
| it('getSpellsForClass returns unique spells per class', () => { | ||
| for (const klass of ['wizard', 'cleric', 'sorcerer', 'warlock'] as const) { | ||
| const list = getSpellsForClass(klass); | ||
| const ids = list.map((s) => s.id); | ||
| expect(new Set(ids).size, `${klass} contained duplicates`).toBe(ids.length); | ||
| } | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.