Skip to main content
Glama
theft-tools.ts17.5 kB
import { z } from 'zod'; import { getDb } from '../storage/index.js'; import { TheftRepository } from '../storage/repos/theft.repo.js'; import { HeatLevelSchema, HEAT_VALUES, compareHeatLevels } from '../schema/theft.js'; import { SessionContext } from './types.js'; /** * HIGH-008: Theft System Tools * Tools for stolen item tracking, heat decay, and fence mechanics */ // ============================================================ // TOOL DEFINITIONS // ============================================================ export const TheftTools = { STEAL_ITEM: { name: 'steal_item', description: `Record a theft event. Marks an item as stolen from one character and creates a "hot" theft record. The theft creates a provenance record that: - Can be detected by the original owner - May trigger guard searches - Affects NPC disposition if detected - Heat decays over time (burning → hot → warm → cool → cold) Example: { "thiefId": "rogue-1", "victimId": "merchant-1", "itemId": "ruby-necklace", "witnesses": ["guard-1"], "locationId": "marketplace" }`, inputSchema: z.object({ thiefId: z.string().describe('Character performing the theft'), victimId: z.string().describe('Character being stolen from'), itemId: z.string().describe('Item being stolen'), witnesses: z.array(z.string()).optional().describe('NPCs who witnessed the theft'), locationId: z.string().optional().describe('Where the theft occurred') }) }, CHECK_ITEM_STOLEN: { name: 'check_item_stolen', description: 'Check if an item is stolen and get its provenance details.', inputSchema: z.object({ itemId: z.string() }) }, CHECK_STOLEN_ITEMS_ON_CHARACTER: { name: 'check_stolen_items_on_character', description: 'Check if a character carries stolen items. Useful for guard searches.', inputSchema: z.object({ characterId: z.string(), checkerId: z.string().optional().describe('The NPC/guard doing the checking') }) }, CHECK_ITEM_RECOGNITION: { name: 'check_item_recognition', description: 'Check if NPC recognizes stolen item. Owner always recognizes; guards check vs heat/bounty.', inputSchema: z.object({ npcId: z.string().describe('NPC who might recognize the item'), characterId: z.string().describe('Character carrying the item'), itemId: z.string().describe('Item to check') }) }, SELL_TO_FENCE: { name: 'sell_to_fence', description: 'Sell stolen item to a fence NPC for reduced price. Clears stolen flag after cooldown.', inputSchema: z.object({ sellerId: z.string(), fenceId: z.string(), itemId: z.string(), itemValue: z.number().int().min(0).describe('Base value of the item in gold') }) }, REGISTER_FENCE: { name: 'register_fence', description: 'Register an NPC as a fence (buys stolen goods).', inputSchema: z.object({ npcId: z.string(), factionId: z.string().optional(), buyRate: z.number().min(0.1).max(1.0).optional().default(0.4), maxHeatLevel: HeatLevelSchema.optional().default('hot'), dailyHeatCapacity: z.number().int().min(0).optional().default(100), specializations: z.array(z.string()).optional(), cooldownDays: z.number().int().min(0).optional().default(7) }) }, REPORT_THEFT: { name: 'report_theft', description: 'Report a theft to guards, setting bounty and increasing detection chance.', inputSchema: z.object({ reporterId: z.string(), itemId: z.string(), bountyOffered: z.number().int().min(0).optional().default(0) }) }, ADVANCE_HEAT_DECAY: { name: 'advance_heat_decay', description: 'Process heat decay for all stolen items when game time advances.', inputSchema: z.object({ daysAdvanced: z.number().int().min(1) }) }, GET_FENCE: { name: 'get_fence', description: 'Get information about a fence NPC.', inputSchema: z.object({ npcId: z.string() }) }, LIST_FENCES: { name: 'list_fences', description: 'List all registered fences, optionally filtered by faction.', inputSchema: z.object({ factionId: z.string().optional() }) } } as const; // ============================================================ // TOOL HANDLERS // ============================================================ function getRepo(): TheftRepository { const db = getDb(process.env.NODE_ENV === 'test' ? ':memory:' : 'rpg.db'); return new TheftRepository(db); } export async function handleStealItem(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.STEAL_ITEM.inputSchema.parse(args); const repo = getRepo(); // EDGE-001: Prevent self-theft - a character cannot steal from themselves if (parsed.thiefId === parsed.victimId) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'A character cannot steal from themselves' }, null, 2) }] }; } const record = repo.recordTheft({ itemId: parsed.itemId, stolenFrom: parsed.victimId, stolenBy: parsed.thiefId, stolenLocation: parsed.locationId ?? null, witnesses: parsed.witnesses ?? [] }); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, record, message: `Item ${parsed.itemId} marked as stolen from ${parsed.victimId} by ${parsed.thiefId}`, heatLevel: record.heatLevel, witnesses: record.witnesses.length }, null, 2) }] }; } export async function handleCheckItemStolen(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.CHECK_ITEM_STOLEN.inputSchema.parse(args); const repo = getRepo(); const record = repo.getTheftRecord(parsed.itemId); const isStolen = record !== null; return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, isStolen, record: record ?? undefined, heatLevel: record?.heatLevel ?? null, originalOwner: record?.stolenFrom ?? null, thief: record?.stolenBy ?? null, reportedToGuards: record?.reportedToGuards ?? false, bounty: record?.bounty ?? 0 }, null, 2) }] }; } export async function handleCheckStolenItemsOnCharacter(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.CHECK_STOLEN_ITEMS_ON_CHARACTER.inputSchema.parse(args); const repo = getRepo(); const stolenItems = repo.getStolenItemsHeldBy(parsed.characterId); // Calculate detection risk based on heat levels let detectionRisk = 'none'; let hottest = 'cold'; for (const item of stolenItems) { if (compareHeatLevels(item.heatLevel, hottest as any) > 0) { hottest = item.heatLevel; } } if (hottest === 'burning') detectionRisk = 'very high'; else if (hottest === 'hot') detectionRisk = 'high'; else if (hottest === 'warm') detectionRisk = 'moderate'; else if (hottest === 'cool') detectionRisk = 'low'; return { content: [{ type: 'text' as const, text: JSON.stringify({ characterId: parsed.characterId, stolenItemCount: stolenItems.length, detectionRisk, hottestItem: hottest, items: stolenItems.map(i => ({ itemId: i.itemId, heatLevel: i.heatLevel, stolenFrom: i.stolenFrom, reportedToGuards: i.reportedToGuards, bounty: i.bounty })) }, null, 2) }] }; } export async function handleCheckItemRecognition(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.CHECK_ITEM_RECOGNITION.inputSchema.parse(args); const repo = getRepo(); const record = repo.getTheftRecord(parsed.itemId); if (!record) { return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, recognized: false, isStolen: false, reason: 'Item is not stolen' }, null, 2) }] }; } // Original owner ALWAYS recognizes if (parsed.npcId === record.stolenFrom) { return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, recognized: true, isStolen: true, recognizedBy: 'original_owner', message: 'That belongs to me! THIEF!', reaction: 'hostile', stolenFrom: record.stolenFrom, stolenAt: record.stolenAt }, null, 2) }] }; } // Witnesses recognize if (record.witnesses.includes(parsed.npcId)) { return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, recognized: true, isStolen: true, recognizedBy: 'witness', message: 'I saw you steal that!', reaction: 'suspicious' }, null, 2) }] }; } // Guards check based on heat and bounty // TODO: Check if NPC is a guard based on faction/role const heatValue = HEAT_VALUES[record.heatLevel]; const recognitionChance = Math.min(100, heatValue + record.bounty / 10); const roll = Math.random() * 100; if (roll < recognitionChance) { return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, recognized: true, isStolen: true, recognizedBy: 'suspicion', roll: Math.floor(roll), threshold: Math.floor(recognitionChance), message: 'That looks suspicious...', reaction: 'suspicious' }, null, 2) }] }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ itemId: parsed.itemId, recognized: false, isStolen: true, roll: Math.floor(roll), threshold: Math.floor(recognitionChance), reason: 'NPC did not recognize the item' }, null, 2) }] }; } export async function handleSellToFence(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.SELL_TO_FENCE.inputSchema.parse(args); const repo = getRepo(); const record = repo.getTheftRecord(parsed.itemId); if (!record) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, reason: 'Item is not stolen - no need for a fence' }, null, 2) }] }; } const check = repo.canFenceAccept(parsed.fenceId, record, parsed.itemValue); if (!check.accepted) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, reason: check.reason }, null, 2) }] }; } // Record the transaction repo.recordFenceTransaction(parsed.fenceId, parsed.itemId, record.heatLevel); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, itemId: parsed.itemId, fenceId: parsed.fenceId, price: check.price, baseValue: parsed.itemValue, heatLevel: record.heatLevel, message: `Sold for ${check.price} gold (${Math.floor((check.price! / parsed.itemValue) * 100)}% of value)` }, null, 2) }] }; } export async function handleRegisterFence(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.REGISTER_FENCE.inputSchema.parse(args); const repo = getRepo(); // EDGE-006: Prevent theft victims from being registered as fences // This creates immersion-breaking scenarios where victims buy back their own stolen goods const stolenFromVictim = repo.getItemsStolenFrom(parsed.npcId); if (stolenFromVictim.length > 0) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, error: 'Cannot register a theft victim as a fence', reason: `${parsed.npcId} has had ${stolenFromVictim.length} item(s) stolen from them`, suggestion: 'Theft victims cannot act as fences for narrative consistency' }, null, 2) }] }; } const fence = repo.registerFence({ npcId: parsed.npcId, factionId: parsed.factionId ?? null, buyRate: parsed.buyRate, maxHeatLevel: parsed.maxHeatLevel, dailyHeatCapacity: parsed.dailyHeatCapacity, specializations: parsed.specializations ?? [], cooldownDays: parsed.cooldownDays }); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, fence, message: `${parsed.npcId} registered as a fence` }, null, 2) }] }; } export async function handleReportTheft(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.REPORT_THEFT.inputSchema.parse(args); const repo = getRepo(); const record = repo.getTheftRecord(parsed.itemId); if (!record) { return { content: [{ type: 'text' as const, text: JSON.stringify({ success: false, reason: 'No theft record found for this item' }, null, 2) }] }; } repo.reportToGuards(parsed.itemId, parsed.bountyOffered ?? 0); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, itemId: parsed.itemId, reportedBy: parsed.reporterId, bounty: parsed.bountyOffered ?? 0, message: 'Theft reported to guards' }, null, 2) }] }; } export async function handleAdvanceHeatDecay(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.ADVANCE_HEAT_DECAY.inputSchema.parse(args); const repo = getRepo(); const changes = repo.processHeatDecay(parsed.daysAdvanced); // Also reset fence daily capacity repo.resetFenceDailyCapacity(); return { content: [{ type: 'text' as const, text: JSON.stringify({ success: true, daysAdvanced: parsed.daysAdvanced, itemsDecayed: changes.length, changes: changes.map(c => ({ itemId: c.itemId, from: c.oldHeat, to: c.newHeat })) }, null, 2) }] }; } export async function handleGetFence(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.GET_FENCE.inputSchema.parse(args); const repo = getRepo(); const fence = repo.getFence(parsed.npcId); if (!fence) { return { content: [{ type: 'text' as const, text: JSON.stringify({ found: false, npcId: parsed.npcId, message: 'NPC is not a registered fence' }, null, 2) }] }; } return { content: [{ type: 'text' as const, text: JSON.stringify({ found: true, fence }, null, 2) }] }; } export async function handleListFences(args: unknown, _ctx: SessionContext) { const parsed = TheftTools.LIST_FENCES.inputSchema.parse(args); const repo = getRepo(); const fences = repo.listFences(parsed.factionId); return { content: [{ type: 'text' as const, text: JSON.stringify({ count: fences.length, factionFilter: parsed.factionId ?? 'all', fences }, 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