Skip to main content
Glama
EDGE_CASE_IMPLEMENTATION_PROMPT.md43.9 kB
# RPG-MCP Edge Case Implementation Prompt ## For: Coding Team / Claude Code Agent ## Date: December 5, 2025 ## Priority: P0-P2 Implementation Queue --- ## CRITICAL CONTEXT: READ FIRST Before implementing ANY features in this document: 1. **Review the Agents/ folder** for existing patterns and resolved issues 2. **Check EMERGENT_DISCOVERY_LOG.md** for related bugs/fixes 3. **Follow TDD workflow** from `/mnt/project/_QUEST_KEEPER_AI_-_COMPREHENSIVE_AUDIT___TDD_FRAMEWORK` 4. **Use the Git Pulse pattern**: Commit after each working test (RED → GREEN → COMMIT) ### Architecture Principle: "LLM Describes, Engine Validates" The LLM acts as a **Logic Adapter** translating creative player intent into hard mathematical inputs. The engine NEVER trusts raw LLM output—all game state changes flow through validated MCP tool calls. --- ## PART 1: THE "RULE OF COOL" SYSTEM (P0 - CRITICAL) ### 1.1 Improvised Stunt Resolution Tool **Problem**: Players do creative things not covered by standard rules (kick mine cart, swing chandelier, collapse ceiling). Without this tool, the AI either: - Refuses creative actions (bad UX) - Hallucinates damage/effects (breaks trust) **Solution**: A single flexible tool that lets the AI construct mini-mechanics JIT. ```typescript // File: src/server/stunt-tools.ts import { z } from 'zod'; export const StuntTools = { RESOLVE_IMPROVISED_STUNT: { name: 'resolve_improvised_stunt', description: `Resolves creative player actions not covered by standard combat rules. The AI DM uses this to translate narrative creativity into mechanical outcomes. Examples: - Kicking a mine cart into enemies - Swinging from a chandelier - Collapsing a bookshelf onto foes - Using a chair as an improvised weapon - Tripping an enemy with a rope The AI MUST set appropriate DC and damage based on: - Object mass/danger level - Environmental plausibility - Dramatic appropriateness Guidelines: - Tiny objects (bottle, rock): 1d4 damage, DC 10 - Small objects (chair, torch): 1d6 damage, DC 12 - Medium objects (table, barrel): 2d6 damage, DC 14 - Large objects (cart, statue): 3d6-4d6 damage, DC 15-16 - Massive objects (boulder, collapse): 6d6-8d6 damage, DC 17-20 - Explosive/magical amplification: +2d6 to +4d6 CRITICAL: This tool exists to VALIDATE creative play, not to enable cheating. The AI should set DCs that are challenging but fair.`, inputSchema: z.object({ encounterId: z.string().describe('The encounter this stunt occurs in'), actorId: z.string().describe('The character attempting the stunt'), targetIds: z.array(z.string()).optional() .describe('IDs of creatures affected by the stunt'), narrativeIntent: z.string() .describe('What is the player trying to do? Be specific.'), skillCheck: z.object({ skill: z.enum([ 'athletics', 'acrobatics', 'sleight_of_hand', 'stealth', 'arcana', 'history', 'investigation', 'nature', 'religion', 'animal_handling', 'insight', 'medicine', 'perception', 'survival', 'deception', 'intimidation', 'performance', 'persuasion', 'strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma' ]).describe('The skill or ability check required'), dc: z.number().int().min(5).max(30) .describe('Difficulty Class (5=trivial, 15=medium, 20=hard, 25=heroic, 30=legendary)'), advantage: z.boolean().optional() .describe('Does the actor have advantage on this check?'), disadvantage: z.boolean().optional() .describe('Does the actor have disadvantage on this check?') }), consequences: z.object({ successDamage: z.string().optional() .describe('Dice notation for damage on success (e.g., "3d6")'), failureDamage: z.string().optional() .describe('Dice notation for self-damage on critical failure'), damageType: z.enum([ 'bludgeoning', 'piercing', 'slashing', 'fire', 'cold', 'lightning', 'thunder', 'poison', 'acid', 'necrotic', 'radiant', 'force', 'psychic' ]).default('bludgeoning'), applyCondition: z.enum([ 'prone', 'restrained', 'stunned', 'blinded', 'deafened', 'frightened', 'grappled', 'none' ]).optional().default('none'), conditionDuration: z.number().int().min(1).max(10).optional() .describe('Rounds the condition lasts'), conditionSaveDC: z.number().int().optional() .describe('DC to end the condition early (save at end of turn)'), moveTarget: z.boolean().optional() .describe('Does this physically displace targets?'), moveDistance: z.number().int().optional() .describe('Tiles to move targets (if moveTarget is true)'), areaOfEffect: z.object({ shape: z.enum(['line', 'cone', 'sphere', 'cube']), size: z.number().int().min(1).max(60) .describe('Size in feet') }).optional().describe('For stunts affecting an area'), savingThrow: z.object({ ability: z.enum(['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']), dc: z.number().int().min(1).max(30), halfDamageOnSave: z.boolean().default(true) }).optional().describe('If targets get a save to reduce effect') }), environmentalDestruction: z.boolean().optional() .describe('Does this destroy/modify the environment permanently?'), narrativeOnSuccess: z.string().optional() .describe('Flavor text for successful stunt'), narrativeOnFailure: z.string().optional() .describe('Flavor text for failed stunt') }) } } as const; ``` ### 1.2 Implementation Handler ```typescript // File: src/server/stunt-tools.ts (continued) export async function handleResolveImprovisedStunt(args: unknown, ctx: SessionContext) { const parsed = StuntTools.RESOLVE_IMPROVISED_STUNT.inputSchema.parse(args); const engine = getCombatManager().get(`${ctx.sessionId}:${parsed.encounterId}`); if (!engine) { throw new Error(`No active encounter with ID ${parsed.encounterId}`); } const state = engine.getState(); if (!state) { throw new Error('Encounter has no active state'); } const actor = state.participants.find(p => p.id === parsed.actorId); if (!actor) { throw new Error(`Actor ${parsed.actorId} not found`); } // Get actor's skill/ability modifier const modifier = getSkillModifier(actor, parsed.skillCheck.skill); // Roll the skill check let roll1 = Math.floor(Math.random() * 20) + 1; let roll2 = Math.floor(Math.random() * 20) + 1; let finalRoll = roll1; if (parsed.skillCheck.advantage && !parsed.skillCheck.disadvantage) { finalRoll = Math.max(roll1, roll2); } else if (parsed.skillCheck.disadvantage && !parsed.skillCheck.advantage) { finalRoll = Math.min(roll1, roll2); } const total = finalRoll + modifier; const isNat20 = finalRoll === 20; const isNat1 = finalRoll === 1; const success = isNat20 || (total >= parsed.skillCheck.dc && !isNat1); const critSuccess = isNat20 || total >= parsed.skillCheck.dc + 10; const critFailure = isNat1 || total <= parsed.skillCheck.dc - 10; // Build output let output = `\n┌─────────────────────────────────────────┐\n`; output += `│ 🎭 IMPROVISED STUNT\n`; output += `└─────────────────────────────────────────┘\n\n`; output += `${actor.name} attempts: "${parsed.narrativeIntent}"\n\n`; // Show roll output += `🎲 ${parsed.skillCheck.skill.toUpperCase()} Check: `; if (parsed.skillCheck.advantage) output += `(Advantage) `; if (parsed.skillCheck.disadvantage) output += `(Disadvantage) `; output += `d20(${finalRoll}) + ${modifier} = ${total} vs DC ${parsed.skillCheck.dc}\n`; if (isNat20) output += ` ⭐ NATURAL 20!\n`; if (isNat1) output += ` 💀 NATURAL 1!\n`; output += ` ${success ? '✅ SUCCESS' : '❌ FAILURE'}`; if (critSuccess) output += ` (CRITICAL!)`; if (critFailure) output += ` (CRITICAL FAILURE!)`; output += `\n\n`; const results: StuntResult[] = []; if (success) { // Apply success effects if (parsed.narrativeOnSuccess) { output += `📖 ${parsed.narrativeOnSuccess}\n\n`; } // Damage targets if (parsed.consequences.successDamage && parsed.targetIds?.length) { const baseDamage = rollDice(parsed.consequences.successDamage); const actualDamage = critSuccess ? baseDamage * 2 : baseDamage; output += `💥 Damage: ${parsed.consequences.successDamage} = ${actualDamage}`; if (critSuccess) output += ` (DOUBLED!)`; output += ` ${parsed.consequences.damageType}\n\n`; for (const targetId of parsed.targetIds) { const target = state.participants.find(p => p.id === targetId); if (!target) continue; let finalDamage = actualDamage; // Allow saving throw if (parsed.consequences.savingThrow) { const saveMod = getSaveModifier(target, parsed.consequences.savingThrow.ability); const saveRoll = Math.floor(Math.random() * 20) + 1; const saveTotal = saveRoll + saveMod; const saved = saveTotal >= parsed.consequences.savingThrow.dc; output += ` ${target.name} ${parsed.consequences.savingThrow.ability.toUpperCase()} Save: `; output += `d20(${saveRoll}) + ${saveMod} = ${saveTotal} vs DC ${parsed.consequences.savingThrow.dc} `; output += saved ? '✓ SAVED' : '✗ FAILED'; output += `\n`; if (saved && parsed.consequences.savingThrow.halfDamageOnSave) { finalDamage = Math.floor(actualDamage / 2); } else if (saved) { finalDamage = 0; } } // Apply damage const hpBefore = target.hp; engine.applyDamage(targetId, finalDamage); const targetAfter = state.participants.find(p => p.id === targetId)!; output += ` ${target.name}: ${hpBefore} → ${targetAfter.hp}/${target.maxHp} HP`; if (targetAfter.hp <= 0) output += ' 💀 DEFEATED'; output += `\n`; // Apply condition if (parsed.consequences.applyCondition && parsed.consequences.applyCondition !== 'none') { engine.applyCondition(targetId, { type: parsed.consequences.applyCondition as any, durationType: 'rounds' as any, duration: parsed.consequences.conditionDuration || 1, saveDC: parsed.consequences.conditionSaveDC, saveAbility: 'constitution' }); output += ` ⚡ ${target.name} is ${parsed.consequences.applyCondition.toUpperCase()}!\n`; } results.push({ targetId, targetName: target.name, damage: finalDamage, condition: parsed.consequences.applyCondition, defeated: targetAfter.hp <= 0 }); } } } else { // Apply failure effects if (parsed.narrativeOnFailure) { output += `📖 ${parsed.narrativeOnFailure}\n\n`; } // Critical failure self-damage if (critFailure && parsed.consequences.failureDamage) { const selfDamage = rollDice(parsed.consequences.failureDamage); const hpBefore = actor.hp; engine.applyDamage(parsed.actorId, selfDamage); const actorAfter = state.participants.find(p => p.id === parsed.actorId)!; output += `💥 ${actor.name} takes ${selfDamage} damage from the failed stunt!\n`; output += ` ${actor.name}: ${hpBefore} → ${actorAfter.hp}/${actor.maxHp} HP\n`; } } // Create audit log entry const auditEntry = { type: 'improvised_stunt', actorId: parsed.actorId, actorName: actor.name, intent: parsed.narrativeIntent, skill: parsed.skillCheck.skill, dc: parsed.skillCheck.dc, roll: finalRoll, total, success, critSuccess, critFailure, results, timestamp: new Date().toISOString() }; output += `\n[STUNT_LOG: ${JSON.stringify(auditEntry)}]\n`; output += `\n→ Call advance_turn to proceed`; // Save state const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); const repo = new EncounterRepository(db); repo.saveState(parsed.encounterId, engine.getState()!); return { content: [{ type: 'text' as const, text: output }] }; } interface StuntResult { targetId: string; targetName: string; damage?: number; condition?: string; defeated: boolean; } ``` --- ## PART 2: ACTION ECONOMY ENFORCEMENT (P0 - CRITICAL) ### 2.1 The Problem From playtest (T2.1): Players can use multiple bonus actions per turn. The system doesn't track action economy. ### 2.2 Schema Addition ```typescript // Add to CombatParticipant interface in src/engine/combat/engine.ts export interface CombatParticipant { // ... existing fields ... // ACTION ECONOMY (per turn) actionUsed: boolean; bonusActionUsed: boolean; reactionUsed: boolean; // Already exists for opportunity attacks movementUsed: number; // Feet of movement used this turn maxMovement: number; // Base movement speed (default 30) // Free object interaction freeInteractionUsed: boolean; } ``` ### 2.3 Action Validator Tool ```typescript // File: src/server/action-economy-tools.ts export const ActionEconomyTools = { VALIDATE_ACTION: { name: 'validate_action', description: `Check if a character can take a specific action type this turn. Returns whether the action is valid and why/why not. Use BEFORE executing actions to prevent invalid states.`, inputSchema: z.object({ encounterId: z.string(), actorId: z.string(), actionType: z.enum(['action', 'bonus_action', 'reaction', 'movement', 'free_interaction']) }) }, USE_ACTION: { name: 'use_action', description: `Mark an action type as used for this turn. Call this AFTER executing any action that consumes action economy. The combat system will track and reset these at turn boundaries.`, inputSchema: z.object({ encounterId: z.string(), actorId: z.string(), actionType: z.enum(['action', 'bonus_action', 'reaction', 'free_interaction']), movementUsed: z.number().optional().describe('Feet of movement consumed') }) }, GET_ACTION_ECONOMY: { name: 'get_action_economy', description: 'Get remaining actions for a character this turn.', inputSchema: z.object({ encounterId: z.string(), actorId: z.string() }) } } as const; ``` ### 2.4 Integration with Combat Engine ```typescript // Add to CombatEngine class /** * Reset action economy at start of turn */ private resetActionEconomy(participant: CombatParticipant): void { participant.actionUsed = false; participant.bonusActionUsed = false; // Reaction resets at START of YOUR turn, not end participant.reactionUsed = false; participant.movementUsed = 0; participant.freeInteractionUsed = false; participant.hasDisengaged = false; } /** * Validate if an action can be taken */ canTakeActionType(participantId: string, actionType: ActionType): { valid: boolean; reason?: string } { if (!this.state) return { valid: false, reason: 'No active combat' }; const participant = this.state.participants.find(p => p.id === participantId); if (!participant) return { valid: false, reason: 'Participant not found' }; // Check if it's their turn (except for reactions) const currentId = this.state.turnOrder[this.state.currentTurnIndex]; if (actionType !== 'reaction' && currentId !== participantId) { return { valid: false, reason: 'Not your turn' }; } switch (actionType) { case 'action': if (participant.actionUsed) { return { valid: false, reason: 'Action already used this turn' }; } break; case 'bonus_action': if (participant.bonusActionUsed) { return { valid: false, reason: 'Bonus action already used this turn' }; } break; case 'reaction': if (participant.reactionUsed) { return { valid: false, reason: 'Reaction already used this round' }; } break; case 'movement': const remaining = (participant.maxMovement || 30) - (participant.movementUsed || 0); if (remaining <= 0) { return { valid: false, reason: 'No movement remaining' }; } break; case 'free_interaction': if (participant.freeInteractionUsed) { return { valid: false, reason: 'Free object interaction already used' }; } break; } // Check incapacitating conditions if (!this.canTakeActions(participantId) && actionType !== 'reaction') { return { valid: false, reason: 'Incapacitated - cannot take actions' }; } return { valid: true }; } ``` --- ## PART 3: ENVIRONMENTAL DAMAGE SYSTEM (P1 - HIGH) ### 3.1 The Problem From playtest: Environmental effects (falling beams, traps, explosions) bypass the combat engine. HP changes happen in narrative only. ### 3.2 Tool Definition ```typescript // File: src/server/environment-tools.ts export const EnvironmentTools = { APPLY_ENVIRONMENTAL_DAMAGE: { name: 'apply_environmental_damage', description: `Apply damage from environmental sources to one or more targets. Use for: falling objects, traps, hazardous terrain, explosions, cave-ins, lava, etc. The engine validates all damage and updates HP correctly. Targets can make saving throws to reduce/avoid damage.`, inputSchema: z.object({ encounterId: z.string(), source: z.string().describe('What caused the damage (e.g., "collapsing ceiling", "pit trap")'), targetIds: z.array(z.string()), damage: z.string().describe('Dice notation (e.g., "3d6", "2d10+5")'), damageType: z.enum([ 'bludgeoning', 'piercing', 'slashing', 'fire', 'cold', 'lightning', 'thunder', 'poison', 'acid', 'necrotic', 'radiant', 'force', 'psychic' ]), savingThrow: z.object({ ability: z.enum(['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']), dc: z.number().int().min(1).max(30), effectOnSave: z.enum(['none', 'half', 'quarter']).default('half') }).optional(), applyCondition: z.object({ type: z.enum(['prone', 'restrained', 'stunned', 'blinded', 'deafened', 'frightened', 'grappled']), duration: z.number().int().min(1).max(10), saveEnds: z.boolean().default(true) }).optional(), destroyTerrain: z.boolean().optional() .describe('Does this modify the battlefield terrain?'), newObstacles: z.array(z.string()).optional() .describe('New blocked tiles created (e.g., ["5,5", "5,6", "6,5"])') }) }, TRIGGER_TRAP: { name: 'trigger_trap', description: `Trigger a trap that affects creatures in an area. Traps are environmental hazards that activate on certain conditions. This tool handles the mechanical resolution.`, inputSchema: z.object({ encounterId: z.string(), trapName: z.string(), trapType: z.enum(['pit', 'dart', 'blade', 'poison_gas', 'alarm', 'magical', 'custom']), triggerLocation: z.object({ x: z.number(), y: z.number() }), areaOfEffect: z.object({ shape: z.enum(['single', 'line', 'cone', 'sphere', 'cube']), size: z.number().int().min(5).max(60) }).optional(), damage: z.string().optional(), damageType: z.string().optional(), savingThrow: z.object({ ability: z.enum(['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']), dc: z.number().int() }).optional(), specialEffect: z.string().optional() .describe('Non-damage effect (e.g., "alerts guards", "teleports to cell")') }) } } as const; ``` --- ## PART 4: AREA OF EFFECT SYSTEM (P1 - HIGH) ### 4.1 The Problem Spells like Fireball, Breath Weapons, and lair actions affect multiple targets in an area. Currently no spatial AoE calculation exists. ### 4.2 Tool Definition ```typescript // File: src/server/aoe-tools.ts export const AoETools = { RESOLVE_AREA_EFFECT: { name: 'resolve_area_effect', description: `Apply an effect to all creatures in an area. Calculates which creatures are affected based on position and shape. Handles saving throws, damage, and conditions for each target. Shapes: - sphere: All creatures within radius of center point - cube: All creatures within a cube from origin point - cone: All creatures in a cone from origin in a direction - line: All creatures along a line from origin - cylinder: Like sphere but only horizontal (for ground effects)`, inputSchema: z.object({ encounterId: z.string(), source: z.object({ type: z.enum(['spell', 'breath_weapon', 'lair_action', 'item', 'environmental']), name: z.string(), casterId: z.string().optional() }), origin: z.object({ x: z.number(), y: z.number() }), shape: z.enum(['sphere', 'cube', 'cone', 'line', 'cylinder']), size: z.number().int().min(5).max(120) .describe('Radius for sphere/cylinder, side length for cube, length for line/cone'), direction: z.object({ x: z.number(), y: z.number() }).optional().describe('Required for cone and line shapes'), damage: z.string().optional().describe('Dice notation'), damageType: z.string().optional(), savingThrow: z.object({ ability: z.enum(['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']), dc: z.number().int(), effectOnSave: z.enum(['none', 'half']).default('half') }).optional(), applyCondition: z.object({ type: z.string(), duration: z.number().int(), saveDC: z.number().int().optional() }).optional(), excludeAllies: z.boolean().optional() .describe('Exclude caster\'s allies from effect'), excludeSelf: z.boolean().optional().default(true) }) } } as const; // Spatial calculation helpers export function getCreaturesInSphere( center: { x: number; y: number }, radiusFeet: number, participants: Array<{ id: string; position?: { x: number; y: number } }> ): string[] { const radiusTiles = radiusFeet / 5; // 5 feet per tile return participants .filter(p => { if (!p.position) return false; const dx = p.position.x - center.x; const dy = p.position.y - center.y; const distance = Math.sqrt(dx * dx + dy * dy); return distance <= radiusTiles; }) .map(p => p.id); } export function getCreaturesInCone( origin: { x: number; y: number }, direction: { x: number; y: number }, lengthFeet: number, participants: Array<{ id: string; position?: { x: number; y: number } }> ): string[] { const lengthTiles = lengthFeet / 5; const halfAngle = Math.PI / 6; // 60 degree cone = 30 degrees each side // Normalize direction const dirLen = Math.sqrt(direction.x * direction.x + direction.y * direction.y); const normDir = { x: direction.x / dirLen, y: direction.y / dirLen }; return participants .filter(p => { if (!p.position) return false; const dx = p.position.x - origin.x; const dy = p.position.y - origin.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > lengthTiles || distance === 0) return false; // Check angle const dot = (dx * normDir.x + dy * normDir.y) / distance; const angle = Math.acos(Math.max(-1, Math.min(1, dot))); return angle <= halfAngle; }) .map(p => p.id); } export function getCreaturesInLine( origin: { x: number; y: number }, direction: { x: number; y: number }, lengthFeet: number, widthFeet: number = 5, participants: Array<{ id: string; position?: { x: number; y: number } }> ): string[] { const lengthTiles = lengthFeet / 5; const widthTiles = widthFeet / 5; // Normalize direction const dirLen = Math.sqrt(direction.x * direction.x + direction.y * direction.y); const normDir = { x: direction.x / dirLen, y: direction.y / dirLen }; const perpDir = { x: -normDir.y, y: normDir.x }; return participants .filter(p => { if (!p.position) return false; const dx = p.position.x - origin.x; const dy = p.position.y - origin.y; // Project onto line direction const alongLine = dx * normDir.x + dy * normDir.y; if (alongLine < 0 || alongLine > lengthTiles) return false; // Check perpendicular distance const perpDist = Math.abs(dx * perpDir.x + dy * perpDir.y); return perpDist <= widthTiles / 2; }) .map(p => p.id); } ``` --- ## PART 5: GRAPPLING & SHOVING (P1 - HIGH) ### 5.1 Tool Definition ```typescript // File: src/server/grapple-tools.ts export const GrappleTools = { ATTEMPT_GRAPPLE: { name: 'attempt_grapple', description: `Attempt to grapple a target creature. D&D 5e Grappling Rules: - Uses your ACTION - Contest: Your Athletics vs target's Athletics OR Acrobatics (target chooses) - On success: Target gains GRAPPLED condition - Grappled creature: Speed becomes 0, can escape using action - Grappler: Can drag/carry target at half speed - Ends if: Grappler incapacitated, target forced out of reach, or escaped`, inputSchema: z.object({ encounterId: z.string(), grappledId: z.string(), targetId: z.string() }) }, ESCAPE_GRAPPLE: { name: 'escape_grapple', description: `Attempt to escape from a grapple. Uses your ACTION. Contest: Your Athletics OR Acrobatics vs grappler's Athletics. On success: You are no longer grappled.`, inputSchema: z.object({ encounterId: z.string(), escapeeId: z.string() }) }, ATTEMPT_SHOVE: { name: 'attempt_shove', description: `Attempt to shove a creature. D&D 5e Shoving Rules: - Uses your ACTION (or replaces one attack if you have Extra Attack) - Contest: Your Athletics vs target's Athletics OR Acrobatics - On success, choose one: - Push target 5 feet away - Knock target PRONE`, inputSchema: z.object({ encounterId: z.string(), shoverId: z.string(), targetId: z.string(), effect: z.enum(['push', 'prone']), pushDirection: z.object({ x: z.number().int().min(-1).max(1), y: z.number().int().min(-1).max(1) }).optional().describe('Direction to push (required if effect is "push")') }) } } as const; ``` --- ## PART 6: MISSING SYSTEMS FROM PLAYTEST AUDIT ### 6.1 Cover System (MED-002) ```typescript export const CoverTools = { CALCULATE_COVER: { name: 'calculate_cover', description: `Calculate cover bonus between attacker and target. D&D 5e Cover Rules: - Half Cover (+2 AC, +2 Dex saves): Obstacle blocks half the target - Three-Quarters Cover (+5 AC, +5 Dex saves): Obstacle blocks 3/4 of target - Total Cover: Cannot be targeted directly Uses line-of-sight calculation from attacker to target.`, inputSchema: z.object({ encounterId: z.string(), attackerId: z.string(), targetId: z.string() }) } } as const; ``` ### 6.2 Stolen Item Tracking (HIGH-008) ```typescript // File: src/schema/theft.ts - ALREADY EXISTS but needs enhancement // Add to existing schema: export const StolenItemSchema = z.object({ itemId: z.string(), originalOwnerId: z.string(), originalOwnerName: z.string(), stolenById: z.string(), stolenByName: z.string(), stolenAt: z.string().datetime(), location: z.string().optional().describe('Where the theft occurred'), witnessed: z.boolean().default(false), witnesses: z.array(z.string()).optional(), heatLevel: z.number().int().min(0).max(100).default(0) .describe('How "hot" the item is (0=cold, 100=guards actively searching)'), fencedTo: z.string().optional().describe('Who bought the stolen goods'), fencedAt: z.string().datetime().optional(), fencePrice: z.number().optional() }); // Theft-related tools needed: // - mark_item_stolen // - check_item_provenance // - fence_stolen_item // - report_theft (increases heat) // - clear_item_heat (time decay or fence) ``` ### 6.3 Advantage/Disadvantage in Combat (Missing from execute_combat_action) ```typescript // Update execute_combat_action schema to include: advantage: z.boolean().optional() .describe('Roll with advantage (take higher of 2d20)'), disadvantage: z.boolean().optional() .describe('Roll with disadvantage (take lower of 2d20)'), ``` ### 6.4 Corpse Looting (MED-001) ```typescript // File: src/server/corpse-tools.ts - EXISTS but needs exposure via MCP // Tools needed: // - create_corpse (on creature death) // - loot_corpse (transfer items to character) // - search_corpse (reveal hidden items) // - destroy_corpse (for necromancy prevention, etc.) ``` --- ## PART 7: DIALOGUE SYSTEM (MED-007 / FAILED-002) ### 7.1 The Problem NPC dialogue is pure narrative. No mechanical tracking of: - What topics have been discussed - Persuasion/Intimidation/Deception outcomes - Information revealed - Relationship changes ### 7.2 Tool Definition ```typescript // File: src/server/dialogue-tools.ts export const DialogueTools = { START_CONVERSATION: { name: 'start_conversation', description: 'Begin a tracked conversation with an NPC.', inputSchema: z.object({ characterId: z.string().describe('Player character initiating'), npcId: z.string().describe('NPC being spoken to'), context: z.string().optional().describe('Where/why the conversation is happening') }) }, MAKE_SOCIAL_CHECK: { name: 'make_social_check', description: `Make a social skill check during conversation. Use for persuasion, deception, intimidation, insight, etc. Outcome affects NPC disposition and information revealed.`, inputSchema: z.object({ conversationId: z.string(), characterId: z.string(), npcId: z.string(), skill: z.enum(['persuasion', 'deception', 'intimidation', 'insight', 'performance']), dc: z.number().int().min(5).max(30), intent: z.string().describe('What the character is trying to achieve'), stakes: z.enum(['low', 'medium', 'high', 'critical']).default('medium') .describe('How much this matters to the NPC') }) }, REVEAL_INFORMATION: { name: 'reveal_information', description: 'Track that an NPC has revealed specific information.', inputSchema: z.object({ conversationId: z.string(), npcId: z.string(), topic: z.string(), information: z.string(), importance: z.enum(['trivial', 'useful', 'important', 'critical']), linkedSecretId: z.string().optional() .describe('If this reveals part of a tracked secret') }) }, END_CONVERSATION: { name: 'end_conversation', description: 'End the conversation and record final disposition.', inputSchema: z.object({ conversationId: z.string(), characterId: z.string(), npcId: z.string(), dispositionChange: z.number().int().min(-50).max(50).optional() .describe('How much the NPC\'s opinion changed'), summary: z.string().describe('Brief summary of what was discussed') }) } } as const; ``` --- ## PART 8: READIED ACTIONS (P2 - MEDIUM) ### 8.1 Tool Definition ```typescript export const ReadiedActionTools = { READY_ACTION: { name: 'ready_action', description: `Ready an action to trigger on a specific condition. D&D 5e Ready Rules: - Uses your ACTION to ready - Specify a trigger and an action - When trigger occurs, use your REACTION to execute - Concentration required (for spells) - If trigger doesn't occur, action is wasted`, inputSchema: z.object({ encounterId: z.string(), actorId: z.string(), trigger: z.string().describe('When does this activate? (e.g., "enemy moves within 5 feet")'), readiedAction: z.enum(['attack', 'cast_spell', 'dash', 'disengage', 'dodge', 'help', 'hide', 'use_object']), targetId: z.string().optional(), spellName: z.string().optional().describe('If readied action is cast_spell') }) }, CHECK_TRIGGERS: { name: 'check_readied_triggers', description: 'Check if any readied actions should trigger based on current game state.', inputSchema: z.object({ encounterId: z.string(), eventType: z.enum(['movement', 'attack', 'spell_cast', 'turn_start', 'turn_end', 'damage_taken']), eventActorId: z.string(), eventDetails: z.any().optional() }) } } as const; ``` --- ## PART 9: LEGENDARY CREATURE FIXES (HIGH-006, HIGH-007) ### 9.1 The Problem - Lair actions exist but never trigger automatically - Legendary actions/resistances not persisted to character table ### 9.2 Required Changes ```typescript // 1. Update CharacterRepository.create() to accept legendary fields // Already in schema, but verify DB migration exists // 2. Add automatic lair action injection to initiative // In CombatEngine.startEncounter(): // - Check if any participant has hasLairActions = true // - If so, add 'LAIR' entry to turn order at initiative 20 // DONE: This exists in engine.ts but needs testing // 3. Add tool to execute legendary actions between turns export const LegendaryTools = { USE_LEGENDARY_ACTION: { name: 'use_legendary_action', description: `Use a legendary action at the end of another creature's turn. Legendary creatures can take special actions outside their turn: - Can only use at END of another creature's turn - Cannot use on their own turn - Actions have different costs (1-3 legendary actions) - All legendary actions restore at the start of the creature's turn`, inputSchema: z.object({ encounterId: z.string(), creatureId: z.string().describe('The legendary creature using the action'), actionName: z.string().describe('Name of the legendary action'), cost: z.number().int().min(1).max(3).default(1), targetId: z.string().optional(), damage: z.string().optional(), damageType: z.string().optional(), effect: z.string().optional() }) }, USE_LEGENDARY_RESISTANCE: { name: 'use_legendary_resistance', description: `Use legendary resistance to automatically succeed on a failed save. When a legendary creature fails a saving throw, it can choose to succeed instead. Limited uses per day (usually 3). Does NOT restore between rounds - only on long rest.`, inputSchema: z.object({ encounterId: z.string(), creatureId: z.string(), failedSaveType: z.string().describe('What save was failed (e.g., "Wisdom save vs Hold Monster")') }) } } as const; ``` --- ## PART 10: TESTING REQUIREMENTS ### 10.1 Required Test Files Create these test files following TDD: ``` tests/ ├── combat/ │ ├── action-economy.test.ts # T2.1 - Bonus action enforcement │ ├── improvised-stunts.test.ts # Rule of Cool system │ ├── grapple-shove.test.ts # Contested checks │ └── legendary-creatures.test.ts # Lair/legendary actions ├── environmental/ │ ├── environmental-damage.test.ts # Traps, hazards │ ├── area-of-effect.test.ts # Spatial AoE calculations │ └── cover.test.ts # Cover bonus calculations ├── social/ │ ├── dialogue-system.test.ts # Conversation tracking │ └── npc-memory.test.ts # Relationship persistence └── economy/ └── theft-system.test.ts # Stolen item tracking ``` ### 10.2 Test Template ```typescript // Example: tests/combat/improvised-stunts.test.ts import { describe, it, expect, beforeEach } from 'vitest'; import { handleResolveImprovisedStunt } from '../../src/server/stunt-tools.js'; import { CombatEngine } from '../../src/engine/combat/engine.js'; describe('Improvised Stunt System', () => { let engine: CombatEngine; let encounterId: string; beforeEach(() => { // Setup test encounter engine = new CombatEngine('test-seed'); // ... setup participants }); describe('Mine Cart Gambit (from playtest)', () => { it('should resolve athletics check for kicking cart', async () => { const result = await handleResolveImprovisedStunt({ encounterId, actorId: 'theron', targetIds: ['zombie-1'], narrativeIntent: 'Kick the rusty mine cart to bowl over the zombie', skillCheck: { skill: 'athletics', dc: 15 }, consequences: { successDamage: '4d6', damageType: 'bludgeoning', applyCondition: 'prone', moveTarget: true } }, { sessionId: 'test' }); // Verify output contains roll information expect(result.content[0].text).toContain('ATHLETICS Check'); expect(result.content[0].text).toContain('vs DC 15'); // Verify audit log created expect(result.content[0].text).toContain('STUNT_LOG'); }); it('should apply doubled damage on critical success', async () => { // Force nat 20 via seeded RNG // ... test implementation }); it('should apply self-damage on critical failure', async () => { // Force nat 1 via seeded RNG // ... test implementation }); }); describe('Action Economy Integration', () => { it('should consume an action when performing stunt', async () => { // Verify action economy is tracked }); it('should reject stunt if action already used', async () => { // Verify action economy enforcement }); }); }); ``` --- ## PART 11: IMPLEMENTATION PRIORITY ### Sprint 1 (P0 - This Week) 1. ✅ `resolve_improvised_stunt` - The Rule of Cool tool 2. ✅ Action Economy enforcement (T2.1 bug fix) 3. ✅ Advantage/Disadvantage in combat ### Sprint 2 (P1 - Next Week) 4. Environmental damage system 5. Area of Effect calculations 6. Grappling & Shoving 7. Legendary creature fixes ### Sprint 3 (P2 - Following Week) 8. Cover system 9. Dialogue system 10. Readied actions 11. Stolen item enhancements --- ## APPENDIX: DM GUIDANCE FOR RULE OF COOL Include this in system prompt for AI DM: ```markdown ## Improvised Stunt Guidelines When a player attempts something creative not covered by standard rules, use `resolve_improvised_stunt`. ### Setting DCs | Difficulty | DC | Example | |------------|-----|---------| | Trivial | 5 | Kick open an unlocked door | | Easy | 10 | Swing from a rope | | Medium | 15 | Kick a stuck mine cart loose | | Hard | 20 | Leap across a 20-foot chasm | | Very Hard | 25 | Catch an arrow mid-flight | | Nearly Impossible | 30 | Dodge lightning | ### Setting Damage | Impact | Damage | Example | |--------|--------|---------| | Minimal | 1d4 | Thrown mug | | Light | 1d6 | Chair smash | | Moderate | 2d6 | Barrel roll | | Heavy | 3d6-4d6 | Mine cart | | Massive | 6d6 | Chandelier drop | | Catastrophic | 8d6+ | Building collapse | ### Conditions - **Prone**: Target is knocked down (heavy impacts, sweeps) - **Restrained**: Target is trapped (nets, rubble, grapple) - **Stunned**: Target is dazed (head trauma, explosions) - **Blinded**: Target can't see (sand, smoke, flash) ### The Golden Rule If it's dramatically appropriate and the player rolls well, let it work spectacularly. If they roll poorly, make the failure interesting, not just "nothing happens." ``` --- ## COMMIT CHECKLIST Before merging any implementation: - [ ] TDD test written and passing - [ ] Tool registered in server/index.ts - [ ] Schema added/updated if needed - [ ] Database migration if schema changed - [ ] Handler exported and imported - [ ] Documentation updated - [ ] Playtest scenario validated - [ ] Git commit with descriptive message --- **END OF IMPLEMENTATION PROMPT** *Generated from 5-hour playtest session + codebase audit* *Date: December 5, 2025*

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