Skip to main content
Glama
starting-equipment.service.ts19.7 kB
/** * Starting Equipment & Spell Provisioning Service * * Automatically grants class-appropriate starting equipment, spells, and currency * when creating new characters. Uses D&D 5e SRD as baseline. * * DESIGN PHILOSOPHY: * - Auto-provision by default (reduces manual follow-up prompts) * - Allow improv/override (LLM can specify custom equipment) * - Fail gracefully (if item creation fails, character still works) */ import { randomUUID } from 'crypto'; import Database from 'better-sqlite3'; import { ItemRepository } from '../storage/repos/item.repo.js'; import { InventoryRepository } from '../storage/repos/inventory.repo.js'; import { Item } from '../schema/inventory.js'; import { CLASS_DATA, getDefaultStartingEquipment, getSpellSlots, isSpellcaster, EquipmentPacks, D5EClass } from '../data/class-starting-data.js'; export interface ProvisioningResult { itemsGranted: string[]; spellsGranted: string[]; cantripsGranted: string[]; spellSlots: number[] | null; pactMagicSlots: { slots: number; level: number } | null; startingGold: number; errors: string[]; } export interface ProvisioningOptions { /** Skip equipment provisioning entirely */ skipEquipment?: boolean; /** Skip spell provisioning entirely */ skipSpells?: boolean; /** Override starting gold (otherwise uses class default) */ startingGold?: number; /** Custom equipment to grant instead of defaults */ customEquipment?: string[]; /** Custom spells to grant instead of defaults */ customSpells?: string[]; /** Custom cantrips to grant instead of defaults */ customCantrips?: string[]; } /** * Provision starting equipment and spells for a newly created character */ export function provisionStartingEquipment( db: Database.Database, characterId: string, className: string, level: number = 1, options: ProvisioningOptions = {} ): ProvisioningResult { const itemRepo = new ItemRepository(db); const invRepo = new InventoryRepository(db); const result: ProvisioningResult = { itemsGranted: [], spellsGranted: [], cantripsGranted: [], spellSlots: null, pactMagicSlots: null, startingGold: 0, errors: [] }; // Normalize class name for lookup const normalizedClass = normalizeClassName(className); const classData = CLASS_DATA[normalizedClass]; // ========================================================= // EQUIPMENT PROVISIONING // ========================================================= if (!options.skipEquipment) { const equipmentList = options.customEquipment?.length ? options.customEquipment : classData ? getDefaultStartingEquipment(normalizedClass) : getGenericStartingEquipment(); for (const itemName of equipmentList) { try { const itemId = ensureItemExists(itemRepo, itemName); invRepo.addItem(characterId, itemId, 1); result.itemsGranted.push(itemName); } catch (err) { result.errors.push(`Failed to grant "${itemName}": ${(err as Error).message}`); } } // Grant starting gold const goldAmount = options.startingGold ?? (classData?.startingGold ?? 10); try { invRepo.addCurrency(characterId, { gold: goldAmount }); result.startingGold = goldAmount; } catch (err) { result.errors.push(`Failed to grant starting gold: ${(err as Error).message}`); } } // ========================================================= // SPELL PROVISIONING // ========================================================= if (!options.skipSpells && classData && isSpellcaster(normalizedClass)) { // Get spell slots for level const slots = getSpellSlots(normalizedClass, level); if (slots) { if (normalizedClass === 'warlock') { // Warlock slots are in array format [0,0,2,0,...] - convert to pact magic format // Find the non-zero slot count and its spell level (1-indexed) const slotCount = slots.find(s => s > 0) || 1; const slotLevel = slots.findIndex(s => s > 0) + 1; result.pactMagicSlots = { slots: slotCount, level: slotLevel || 1 }; } else { result.spellSlots = slots as number[]; } } // Grant cantrips const cantrips = options.customCantrips?.length ? options.customCantrips : classData.startingCantrips || []; result.cantripsGranted = cantrips; // Grant spells const spells = options.customSpells?.length ? options.customSpells : classData.startingSpells || []; result.spellsGranted = spells; } return result; } /** * Normalize class name to match CLASS_DATA keys */ function normalizeClassName(className: string): D5EClass { const normalized = className.toLowerCase().trim(); // Direct match if (normalized in CLASS_DATA) { return normalized as D5EClass; } // Common aliases const aliases: Record<string, D5EClass> = { 'mage': 'wizard', 'arcane caster': 'wizard', 'priest': 'cleric', 'healer': 'cleric', 'thief': 'rogue', 'assassin': 'rogue', 'berserker': 'barbarian', 'knight': 'fighter', 'warrior': 'fighter', 'soldier': 'fighter', 'nature priest': 'druid', 'shapeshifter': 'druid', 'holy warrior': 'paladin', 'crusader': 'paladin', 'hunter': 'ranger', 'scout': 'ranger', 'wild mage': 'sorcerer', 'bloodmage': 'sorcerer', 'hexblade': 'warlock', 'pact mage': 'warlock', 'performer': 'bard', 'skald': 'bard', 'martial artist': 'monk', 'mystic': 'monk' }; if (normalized in aliases) { return aliases[normalized]; } // Partial match - check if class name contains a known class for (const knownClass of Object.keys(CLASS_DATA)) { if (normalized.includes(knownClass)) { return knownClass as D5EClass; } } // Unknown class - return as-is (will fall back to generic equipment) return normalized as D5EClass; } /** * Generic starting equipment for unknown/custom classes */ function getGenericStartingEquipment(): string[] { return [ 'Simple Weapon', 'Leather Armor', 'Backpack', 'Bedroll', 'Torch', 'Rations (1 day)', 'Waterskin' ]; } /** * Ensure an item exists in the database, creating it if necessary */ function ensureItemExists(itemRepo: ItemRepository, itemName: string): string { // Check if item already exists const existing = itemRepo.findByName(itemName); if (existing.length > 0) { return existing[0].id; } // Create new item with sensible defaults const now = new Date().toISOString(); const itemData = getItemDefaults(itemName); const item: Item = { id: randomUUID(), name: itemName, description: itemData.description, type: itemData.type, weight: itemData.weight, value: itemData.value, properties: itemData.properties, createdAt: now, updatedAt: now }; itemRepo.create(item); return item.id; } /** * Get sensible defaults for common D&D items */ function getItemDefaults(itemName: string): { type: 'weapon' | 'armor' | 'consumable' | 'quest' | 'misc'; weight: number; value: number; description: string; properties?: Record<string, unknown>; } { const name = itemName.toLowerCase(); // Weapons if (name.includes('sword') || name.includes('blade')) { const isTwoHanded = name.includes('great') || name.includes('two-handed'); return { type: 'weapon', weight: isTwoHanded ? 6 : 3, value: isTwoHanded ? 50 : 15, description: `A ${itemName.toLowerCase()}.`, properties: { damage: isTwoHanded ? '2d6' : '1d8', damageType: 'slashing', versatile: !isTwoHanded } }; } if (name.includes('axe')) { const isGreat = name.includes('great'); return { type: 'weapon', weight: isGreat ? 7 : 4, value: isGreat ? 30 : 10, description: `A ${itemName.toLowerCase()}.`, properties: { damage: isGreat ? '1d12' : '1d8', damageType: 'slashing' } }; } if (name.includes('bow')) { const isLong = name.includes('long'); return { type: 'weapon', weight: isLong ? 2 : 1, value: isLong ? 50 : 25, description: `A ${itemName.toLowerCase()}.`, properties: { damage: isLong ? '1d8' : '1d6', damageType: 'piercing', range: isLong ? '150/600' : '80/320', ammunition: true } }; } if (name.includes('crossbow')) { const isHand = name.includes('hand'); const isHeavy = name.includes('heavy'); return { type: 'weapon', weight: isHand ? 3 : (isHeavy ? 18 : 5), value: isHand ? 75 : (isHeavy ? 50 : 25), description: `A ${itemName.toLowerCase()}.`, properties: { damage: isHand ? '1d6' : (isHeavy ? '1d10' : '1d8'), damageType: 'piercing', ammunition: true, loading: true } }; } if (name.includes('dagger')) { return { type: 'weapon', weight: 1, value: 2, description: 'A simple dagger.', properties: { damage: '1d4', damageType: 'piercing', finesse: true, light: true, thrown: '20/60' } }; } if (name.includes('quarterstaff') || name.includes('staff')) { return { type: 'weapon', weight: 4, value: 2, description: 'A wooden staff.', properties: { damage: '1d6', damageType: 'bludgeoning', versatile: '1d8' } }; } if (name.includes('mace')) { return { type: 'weapon', weight: 4, value: 5, description: 'A metal mace.', properties: { damage: '1d6', damageType: 'bludgeoning' } }; } if (name.includes('javelin')) { return { type: 'weapon', weight: 2, value: 0.5, description: 'A throwing javelin.', properties: { damage: '1d6', damageType: 'piercing', thrown: '30/120' } }; } if (name.includes('handaxe')) { return { type: 'weapon', weight: 2, value: 5, description: 'A small throwing axe.', properties: { damage: '1d6', damageType: 'slashing', light: true, thrown: '20/60' } }; } if (name.includes('rapier')) { return { type: 'weapon', weight: 2, value: 25, description: 'A slender thrusting sword.', properties: { damage: '1d8', damageType: 'piercing', finesse: true } }; } if (name.includes('scimitar')) { return { type: 'weapon', weight: 3, value: 25, description: 'A curved slashing blade.', properties: { damage: '1d6', damageType: 'slashing', finesse: true, light: true } }; } if (name.includes('shortbow')) { return { type: 'weapon', weight: 2, value: 25, description: 'A compact bow.', properties: { damage: '1d6', damageType: 'piercing', range: '80/320', ammunition: true } }; } // Armor if (name.includes('chain mail') || name.includes('chainmail')) { return { type: 'armor', weight: 55, value: 75, description: 'Heavy armor made of interlocking metal rings.', properties: { ac: 16, stealthDisadvantage: true, strengthRequired: 13 } }; } if (name.includes('scale mail')) { return { type: 'armor', weight: 45, value: 50, description: 'Medium armor of overlapping metal scales.', properties: { ac: 14, maxDexBonus: 2, stealthDisadvantage: true } }; } if (name.includes('leather armor') || name === 'leather') { return { type: 'armor', weight: 10, value: 10, description: 'Light armor made of cured leather.', properties: { ac: 11 } }; } if (name.includes('studded leather')) { return { type: 'armor', weight: 13, value: 45, description: 'Leather armor reinforced with metal studs.', properties: { ac: 12 } }; } if (name.includes('hide armor') || name === 'hide') { return { type: 'armor', weight: 12, value: 10, description: 'Medium armor made of thick animal hides.', properties: { ac: 12, maxDexBonus: 2 } }; } if (name.includes('shield')) { return { type: 'armor', weight: 6, value: 10, description: 'A wooden or metal shield.', properties: { acBonus: 2 } }; } // Equipment Packs if (name.includes('pack')) { const packItems = getPackContents(name); return { type: 'misc', weight: 30, value: 10, description: `An adventuring pack containing: ${packItems.join(', ')}.`, properties: { contains: packItems } }; } // Focus items if (name.includes('arcane focus') || name.includes('component pouch')) { return { type: 'misc', weight: 1, value: 10, description: 'A spellcasting focus or component pouch.', properties: { spellcastingFocus: true } }; } if (name.includes('holy symbol')) { return { type: 'misc', weight: 1, value: 5, description: 'A divine spellcasting focus.', properties: { spellcastingFocus: true, divine: true } }; } if (name.includes('druidic focus')) { return { type: 'misc', weight: 1, value: 5, description: 'A natural spellcasting focus.', properties: { spellcastingFocus: true, druidic: true } }; } if (name.includes('spellbook')) { return { type: 'misc', weight: 3, value: 50, description: 'A wizard\'s spellbook for recording spells.', properties: { spellbook: true } }; } // Musical instruments if (name.includes('lute') || name.includes('drum') || name.includes('flute') || name.includes('horn') || name.includes('instrument')) { return { type: 'misc', weight: 2, value: 30, description: 'A musical instrument.', properties: { instrument: true, bardFocus: true } }; } // Thieves' tools if (name.includes("thieves' tools") || name.includes('thieves tools')) { return { type: 'misc', weight: 1, value: 25, description: 'A set of lockpicks and tools for disabling traps.', properties: { proficiencyRequired: true } }; } // Ammunition if (name.includes('arrow')) { const match = name.match(/(\d+)/); const count = match ? parseInt(match[1]) : 20; return { type: 'misc', weight: 1, value: 1, description: `A quiver of ${count} arrows.`, properties: { ammunition: true, count } }; } if (name.includes('bolt')) { const match = name.match(/(\d+)/); const count = match ? parseInt(match[1]) : 20; return { type: 'misc', weight: 1.5, value: 1, description: `A case of ${count} crossbow bolts.`, properties: { ammunition: true, count } }; } // Consumables if (name.includes('potion')) { return { type: 'consumable', weight: 0.5, value: 50, description: 'A magical potion.', properties: {} }; } if (name.includes('rations')) { return { type: 'consumable', weight: 2, value: 0.5, description: 'A day\'s worth of travel rations.', properties: {} }; } // Default for unknown items return { type: 'misc', weight: 1, value: 1, description: `A ${itemName.toLowerCase()}.`, properties: {} }; } /** * Get contents of equipment packs */ function getPackContents(packName: string): string[] { const name = packName.toLowerCase(); if (name.includes('explorer')) { return EquipmentPacks.explorersPack; } if (name.includes('dungeoneer')) { return EquipmentPacks.dungeoneersPack; } if (name.includes('priest')) { return EquipmentPacks.priestsPack; } if (name.includes('scholar')) { return EquipmentPacks.scholarsPack; } if (name.includes('burglar')) { return EquipmentPacks.burglarsPack; } if (name.includes('diplomat')) { return EquipmentPacks.diplomatsPack; } if (name.includes('entertainer')) { return EquipmentPacks.entertainersPack; } // Default pack contents return ['Backpack', 'Bedroll', 'Rations (5 days)', 'Waterskin', 'Torch']; } /** * Get the spellcasting ability modifier for spell save DC and attack bonus calculation */ export function getSpellcastingAbility(className: string): 'int' | 'wis' | 'cha' | null { const normalized = normalizeClassName(className); const classData = CLASS_DATA[normalized]; if (!classData?.spellcastingAbility) return null; return classData.spellcastingAbility as 'int' | 'wis' | 'cha'; } /** * Calculate spell save DC */ export function calculateSpellSaveDC( proficiencyBonus: number, abilityModifier: number ): number { return 8 + proficiencyBonus + abilityModifier; } /** * Calculate spell attack bonus */ export function calculateSpellAttackBonus( proficiencyBonus: number, abilityModifier: number ): number { return proficiencyBonus + abilityModifier; }

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