Skip to main content
Glama
skill-check-tools.ts12.9 kB
import { z } from 'zod'; import { getDb } from '../storage/index.js'; import { CharacterRepository } from '../storage/repos/character.repo.js'; import { InventoryRepository } from '../storage/repos/inventory.repo.js'; import { DiceEngine } from '../math/dice.js'; import { SessionContext } from './types.js'; /** * Skill Check Tools - Stat-based dice rolling * Reads character stats/proficiencies and rolls with bonuses */ // All 18 D&D 5e skills export const SkillEnum = z.enum([ 'acrobatics', 'animal_handling', 'arcana', 'athletics', 'deception', 'history', 'insight', 'intimidation', 'investigation', 'medicine', 'nature', 'perception', 'performance', 'persuasion', 'religion', 'sleight_of_hand', 'stealth', 'survival' ]); export const AbilityEnum = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']); // Skill → Ability mapping (D&D 5e) const SKILL_ABILITY_MAP: Record<string, 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha'> = { athletics: 'str', acrobatics: 'dex', sleight_of_hand: 'dex', stealth: 'dex', arcana: 'int', history: 'int', investigation: 'int', nature: 'int', religion: 'int', animal_handling: 'wis', insight: 'wis', medicine: 'wis', perception: 'wis', survival: 'wis', deception: 'cha', intimidation: 'cha', performance: 'cha', persuasion: 'cha' }; function ensureDb() { const dbPath = process.env.NODE_ENV === 'test' ? ':memory:' : process.env.RPG_DATA_DIR ? `${process.env.RPG_DATA_DIR}/rpg.db` : 'rpg.db'; const db = getDb(dbPath); const charRepo = new CharacterRepository(db); const invRepo = new InventoryRepository(db); return { charRepo, invRepo }; } /** * Check if character has equipped armor that imposes stealth disadvantage */ function hasArmorStealthDisadvantage(invRepo: InventoryRepository, characterId: string): { hasDisadvantage: boolean; armorName?: string } { try { const inventory = invRepo.getInventoryWithDetails(characterId); // Find equipped armor with stealth disadvantage for (const entry of inventory.items) { if (entry.equipped && entry.item.type === 'armor') { const props = entry.item.properties; if (props && props.stealthDisadvantage === true) { return { hasDisadvantage: true, armorName: entry.item.name }; } } } return { hasDisadvantage: false }; } catch { // If inventory check fails, don't block the roll return { hasDisadvantage: false }; } } /** * Calculate proficiency bonus by level (D&D 5e) */ function getProficiencyBonus(level: number): number { return Math.floor((level - 1) / 4) + 2; } /** * Calculate ability modifier */ function getAbilityModifier(abilityScore: number): number { return Math.floor((abilityScore - 10) / 2); } export const SkillCheckTools = { ROLL_SKILL_CHECK: { name: 'roll_skill_check', description: `Roll a skill check using character stats. Automatically applies ability modifier and proficiency bonus if proficient. Example: roll_skill_check with characterId and skill="perception" for active character's Perception check.`, inputSchema: z.object({ characterId: z.string().describe('ID of the character making the check'), skill: SkillEnum.describe('Skill to roll (e.g., perception, stealth, athletics)'), advantage: z.boolean().optional().default(false).describe('Roll with advantage'), disadvantage: z.boolean().optional().default(false).describe('Roll with disadvantage'), dc: z.number().int().min(1).optional().describe('Difficulty Class - if provided, returns pass/fail'), bonusModifier: z.number().int().optional().default(0).describe('Additional situational modifier') }) }, ROLL_ABILITY_CHECK: { name: 'roll_ability_check', description: 'Roll a raw ability check (no skill proficiency). Uses only the ability modifier.', inputSchema: z.object({ characterId: z.string().describe('ID of the character making the check'), ability: AbilityEnum.describe('Ability score to use (str, dex, con, int, wis, cha)'), advantage: z.boolean().optional().default(false), disadvantage: z.boolean().optional().default(false), dc: z.number().int().min(1).optional(), bonusModifier: z.number().int().optional().default(0) }) }, ROLL_SAVING_THROW: { name: 'roll_saving_throw', description: 'Roll a saving throw. Applies proficiency bonus if character has save proficiency.', inputSchema: z.object({ characterId: z.string().describe('ID of the character making the save'), ability: AbilityEnum.describe('Saving throw type (str, dex, con, int, wis, cha)'), advantage: z.boolean().optional().default(false), disadvantage: z.boolean().optional().default(false), dc: z.number().int().min(1).optional().describe('DC to beat'), bonusModifier: z.number().int().optional().default(0) }) } } as const; export async function handleRollSkillCheck(args: unknown, _ctx: SessionContext) { const { charRepo, invRepo } = ensureDb(); const parsed = SkillCheckTools.ROLL_SKILL_CHECK.inputSchema.parse(args); const character = charRepo.findById(parsed.characterId); if (!character) { throw new Error(`Character not found: ${parsed.characterId}`); } // Get the ability for this skill const ability = SKILL_ABILITY_MAP[parsed.skill]; const abilityScore = character.stats[ability]; const abilityMod = getAbilityModifier(abilityScore); // Check proficiency const skillProfs = (character as any).skillProficiencies || []; const expertise = (character as any).expertise || []; const isProficient = skillProfs.includes(parsed.skill); const hasExpertise = expertise.includes(parsed.skill); const profBonus = getProficiencyBonus(character.level); let totalMod = abilityMod + parsed.bonusModifier!; if (hasExpertise) { totalMod += profBonus * 2; } else if (isProficient) { totalMod += profBonus; } // Check for armor stealth disadvantage (D&D 5e rule) let armorDisadvantage: { hasDisadvantage: boolean; armorName?: string } = { hasDisadvantage: false }; if (parsed.skill === 'stealth') { armorDisadvantage = hasArmorStealthDisadvantage(invRepo, parsed.characterId); } // Determine final advantage/disadvantage state // D&D 5e: advantage and disadvantage cancel out let hasAdvantage = parsed.advantage === true; let hasDisadvantage = parsed.disadvantage === true || armorDisadvantage.hasDisadvantage; // If both advantage and disadvantage, they cancel if (hasAdvantage && hasDisadvantage) { hasAdvantage = false; hasDisadvantage = false; } // Roll with advantage/disadvantage const dice = new DiceEngine(); const diceExpr = { count: 1, sides: 20, modifier: totalMod, explode: false, advantage: hasAdvantage, disadvantage: hasDisadvantage }; const result = dice.roll(diceExpr); // Format skill name nicely const skillName = parsed.skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const abilityName = ability.toUpperCase(); const rollTotal = typeof result.result === 'number' ? result.result : parseInt(String(result.result), 10); const rolls = (result.metadata as any)?.rolls as number[] | undefined; // Build response with armor disadvantage info for transparency const response: Record<string, unknown> = { character: character.name, skill: skillName, ability: abilityName, roll: rollTotal, breakdown: { d20: rolls?.[0] ?? (rollTotal - totalMod), abilityMod, proficiencyBonus: hasExpertise ? profBonus * 2 : (isProficient ? profBonus : 0), bonusModifier: parsed.bonusModifier, total: rollTotal }, proficient: isProficient, expertise: hasExpertise, advantage: hasAdvantage, disadvantage: hasDisadvantage }; // Add armor disadvantage info if applicable if (armorDisadvantage.hasDisadvantage) { response.armorDisadvantage = true; response.armorName = armorDisadvantage.armorName; } if (parsed.dc !== undefined) { response.dc = parsed.dc; response.success = rollTotal >= parsed.dc; response.margin = rollTotal - parsed.dc; } return { content: [{ type: 'text' as const, text: JSON.stringify(response, null, 2) }] }; } export async function handleRollAbilityCheck(args: unknown, _ctx: SessionContext) { const { charRepo } = ensureDb(); const parsed = SkillCheckTools.ROLL_ABILITY_CHECK.inputSchema.parse(args); const character = charRepo.findById(parsed.characterId); if (!character) { throw new Error(`Character not found: ${parsed.characterId}`); } const abilityScore = character.stats[parsed.ability]; const abilityMod = getAbilityModifier(abilityScore); const totalMod = abilityMod + parsed.bonusModifier!; const dice = new DiceEngine(); const diceExpr = { count: 1, sides: 20, modifier: totalMod, explode: false, advantage: parsed.advantage && !parsed.disadvantage, disadvantage: parsed.disadvantage && !parsed.advantage }; const result = dice.roll(diceExpr); const rollTotal = typeof result.result === 'number' ? result.result : parseInt(String(result.result), 10); const rolls = (result.metadata as any)?.rolls as number[] | undefined; const abilityName = parsed.ability.toUpperCase(); const response: Record<string, unknown> = { character: character.name, ability: abilityName, roll: rollTotal, breakdown: { d20: rolls?.[0] ?? (rollTotal - totalMod), abilityMod, bonusModifier: parsed.bonusModifier, total: rollTotal }, advantage: parsed.advantage, disadvantage: parsed.disadvantage }; if (parsed.dc !== undefined) { response.dc = parsed.dc; response.success = rollTotal >= parsed.dc; response.margin = rollTotal - parsed.dc; } return { content: [{ type: 'text' as const, text: JSON.stringify(response, null, 2) }] }; } export async function handleRollSavingThrow(args: unknown, _ctx: SessionContext) { const { charRepo } = ensureDb(); const parsed = SkillCheckTools.ROLL_SAVING_THROW.inputSchema.parse(args); const character = charRepo.findById(parsed.characterId); if (!character) { throw new Error(`Character not found: ${parsed.characterId}`); } const abilityScore = character.stats[parsed.ability]; const abilityMod = getAbilityModifier(abilityScore); // Check save proficiency const saveProfs = (character as any).saveProficiencies || []; const isProficient = saveProfs.includes(parsed.ability); const profBonus = isProficient ? getProficiencyBonus(character.level) : 0; const totalMod = abilityMod + profBonus + parsed.bonusModifier!; const dice = new DiceEngine(); const diceExpr = { count: 1, sides: 20, modifier: totalMod, explode: false, advantage: parsed.advantage && !parsed.disadvantage, disadvantage: parsed.disadvantage && !parsed.advantage }; const result = dice.roll(diceExpr); const rollTotal = typeof result.result === 'number' ? result.result : parseInt(String(result.result), 10); const rolls = (result.metadata as any)?.rolls as number[] | undefined; const abilityName = parsed.ability.toUpperCase(); const response: Record<string, unknown> = { character: character.name, savingThrow: `${abilityName} Save`, roll: rollTotal, breakdown: { d20: rolls?.[0] ?? (rollTotal - totalMod), abilityMod, proficiencyBonus: profBonus, bonusModifier: parsed.bonusModifier, total: rollTotal }, proficient: isProficient, advantage: parsed.advantage, disadvantage: parsed.disadvantage }; if (parsed.dc !== undefined) { response.dc = parsed.dc; response.success = rollTotal >= parsed.dc; response.margin = rollTotal - parsed.dc; } return { content: [{ type: 'text' as const, text: JSON.stringify(response, null, 2) }] }; }

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