8000 fix(magic): normalize spell-database keys so kebab-case lookups resolve by Mnehmos · Pull Request #53 · Mnehmos/mnehmos.rpg.mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 98 additions & 9 deletions src/engine/magic/spell-database.ts
8000
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate results when adding secondary spell keys

Adding a second Map entry for each spell can cause duplicate results from getSpellsByLevel/getSpellsForClass, because both functions iterate SPELL_DATABASE.values() without deduplication. This appears when normalizeSpellKey(spell.name) and normalizeSpellKey(spell.id) differ (for example, punctuation differences such as apostrophes), so one logical spell can be emitted multiple times and skew selection/count logic in callers.

Useful? React with 👍 / 👎.

}

// ============================================================================
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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
done

Repository: 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 -20

Repository: 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 -20

Repository: 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 -A5

Repository: 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 -l

Repository: 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 -100

Repository: 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 -A3

Repository: 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 -30

Repository: 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 -A2

Repository: 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.ts

Repository: 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.ts

Repository: 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 -A2

Repository: 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.ts

Repository: 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 -20

Repository: Mnehmos/mnehmos.rpg.mcp

Length of output: 2523


Add cast-path tests for the three newly registered spells.

The three spells—healing-word, scorching-ray, and web—currently appear only in the lookup/dedup regression test (spell-database.test.ts), confirming they can be resolved by name and kebab-id variants. However, they lack spell-casting tests that verify their effects and mechanics (as other spells like Magic Missile, Fireball, and Cure Wounds have in spellcasting.test.ts). Add at least one cast-oriented test per spell to validate healing amounts, damage rolls, and condition applications.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/engine/magic/spell-database.ts` around lines 242 - 313, Add cast-path
tests in spellcasting.test.ts for the three new spells: for 'healing-word' (id:
healing-word) write a test that uses the existing castSpell helper to target a
creature and assert HP increases by 1d4 + spellcasting modifier (and that
upcasting (slot >1) increases healing by +1d4 per slot via the upcastBonus); for
'scorching-ray' (id: scorching-ray) write a test that casts the spell and
asserts it produces three separate attack/damage instances of 2d6 fire each (and
that upcasting grants extra ray(s) per upcastBonus); and for 'web' (id: web)
write a test that casts on an area and asserts affected creatures must make a
Dexterity save and failing results in the RESTRAINED condition being applied
(and concentration/duration behavior as appropriate). Use the same test helpers
and assertions used by existing spell tests (e.g., castSpell, getSpellById,
checking resulting HP/damage events and conditions) to mirror style and sizing.


registerSpell({
id: 'hold-person',
name: 'Hold Person',
Expand Down Expand Up @@ -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));
}
Comment thread
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)
);
}
Expand Down Expand Up @@ -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;
73 changes: 73 additions & 0 deletions tests/engine/magic/spell-database.test.ts
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);
}
});
});
Loading
0