Skip to main content
Glama
DESIGN_PROPOSAL_THEFT_AND_LOOT.md31.7 kB
# Design Proposal: Theft & Loot Systems ## HIGH-008 (Stolen Item Tracking) + FAILED-004 (Loot/Corpse System) **Date:** December 5, 2025 **Status:** DESIGN PHASE **Author:** Claude (TDD Framework Session) **Estimated Effort:** 4-6 hours implementation + 2 hours testing --- ## Executive Summary This proposal outlines a modular, robust implementation for two interconnected systems: 1. **Stolen Item Tracking** (HIGH-008): Provenance tracking, heat decay, fence mechanics 2. **Corpse/Loot System** (FAILED-004): Death-triggered loot containers, loot tables, harvesting These systems share infrastructure (item provenance tracking) and must integrate cleanly with existing inventory, combat, and NPC memory systems. --- ## Part 1: Stolen Item Tracking System (HIGH-008) ### Core Data Model ```typescript // New schema: src/schema/theft.ts import { z } from 'zod'; export const HeatLevelSchema = z.enum(['burning', 'hot', 'warm', 'cool', 'cold']); export type HeatLevel = z.infer<typeof HeatLevelSchema>; export const StolenItemRecordSchema = z.object({ id: z.string().uuid(), itemId: z.string(), stolenFrom: z.string().describe('Original owner character ID'), stolenBy: z.string().describe('Thief character ID'), stolenAt: z.string().datetime(), stolenLocation: z.string().nullable().describe('Region/structure ID where theft occurred'), // Heat system heatLevel: HeatLevelSchema.default('burning'), heatUpdatedAt: z.string().datetime(), // Detection reportedToGuards: z.boolean().default(false), bounty: z.number().int().min(0).default(0), witnesses: z.array(z.string()).default([]).describe('NPC IDs who witnessed the theft'), // Resolution recovered: z.boolean().default(false), recoveredAt: z.string().datetime().nullable(), fenced: z.boolean().default(false), fencedAt: z.string().datetime().nullable(), fencedTo: z.string().nullable().describe('Fence NPC ID'), createdAt: z.string().datetime(), updatedAt: z.string().datetime() }); export type StolenItemRecord = z.infer<typeof StolenItemRecordSchema>; export const FenceNpcSchema = z.object({ npcId: z.string(), factionId: z.string().nullable().describe("e.g., 'thieves-guild'"), buyRate: z.number().min(0.1).max(1.0).default(0.4).describe('Fraction of item value they pay'), maxHeatLevel: HeatLevelSchema.default('hot').describe('Maximum heat they will accept'), dailyHeatCapacity: z.number().int().min(0).default(100).describe('Total heat points they can absorb per day'), currentDailyHeat: z.number().int().min(0).default(0), lastResetAt: z.string().datetime(), specializations: z.array(z.string()).default([]).describe('Item types they prefer'), cooldownDays: z.number().int().min(0).default(7).describe('Days to remove stolen flag'), reputation: z.number().int().min(0).max(100).default(50).describe('Fence reliability') }); export type FenceNpc = z.infer<typeof FenceNpcSchema>; // Heat level to numeric value for capacity calculations export const HEAT_VALUES: Record<HeatLevel, number> = { burning: 100, hot: 50, warm: 25, cool: 10, cold: 5 }; // Heat decay rules (in game days) export const HEAT_DECAY_RULES = { burning_to_hot: 1, // 1 day hot_to_warm: 3, // 3 days warm_to_cool: 7, // 1 week cool_to_cold: 14, // 2 weeks cold_fully: 30 // Never fully clears for unique items }; ``` ### Database Tables ```sql -- Add to migrations.ts CREATE TABLE IF NOT EXISTS stolen_items( id TEXT PRIMARY KEY, item_id TEXT NOT NULL, stolen_from TEXT NOT NULL, stolen_by TEXT NOT NULL, stolen_at TEXT NOT NULL, stolen_location TEXT, heat_level TEXT NOT NULL DEFAULT 'burning' CHECK (heat_level IN ('burning', 'hot', 'warm', 'cool', 'cold')), heat_updated_at TEXT NOT NULL, reported_to_guards INTEGER NOT NULL DEFAULT 0, bounty INTEGER NOT NULL DEFAULT 0, witnesses TEXT NOT NULL DEFAULT '[]', recovered INTEGER NOT NULL DEFAULT 0, recovered_at TEXT, fenced INTEGER NOT NULL DEFAULT 0, fenced_at TEXT, fenced_to TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY(stolen_from) REFERENCES characters(id), FOREIGN KEY(stolen_by) REFERENCES characters(id) ); CREATE INDEX IF NOT EXISTS idx_stolen_items_item ON stolen_items(item_id); CREATE INDEX IF NOT EXISTS idx_stolen_items_thief ON stolen_items(stolen_by); CREATE INDEX IF NOT EXISTS idx_stolen_items_victim ON stolen_items(stolen_from); CREATE INDEX IF NOT EXISTS idx_stolen_items_heat ON stolen_items(heat_level); CREATE TABLE IF NOT EXISTS fence_npcs( npc_id TEXT PRIMARY KEY, faction_id TEXT, buy_rate REAL NOT NULL DEFAULT 0.4, max_heat_level TEXT NOT NULL DEFAULT 'hot', daily_heat_capacity INTEGER NOT NULL DEFAULT 100, current_daily_heat INTEGER NOT NULL DEFAULT 0, last_reset_at TEXT NOT NULL, specializations TEXT NOT NULL DEFAULT '[]', cooldown_days INTEGER NOT NULL DEFAULT 7, reputation INTEGER NOT NULL DEFAULT 50, FOREIGN KEY(npc_id) REFERENCES characters(id) ON DELETE CASCADE ); ``` ### Repository Layer ```typescript // src/storage/repos/theft.repo.ts export class TheftRepository { constructor(private db: Database.Database) {} // ============================================================ // STOLEN ITEM OPERATIONS // ============================================================ /** * Record a theft event */ recordTheft(record: Omit<StolenItemRecord, 'id' | 'createdAt' | 'updatedAt' | 'heatUpdatedAt' | 'recovered' | 'fenced'>): StolenItemRecord; /** * Check if an item is stolen */ isStolen(itemId: string): boolean; /** * Get theft record for an item */ getTheftRecord(itemId: string): StolenItemRecord | null; /** * Get all stolen items held by a character */ getStolenItemsHeldBy(characterId: string): StolenItemRecord[]; /** * Get all items stolen FROM a character */ getItemsStolenFrom(characterId: string): StolenItemRecord[]; /** * Update heat level (called by decay system) */ updateHeatLevel(itemId: string, newHeat: HeatLevel): void; /** * Mark item as recovered (returned to owner or confiscated) */ markRecovered(itemId: string): void; /** * Mark item as fenced (sold to fence, stolen flag can be removed later) */ markFenced(itemId: string, fenceId: string): void; /** * Remove stolen flag completely (after cooldown period) */ clearStolenFlag(itemId: string): void; /** * Process heat decay for all stolen items (call on game time advance) */ processHeatDecay(gameTimeAdvancedDays: number): void; // ============================================================ // FENCE OPERATIONS // ============================================================ /** * Register an NPC as a fence */ registerFence(fence: Omit<FenceNpc, 'currentDailyHeat' | 'lastResetAt'>): void; /** * Get fence data for an NPC */ getFence(npcId: string): FenceNpc | null; /** * List all fences (optionally by faction) */ listFences(factionId?: string): FenceNpc[]; /** * Check if fence will accept an item */ canFenceAccept(fenceId: string, stolenRecord: StolenItemRecord): { accepted: boolean; reason?: string; price?: number; }; /** * Record a fence transaction */ recordFenceTransaction(fenceId: string, stolenRecord: StolenItemRecord, price: number): void; /** * Reset daily heat capacity for all fences (call on game day advance) */ resetFenceDailyCapacity(): void; } ``` ### MCP Tools ```typescript // New tools for theft system export const TheftTools = { STEAL_ITEM: { name: 'steal_item', description: `Record a theft event. This marks an item as stolen from one character and gives it to another. The theft creates a "hot" item record that: - Can be detected by the original owner - May trigger guard searches - Affects NPC disposition if detected - Decays over time (burning → 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 is carrying any stolen items. Useful for guard searches, merchant inspections, etc.`, 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 an NPC recognizes a stolen item as theirs or as stolen. - Original owner always recognizes their items - Guards have a chance based on bounty/heat - Other NPCs rarely recognize unless item is famous`, 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 a stolen item to a fence NPC. Fences pay reduced prices but don't ask questions. After cooldown period, the stolen flag is removed.`, inputSchema: z.object({ sellerId: z.string(), fenceId: z.string(), itemId: z.string() }) }, 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(), maxHeatLevel: HeatLevelSchema.optional(), specializations: z.array(z.string()).optional() }) }, 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() }) }, ADVANCE_HEAT_DECAY: { name: 'advance_heat_decay', description: 'Process heat decay for all stolen items (call when game time advances).', inputSchema: z.object({ daysAdvanced: z.number().int().min(1) }) } }; ``` ### Integration with Existing Systems #### NPC Memory Integration When an NPC recognizes a stolen item: ```typescript // Automatic relationship update npcMemoryRepo.upsertRelationship({ characterId: thief.id, npcId: victim.id, familiarity: 'enemy', // Caught stealing disposition: 'hostile', notes: `Caught stealing ${item.name} on ${timestamp}` }); npcMemoryRepo.recordMemory({ characterId: thief.id, npcId: victim.id, summary: `${thief.name} stole my ${item.name}!`, importance: 'critical', topics: ['theft', 'crime', item.name] }); ``` #### Inventory Integration The `inventory.repo.ts` needs a hook for theft tracking: ```typescript // In transferItem or removeItem for theft context if (isTheftContext) { theftRepo.recordTheft({ ... }); } ``` --- ## Part 2: Corpse/Loot System (FAILED-004) ### Core Data Model ```typescript // New schema: src/schema/corpse.ts import { z } from 'zod'; export const CorpseStateSchema = z.enum(['fresh', 'decaying', 'skeletal', 'gone']); export type CorpseState = z.infer<typeof CorpseStateSchema>; export const CorpseSchema = z.object({ id: z.string().uuid(), characterId: z.string().describe('Original character/creature ID'), characterName: z.string(), characterType: z.enum(['pc', 'npc', 'enemy', 'neutral']), // Location worldId: z.string().nullable(), regionId: z.string().nullable(), position: z.object({ x: z.number(), y: z.number() }).nullable(), encounterId: z.string().nullable().describe('Encounter where death occurred'), // State state: CorpseStateSchema.default('fresh'), stateUpdatedAt: z.string().datetime(), // Loot lootGenerated: z.boolean().default(false), looted: z.boolean().default(false), lootedBy: z.string().nullable(), lootedAt: z.string().datetime().nullable(), // Harvesting harvestable: z.boolean().default(false), harvestableResources: z.array(z.object({ resourceType: z.string(), quantity: z.number().int(), harvested: z.boolean().default(false) })).default([]), createdAt: z.string().datetime(), updatedAt: z.string().datetime() }); export type Corpse = z.infer<typeof CorpseSchema>; export const LootTableEntrySchema = z.object({ itemId: z.string().nullable().describe('Specific item ID, or null for template-based'), itemTemplateId: z.string().nullable().describe('Item template to instantiate'), itemName: z.string().optional().describe('Name for dynamic item creation'), quantity: z.object({ min: z.number().int().min(0), max: z.number().int().min(0) }), weight: z.number().min(0).max(1).describe('Drop probability 0-1'), conditions: z.array(z.string()).optional().describe('Conditions for this drop') }); export const LootTableSchema = z.object({ id: z.string(), name: z.string(), creatureTypes: z.array(z.string()).describe('Creature types this applies to (e.g., "goblin", "dragon")'), crRange: z.object({ min: z.number().min(0), max: z.number().min(0) }).optional(), guaranteedDrops: z.array(LootTableEntrySchema).default([]), randomDrops: z.array(LootTableEntrySchema).default([]), currencyRange: z.object({ gold: z.object({ min: z.number(), max: z.number() }), silver: z.object({ min: z.number(), max: z.number() }).optional(), copper: z.object({ min: z.number(), max: z.number() }).optional() }).optional(), harvestableResources: z.array(z.object({ resourceType: z.string(), quantity: z.object({ min: z.number(), max: z.number() }), dcRequired: z.number().int().optional() })).optional() }); export type LootTable = z.infer<typeof LootTableSchema>; // Corpse decay rules (in game hours) export const CORPSE_DECAY_RULES = { fresh_to_decaying: 24, // 1 day decaying_to_skeletal: 168, // 1 week skeletal_to_gone: 720 // 30 days }; ``` ### Database Tables ```sql CREATE TABLE IF NOT EXISTS corpses( id TEXT PRIMARY KEY, character_id TEXT NOT NULL, character_name TEXT NOT NULL, character_type TEXT NOT NULL, world_id TEXT, region_id TEXT, position_x INTEGER, position_y INTEGER, encounter_id TEXT, state TEXT NOT NULL DEFAULT 'fresh' CHECK (state IN ('fresh', 'decaying', 'skeletal', 'gone')), state_updated_at TEXT NOT NULL, loot_generated INTEGER NOT NULL DEFAULT 0, looted INTEGER NOT NULL DEFAULT 0, looted_by TEXT, looted_at TEXT, harvestable INTEGER NOT NULL DEFAULT 0, harvestable_resources TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_corpses_encounter ON corpses(encounter_id); CREATE INDEX IF NOT EXISTS idx_corpses_world_position ON corpses(world_id, position_x, position_y); CREATE INDEX IF NOT EXISTS idx_corpses_state ON corpses(state); CREATE TABLE IF NOT EXISTS corpse_inventory( corpse_id TEXT NOT NULL, item_id TEXT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, looted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(corpse_id, item_id), FOREIGN KEY(corpse_id) REFERENCES corpses(id) ON DELETE CASCADE, FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS loot_tables( id TEXT PRIMARY KEY, name TEXT NOT NULL, creature_types TEXT NOT NULL DEFAULT '[]', cr_min REAL, cr_max REAL, guaranteed_drops TEXT NOT NULL DEFAULT '[]', random_drops TEXT NOT NULL DEFAULT '[]', currency_range TEXT, harvestable_resources TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_loot_tables_name ON loot_tables(name); ``` ### Repository Layer ```typescript // src/storage/repos/corpse.repo.ts export class CorpseRepository { constructor(private db: Database.Database) {} /** * Create a corpse when a character dies */ createFromDeath(characterId: string, options: { encounterId?: string; position?: { x: number; y: number }; worldId?: string; regionId?: string; }): Corpse; /** * Get corpse by ID */ findById(id: string): Corpse | null; /** * Get corpse for a specific character */ findByCharacterId(characterId: string): Corpse | null; /** * Get all corpses in an encounter */ findByEncounterId(encounterId: string): Corpse[]; /** * Get corpses in a region */ findByRegion(worldId: string, regionId: string): Corpse[]; /** * Get corpses at a specific position */ findAtPosition(worldId: string, x: number, y: number): Corpse[]; /** * Generate loot for a corpse based on loot tables */ generateLoot(corpseId: string, creatureType: string, cr?: number): void; /** * Get items in corpse inventory (not yet looted) */ getCorpseInventory(corpseId: string): Array<{ itemId: string; quantity: number; looted: boolean }>; /** * Loot an item from a corpse */ lootItem(corpseId: string, itemId: string, looterId: string, quantity?: number): boolean; /** * Loot all items from a corpse */ lootAll(corpseId: string, looterId: string): Array<{ itemId: string; quantity: number }>; /** * Harvest resources from a corpse */ harvestResource(corpseId: string, resourceType: string, harvesterId: string): { success: boolean; quantity: number; resourceType: string; }; /** * Process corpse decay (call on game time advance) */ processDecay(gameTimeAdvancedHours: number): void; /** * Clean up gone corpses */ cleanupGoneCorpses(): number; } // src/storage/repos/loot-table.repo.ts export class LootTableRepository { constructor(private db: Database.Database) {} create(table: LootTable): void; findById(id: string): LootTable | null; findByCreatureType(type: string, cr?: number): LootTable | null; list(): LootTable[]; update(id: string, updates: Partial<LootTable>): LootTable | null; delete(id: string): boolean; /** * Roll on a loot table and return items to add */ rollLoot(tableId: string, seed?: string): Array<{ itemId?: string; itemTemplateId?: string; itemName?: string; quantity: number; }>; } ``` ### Combat Integration The key integration point is `handleEndEncounter` in `combat-tools.ts`: ```typescript // In handleEndEncounter, after syncing HP: // FAILED-004: Create corpses for defeated combatants const corpseRepo = new CorpseRepository(db); const lootTableRepo = new LootTableRepository(db); for (const participant of finalState.participants) { if (participant.hp <= 0 && participant.isEnemy) { // Create corpse const corpse = corpseRepo.createFromDeath(participant.id, { encounterId: parsed.encounterId, position: participant.position }); // Try to find and generate loot const lootTable = lootTableRepo.findByCreatureType( participant.creatureType || 'generic', participant.cr ); if (lootTable) { corpseRepo.generateLoot(corpse.id, participant.creatureType, participant.cr); } else { // Fallback: Transfer participant's inventory to corpse // (if they had any items) } } } ``` ### MCP Tools ```typescript export const CorpseTools = { GET_CORPSE: { name: 'get_corpse', description: 'Get details about a corpse, including loot and harvestable resources.', inputSchema: z.object({ corpseId: z.string() }) }, LIST_CORPSES_IN_ENCOUNTER: { name: 'list_corpses_in_encounter', description: 'List all corpses from a combat encounter.', inputSchema: z.object({ encounterId: z.string() }) }, LIST_CORPSES_NEARBY: { name: 'list_corpses_nearby', description: 'List corpses near a position in the world.', inputSchema: z.object({ worldId: z.string(), x: z.number(), y: z.number(), radius: z.number().int().min(1).max(10).default(3) }) }, LOOT_CORPSE: { name: 'loot_corpse', description: `Loot items from a corpse. Can loot specific items or all at once. Example (loot specific): { "characterId": "hero-1", "corpseId": "corpse-goblin-1", "itemId": "rusty-sword", "quantity": 1 } Example (loot all): { "characterId": "hero-1", "corpseId": "corpse-goblin-1", "lootAll": true }`, inputSchema: z.object({ characterId: z.string().describe('Character doing the looting'), corpseId: z.string(), itemId: z.string().optional().describe('Specific item to loot'), quantity: z.number().int().min(1).optional(), lootAll: z.boolean().optional().describe('Loot everything from the corpse') }) }, HARVEST_CORPSE: { name: 'harvest_corpse', description: `Harvest resources from a corpse (e.g., dragon scales, wolf pelts). May require a skill check depending on the resource.`, inputSchema: z.object({ characterId: z.string(), corpseId: z.string(), resourceType: z.string(), skillBonus: z.number().int().optional().describe('Skill bonus for harvest check') }) }, CREATE_LOOT_TABLE: { name: 'create_loot_table', description: 'Create a loot table for a creature type.', inputSchema: LootTableSchema.omit({ id: true }) }, GET_LOOT_TABLE: { name: 'get_loot_table', description: 'Get a loot table by ID or creature type.', inputSchema: z.object({ id: z.string().optional(), creatureType: z.string().optional(), cr: z.number().optional() }) } }; ``` --- ## Part 3: Edge Cases & TDD Failure Points ### Theft System Edge Cases | Scenario | Expected Behavior | Test Case | |----------|-------------------|-----------| | Steal same item twice | Record updates, heat resets to burning | `theft-double-steal.test.ts` | | Steal from corpse | Not tracked as theft (looting) | `theft-corpse-loot.test.ts` | | Transfer stolen item to accomplice | Theft record follows item, heat preserved | `theft-transfer.test.ts` | | Original owner dies | Heat still decays, but can't be returned | `theft-owner-death.test.ts` | | Fence at capacity | Transaction rejected | `fence-capacity.test.ts` | | Fence faction requirement | Non-guild members pay more/rejected | `fence-faction.test.ts` | | Unique item sold to fence | Stolen flag eventually clears but item traceable | `fence-unique-item.test.ts` | | Guard search, no stolen items | Clean result, no reputation change | `search-clean.test.ts` | | Guard search, hot items | Detection based on heat level | `search-heat-detection.test.ts` | | Item recognition in conversation | NPC mentions item if visible and recognized | `recognition-dialogue.test.ts` | ### Corpse/Loot System Edge Cases | Scenario | Expected Behavior | Test Case | |----------|-------------------|-----------| | PC dies, becomes corpse | Corpse created but marked differently (resurrection possible) | `pc-death-corpse.test.ts` | | Enemy has no loot table | Empty corpse or generic loot | `no-loot-table.test.ts` | | Loot from decayed corpse | Some items may be destroyed | `decayed-corpse-loot.test.ts` | | Skeletal corpse | Only bones/durable items remain | `skeletal-corpse.test.ts` | | Corpse gone, items still in DB | Items deleted on cleanup | `corpse-cleanup.test.ts` | | Harvest requires DC, no skill | Automatic failure or default DC | `harvest-no-skill.test.ts` | | Multiple characters loot same corpse | First come, first served | `concurrent-loot.test.ts` | | Loot stolen item from corpse | Theft provenance preserved | `loot-stolen-from-corpse.test.ts` | | Corpse in encounter that ended | Corpse persists in region | `corpse-after-encounter.test.ts` | | Resurrect character with looted corpse | Corpse becomes invalid, character HP = 1 | `resurrect-looted.test.ts` | ### Cross-System Edge Cases | Scenario | Systems Involved | Expected Behavior | |----------|------------------|-------------------| | Steal item, victim dies, loot corpse | Theft + Corpse | Item on corpse shows stolen provenance | | Fence stolen item from corpse | Theft + Corpse + Fence | Works, but heat still applies | | Kill witness to theft | Theft + Combat + Corpse | Witness removed from record | | NPC recognizes stolen item, attacks | Theft + NPC Memory + Combat | Combat initiated, relationship hostile | | Party member caught stealing | Theft + Party | Party reputation affected | --- ## Part 4: Implementation Plan ### Phase 1: Schema & Migrations (1 hour) 1. Create `src/schema/theft.ts` 2. Create `src/schema/corpse.ts` 3. Update `src/storage/migrations.ts` with new tables 4. Run migrations on test database ### Phase 2: Repository Layer (2 hours) 1. Implement `TheftRepository` 2. Implement `CorpseRepository` 3. Implement `LootTableRepository` 4. Write repository unit tests ### Phase 3: MCP Tools (2 hours) 1. Implement theft tools in `src/server/theft-tools.ts` 2. Implement corpse/loot tools in `src/server/corpse-tools.ts` 3. Register tools in MCP server 4. Write integration tests ### Phase 4: Combat Integration (1 hour) 1. Update `handleEndEncounter` to create corpses 2. Update combat state to track creature types/CR 3. Test combat → corpse flow ### Phase 5: Testing & Documentation (1 hour) 1. Write TDD test suite covering edge cases 2. Update `Agents/TOOL_CATALOG.md` 3. Update `Agents/EMERGENT_DISCOVERY_LOG.md` --- ## Part 5: Test File Structure ``` tests/ ├── server/ │ ├── theft.test.ts # Core theft functionality │ ├── theft-fence.test.ts # Fence mechanics │ ├── theft-detection.test.ts # Recognition and searches │ ├── corpse.test.ts # Core corpse functionality │ ├── corpse-loot.test.ts # Looting mechanics │ ├── corpse-harvest.test.ts # Resource harvesting │ └── loot-tables.test.ts # Loot table CRUD and rolling ├── integration/ │ ├── theft-inventory.test.ts # Theft + inventory system │ ├── theft-npc-memory.test.ts # Theft + NPC relationships │ ├── combat-corpse.test.ts # Combat → corpse creation │ └── theft-corpse-fence.test.ts # Full theft pipeline └── edge-cases/ ├── theft-edge-cases.test.ts └── corpse-edge-cases.test.ts ``` --- ## Part 6: Acceptance Criteria ### HIGH-008 (Stolen Items) Complete When: - [ ] `steal_item` tool creates theft record with heat tracking - [ ] `check_item_stolen` returns provenance data - [ ] Original owner always recognizes their items - [ ] Heat decays over game time - [ ] Fences buy stolen goods at reduced rate - [ ] Fences have daily capacity limits - [ ] Stolen flag can be cleared after cooldown - [ ] Guards detect stolen items based on heat level - [ ] NPC relationships update on theft detection - [ ] All 10 theft edge case tests pass ### FAILED-004 (Corpse/Loot) Complete When: - [ ] Corpses created automatically when enemies die in combat - [ ] Corpses have position data from combat - [ ] Loot tables can be defined per creature type - [ ] `loot_corpse` transfers items to character - [ ] `harvest_corpse` extracts resources with optional DC - [ ] Corpses decay over game time - [ ] Decayed corpses have reduced/destroyed loot - [ ] Skeletal corpses cleaned up automatically - [ ] All 10 corpse edge case tests pass - [ ] Combat integration creates corpses on encounter end --- ## Appendix: Default Loot Tables ```typescript export const DEFAULT_LOOT_TABLES: LootTable[] = [ { id: 'loot-goblin', name: 'Goblin Loot', creatureTypes: ['goblin', 'hobgoblin'], crRange: { min: 0, max: 2 }, guaranteedDrops: [], randomDrops: [ { itemName: 'Rusty Scimitar', quantity: { min: 0, max: 1 }, weight: 0.3 }, { itemName: 'Shortbow', quantity: { min: 0, max: 1 }, weight: 0.2 }, { itemName: 'Crude Arrow', quantity: { min: 1, max: 10 }, weight: 0.5 } ], currencyRange: { gold: { min: 0, max: 2 }, silver: { min: 1, max: 10 }, copper: { min: 5, max: 30 } }, harvestableResources: [ { resourceType: 'goblin ear', quantity: { min: 1, max: 2 }, dcRequired: 10 } ] }, { id: 'loot-dragon', name: 'Dragon Loot', creatureTypes: ['dragon', 'drake', 'wyvern'], crRange: { min: 5, max: 30 }, guaranteedDrops: [ { itemName: 'Dragon Scale', quantity: { min: 3, max: 10 }, weight: 1.0 } ], randomDrops: [ { itemName: 'Dragon Tooth', quantity: { min: 1, max: 4 }, weight: 0.6 }, { itemName: 'Dragon Blood Vial', quantity: { min: 0, max: 2 }, weight: 0.3 } ], currencyRange: { gold: { min: 500, max: 5000 } }, harvestableResources: [ { resourceType: 'dragon hide', quantity: { min: 5, max: 20 }, dcRequired: 15 }, { resourceType: 'dragon heart', quantity: { min: 1, max: 1 }, dcRequired: 20 } ] } ]; ``` --- ## Ready for Implementation This design document provides a complete, modular, TDD-ready specification for both systems. The implementation follows existing patterns in the codebase (Zod schemas, repository layer, MCP tool registration) and integrates cleanly with combat, inventory, and NPC memory systems. **Next Step:** Begin with Phase 1 (Schema & Migrations) and write failing tests for the core repository methods.

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