Skip to main content
Glama
combat.ts61.4 kB
/** * Combat Module - Condition Management & Encounters * Handles D&D 5e conditions with duration tracking and encounter management */ import { z } from 'zod'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { randomUUID } from 'crypto'; import { ConditionSchema, AbilitySchema, PositionSchema, SizeSchema, DamageTypeSchema, LightSchema, type Condition, type Ability } from '../types.js'; import { createBox, centerText, padText, BOX } from './ascii-art.js'; // Use AppData for persistent character storage (cross-session persistence) const getDataDir = () => { if (process.platform === 'win32') { return path.join(process.env.APPDATA || os.homedir(), 'rpg-lite-mcp'); } else { // For macOS/Linux, use standard config directory return path.join(os.homedir(), '.config', 'rpg-lite-mcp'); } }; const DATA_ROOT = getDataDir(); // ============================================================ // SCHEMAS // ============================================================ // Single condition operation const singleConditionSchema = z.object({ targetId: z.string(), encounterId: z.string().optional(), operation: z.enum(['add', 'remove', 'query', 'tick']), // For add/remove condition: ConditionSchema.optional(), source: z.string().optional(), duration: z.union([ z.number(), // Rounds z.literal('concentration'), z.literal('until_dispelled'), z.literal('until_rest'), z.literal('save_ends'), ]).optional(), saveDC: z.number().optional(), saveAbility: AbilitySchema.optional(), // For exhaustion exhaustionLevels: z.number().min(1).optional(), // For tick roundNumber: z.number().optional(), }); // Support both single and batch operations export const manageConditionSchema = z.union([ singleConditionSchema, z.object({ batch: z.array(singleConditionSchema).min(1).max(20), }), ]); export type ManageConditionInput = z.infer<typeof singleConditionSchema>; // ============================================================ // CONDITION DATA // ============================================================ // Mechanical effects that conditions can apply to character stats interface ConditionEffect { // Stat multipliers (e.g., 0.5 = halved) maxHpMultiplier?: number; speedMultiplier?: number; // Stat modifiers (flat adjustments) maxHpModifier?: number; speedModifier?: number; acModifier?: number; // Ability score modifiers strModifier?: number; dexModifier?: number; conModifier?: number; intModifier?: number; wisModifier?: number; chaModifier?: number; // Advantage/disadvantage tracking disadvantageOn?: string[]; // e.g., ["ability_checks", "attack_rolls", "dex_saves"] advantageOn?: string[]; // e.g., ["stealth_checks"] // Auto-fail certain checks/saves autoFailSaves?: string[]; // e.g., ["str", "dex"] // Movement restrictions cannotMove?: boolean; canOnlyCrawl?: boolean; // Incapacitation incapacitated?: boolean; // Custom effects (for future extensibility) customEffects?: Record<string, number | string | boolean>; } interface ActiveCondition { condition: Condition | string; // Allow custom condition names description?: string; // Custom description (optional, falls back to CONDITION_EFFECTS) mechanicalEffects?: ConditionEffect; // Mechanical effects on stats source?: string; duration?: number | 'concentration' | 'until_dispelled' | 'until_rest' | 'save_ends'; saveDC?: number; saveAbility?: Ability; exhaustionLevel?: number; roundsRemaining?: number; } // In-memory condition storage (per target) const conditionStore = new Map<string, ActiveCondition[]>(); // D&D 5e Condition Effects const CONDITION_EFFECTS: Record<Condition, string> = { blinded: "Can't see, auto-fails sight checks. Attacks have disadvantage, attacks against have advantage.", charmed: "Can't attack charmer or target with harmful abilities/effects. Charmer has advantage on social interactions.", deafened: "Can't hear, auto-fails hearing checks.", frightened: "Disadvantage on ability checks and attacks while source is visible. Can't willingly move closer to source.", grappled: "Speed becomes 0, can't benefit from speed bonuses. Ends if grappler is incapacitated or moved away.", incapacitated: "Can't take actions or reactions.", invisible: "Impossible to see without special sense. Heavily obscured for hiding. Attacks have advantage, attacks against have disadvantage.", paralyzed: "Incapacitated, can't move or speak. Auto-fails STR and DEX saves. Attacks have advantage. Melee hits within 5ft are auto-crits.", petrified: "Transformed to solid substance, incapacitated, can't move/speak, unaware. Weight x10. Auto-fails STR/DEX saves. Resistance to all damage. Immune to poison/disease.", poisoned: "Disadvantage on attack rolls and ability checks.", prone: "Only movement is crawling (costs extra). Attacks have disadvantage. Melee attacks against have advantage, ranged have disadvantage.", restrained: "Speed becomes 0, can't benefit from speed bonuses. Attacks have disadvantage. Attacks against have advantage. Disadvantage on DEX saves.", stunned: "Incapacitated, can't move, can speak only falteringly. Auto-fails STR and DEX saves. Attacks against have advantage.", unconscious: "Incapacitated, can't move/speak, unaware. Drops held items, falls prone. Auto-fails STR/DEX saves. Attacks have advantage. Melee hits within 5ft are auto-crits.", exhaustion: "Stacking penalty with 6 levels (see exhaustion table).", }; // Exhaustion level effects const EXHAUSTION_EFFECTS: Record<number, string> = { 1: "Disadvantage on ability checks", 2: "Speed halved", 3: "Disadvantage on attack rolls and saving throws", 4: "Hit point maximum halved", 5: "Speed reduced to 0", 6: "DEATH", }; // ============================================================ // MECHANICAL EFFECTS: Built-in conditions // ============================================================ /** * Get the mechanical effects for built-in D&D 5e conditions * Returns undefined for conditions with no mechanical stat effects */ function getBuiltInMechanicalEffects(condition: Condition | string, exhaustionLevel?: number): ConditionEffect | undefined { // Exhaustion has level-based effects if (condition === 'exhaustion' && exhaustionLevel) { const effects: ConditionEffect = {}; // Level 1: Disadvantage on ability checks if (exhaustionLevel >= 1) { effects.disadvantageOn = ['ability_checks']; } // Level 2: Speed halved if (exhaustionLevel >= 2) { effects.speedMultiplier = 0.5; } // Level 3: Disadvantage on attack rolls and saving throws if (exhaustionLevel >= 3) { effects.disadvantageOn = [...(effects.disadvantageOn || []), 'attack_rolls', 'saving_throws']; } // Level 4: Hit point maximum halved if (exhaustionLevel >= 4) { effects.maxHpMultiplier = 0.5; } // Level 5: Speed reduced to 0 if (exhaustionLevel >= 5) { effects.speedMultiplier = 0; effects.cannotMove = true; } // Level 6: DEATH (no mechanical effects to track, character is dead) if (exhaustionLevel >= 6) { effects.customEffects = { isDead: true }; } return effects; } // Other conditions with mechanical effects const effects: ConditionEffect = {}; switch (condition) { case 'poisoned': effects.disadvantageOn = ['attack_rolls', 'ability_checks']; break; case 'grappled': case 'restrained': effects.speedModifier = 0; effects.cannotMove = true; if (condition === 'restrained') { effects.disadvantageOn = ['attack_rolls', 'dex_saves']; } break; case 'prone': effects.canOnlyCrawl = true; effects.disadvantageOn = ['attack_rolls']; break; case 'paralyzed': case 'stunned': case 'unconscious': effects.incapacitated = true; effects.autoFailSaves = ['str', 'dex']; effects.cannotMove = true; break; case 'incapacitated': effects.incapacitated = true; break; case 'petrified': effects.incapacitated = true; effects.cannotMove = true; effects.autoFailSaves = ['str', 'dex']; break; default: // Conditions with no mechanical stat effects (blinded, charmed, deafened, frightened, invisible) return undefined; } return Object.keys(effects).length > 0 ? effects : undefined; } // ============================================================ // HELPER: Get Character Name from ID // ============================================================ function getCharacterNameById(characterId: string): string | null { const dataDir = path.join(DATA_ROOT, 'characters'); const filePath = path.join(dataDir, `${characterId}.json`); if (!fs.existsSync(filePath)) { return null; } try { const fileContent = fs.readFileSync(filePath, 'utf-8'); const character = JSON.parse(fileContent); return character.name || null; } catch { return null; } } // ============================================================ // PUBLIC API: Condition Effects for Character Stats // ============================================================ /** * Get all active conditions for a character * Used by get_character to display condition-affected stats */ export function getActiveConditions(characterId: string): ActiveCondition[] { return conditionStore.get(characterId) || []; } /** * Calculate effective character stats after applying all active condition effects * Returns object with base values and effective values */ export function calculateEffectiveStats(characterId: string, baseStats: { maxHp: number; hp: number; speed: number; ac?: number; }): { maxHp: { base: number; effective: number; modified: boolean }; speed: { base: number; effective: number; modified: boolean }; ac?: { base: number; effective: number; modified: boolean }; conditionEffects: string[]; // Human-readable list of active effects } { const conditions = getActiveConditions(characterId); const result = { maxHp: { base: baseStats.maxHp, effective: baseStats.maxHp, modified: false }, speed: { base: baseStats.speed, effective: baseStats.speed, modified: false }, ac: baseStats.ac ? { base: baseStats.ac, effective: baseStats.ac, modified: false } : undefined, conditionEffects: [] as string[], }; // Apply each condition's mechanical effects for (const cond of conditions) { // Get mechanical effects (either custom or built-in) let effects = cond.mechanicalEffects; if (!effects && (cond.condition in CONDITION_EFFECTS || cond.condition === 'exhaustion')) { effects = getBuiltInMechanicalEffects(cond.condition, cond.exhaustionLevel); } if (!effects) continue; // Apply HP maximum modifier if (effects.maxHpMultiplier !== undefined) { result.maxHp.effective = Math.floor(result.maxHp.effective * effects.maxHpMultiplier); result.maxHp.modified = true; const condName = cond.condition === 'exhaustion' ? `Exhaustion ${cond.exhaustionLevel}` : String(cond.condition).charAt(0).toUpperCase() + String(cond.condition).slice(1); result.conditionEffects.push(`${condName}: HP max ×${effects.maxHpMultiplier}`); } if (effects.maxHpModifier !== undefined) { result.maxHp.effective += effects.maxHpModifier; result.maxHp.modified = true; result.conditionEffects.push(`HP max ${effects.maxHpModifier > 0 ? '+' : ''}${effects.maxHpModifier}`); } // Apply speed modifier if (effects.speedMultiplier !== undefined) { result.speed.effective = Math.floor(result.speed.effective * effects.speedMultiplier); result.speed.modified = true; const condName = cond.condition === 'exhaustion' ? `Exhaustion ${cond.exhaustionLevel}` : String(cond.condition).charAt(0).toUpperCase() + String(cond.condition).slice(1); result.conditionEffects.push(`${condName}: Speed ×${effects.speedMultiplier}`); } if (effects.speedModifier !== undefined) { result.speed.effective += effects.speedModifier; result.speed.modified = true; result.conditionEffects.push(`Speed ${effects.speedModifier > 0 ? '+' : ''}${effects.speedModifier}`); } if (effects.cannotMove) { result.speed.effective = 0; result.speed.modified = true; } // Apply AC modifier if (result.ac && effects.acModifier !== undefined) { result.ac.effective += effects.acModifier; result.ac.modified = true; result.conditionEffects.push(`AC ${effects.acModifier > 0 ? '+' : ''}${effects.acModifier}`); } // Track other effects (disadvantage, auto-fail, etc.) if (effects.disadvantageOn && effects.disadvantageOn.length > 0) { const condName = cond.condition === 'exhaustion' ? `Exhaustion ${cond.exhaustionLevel}` : String(cond.condition).charAt(0).toUpperCase() + String(cond.condition).slice(1); result.conditionEffects.push(`${condName}: Disadv. on ${effects.disadvantageOn.join(', ')}`); } if (effects.autoFailSaves && effects.autoFailSaves.length > 0) { result.conditionEffects.push(`Auto-fail ${effects.autoFailSaves.join(', ').toUpperCase()} saves`); } if (effects.incapacitated) { result.conditionEffects.push('Incapacitated'); } } return result; } // ============================================================ // HANDLER // ============================================================ export function manageCondition(input: ManageConditionInput | { batch: ManageConditionInput[] }): string { // Check if this is a batch operation if ('batch' in input && input.batch) { return handleBatchConditions(input.batch); } const singleInput = input as ManageConditionInput; const { targetId, operation } = singleInput; switch (operation) { case 'add': return handleAddCondition(targetId, singleInput); case 'remove': return handleRemoveCondition(targetId, singleInput); case 'query': return handleQueryConditions(targetId); case 'tick': return handleTickDuration(targetId, singleInput); default: throw new Error(`Unknown operation: ${operation}`); } } // ============================================================ // BATCH HANDLER // ============================================================ function handleBatchConditions(operations: ManageConditionInput[]): string { const results: Array<{ targetId: string; targetName: string | null; operation: string; condition?: string; success: boolean; message: string; queryResult?: string; // Store full query result for query operations }> = []; for (const op of operations) { const targetName = getCharacterNameById(op.targetId); try { const result = manageCondition(op); results.push({ targetId: op.targetId, targetName, operation: op.operation, condition: op.condition, success: true, message: op.operation === 'query' ? 'Queried' : 'Applied', queryResult: op.operation === 'query' ? result : undefined, }); } catch (error) { results.push({ targetId: op.targetId, targetName, operation: op.operation, condition: op.condition, success: false, message: error instanceof Error ? error.message : 'Unknown error', }); } } return formatBatchResults(results); } function formatBatchResults(results: Array<{ targetId: string; targetName: string | null; operation: string; condition?: string; success: boolean; message: string; queryResult?: string; }>): string { const content: string[] = []; // Check if this is all query operations const allQueries = results.every(r => r.operation === 'query'); if (allQueries && results.length > 0) { // Special formatting for query batch - show full details content.push(centerText(`CONDITION STATUS - ${results.length} CHARACTERS`, 68)); content.push(''); content.push('═'.repeat(68)); for (const result of results) { content.push(''); const displayName = result.targetName || result.targetId; content.push(centerText(displayName.toUpperCase(), 68)); content.push('─'.repeat(68)); if (result.success && result.queryResult) { // Extract just the condition details from the query result // The query result is a full box, so we'll strip the outer box const lines = result.queryResult.split('\n'); // Find lines that contain actual condition info (skip the box borders) const relevantLines = lines.filter(line => { return line.trim() && !line.match(/^[╔╗╚╝║═─┌┐└┘│]+$/) && !line.includes('CONDITION STATUS'); }); for (const line of relevantLines) { content.push(line); } } else if (!result.success) { content.push(padText(` Error: ${result.message}`, 68, 'left')); } else { content.push(padText(` No active conditions`, 68, 'left')); } content.push(''); } content.push('═'.repeat(68)); return createBox('BATCH CONDITION QUERY', content, undefined, 'HEAVY'); } // Standard batch operation formatting (add/remove/tick) content.push(centerText(`PROCESSED ${results.length} OPERATIONS`, 68)); content.push(''); content.push('─'.repeat(68)); content.push(''); const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; for (const result of results) { const status = result.success ? '✓' : '✗'; const displayName = result.targetName || result.targetId; const opDesc = result.condition ? `${result.operation} ${result.condition}` : result.operation; content.push(padText(`${status} ${displayName}: ${opDesc}`, 68, 'left')); if (!result.success) { content.push(padText(` Error: ${result.message}`, 68, 'left')); } } content.push(''); content.push('─'.repeat(68)); content.push(''); content.push(padText(`Success: ${successCount} | Failed: ${failCount}`, 68, 'left')); return createBox('BATCH CONDITION UPDATE', content, undefined, 'HEAVY'); } // Helper for testing: clear all conditions export function clearAllConditions(): void { conditionStore.clear(); } // ============================================================ // ADD CONDITION // ============================================================ function handleAddCondition(targetId: string, input: ManageConditionInput): string { if (!input.condition) { throw new Error('condition is required for add operation'); } const conditions = conditionStore.get(targetId) || []; // Handle exhaustion specially if (input.condition === 'exhaustion') { return handleAddExhaustion(targetId, conditions, input); } // Check if condition already exists const existing = conditions.find(c => c.condition === input.condition); if (existing) { return formatConditionUpdate(targetId, input.condition, 'already has', existing); } // Create new condition const newCondition: ActiveCondition = { condition: input.condition, source: input.source, duration: input.duration, saveDC: input.saveDC, saveAbility: input.saveAbility, }; // Set rounds remaining for numeric durations if (typeof input.duration === 'number') { newCondition.roundsRemaining = input.duration; } conditions.push(newCondition); conditionStore.set(targetId, conditions); return formatConditionUpdate(targetId, input.condition, 'added', newCondition); } function handleAddExhaustion( targetId: string, conditions: ActiveCondition[], input: ManageConditionInput ): string { const existing = conditions.find(c => c.condition === 'exhaustion'); const levelsToAdd = input.exhaustionLevels || 1; if (existing) { // Increment existing exhaustion const currentLevel = existing.exhaustionLevel || 1; const newLevel = Math.min(6, currentLevel + levelsToAdd); existing.exhaustionLevel = newLevel; conditionStore.set(targetId, conditions); return formatExhaustionLevel(targetId, newLevel, 'increased to'); } else { // Add new exhaustion const level = Math.min(6, levelsToAdd); const newCondition: ActiveCondition = { condition: 'exhaustion', exhaustionLevel: level, }; conditions.push(newCondition); conditionStore.set(targetId, conditions); return formatExhaustionLevel(targetId, level, 'added'); } } // ============================================================ // REMOVE CONDITION // ============================================================ function handleRemoveCondition(targetId: string, input: ManageConditionInput): string { const conditions = conditionStore.get(targetId) || []; // Special handling for "all" if (input.condition === 'all' as any) { conditionStore.set(targetId, []); const content: string[] = []; content.push(centerText(`TARGET: ${targetId}`, 58)); content.push(''); content.push(centerText('All conditions removed', 58)); return createBox('CONDITIONS CLEARED', content, undefined, 'HEAVY'); } if (!input.condition) { throw new Error('condition is required for remove operation'); } // Handle exhaustion specially if (input.condition === 'exhaustion') { return handleRemoveExhaustion(targetId, conditions, input); } const index = conditions.findIndex(c => c.condition === input.condition); if (index === -1) { const content: string[] = []; content.push(centerText(`TARGET: ${targetId}`, 58)); content.push(''); content.push(centerText(`Not affected by ${input.condition}`, 58)); return createBox('CONDITION NOT FOUND', content, undefined, 'HEAVY'); } const removed = conditions.splice(index, 1)[0]; conditionStore.set(targetId, conditions); return formatConditionUpdate(targetId, input.condition, 'removed', removed); } function handleRemoveExhaustion( targetId: string, conditions: ActiveCondition[], input: ManageConditionInput ): string { const existing = conditions.find(c => c.condition === 'exhaustion'); if (!existing) { const content: string[] = []; content.push(centerText(`TARGET: ${targetId}`, 58)); content.push(''); content.push(centerText('Not affected by exhaustion', 58)); return createBox('CONDITION NOT FOUND', content, undefined, 'HEAVY'); } const currentLevel = existing.exhaustionLevel || 1; const levelsToRemove = input.exhaustionLevels || 1; const newLevel = currentLevel - levelsToRemove; if (newLevel <= 0) { // Remove exhaustion entirely const index = conditions.findIndex(c => c.condition === 'exhaustion'); conditions.splice(index, 1); conditionStore.set(targetId, conditions); const content: string[] = []; content.push(centerText(`TARGET: ${targetId}`, 58)); content.push(''); content.push(centerText('Exhaustion removed completely', 58)); return createBox('EXHAUSTION REMOVED', content, undefined, 'HEAVY'); } else { // Reduce level existing.exhaustionLevel = newLevel; conditionStore.set(targetId, conditions); return formatExhaustionLevel(targetId, newLevel, 'reduced to'); } } // ============================================================ // QUERY CONDITIONS // ============================================================ function handleQueryConditions(targetId: string): string { const conditions = conditionStore.get(targetId) || []; const targetName = getCharacterNameById(targetId); const displayName = targetName || targetId; const content: string[] = []; const WIDTH = 58; content.push(centerText(`TARGET: ${displayName}`, WIDTH)); content.push(''); if (conditions.length === 0) { content.push(centerText('No active conditions', WIDTH)); return createBox('CONDITION STATUS', content, undefined, 'HEAVY'); } const box = BOX.LIGHT; content.push(box.H.repeat(WIDTH)); content.push(''); for (const cond of conditions) { if (cond.condition === 'exhaustion') { const level = cond.exhaustionLevel || 1; content.push(centerText(`EXHAUSTION - LEVEL ${level}`, WIDTH)); content.push(''); content.push(padText(`Current Effect: ${EXHAUSTION_EFFECTS[level]}`, WIDTH, 'left')); content.push(''); // Show all effects up to current level content.push(padText('All Active Effects:', WIDTH, 'left')); for (let i = 1; i <= level; i++) { content.push(padText(` ${i}. ${EXHAUSTION_EFFECTS[i]}`, WIDTH, 'left')); } } else { const condName = cond.condition.charAt(0).toUpperCase() + cond.condition.slice(1); content.push(centerText(condName.toUpperCase(), WIDTH)); content.push(''); // Show effect description (either built-in or custom) const effectText = cond.description || (cond.condition in CONDITION_EFFECTS ? CONDITION_EFFECTS[cond.condition as Condition] : 'Custom condition'); content.push(padText(`Effect: ${effectText}`, WIDTH, 'left')); content.push(''); if (cond.source) { content.push(padText(`Source: ${cond.source}`, WIDTH, 'left')); } if (cond.duration !== undefined) { if (typeof cond.duration === 'number' || cond.roundsRemaining !== undefined) { const rounds = cond.roundsRemaining ?? cond.duration; content.push(padText(`Duration: ${rounds} round${rounds !== 1 ? 's' : ''} remaining`, WIDTH, 'left')); } else { content.push(padText(`Duration: ${cond.duration.replace(/_/g, ' ')}`, WIDTH, 'left')); } } if (cond.saveDC) { const ability = cond.saveAbility?.toUpperCase() || 'SAVE'; content.push(padText(`Save: DC ${cond.saveDC} ${ability} to end`, WIDTH, 'left')); } } content.push(''); content.push(box.H.repeat(WIDTH)); content.push(''); } return createBox('CONDITION STATUS', content, undefined, 'HEAVY'); } // ============================================================ // TICK DURATION // ============================================================ function handleTickDuration(targetId: string, input: ManageConditionInput): string { const conditions = conditionStore.get(targetId) || []; const expired: string[] = []; const updated: string[] = []; for (let i = conditions.length - 1; i >= 0; i--) { const cond = conditions[i]; // Only tick numeric durations if (cond.roundsRemaining !== undefined && typeof cond.roundsRemaining === 'number') { cond.roundsRemaining -= 1; if (cond.roundsRemaining <= 0) { expired.push(cond.condition); conditions.splice(i, 1); } else { updated.push(`${cond.condition} (${cond.roundsRemaining} round${cond.roundsRemaining !== 1 ? 's' : ''})`); } } } conditionStore.set(targetId, conditions); const content: string[] = []; const WIDTH = 58; content.push(centerText(`TARGET: ${targetId}`, WIDTH)); content.push(''); const box = BOX.LIGHT; content.push(box.H.repeat(WIDTH)); content.push(''); if (expired.length > 0) { content.push(padText(`Expired: ${expired.join(', ')}`, WIDTH, 'left')); } if (updated.length > 0) { if (expired.length > 0) content.push(''); content.push(padText(`Updated: ${updated.join(', ')}`, WIDTH, 'left')); } if (expired.length === 0 && updated.length === 0) { content.push(centerText('No round-based conditions to update', WIDTH)); } return createBox('CONDITION TICK', content, undefined, 'HEAVY'); } // ============================================================ // FORMATTING HELPERS // ============================================================ function formatConditionUpdate( targetId: string, condition: Condition, action: string, conditionData: ActiveCondition ): string { const targetName = getCharacterNameById(targetId); const displayName = targetName || targetId; const content: string[] = []; const WIDTH = 58; const condName = condition.charAt(0).toUpperCase() + condition.slice(1); const actionTitle = action.charAt(0).toUpperCase() + action.slice(1); content.push(centerText(`TARGET: ${displayName}`, WIDTH)); content.push(centerText(`${condName} ${action}`, WIDTH)); content.push(''); const box = BOX.LIGHT; content.push(box.H.repeat(WIDTH)); content.push(''); content.push(centerText(condName.toUpperCase(), WIDTH)); content.push(''); content.push(padText(`Effect: ${CONDITION_EFFECTS[condition]}`, WIDTH, 'left')); if (conditionData.source) { content.push(''); content.push(padText(`Source: ${conditionData.source}`, WIDTH, 'left')); } if (conditionData.duration !== undefined) { if (typeof conditionData.duration === 'number' || conditionData.roundsRemaining !== undefined) { const rounds = conditionData.roundsRemaining ?? conditionData.duration; content.push(''); content.push(padText(`Duration: ${rounds} round${rounds !== 1 ? 's' : ''}`, WIDTH, 'left')); } else { content.push(''); content.push(padText(`Duration: ${conditionData.duration.replace(/_/g, ' ')}`, WIDTH, 'left')); } } if (conditionData.saveDC) { const ability = conditionData.saveAbility?.toUpperCase() || 'SAVE'; content.push(''); content.push(padText(`Save: DC ${conditionData.saveDC} ${ability} to end`, WIDTH, 'left')); } return createBox(`CONDITION ${actionTitle.toUpperCase()}`, content, undefined, 'HEAVY'); } function formatExhaustionLevel(targetId: string, level: number, action: string): string { const targetName = getCharacterNameById(targetId); const displayName = targetName || targetId; const content: string[] = []; const WIDTH = 58; const actionTitle = action.charAt(0).toUpperCase() + action.slice(1); content.push(centerText(`TARGET: ${displayName}`, WIDTH)); content.push(centerText(`Exhaustion Level ${level}`, WIDTH)); content.push(''); const box = BOX.LIGHT; content.push(box.H.repeat(WIDTH)); content.push(''); content.push(centerText('EXHAUSTION', WIDTH)); content.push(''); content.push(padText(`Current Effect: ${EXHAUSTION_EFFECTS[level]}`, WIDTH, 'left')); content.push(''); content.push(padText('All Active Effects:', WIDTH, 'left')); for (let i = 1; i <= level; i++) { content.push(padText(` ${i}. ${EXHAUSTION_EFFECTS[i]}`, WIDTH, 'left')); } return createBox(`EXHAUSTION ${actionTitle.toUpperCase()}`, content, undefined, 'HEAVY'); } // ============================================================ // ENCOUNTER MANAGEMENT // ============================================================ // Encounter Participant Schema const ParticipantSchema = z.object({ id: z.string(), name: z.string(), hp: z.number().positive(), maxHp: z.number().positive(), ac: z.number().min(0).default(10), initiativeBonus: z.number().default(0), position: PositionSchema, isEnemy: z.boolean().default(false), size: SizeSchema.default('medium'), speed: z.number().default(30), resistances: z.array(DamageTypeSchema).optional(), immunities: z.array(DamageTypeSchema).optional(), vulnerabilities: z.array(DamageTypeSchema).optional(), conditionImmunities: z.array(ConditionSchema).optional(), }); // Terrain Schema const TerrainSchema = z.object({ width: z.number().min(5).max(100).default(20), height: z.number().min(5).max(100).default(20), obstacles: z.array(z.string()).optional(), difficultTerrain: z.array(z.string()).optional(), water: z.array(z.string()).optional(), hazards: z.array(z.object({ position: z.string(), type: z.string(), damage: z.string().optional(), dc: z.number().optional(), })).optional(), }); // Create Encounter Schema export const createEncounterSchema = z.object({ seed: z.string().optional(), participants: z.array(ParticipantSchema).min(1), terrain: TerrainSchema.optional(), lighting: LightSchema.default('bright'), surprise: z.array(z.string()).optional(), }); export type CreateEncounterInput = z.infer<typeof createEncounterSchema>; export type Participant = z.infer<typeof ParticipantSchema>; // Encounter State interface EncounterState { id: string; round: number; participants: Array<Participant & { initiative: number; surprised?: boolean }>; terrain: z.infer<typeof TerrainSchema>; lighting: string; currentTurnIndex: number; } // In-memory encounter storage const encounterStore = new Map<string, EncounterState>(); // Helper: Roll d20 function rollD20(): number { return Math.floor(Math.random() * 20) + 1; } // Create Encounter Handler export function createEncounter(input: CreateEncounterInput): string { // Validate unique participant IDs const ids = new Set<string>(); for (const p of input.participants) { if (ids.has(p.id)) { throw new Error(`Duplicate participant ID: ${p.id}`); } ids.add(p.id); } // Generate unique encounter ID const encounterId = randomUUID(); // Apply defaults const terrain = input.terrain || { width: 20, height: 20 }; const lighting = input.lighting || 'bright'; const surpriseSet = new Set(input.surprise || []); // Roll initiative for all participants and mark surprised const participantsWithInitiative = input.participants.map(p => { const initiativeRoll = rollD20(); const initiative = initiativeRoll + (p.initiativeBonus || 0); const surprised = surpriseSet.has(p.id); return { ...p, initiative, surprised, }; }); // Sort by initiative (highest first) participantsWithInitiative.sort((a, b) => { if (b.initiative !== a.initiative) { return b.initiative - a.initiative; } // Tie-breaker: higher initiative bonus goes first return (b.initiativeBonus || 0) - (a.initiativeBonus || 0); }); // Store encounter state const encounterState: EncounterState = { id: encounterId, round: 1, participants: participantsWithInitiative, terrain, lighting, currentTurnIndex: 0, }; encounterStore.set(encounterId, encounterState); // Format output return formatEncounterCreated(encounterState); } // Format encounter creation output function formatEncounterCreated(encounter: EncounterState): string { const content: string[] = []; const WIDTH = 72; // Header content.push(centerText(`Encounter ID: ${encounter.id}`, WIDTH)); content.push(centerText(`Round: ${encounter.round} | Lighting: ${encounter.lighting}`, WIDTH)); content.push(centerText(`${encounter.participants.length} Participants`, WIDTH)); content.push(''); content.push('═'.repeat(WIDTH)); content.push(''); // Initiative Order content.push(centerText('INITIATIVE ORDER', WIDTH)); content.push(''); content.push('─'.repeat(WIDTH)); content.push(''); // Table header - widened position column to 14 chars to fit "(100, 100) 50ft" const header = ' # | Name | Init | HP | AC | Position '; content.push(header); content.push('─'.repeat(WIDTH)); // Participants for (let i = 0; i < encounter.participants.length; i++) { const p = encounter.participants[i]; const num = (i + 1).toString().padStart(2); const name = p.name.padEnd(19).substring(0, 19); const init = p.initiative.toString().padStart(4); const hp = `${p.hp}/${p.maxHp}`.padEnd(11).substring(0, 11); const ac = p.ac.toString().padStart(2); // Format position - wider column to fit elevation let posStr = `(${p.position.x}, ${p.position.y})`; if (p.position.z && p.position.z !== 0) { posStr += ` ${p.position.z}ft`; } posStr = posStr.padEnd(14).substring(0, 14); let line = ` ${num} | ${name} | ${init} | ${hp} | ${ac} | ${posStr}`; // Add markers const markers: string[] = []; if (p.isEnemy) markers.push('[ENEMY]'); if (p.surprised) markers.push('[SURPRISED]'); if (p.size && p.size !== 'medium') markers.push(`[${p.size.toUpperCase()}]`); if (markers.length > 0) { line += ' ' + markers.join(' '); } content.push(line); } content.push(''); // Initiative summary for each participant (helps with test regex matching) content.push('─'.repeat(WIDTH)); content.push(''); for (const p of encounter.participants) { const surprised = p.surprised ? ' (SURPRISED)' : ''; const sizeStr = p.size ? ` [${p.size}]` : ''; content.push(padText(`${p.name}: Initiative: ${p.initiative}${sizeStr}${surprised}`, WIDTH, 'left')); } content.push(''); content.push('═'.repeat(WIDTH)); content.push(''); // Terrain info const terrainParts: string[] = []; terrainParts.push(`Terrain: ${encounter.terrain.width}x${encounter.terrain.height}`); if (encounter.terrain.obstacles && encounter.terrain.obstacles.length > 0) { terrainParts.push(`Obstacles: ${encounter.terrain.obstacles.length}`); } if (encounter.terrain.difficultTerrain && encounter.terrain.difficultTerrain.length > 0) { terrainParts.push(`Difficult terrain: ${encounter.terrain.difficultTerrain.length}`); } if (encounter.terrain.water && encounter.terrain.water.length > 0) { terrainParts.push(`Water: ${encounter.terrain.water.length}`); } if (encounter.terrain.hazards && encounter.terrain.hazards.length > 0) { terrainParts.push(`Hazards: ${encounter.terrain.hazards.length}`); } content.push(padText(terrainParts.join(' | '), WIDTH, 'left')); // Hazard details if present if (encounter.terrain.hazards && encounter.terrain.hazards.length > 0) { content.push(''); content.push(padText('Hazards:', WIDTH, 'left')); for (const h of encounter.terrain.hazards) { let hazardStr = ` ${h.position}: ${h.type}`; if (h.dc) hazardStr += ` (DC ${h.dc})`; if (h.damage) hazardStr += ` ${h.damage} damage`; content.push(padText(hazardStr, WIDTH, 'left')); } } return createBox('ENCOUNTER CREATED', content, undefined, 'HEAVY'); } // Helper for testing: get encounter by ID export function getEncounter(id: string): EncounterState | undefined { return encounterStore.get(id); } // Helper for testing: get encounter participant by ID export function getEncounterParticipant(encounterId: string, participantId: string): Participant & { initiative: number; surprised?: boolean } | undefined { const encounter = encounterStore.get(encounterId); if (!encounter) { return undefined; } return encounter.participants.find(p => p.id === participantId); } // Helper for testing: clear all encounters export function clearAllEncounters(): void { encounterStore.clear(); } // ============================================================ // EXECUTE ACTION (ADR-001 Phase 1: Attack + Movement) // ============================================================ import { ActionTypeSchema, ActionCostSchema, WeaponTypeSchema, type ActionType, type ActionCost, type WeaponType, type DamageType, type Position } from '../types.js'; import { parseDice } from './dice.js'; // Execute Action Schema (Phase 1) export const executeActionSchema = z.object({ encounterId: z.string(), // Actor (either/or) actorId: z.string().optional(), actorName: z.string().optional(), // Action selection actionType: ActionTypeSchema, // Action economy actionCost: ActionCostSchema.default('action'), // Target (for attack) targetId: z.string().optional(), targetName: z.string().optional(), // Attack options weaponType: WeaponTypeSchema.optional(), damageExpression: z.string().optional(), damageType: DamageTypeSchema.optional(), // Movement options moveTo: PositionSchema.optional(), // Advantage/disadvantage advantage: z.boolean().optional(), disadvantage: z.boolean().optional(), // Manual rolls (pre-rolled dice) manualAttackRoll: z.number().optional(), manualDamageRoll: z.number().optional(), // Phase 2: Shove direction (away or prone) shoveDirection: z.enum(['away', 'prone']).optional(), }); export type ExecuteActionInput = z.infer<typeof executeActionSchema>; // Per-turn action tracking (in-memory, clears on advance_turn) const turnActionTracker = new Map<string, { actionUsed: boolean; bonusActionUsed: boolean; reactionUsed: boolean; movementUsed: number; hasDashed: boolean; disengagedThisTurn: boolean; // Phase 2 isDodging: boolean; // Phase 2 }>(); // Get or initialize turn tracker for an actor function getTurnTracker(encounterId: string, actorId: string) { const key = `${encounterId}:${actorId}`; if (!turnActionTracker.has(key)) { turnActionTracker.set(key, { actionUsed: false, bonusActionUsed: false, reactionUsed: false, movementUsed: 0, hasDashed: false, disengagedThisTurn: false, isDodging: false, }); } return turnActionTracker.get(key)!; } // Clear turn tracker for an encounter (for testing or advance_turn) export function clearTurnTracker(encounterId?: string): void { if (encounterId) { for (const key of turnActionTracker.keys()) { if (key.startsWith(encounterId)) { turnActionTracker.delete(key); } } } else { turnActionTracker.clear(); } } // Main execute action handler export function executeAction(input: ExecuteActionInput): string { // Get encounter const encounter = encounterStore.get(input.encounterId); if (!encounter) { throw new Error(`Encounter not found: ${input.encounterId}`); } // Find actor let actor = input.actorId ? encounter.participants.find(p => p.id === input.actorId) : encounter.participants.find(p => p.name.toLowerCase() === input.actorName?.toLowerCase()); if (!actor) { throw new Error(`Actor not found: ${input.actorId || input.actorName}`); } // Dispatch by action type switch (input.actionType) { case 'attack': return handleAttackAction(encounter, actor, input); case 'dash': return handleDashAction(encounter, actor, input); case 'disengage': return handleDisengageAction(encounter, actor, input); case 'dodge': return handleDodgeAction(encounter, actor, input); case 'grapple': return handleGrappleAction(encounter, actor, input); case 'shove': return handleShoveAction(encounter, actor, input); default: // Other actions return a placeholder return formatActionNotImplemented(actor.name, input.actionType); } } // ============================================================ // ATTACK ACTION HANDLER // ============================================================ function handleAttackAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { // Find target let target = input.targetId ? encounter.participants.find(p => p.id === input.targetId) : encounter.participants.find(p => p.name.toLowerCase() === input.targetName?.toLowerCase()); if (!target) { throw new Error(`Target not found: ${input.targetId || input.targetName || 'No target specified'}`); } // Check if target is dodging - applies disadvantage const targetTracker = getTurnTracker(input.encounterId, target.id); const targetIsDodging = targetTracker.isDodging; const effectiveDisadvantage = input.disadvantage || targetIsDodging; // Roll attack (d20) let attackRoll: number; let isNat20 = false; let isNat1 = false; let rollDescription: string; if (input.manualAttackRoll !== undefined) { attackRoll = input.manualAttackRoll; isNat20 = attackRoll === 20; isNat1 = attackRoll === 1; rollDescription = `${attackRoll} (manual)`; } else if (input.advantage || effectiveDisadvantage) { // Roll 2d20 const roll1 = Math.floor(Math.random() * 20) + 1; const roll2 = Math.floor(Math.random() * 20) + 1; if (input.advantage && !effectiveDisadvantage) { attackRoll = Math.max(roll1, roll2); rollDescription = `${attackRoll} (${roll1}, ${roll2} - advantage)`; } else if (effectiveDisadvantage && !input.advantage) { attackRoll = Math.min(roll1, roll2); const reason = targetIsDodging ? 'target dodging' : 'disadvantage'; rollDescription = `${attackRoll} (${roll1}, ${roll2} - ${reason})`; } else { // Both advantage and disadvantage = straight roll attackRoll = Math.floor(Math.random() * 20) + 1; rollDescription = `${attackRoll} (adv/disadv cancel)`; } isNat20 = attackRoll === 20; isNat1 = attackRoll === 1; } else { attackRoll = Math.floor(Math.random() * 20) + 1; isNat20 = attackRoll === 20; isNat1 = attackRoll === 1; rollDescription = `${attackRoll}`; } // Calculate hit (natural 20 always hits, natural 1 always misses) const isHit = isNat20 || (!isNat1 && attackRoll >= target.ac); const isCritical = isNat20; // Calculate damage if hit let damage = 0; let damageRolls: number[] = []; let damageDescription = ''; if (isHit && input.damageExpression) { if (input.manualDamageRoll !== undefined) { damage = input.manualDamageRoll; damageDescription = `${damage} (manual)`; } else { // Parse and roll damage const diceResult = parseDice(input.damageExpression); damage = diceResult.total; damageRolls = diceResult.rolls; // Double dice on critical hit (roll again and add) if (isCritical) { const critBonus = parseDice(input.damageExpression.replace(/[+-]\d+$/, '')); // Remove modifier for crit damage += critBonus.total; damageRolls = [...damageRolls, ...critBonus.rolls]; damageDescription = `${damage} (${damageRolls.join(', ')} - CRITICAL!)`; } else { damageDescription = `${damage} (${damageRolls.join(', ')})`; } } } // Apply damage to target let oldHp = target.hp; if (isHit && damage > 0) { target.hp = Math.max(0, target.hp - damage); } // Handle movement if specified let movementInfo = ''; if (input.moveTo) { const moveResult = handleMoveInternal(encounter, actor, input.moveTo); movementInfo = moveResult; } // Format output return formatAttackResult( actor.name, target.name, attackRoll, rollDescription, target.ac, isHit, isCritical, isNat1, damage, damageDescription, input.damageType || 'slashing', oldHp, target.hp, target.maxHp, input.actionCost || 'action', input.advantage, input.disadvantage, movementInfo ); } // ============================================================ // DASH ACTION HANDLER // ============================================================ function handleDashAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { const tracker = getTurnTracker(input.encounterId, actor.id); tracker.hasDashed = true; const content: string[] = []; content.push(centerText(`${actor.name.toUpperCase()} DASHES!`, 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(centerText(`Movement: ${actor.speed}ft → ${actor.speed * 2}ft`, 50)); content.push(centerText('(Doubled for this turn)', 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(padText(`Action Cost: ${input.actionCost || 'action'}`, 50, 'left')); // Handle movement if specified if (input.moveTo) { content.push(''); content.push('═'.repeat(50)); content.push(''); // Check for opportunity attacks const oaResults = checkOpportunityAttacks(encounter, actor, input.moveTo, tracker.disengagedThisTurn); for (const oa of oaResults) { content.push(centerText(`⚔ OPPORTUNITY ATTACK ⚔`, 50)); content.push(centerText(`${oa.attackerName} attacks!`, 50)); content.push(centerText(oa.result, 50)); content.push(''); } // Then process movement const moveResult = handleMoveInternal(encounter, actor, input.moveTo); content.push(padText(`Movement: ${moveResult}`, 50, 'left')); } return createBox('DASH ACTION', content, undefined, 'HEAVY'); } // ============================================================ // OPPORTUNITY ATTACK CHECKER // ============================================================ interface OpportunityAttackResult { attackerName: string; attackerId: string; result: string; damage: number; hit: boolean; } function checkOpportunityAttacks( encounter: EncounterState, mover: Participant & { initiative: number; surprised?: boolean }, destination: Position, disengaged: boolean ): OpportunityAttackResult[] { // If disengaged, no opportunity attacks if (disengaged) { return []; } const results: OpportunityAttackResult[] = []; const MELEE_REACH = 1; // 1 square = 5ft for (const participant of encounter.participants) { // Skip self if (participant.id === mover.id) continue; // Skip allies (simple check: same isEnemy status) if (participant.isEnemy === mover.isEnemy) continue; // Get tracker for this potential attacker const attackerTracker = getTurnTracker(encounter.id, participant.id); // Check if attacker has reaction available if (attackerTracker.reactionUsed) continue; // Check if mover was in attacker's reach const distBefore = Math.max( Math.abs(mover.position.x - participant.position.x), Math.abs(mover.position.y - participant.position.y) ); // Check if mover is leaving attacker's reach const distAfter = Math.max( Math.abs(destination.x - participant.position.x), Math.abs(destination.y - participant.position.y) ); // OA triggers when: was in reach, and leaving reach if (distBefore <= MELEE_REACH && distAfter > MELEE_REACH) { // Use attacker's reaction attackerTracker.reactionUsed = true; // Roll attack const attackRoll = Math.floor(Math.random() * 20) + 1; const hit = attackRoll >= mover.ac; let result: string; let damage = 0; if (hit) { damage = Math.floor(Math.random() * 6) + 1 + 2; // 1d6+2 default mover.hp = Math.max(0, mover.hp - damage); result = `Roll: ${attackRoll} vs AC ${mover.ac} - HIT! ${damage} damage`; } else { result = `Roll: ${attackRoll} vs AC ${mover.ac} - MISS`; } results.push({ attackerName: participant.name, attackerId: participant.id, result, damage, hit, }); } } return results; } // ============================================================ // INTERNAL MOVEMENT HANDLER // ============================================================ function handleMoveInternal( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, destination: Position ): string { const tracker = getTurnTracker(encounter.id, actor.id); const maxMovement = tracker.hasDashed ? actor.speed * 2 : actor.speed; const availableMovement = maxMovement - tracker.movementUsed; // Calculate distance (simple grid distance, 5ft per square) const dx = Math.abs(destination.x - actor.position.x); const dy = Math.abs(destination.y - actor.position.y); const distanceSquares = Math.max(dx, dy); // D&D 5e diagonal = 5ft const distanceFeet = distanceSquares * 5; if (distanceFeet > availableMovement) { throw new Error(`Insufficient movement: need ${distanceFeet}ft, have ${availableMovement}ft remaining`); } // Update position const oldPosition = { ...actor.position }; actor.position = destination; tracker.movementUsed += distanceFeet; return `Moved from (${oldPosition.x}, ${oldPosition.y}) to (${destination.x}, ${destination.y}) - ${distanceFeet}ft`; } // ============================================================ // FORMATTING HELPERS // ============================================================ function formatAttackResult( attackerName: string, targetName: string, attackRoll: number, rollDescription: string, targetAC: number, isHit: boolean, isCritical: boolean, isNat1: boolean, damage: number, damageDescription: string, damageType: DamageType | string, oldHp: number, newHp: number, maxHp: number, actionCost: ActionCost | string, advantage?: boolean, disadvantage?: boolean, movementInfo?: string ): string { const content: string[] = []; const WIDTH = 60; // Header content.push(centerText(`${attackerName} ATTACKS ${targetName}`, WIDTH)); content.push(''); content.push('═'.repeat(WIDTH)); content.push(''); // Roll info if (advantage) { content.push(centerText('⚔ ATTACK ROLL (ADVANTAGE) ⚔', WIDTH)); } else if (disadvantage) { content.push(centerText('⚔ ATTACK ROLL (DISADVANTAGE) ⚔', WIDTH)); } else { content.push(centerText('⚔ ATTACK ROLL ⚔', WIDTH)); } content.push(''); content.push(centerText(`Roll: ${rollDescription}`, WIDTH)); content.push(centerText(`vs AC ${targetAC}`, WIDTH)); content.push(''); // Result if (isCritical) { content.push(centerText('★★★ CRITICAL HIT! ★★★', WIDTH)); } else if (isNat1) { content.push(centerText('✗ CRITICAL MISS ✗', WIDTH)); } else if (isHit) { content.push(centerText('✓ HIT ✓', WIDTH)); } else { content.push(centerText('✗ MISS ✗', WIDTH)); } content.push(''); content.push('─'.repeat(WIDTH)); content.push(''); // Damage (if hit) if (isHit && damage > 0) { content.push(centerText('DAMAGE', WIDTH)); content.push(''); content.push(centerText(`${damageDescription} ${damageType}`, WIDTH)); content.push(''); // HP bar const hpPercent = Math.floor((newHp / maxHp) * 20); const hpBar = '█'.repeat(hpPercent) + '░'.repeat(20 - hpPercent); content.push(centerText(`${targetName}: ${oldHp} → ${newHp}/${maxHp} HP`, WIDTH)); content.push(centerText(`[${hpBar}]`, WIDTH)); if (newHp === 0) { content.push(''); content.push(centerText('☠ TARGET DOWN ☠', WIDTH)); } } // Movement (if any) if (movementInfo) { content.push(''); content.push('─'.repeat(WIDTH)); content.push(''); content.push(padText(`Movement: ${movementInfo}`, WIDTH, 'left')); } // Footer content.push(''); content.push('═'.repeat(WIDTH)); content.push(''); content.push(padText(`Action Cost: ${actionCost}`, WIDTH, 'left')); return createBox('ATTACK RESULT', content, undefined, 'HEAVY'); } // ============================================================ // PHASE 2: TACTICAL ACTION HANDLERS // ============================================================ function handleDisengageAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { const tracker = getTurnTracker(input.encounterId, actor.id); tracker.disengagedThisTurn = true; const content: string[] = []; content.push(centerText(`${actor.name.toUpperCase()} DISENGAGES!`, 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(centerText('Movement will not provoke', 50)); content.push(centerText('opportunity attacks this turn', 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(padText(`Action Cost: ${input.actionCost || 'action'}`, 50, 'left')); return createBox('DISENGAGE ACTION', content, undefined, 'HEAVY'); } function handleDodgeAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { const tracker = getTurnTracker(input.encounterId, actor.id); tracker.isDodging = true; const content: string[] = []; content.push(centerText(`${actor.name.toUpperCase()} DODGES!`, 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(centerText('Until next turn:', 50)); content.push(centerText('• Attacks against you have DISADVANTAGE', 50)); content.push(centerText('• DEX saves have ADVANTAGE', 50)); content.push(''); content.push('─'.repeat(50)); content.push(''); content.push(padText(`Action Cost: ${input.actionCost || 'action'}`, 50, 'left')); return createBox('DODGE ACTION', content, undefined, 'HEAVY'); } function handleGrappleAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { // Require target let target = input.targetId ? encounter.participants.find(p => p.id === input.targetId) : encounter.participants.find(p => p.name.toLowerCase() === input.targetName?.toLowerCase()); if (!target) { throw new Error(`Target not found: ${input.targetId || input.targetName || 'No target specified'}`); } // Roll contested Athletics check const attackerRoll = input.manualAttackRoll ?? (Math.floor(Math.random() * 20) + 1); const defenderRoll = Math.floor(Math.random() * 20) + 1; const success = attackerRoll > defenderRoll; const content: string[] = []; content.push(centerText(`${actor.name.toUpperCase()} GRAPPLES ${target.name.toUpperCase()}`, 50)); content.push(''); content.push('═'.repeat(50)); content.push(''); content.push(centerText('CONTESTED ATHLETICS CHECK', 50)); content.push(''); content.push(centerText(`${actor.name}: ${attackerRoll}`, 50)); content.push(centerText(`${target.name}: ${defenderRoll}`, 50)); content.push(''); if (success) { content.push(centerText('✓ GRAPPLE SUCCESS! ✓', 50)); content.push(''); content.push(centerText(`${target.name} is now GRAPPLED`, 50)); content.push(centerText('(Speed 0, can attempt escape)', 50)); // Apply grappled condition would go here via manage_condition } else { content.push(centerText('✗ GRAPPLE FAILED ✗', 50)); content.push(''); content.push(centerText(`${target.name} breaks free!`, 50)); } content.push(''); content.push('═'.repeat(50)); content.push(''); content.push(padText(`Action Cost: ${input.actionCost || 'action'}`, 50, 'left')); return createBox('GRAPPLE ACTION', content, undefined, 'HEAVY'); } function handleShoveAction( encounter: EncounterState, actor: Participant & { initiative: number; surprised?: boolean }, input: ExecuteActionInput ): string { // Require target let target = input.targetId ? encounter.participants.find(p => p.id === input.targetId) : encounter.participants.find(p => p.name.toLowerCase() === input.targetName?.toLowerCase()); if (!target) { throw new Error(`Target not found: ${input.targetId || input.targetName || 'No target specified'}`); } const shoveDirection = input.shoveDirection || 'away'; // Roll contested Athletics check const attackerRoll = input.manualAttackRoll ?? (Math.floor(Math.random() * 20) + 1); const defenderRoll = Math.floor(Math.random() * 20) + 1; const success = attackerRoll > defenderRoll; const content: string[] = []; content.push(centerText(`${actor.name.toUpperCase()} SHOVES ${target.name.toUpperCase()}`, 50)); content.push(''); content.push('═'.repeat(50)); content.push(''); content.push(centerText('CONTESTED ATHLETICS CHECK', 50)); content.push(''); content.push(centerText(`${actor.name}: ${attackerRoll}`, 50)); content.push(centerText(`${target.name}: ${defenderRoll}`, 50)); content.push(''); if (success) { if (shoveDirection === 'prone') { content.push(centerText('✓ SHOVE SUCCESS! ✓', 50)); content.push(''); content.push(centerText(`${target.name} is knocked PRONE`, 50)); // Apply prone condition would go here } else { // Push 5ft away const dx = target.position.x - actor.position.x; const dy = target.position.y - actor.position.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > 0) { // Normalize and push 1 square (5ft) target.position.x += Math.sign(dx); target.position.y += Math.sign(dy); } content.push(centerText('✓ SHOVE SUCCESS! ✓', 50)); content.push(''); content.push(centerText(`${target.name} pushed 5ft away!`, 50)); content.push(centerText(`New position: (${target.position.x}, ${target.position.y})`, 50)); } } else { content.push(centerText('✗ SHOVE FAILED ✗', 50)); content.push(''); content.push(centerText(`${target.name} holds their ground!`, 50)); } content.push(''); content.push('═'.repeat(50)); content.push(''); content.push(padText(`Action Cost: ${input.actionCost || 'action'}`, 50, 'left')); return createBox('SHOVE ACTION', content, undefined, 'HEAVY'); } function formatActionNotImplemented(actorName: string, actionType: ActionType): string { const content: string[] = []; content.push(centerText(`${actorName} uses ${actionType.toUpperCase()}`, 50)); content.push(''); content.push(centerText('(Action type not yet implemented)', 50)); content.push(centerText('Supports: attack, dash, disengage, dodge, grapple, shove', 50)); return createBox('ACTION', content, undefined, 'HEAVY'); }

Implementation Reference

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/ChatRPG'

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