Skip to main content
Glama
aura-tools.ts16.3 kB
import { z } from 'zod'; import { SessionContext } from './types.js'; import { getDb } from '../storage/index.js'; import { AuraRepository } from '../storage/repos/aura.repo.js'; import { EncounterRepository } from '../storage/repos/encounter.repo.js'; import { CharacterRepository } from '../storage/repos/character.repo.js'; import { ConcentrationRepository } from '../storage/repos/concentration.repo.js'; import { createAura, endAura, endAurasByOwner, getActiveAuras, checkAuraEffectsForTarget, expireOldAuras, } from '../engine/magic/aura.js'; import { startConcentration, breakConcentration } from '../engine/magic/concentration.js'; import { CreateAuraRequestSchema, AuraTriggerSchema, AuraState, AuraEffectResult } from '../schema/aura.js'; import { Token } from '../schema/encounter.js'; /** * Aura Management Tools * Handles creation, querying, and processing of area-effect auras */ export const AuraTools = { CREATE_AURA: { name: 'create_aura', description: 'Create a new aura effect centered on a character (e.g., Spirit Guardians, Aura of Protection). Auras move with their owner and affect targets within radius. Optionally requires concentration.', inputSchema: z.object({ ownerId: z.string().describe('The ID of the character creating the aura'), spellName: z.string().describe('Name of the spell or ability creating the aura'), spellLevel: z.number().int().min(0).max(9).describe('Spell level (0-9)'), radius: z.number().int().min(1).describe('Radius in feet (e.g., 15 for Spirit Guardians)'), affectsAllies: z.boolean().default(false).describe('Whether the aura affects allied creatures'), affectsEnemies: z.boolean().default(false).describe('Whether the aura affects enemy creatures'), affectsSelf: z.boolean().default(false).describe('Whether the aura affects the caster'), effects: z.array(z.object({ trigger: AuraTriggerSchema.describe('When the effect triggers (enter, exit, start_of_turn, end_of_turn)'), type: z.enum(['damage', 'buff', 'debuff', 'healing', 'condition', 'custom']).describe('Type of effect'), dice: z.string().optional().describe('Dice notation for damage/healing (e.g., "3d8")'), damageType: z.string().optional().describe('Damage type (e.g., "radiant", "necrotic")'), saveType: z.string().optional().describe('Ability for saving throw (e.g., "wisdom", "dexterity")'), saveDC: z.number().int().optional().describe('DC for saving throw'), conditions: z.array(z.string()).optional().describe('Conditions to apply (e.g., ["frightened"])'), description: z.string().optional().describe('Custom effect description'), bonusAmount: z.number().int().optional().describe('Bonus amount for buffs/debuffs'), bonusType: z.string().optional().describe('What the bonus applies to (e.g., "ac", "saves")'), })).describe('Array of effects the aura applies'), currentRound: z.number().int().min(1).describe('Current combat round number'), maxDuration: z.number().int().optional().describe('Maximum duration in rounds (omit for indefinite)'), requiresConcentration: z.boolean().default(false).describe('Whether the aura requires concentration'), }), }, GET_ACTIVE_AURAS: { name: 'get_active_auras', description: 'List all currently active auras and their properties.', inputSchema: z.object({}), }, GET_AURAS_AFFECTING_CHARACTER: { name: 'get_auras_affecting_character', description: 'Check which auras are currently affecting a specific character based on their position.', inputSchema: z.object({ encounterId: z.string().describe('The encounter ID to check within'), characterId: z.string().describe('The character ID to check for affecting auras'), }), }, PROCESS_AURA_EFFECTS: { name: 'process_aura_effects', description: 'Process aura effects for a target at a specific trigger (e.g., start of turn, entering an aura). Returns all effects that were triggered.', inputSchema: z.object({ encounterId: z.string().describe('The encounter ID'), targetId: z.string().describe('The target character/creature ID'), trigger: AuraTriggerSchema.describe('When the effects trigger (enter, exit, start_of_turn, end_of_turn)'), }), }, REMOVE_AURA: { name: 'remove_aura', description: 'Manually end an aura by ID (e.g., when concentration breaks or spell is dismissed).', inputSchema: z.object({ auraId: z.string().describe('The ID of the aura to remove'), }), }, REMOVE_CHARACTER_AURAS: { name: 'remove_character_auras', description: 'Remove all auras owned by a specific character (e.g., when they die or lose concentration).', inputSchema: z.object({ characterId: z.string().describe('The ID of the character whose auras to remove'), }), }, EXPIRE_AURAS: { name: 'expire_auras', description: 'Check for and remove any auras that have exceeded their duration.', inputSchema: z.object({ currentRound: z.number().int().min(1).describe('Current combat round number'), }), }, } as const; function ensureDb() { const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); return { auraRepo: new AuraRepository(db), encounterRepo: new EncounterRepository(db), characterRepo: new CharacterRepository(db), concentrationRepo: new ConcentrationRepository(db), }; } /** * Handle aura creation */ export async function handleCreateAura(args: unknown, _ctx: SessionContext) { const { auraRepo, characterRepo, concentrationRepo } = ensureDb(); const parsed = CreateAuraRequestSchema.parse(args); // Verify character exists const character = characterRepo.findById(parsed.ownerId); if (!character) { throw new Error(`Character ${parsed.ownerId} not found`); } // If aura requires concentration, start concentration if (parsed.requiresConcentration) { startConcentration( parsed.ownerId, parsed.spellName, parsed.spellLevel, parsed.currentRound, parsed.maxDuration, undefined, // Auras don't track specific target IDs concentrationRepo, characterRepo ); } // Create the aura const aura = createAura(parsed, auraRepo); return { content: [ { type: 'text' as const, text: formatAuraCreated(aura, character.name), }, ], }; } /** * Handle get active auras */ export async function handleGetActiveAuras(_args: unknown, _ctx: SessionContext) { const { auraRepo } = ensureDb(); const auras = getActiveAuras(auraRepo); if (auras.length === 0) { return { content: [ { type: 'text' as const, text: 'No active auras.', }, ], }; } return { content: [ { type: 'text' as const, text: formatAuraList(auras), }, ], }; } /** * Handle get auras affecting character */ export async function handleGetAurasAffectingCharacter(args: unknown, _ctx: SessionContext) { const { auraRepo, encounterRepo } = ensureDb(); const parsed = AuraTools.GET_AURAS_AFFECTING_CHARACTER.inputSchema.parse(args); const encounter = encounterRepo.findById(parsed.encounterId); if (!encounter) { throw new Error(`Encounter ${parsed.encounterId} not found`); } // Parse tokens from JSON string (EncounterRow stores as string) const tokens: Token[] = typeof encounter.tokens === 'string' ? JSON.parse(encounter.tokens) : encounter.tokens; const target = tokens.find(t => t.id === parsed.characterId); if (!target) { throw new Error(`Character ${parsed.characterId} not found in encounter`); } if (!target.position) { return { content: [ { type: 'text' as const, text: `Character ${parsed.characterId} has no position in the encounter.`, }, ], }; } // Get auras at the target's position const { getAurasAtPosition } = await import('../engine/magic/aura.js'); const affectingAuras = getAurasAtPosition(tokens, target.position, auraRepo); if (affectingAuras.length === 0) { return { content: [ { type: 'text' as const, text: `No auras are affecting ${target.name || parsed.characterId}.`, }, ], }; } return { content: [ { type: 'text' as const, text: formatAffectingAuras(target.name || parsed.characterId, affectingAuras, tokens), }, ], }; } /** * Handle process aura effects */ export async function handleProcessAuraEffects(args: unknown, _ctx: SessionContext) { const { auraRepo, encounterRepo } = ensureDb(); const parsed = AuraTools.PROCESS_AURA_EFFECTS.inputSchema.parse(args); const encounter = encounterRepo.findById(parsed.encounterId); if (!encounter) { throw new Error(`Encounter ${parsed.encounterId} not found`); } // Parse tokens from JSON string (EncounterRow stores as string) const tokens: Token[] = typeof encounter.tokens === 'string' ? JSON.parse(encounter.tokens) : encounter.tokens; const results = checkAuraEffectsForTarget( tokens, parsed.targetId, parsed.trigger, auraRepo ); if (results.length === 0) { return { content: [ { type: 'text' as const, text: `No aura effects triggered for ${parsed.targetId} on ${parsed.trigger}.`, }, ], }; } return { content: [ { type: 'text' as const, text: formatAuraEffectResults(results, tokens), }, ], }; } /** * Handle remove aura */ export async function handleRemoveAura(args: unknown, _ctx: SessionContext) { const { auraRepo, concentrationRepo, characterRepo } = ensureDb(); const parsed = AuraTools.REMOVE_AURA.inputSchema.parse(args); const aura = auraRepo.findById(parsed.auraId); if (!aura) { return { content: [ { type: 'text' as const, text: `Aura ${parsed.auraId} not found (may have already expired).`, }, ], }; } const auraName = aura.spellName; const removed = endAura(parsed.auraId, auraRepo); // If aura required concentration, break it if (aura.requiresConcentration) { const concentration = concentrationRepo.findByCharacterId(aura.ownerId); if (concentration && concentration.activeSpell === aura.spellName) { breakConcentration( { characterId: aura.ownerId, reason: 'voluntary' }, concentrationRepo, characterRepo ); } } return { content: [ { type: 'text' as const, text: removed ? `Aura "${auraName}" has been removed.` : `Failed to remove aura ${parsed.auraId}.`, }, ], }; } /** * Handle remove character auras */ export async function handleRemoveCharacterAuras(args: unknown, _ctx: SessionContext) { const { auraRepo } = ensureDb(); const parsed = AuraTools.REMOVE_CHARACTER_AURAS.inputSchema.parse(args); const count = endAurasByOwner(parsed.characterId, auraRepo); return { content: [ { type: 'text' as const, text: count > 0 ? `Removed ${count} aura(s) from character ${parsed.characterId}.` : `Character ${parsed.characterId} had no active auras.`, }, ], }; } /** * Handle expire auras */ export async function handleExpireAuras(args: unknown, _ctx: SessionContext) { const { auraRepo } = ensureDb(); const parsed = AuraTools.EXPIRE_AURAS.inputSchema.parse(args); const expiredIds = expireOldAuras(parsed.currentRound, auraRepo); return { content: [ { type: 'text' as const, text: expiredIds.length > 0 ? `Expired ${expiredIds.length} aura(s): ${expiredIds.join(', ')}` : 'No auras expired this round.', }, ], }; } // ============================================================ // FORMATTING HELPERS // ============================================================ function formatAuraCreated(aura: AuraState, ownerName: string): string { const concentrationText = aura.requiresConcentration ? ' (Requires Concentration)' : ''; const durationText = aura.maxDuration ? ` for ${aura.maxDuration} rounds` : ' (indefinite duration)'; const targetTypes = []; if (aura.affectsSelf) targetTypes.push('self'); if (aura.affectsAllies) targetTypes.push('allies'); if (aura.affectsEnemies) targetTypes.push('enemies'); return `✨ Aura Created: ${aura.spellName}${concentrationText} Owner: ${ownerName} Radius: ${aura.radius} feet Affects: ${targetTypes.join(', ')} Duration: Started round ${aura.startedAt}${durationText} Effects: ${aura.effects.length} effect(s) ${aura.effects.map((e, i) => ` ${i + 1}. ${e.type} on ${e.trigger}${e.dice ? ` (${e.dice}${e.damageType ? ' ' + e.damageType : ''})` : ''}` ).join('\n')}`; } function formatAuraList(auras: AuraState[]): string { return `Active Auras (${auras.length}): ${auras.map((aura, i) => `${i + 1}. ${aura.spellName} (ID: ${aura.id}) Owner: ${aura.ownerId} Radius: ${aura.radius}ft | Started: Round ${aura.startedAt}${aura.maxDuration ? ` | Duration: ${aura.maxDuration} rounds` : ''} Targets: ${[aura.affectsSelf && 'self', aura.affectsAllies && 'allies', aura.affectsEnemies && 'enemies'].filter(Boolean).join(', ')} Effects: ${aura.effects.length}` ).join('\n\n')}`; } function formatAffectingAuras(targetName: string, auras: AuraState[], tokens: any[]): string { return `Auras Affecting ${targetName}: ${auras.map((aura, i) => { const owner = tokens.find(t => t.id === aura.ownerId); return `${i + 1}. ${aura.spellName} (Owner: ${owner?.name || aura.ownerId}) Radius: ${aura.radius}ft Effects: ${aura.effects.map(e => `${e.type} on ${e.trigger}`).join(', ')}`; }).join('\n\n')}`; } function formatAuraEffectResults(results: AuraEffectResult[], tokens: any[]): string { if (results.length === 0) { return 'No aura effects were triggered.'; } const target = tokens.find(t => t.id === results[0].targetId); const targetName = target?.name || results[0].targetId; return `Aura Effects on ${targetName}: ${results.map((result, i) => { let text = `${i + 1}. ${result.auraName} (${result.trigger})`; if (result.saveRoll !== undefined && result.saveDC !== undefined) { text += `\n Save: ${result.saveRoll} + mod = ${result.saveTotal} vs DC ${result.saveDC} - ${result.succeeded ? 'SUCCESS' : 'FAILURE'}`; } if (result.damageDealt !== undefined) { text += `\n Damage: ${result.damageDealt} ${result.damageType || ''}`; } if (result.healingDone !== undefined) { text += `\n Healing: ${result.healingDone}`; } if (result.conditionsApplied && result.conditionsApplied.length > 0) { text += `\n Conditions: ${result.conditionsApplied.join(', ')}`; } if (result.description) { text += `\n ${result.description}`; } return text; }).join('\n\n')}`; }

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