Skip to main content
Glama
spell-resolver.ts11.4 kB
/** * Spell Resolver - Handles spell effects (damage, healing, conditions) * Calculates actual damage/healing based on dice rolls and spell level */ import type { Spell, DamageType, SpellCastResult } from '../../schema/spell.js'; import type { Character } from '../../schema/character.js'; import { calculateUpcastDice } from './spell-database.js'; import { calculateSpellSaveDC, calculateSpellAttackBonus } from './spell-validator.js'; /** * Roll dice and return total * @param diceNotation - e.g., "8d6", "3d4+3", "1d8+4" */ export function rollDice(diceNotation: string): { total: number; rolls: number[]; notation: string } { const notation = diceNotation.trim(); const rolls: number[] = []; let total = 0; // Parse dice notation: XdY+Z or XdY-Z const match = notation.match(/^(\d+)d(\d+)([+-]\d+)?$/); if (!match) { // Try to parse as just a number const num = parseInt(notation); if (!isNaN(num)) { return { total: num, rolls: [num], notation }; } return { total: 0, rolls: [], notation }; } const count = parseInt(match[1]); const size = parseInt(match[2]); const modifier = match[3] ? parseInt(match[3]) : 0; for (let i = 0; i < count; i++) { const roll = Math.floor(Math.random() * size) + 1; rolls.push(roll); total += roll; } total += modifier; return { total: Math.max(0, total), rolls, notation }; } /** * Get cantrip damage dice based on character level */ export function getCantripDamage(baseDice: string, characterLevel: number): string { const match = baseDice.match(/^(\d+)d(\d+)/); if (!match) return baseDice; const baseCount = parseInt(match[1]); const diceSize = match[2]; let diceCount = baseCount; if (characterLevel >= 5) diceCount = baseCount * 2; if (characterLevel >= 11) diceCount = baseCount * 3; if (characterLevel >= 17) diceCount = baseCount * 4; return `${diceCount}d${diceSize}`; } /** * Calculate Magic Missile dart count based on slot level */ export function getMagicMissileDarts(slotLevel: number): number { return 3 + (slotLevel - 1); // Base 3 darts + 1 per level above 1st } export interface SpellResolutionOptions { targetSaveRoll?: number; // For testing - mock the save roll targetAC?: number; // For spell attack rolls casterAbilityMod?: number; // Override ability modifier } export interface SpellResolutionResult { success: boolean; spellName: string; slotUsed?: number; // undefined for cantrips damage?: number; damageType?: DamageType; healing?: number; diceRolled: string; damageRolled?: number; damageApplied?: number; saveResult?: 'passed' | 'failed' | 'none'; saveDC?: number; attackRoll?: number; attackTotal?: number; hit?: boolean; autoHit?: boolean; acBonus?: number; // For Shield dartCount?: number; // For Magic Missile concentration?: boolean; conditionsApplied?: string[]; error?: string; } /** * Resolve a spell's effects */ export function resolveSpell( spell: Spell, caster: Character, slotLevel: number, options: SpellResolutionOptions = {} ): SpellResolutionResult { const result: SpellResolutionResult = { success: true, spellName: spell.name, slotUsed: spell.level === 0 ? undefined : slotLevel, diceRolled: '', concentration: spell.concentration }; // Get caster's spell save DC and attack bonus const spellSaveDC = caster.spellSaveDC || calculateSpellSaveDC(caster); const spellAttackBonus = caster.spellAttackBonus || calculateSpellAttackBonus(caster); // Process effects for (const effect of spell.effects) { switch (effect.type) { case 'damage': { // Calculate dice to roll let diceNotation: string; if (spell.level === 0) { // Cantrip - scales with character level diceNotation = getCantripDamage(effect.dice || '1d10', caster.level); } else { // Leveled spell - may scale with slot level diceNotation = calculateUpcastDice(spell, slotLevel); } result.diceRolled = diceNotation; result.damageType = effect.damageType; // Special handling for Magic Missile if (spell.name.toLowerCase() === 'magic missile') { const darts = getMagicMissileDarts(slotLevel); result.dartCount = darts; // Each dart does 1d4+1 let totalDamage = 0; for (let i = 0; i < darts; i++) { totalDamage += Math.floor(Math.random() * 4) + 1 + 1; } result.damage = totalDamage; result.damageRolled = totalDamage; result.damageApplied = totalDamage; result.autoHit = true; result.diceRolled = `${darts}d4+${darts}`; break; } // Roll damage const damageRoll = rollDice(diceNotation); result.damageRolled = damageRoll.total; // Check if spell requires attack roll or saving throw if (spell.autoHit) { result.autoHit = true; result.damageApplied = damageRoll.total; result.damage = damageRoll.total; } else if (effect.saveType && effect.saveType !== 'none') { // Saving throw spell result.saveDC = spellSaveDC; // Roll save (or use provided mock) const saveRoll = options.targetSaveRoll ?? (Math.floor(Math.random() * 20) + 1); const saveTotal = saveRoll; // TODO: Add target's save modifier if (saveTotal >= spellSaveDC) { result.saveResult = 'passed'; if (effect.saveEffect === 'half') { result.damageApplied = Math.floor(damageRoll.total / 2); } else { result.damageApplied = 0; } } else { result.saveResult = 'failed'; result.damageApplied = damageRoll.total; } result.damage = result.damageApplied; } else { // Spell attack roll const attackRoll = Math.floor(Math.random() * 20) + 1; result.attackRoll = attackRoll; result.attackTotal = attackRoll + spellAttackBonus; const targetAC = options.targetAC ?? 10; result.hit = result.attackTotal >= targetAC; if (result.hit) { result.damageApplied = damageRoll.total; result.damage = damageRoll.total; } else { result.damageApplied = 0; result.damage = 0; } } // Apply conditions if any if (effect.conditions && effect.conditions.length > 0) { result.conditionsApplied = effect.conditions; } break; } case 'healing': { // Calculate healing dice let healingDice = effect.dice || '1d8'; // Add upcast bonus if applicable if (effect.upcastBonus && slotLevel > spell.level) { const bonusLevels = Math.floor((slotLevel - spell.level) / effect.upcastBonus.perLevel); const bonusMatch = effect.upcastBonus.dice.match(/^(\d+)d(\d+)/); const baseMatch = healingDice.match(/^(\d+)d(\d+)/); if (bonusMatch && baseMatch) { const newCount = parseInt(baseMatch[1]) + (parseInt(bonusMatch[1]) * bonusLevels); healingDice = `${newCount}d${baseMatch[2]}`; } } result.diceRolled = healingDice; // Roll healing const healingRoll = rollDice(healingDice); // Add spellcasting modifier const abilityMod = options.casterAbilityMod ?? Math.floor((caster.stats.wis - 10) / 2); result.healing = healingRoll.total + abilityMod; break; } case 'buff': { // Apply buff conditions if (effect.conditions && effect.conditions.length > 0) { result.conditionsApplied = effect.conditions; // Special handling for Shield if (effect.conditions.includes('AC_BONUS_5')) { result.acBonus = 5; } } if (effect.dice) { result.diceRolled = effect.dice; } break; } case 'debuff': { // Saving throw for debuff if (effect.saveType && effect.saveType !== 'none') { result.saveDC = spellSaveDC; const saveRoll = options.targetSaveRoll ?? (Math.floor(Math.random() * 20) + 1); if (saveRoll >= spellSaveDC) { result.saveResult = 'passed'; } else { result.saveResult = 'failed'; if (effect.conditions && effect.conditions.length > 0) { result.conditionsApplied = effect.conditions; } } } else { if (effect.conditions && effect.conditions.length > 0) { result.conditionsApplied = effect.conditions; } } break; } case 'utility': { // Utility spells don't have direct numerical effects result.success = true; break; } case 'summon': { // TODO: Implement summoning result.success = true; break; } } } return result; } /** * Convert spell resolution result to SpellCastResult schema */ export function toSpellCastResult(resolution: SpellResolutionResult): SpellCastResult { return { success: resolution.success, spellName: resolution.spellName, slotUsed: resolution.slotUsed, damage: resolution.damage, damageType: resolution.damageType, healing: resolution.healing, diceRolled: resolution.diceRolled, damageRolled: resolution.damageRolled, damageApplied: resolution.damageApplied, saveResult: resolution.saveResult, autoHit: resolution.autoHit, attackRoll: resolution.attackRoll, acBonus: resolution.acBonus, dartCount: resolution.dartCount, concentration: resolution.concentration, error: resolution.error }; }

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