Skip to main content
Glama
spell-validator.ts24.9 kB
/** * Spell Validator - Core validation layer for spellcasting * Prevents LLM hallucination (CRIT-006) by validating: * - Spell exists in database * - Character knows/has prepared the spell * - Character has spell slots available * - Character can cast at the requested level * - Range and targeting requirements */ import type { Character } from '../../schema/character.js'; import type { Spell, CharacterClass, SpellSlots, SpellcastingAbility } from '../../schema/spell.js'; import { getSpell, isSpellAvailableToClass } from './spell-database.js'; // Spellcasting class configuration interface SpellcastingConfig { canCast: boolean; startLevel: number; ability: SpellcastingAbility; fullCaster: boolean; // Full caster vs half-caster vs third-caster preparationRequired: boolean; pactMagic: boolean; // Warlock special case } const SPELLCASTING_CONFIG: Record<CharacterClass, SpellcastingConfig> = { barbarian: { canCast: false, startLevel: 999, ability: 'charisma', fullCaster: false, preparationRequired: false, pactMagic: false }, bard: { canCast: true, startLevel: 1, ability: 'charisma', fullCaster: true, preparationRequired: false, pactMagic: false }, cleric: { canCast: true, startLevel: 1, ability: 'wisdom', fullCaster: true, preparationRequired: true, pactMagic: false }, druid: { canCast: true, startLevel: 1, ability: 'wisdom', fullCaster: true, preparationRequired: true, pactMagic: false }, fighter: { canCast: false, startLevel: 3, ability: 'intelligence', fullCaster: false, preparationRequired: false, pactMagic: false }, // Eldritch Knight monk: { canCast: false, startLevel: 999, ability: 'wisdom', fullCaster: false, preparationRequired: false, pactMagic: false }, paladin: { canCast: true, startLevel: 2, ability: 'charisma', fullCaster: false, preparationRequired: true, pactMagic: false }, ranger: { canCast: true, startLevel: 2, ability: 'wisdom', fullCaster: false, preparationRequired: false, pactMagic: false }, rogue: { canCast: false, startLevel: 3, ability: 'intelligence', fullCaster: false, preparationRequired: false, pactMagic: false }, // Arcane Trickster sorcerer: { canCast: true, startLevel: 1, ability: 'charisma', fullCaster: true, preparationRequired: false, pactMagic: false }, warlock: { canCast: true, startLevel: 1, ability: 'charisma', fullCaster: false, preparationRequired: false, pactMagic: true }, wizard: { canCast: true, startLevel: 1, ability: 'intelligence', fullCaster: true, preparationRequired: true, pactMagic: false }, artificer: { canCast: true, startLevel: 1, ability: 'intelligence', fullCaster: false, preparationRequired: true, pactMagic: false } }; // Spell slot progression for full casters const FULL_CASTER_SLOTS: Record<number, number[]> = { 1: [2, 0, 0, 0, 0, 0, 0, 0, 0], 2: [3, 0, 0, 0, 0, 0, 0, 0, 0], 3: [4, 2, 0, 0, 0, 0, 0, 0, 0], 4: [4, 3, 0, 0, 0, 0, 0, 0, 0], 5: [4, 3, 2, 0, 0, 0, 0, 0, 0], 6: [4, 3, 3, 0, 0, 0, 0, 0, 0], 7: [4, 3, 3, 1, 0, 0, 0, 0, 0], 8: [4, 3, 3, 2, 0, 0, 0, 0, 0], 9: [4, 3, 3, 3, 1, 0, 0, 0, 0], 10: [4, 3, 3, 3, 2, 0, 0, 0, 0], 11: [4, 3, 3, 3, 2, 1, 0, 0, 0], 12: [4, 3, 3, 3, 2, 1, 0, 0, 0], 13: [4, 3, 3, 3, 2, 1, 1, 0, 0], 14: [4, 3, 3, 3, 2, 1, 1, 0, 0], 15: [4, 3, 3, 3, 2, 1, 1, 1, 0], 16: [4, 3, 3, 3, 2, 1, 1, 1, 0], 17: [4, 3, 3, 3, 2, 1, 1, 1, 1], 18: [4, 3, 3, 3, 3, 1, 1, 1, 1], 19: [4, 3, 3, 3, 3, 2, 1, 1, 1], 20: [4, 3, 3, 3, 3, 2, 2, 1, 1] }; // Half-caster progression (Paladin, Ranger) const HALF_CASTER_SLOTS: Record<number, number[]> = { 2: [2, 0, 0, 0, 0, 0, 0, 0, 0], 3: [3, 0, 0, 0, 0, 0, 0, 0, 0], 4: [3, 0, 0, 0, 0, 0, 0, 0, 0], 5: [4, 2, 0, 0, 0, 0, 0, 0, 0], 6: [4, 2, 0, 0, 0, 0, 0, 0, 0], 7: [4, 3, 0, 0, 0, 0, 0, 0, 0], 8: [4, 3, 0, 0, 0, 0, 0, 0, 0], 9: [4, 3, 2, 0, 0, 0, 0, 0, 0], 10: [4, 3, 2, 0, 0, 0, 0, 0, 0], 11: [4, 3, 3, 0, 0, 0, 0, 0, 0], 12: [4, 3, 3, 0, 0, 0, 0, 0, 0], 13: [4, 3, 3, 1, 0, 0, 0, 0, 0], 14: [4, 3, 3, 1, 0, 0, 0, 0, 0], 15: [4, 3, 3, 2, 0, 0, 0, 0, 0], 16: [4, 3, 3, 2, 0, 0, 0, 0, 0], 17: [4, 3, 3, 3, 1, 0, 0, 0, 0], 18: [4, 3, 3, 3, 1, 0, 0, 0, 0], 19: [4, 3, 3, 3, 2, 0, 0, 0, 0], 20: [4, 3, 3, 3, 2, 0, 0, 0, 0] }; // Warlock pact magic slots const WARLOCK_SLOTS: Record<number, { count: number; level: number }> = { 1: { count: 1, level: 1 }, 2: { count: 2, level: 1 }, 3: { count: 2, level: 2 }, 4: { count: 2, level: 2 }, 5: { count: 2, level: 3 }, 6: { count: 2, level: 3 }, 7: { count: 2, level: 4 }, 8: { count: 2, level: 4 }, 9: { count: 2, level: 5 }, 10: { count: 2, level: 5 }, 11: { count: 3, level: 5 }, 12: { count: 3, level: 5 }, 13: { count: 3, level: 5 }, 14: { count: 3, level: 5 }, 15: { count: 3, level: 5 }, 16: { count: 3, level: 5 }, 17: { count: 4, level: 5 }, 18: { count: 4, level: 5 }, 19: { count: 4, level: 5 }, 20: { count: 4, level: 5 } }; export interface SpellValidationError { code: string; message: string; } export interface SpellValidationResult { valid: boolean; error?: SpellValidationError; spell?: Spell; effectiveSlotLevel?: number; } /** * Get max spell level a character can cast based on class and level */ export function getMaxSpellLevel(characterClass: CharacterClass, level: number): number { const config = SPELLCASTING_CONFIG[characterClass]; if (!config.canCast) return 0; if (level < config.startLevel) return 0; if (config.pactMagic) { // Warlock uses pact magic const warlockSlots = WARLOCK_SLOTS[level]; return warlockSlots?.level || 0; } if (config.fullCaster) { const slots = FULL_CASTER_SLOTS[level]; if (!slots) return 0; // Find highest level with slots for (let i = 8; i >= 0; i--) { if (slots[i] > 0) return i + 1; } return 0; } else { // Half-caster const effectiveLevel = level >= config.startLevel ? level : 0; const slots = HALF_CASTER_SLOTS[effectiveLevel]; if (!slots) return 0; for (let i = 8; i >= 0; i--) { if (slots[i] > 0) return i + 1; } return 0; } } /** * Get initial spell slots for a character based on class and level */ export function getInitialSpellSlots(characterClass: CharacterClass, level: number): SpellSlots { const config = SPELLCASTING_CONFIG[characterClass]; const empty: SpellSlots = { level1: { current: 0, max: 0 }, level2: { current: 0, max: 0 }, level3: { current: 0, max: 0 }, level4: { current: 0, max: 0 }, level5: { current: 0, max: 0 }, level6: { current: 0, max: 0 }, level7: { current: 0, max: 0 }, level8: { current: 0, max: 0 }, level9: { current: 0, max: 0 } }; if (!config.canCast || level < config.startLevel) { return empty; } let slots: number[]; if (config.fullCaster) { slots = FULL_CASTER_SLOTS[level] || [0, 0, 0, 0, 0, 0, 0, 0, 0]; } else if (config.pactMagic) { // Warlock doesn't use standard slots - handled separately return empty; } else { slots = HALF_CASTER_SLOTS[level] || [0, 0, 0, 0, 0, 0, 0, 0, 0]; } return { level1: { current: slots[0], max: slots[0] }, level2: { current: slots[1], max: slots[1] }, level3: { current: slots[2], max: slots[2] }, level4: { current: slots[3], max: slots[3] }, level5: { current: slots[4], max: slots[4] }, level6: { current: slots[5], max: slots[5] }, level7: { current: slots[6], max: slots[6] }, level8: { current: slots[7], max: slots[7] }, level9: { current: slots[8], max: slots[8] } }; } /** * Calculate spell save DC for a character * DC = 8 + proficiency bonus + spellcasting ability modifier */ export function calculateSpellSaveDC(character: Character): number { const config = SPELLCASTING_CONFIG[(character.characterClass || 'fighter') as CharacterClass]; if (!config.canCast) return 0; const profBonus = Math.floor((character.level - 1) / 4) + 2; const abilityMod = getAbilityModifier(character, config.ability); return 8 + profBonus + abilityMod; } /** * Calculate spell attack bonus for a character * Attack = proficiency bonus + spellcasting ability modifier */ export function calculateSpellAttackBonus(character: Character): number { const config = SPELLCASTING_CONFIG[(character.characterClass || 'fighter') as CharacterClass]; if (!config.canCast) return 0; const profBonus = Math.floor((character.level - 1) / 4) + 2; const abilityMod = getAbilityModifier(character, config.ability); return profBonus + abilityMod; } /** * Get ability modifier from stats */ function getAbilityModifier(character: Character, ability: SpellcastingAbility): number { const statMap: Record<SpellcastingAbility, keyof Character['stats']> = { intelligence: 'int', wisdom: 'wis', charisma: 'cha' }; const stat = character.stats[statMap[ability]]; return Math.floor((stat - 10) / 2); } /** * Check if character can cast spells at all */ export function canCastSpells(character: Character): { canCast: boolean; reason?: string } { const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; if (!config.canCast) { return { canCast: false, reason: `${charClass} is not a spellcasting class` }; } if (character.level < config.startLevel) { return { canCast: false, reason: `${charClass} gains spellcasting at level ${config.startLevel}` }; } // Check for incapacitating conditions const conditions = character.conditions || []; if (conditions.some(c => c.name === 'INCAPACITATED') || conditions.some(c => c.name === 'STUNNED') || conditions.some(c => c.name === 'PARALYZED') || conditions.some(c => c.name === 'UNCONSCIOUS')) { return { canCast: false, reason: 'Cannot take actions while incapacitated' }; } return { canCast: true }; } /** * Check if character knows/has prepared a specific spell */ export function characterKnowsSpell(character: Character, spellName: string): { knows: boolean; reason?: string } { const spell = getSpell(spellName); if (!spell) { return { knows: false, reason: `Unknown spell: ${spellName}` }; } const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; // Check if spell is available to class if (!isSpellAvailableToClass(spellName, charClass as any)) { return { knows: false, reason: `${spell.name} is not available to ${charClass} class` }; } // Cantrips: check cantripsKnown if (spell.level === 0) { const cantrips = character.cantripsKnown || []; if (!cantrips.some(c => c.toLowerCase() === spellName.toLowerCase())) { return { knows: false, reason: `${spell.name} is not in your known cantrips` }; } return { knows: true }; } // Leveled spells: check known and prepared const knownSpells = character.knownSpells || []; const preparedSpells = character.preparedSpells || []; // For classes that require preparation if (config.preparationRequired) { if (!preparedSpells.some(s => s.toLowerCase() === spellName.toLowerCase())) { if (knownSpells.some(s => s.toLowerCase() === spellName.toLowerCase())) { return { knows: false, reason: `${spell.name} is not prepared` }; } return { knows: false, reason: `${spell.name} is not in your spellbook` }; } } else { // Classes that cast from known spells (Sorcerer, Bard, Warlock, Ranger) if (!knownSpells.some(s => s.toLowerCase() === spellName.toLowerCase())) { return { knows: false, reason: `${spell.name} is not in your known spells` }; } } return { knows: true }; } /** * Check if character has spell slot available at the given level */ export function hasSpellSlotAvailable(character: Character, minLevel: number): { available: boolean; availableLevel?: number; reason?: string } { const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; if (config.pactMagic) { // Warlock uses pact magic const pactSlots = character.pactMagicSlots; if (!pactSlots || pactSlots.current <= 0) { return { available: false, reason: 'No pact magic slots remaining' }; } if (pactSlots.slotLevel < minLevel) { return { available: false, reason: `Pact magic slot level (${pactSlots.slotLevel}) is lower than spell minimum (${minLevel})` }; } return { available: true, availableLevel: pactSlots.slotLevel }; } // Standard spellcasting const slots = character.spellSlots; if (!slots) { return { available: false, reason: 'No spell slots available' }; } // Find lowest available slot at or above minLevel const slotKeys: (keyof SpellSlots)[] = ['level1', 'level2', 'level3', 'level4', 'level5', 'level6', 'level7', 'level8', 'level9']; for (let i = minLevel - 1; i < 9; i++) { const key = slotKeys[i]; if (slots[key] && slots[key].current > 0) { return { available: true, availableLevel: i + 1 }; } } return { available: false, reason: `No level ${minLevel}+ spell slots available` }; } /** * Check range for spell targeting */ export function validateSpellRange( spell: Spell, casterPosition: { x: number; y: number }, targetPosition?: { x: number; y: number }, options: { casterId?: string; targetId?: string } = {} ): { valid: boolean; reason?: string } { // Self-targeting spells const range = typeof spell.range === 'string' ? spell.range.toLowerCase() : spell.range; if (range === 'self') { if (options.targetId && options.casterId && options.targetId !== options.casterId) { return { valid: false, reason: `${spell.name} can only target self` }; } return { valid: true }; } // Touch spells - must be adjacent (within 1 square / 5 feet) if (range === 'touch') { if (!targetPosition) { return { valid: true }; } const distance = Math.sqrt( Math.pow(targetPosition.x - casterPosition.x, 2) + Math.pow(targetPosition.y - casterPosition.y, 2) ); // Adjacent = within 1.5 squares (allows diagonals) if (distance > 1.5) { return { valid: false, reason: `${spell.name} has range Touch - target must be adjacent` }; } return { valid: true }; } // Ranged spells if (typeof range === 'number') { if (!targetPosition) { return { valid: true }; // No target position to validate } const distanceInSquares = Math.sqrt( Math.pow(targetPosition.x - casterPosition.x, 2) + Math.pow(targetPosition.y - casterPosition.y, 2) ); const distanceInFeet = distanceInSquares * 5; // 5 feet per square if (distanceInFeet > range) { return { valid: false, reason: `${spell.name} has range ${range} feet` }; } return { valid: true }; } return { valid: true }; } /** * Main validation function - validates a spell cast request */ export function validateSpellCast( character: Character, spellName: string, requestedSlotLevel?: number, options: { casterPosition?: { x: number; y: number }; targetPosition?: { x: number; y: number }; targetId?: string; } = {} ): SpellValidationResult { // Check empty spell name if (!spellName || spellName.trim() === '') { return { valid: false, error: { code: 'EMPTY_SPELL_NAME', message: 'Spell name is required' } }; } // Check spell exists const spell = getSpell(spellName); if (!spell) { return { valid: false, error: { code: 'UNKNOWN_SPELL', message: `Unknown spell: ${spellName}` } }; } // Check character can cast spells const castCheck = canCastSpells(character); if (!castCheck.canCast) { return { valid: false, error: { code: 'CANNOT_CAST', message: castCheck.reason! } }; } // Check character knows/has prepared the spell const knowsCheck = characterKnowsSpell(character, spellName); if (!knowsCheck.knows) { return { valid: false, error: { code: 'SPELL_NOT_KNOWN', message: knowsCheck.reason! } }; } // Check conditions that prevent casting const conditions = character.conditions || []; // Check for silence (blocks verbal component spells) if (conditions.some(c => c.name === 'SILENCED') && spell.components.verbal) { return { valid: false, error: { code: 'SILENCED', message: 'Cannot cast spells with verbal components while silenced' } }; } // Handle cantrips (no slot needed) if (spell.level === 0) { // Still need to validate range for cantrips! // Validate Range & Targeting const range = typeof spell.range === 'string' ? spell.range.toLowerCase() : spell.range; if (options.casterPosition) { const rangeCheck = validateSpellRange(spell, options.casterPosition, options.targetPosition, { casterId: character.id, targetId: options.targetId }); if (!rangeCheck.valid) { return { valid: false, error: { code: 'INVALID_TARGET', message: rangeCheck.reason! } }; } } else if (range === 'self' && options.targetId && options.targetId !== character.id) { return { valid: false, error: { code: 'INVALID_TARGET', message: `${spell.name} can only target self` } }; } return { valid: true, spell, effectiveSlotLevel: 0 }; } // Check max spell level const maxLevel = getMaxSpellLevel((character.characterClass || 'fighter') as CharacterClass, character.level); const spellLevel = spell.level; if (spellLevel > maxLevel) { return { valid: false, error: { code: 'SPELL_LEVEL_TOO_HIGH', message: `Cannot cast level ${spellLevel} spells (max spell level: ${maxLevel})` } }; } // Handle requested slot level let targetSlotLevel = requestedSlotLevel || spellLevel; // Cannot downcast if (targetSlotLevel < spellLevel) { return { valid: false, error: { code: 'CANNOT_DOWNCAST', message: `${spell.name} requires minimum slot level ${spellLevel}` } }; } // Cannot upcast beyond max level if (targetSlotLevel > maxLevel) { return { valid: false, error: { code: 'SLOT_LEVEL_TOO_HIGH', message: `Cannot cast at level ${targetSlotLevel} (max available: ${maxLevel})` } }; } // Check spell slot availability const slotCheck = hasSpellSlotAvailable(character, targetSlotLevel); if (!slotCheck.available) { return { valid: false, error: { code: 'NO_SLOTS', message: slotCheck.reason! } }; } // Validate Range & Targeting (moved after level checks) const range = typeof spell.range === 'string' ? spell.range.toLowerCase() : spell.range; if (options.casterPosition) { const rangeCheck = validateSpellRange(spell, options.casterPosition, options.targetPosition, { casterId: character.id, targetId: options.targetId }); if (!rangeCheck.valid) { return { valid: false, error: { code: 'INVALID_TARGET', message: rangeCheck.reason! } }; } } else if (range === 'self' && options.targetId && options.targetId !== character.id) { // Fallback for self-check if no positions provided return { valid: false, error: { code: 'INVALID_TARGET', message: `${spell.name} can only target self` } }; } // For Warlock, always use pact slot level const config = SPELLCASTING_CONFIG[(character.characterClass || 'fighter') as CharacterClass]; if (config.pactMagic) { targetSlotLevel = slotCheck.availableLevel!; } return { valid: true, spell, effectiveSlotLevel: targetSlotLevel }; } /** * Consume a spell slot after successful cast */ export function consumeSpellSlot(character: Character, slotLevel: number): Character { const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; if (config.pactMagic) { // Warlock pact magic if (character.pactMagicSlots && character.pactMagicSlots.current > 0) { return { ...character, pactMagicSlots: { ...character.pactMagicSlots, current: character.pactMagicSlots.current - 1 } }; } return character; } // Standard spellcasting if (!character.spellSlots) return character; const slotKey = `level${slotLevel}` as keyof SpellSlots; const currentSlot = character.spellSlots[slotKey]; if (currentSlot && currentSlot.current > 0) { return { ...character, spellSlots: { ...character.spellSlots, [slotKey]: { ...currentSlot, current: currentSlot.current - 1 } } }; } return character; } /** * Restore all spell slots (for long rest) */ export function restoreAllSpellSlots(character: Character): Character { const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; if (!config.canCast || character.level < config.startLevel) { return character; } if (config.pactMagic) { // Warlock pact magic const warlockSlots = WARLOCK_SLOTS[character.level]; return { ...character, pactMagicSlots: { current: warlockSlots.count, max: warlockSlots.count, slotLevel: warlockSlots.level } }; } // Standard spellcasting const slots = getInitialSpellSlots(charClass as CharacterClass, character.level); return { ...character, spellSlots: slots }; } /** * Restore warlock pact slots (for short rest) */ export function restorePactSlots(character: Character): Character { const charClass = (character.characterClass || 'fighter') as CharacterClass; const config = SPELLCASTING_CONFIG[charClass]; if (!config.pactMagic) { return character; // Not a warlock } const warlockSlots = WARLOCK_SLOTS[character.level]; return { ...character, pactMagicSlots: { current: warlockSlots.count, max: warlockSlots.count, slotLevel: warlockSlots.level } }; } /** * Get spellcasting configuration for a class * Returns default non-caster config for unknown/custom classes */ export function getSpellcastingConfig(characterClass: string): SpellcastingConfig { // Standard D&D classes (case-insensitive lookup) const normalizedClass = characterClass.toLowerCase(); const config = SPELLCASTING_CONFIG[normalizedClass as CharacterClass]; if (config) { return config; } // Default for custom classes: non-caster // Custom caster classes should be handled via custom effects system return { canCast: false, startLevel: 999, ability: 'intelligence', fullCaster: false, preparationRequired: false, pactMagic: false }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Mnehmos/rpg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server