Skip to main content
Glama
theft-corpse.test.ts71.1 kB
/** * THEFT AND CORPSE SYSTEMS TESTS * Comprehensive edge case testing for HIGH-008 (Theft) and FAILED-004 (Corpse/Loot) * * Run: npm test -- tests/server/theft-corpse.test.ts */ import { describe, test, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; import { v4 as uuid } from 'uuid'; import { migrate } from '../../src/storage/migrations.js'; import { TheftRepository } from '../../src/storage/repos/theft.repo.js'; import { CorpseRepository } from '../../src/storage/repos/corpse.repo.js'; import { CharacterRepository } from '../../src/storage/repos/character.repo.js'; import { InventoryRepository } from '../../src/storage/repos/inventory.repo.js'; import type { HeatLevel } from '../../src/schema/theft.js'; import type { CorpseState } from '../../src/schema/corpse.js'; import { HEAT_VALUES, HEAT_DECAY_RULES, compareHeatLevels } from '../../src/schema/theft.js'; import { CORPSE_DECAY_RULES } from '../../src/schema/corpse.js'; // Test utilities let db: Database.Database; let theftRepo: TheftRepository; let corpseRepo: CorpseRepository; let charRepo: CharacterRepository; let invRepo: InventoryRepository; beforeEach(() => { db = new Database(':memory:'); migrate(db); theftRepo = new TheftRepository(db); corpseRepo = new CorpseRepository(db); charRepo = new CharacterRepository(db); invRepo = new InventoryRepository(db); }); afterEach(() => { db.close(); }); // Helper functions function createCharacter(overrides: Partial<any> = {}) { const id = overrides.id || uuid(); charRepo.create({ id, name: overrides.name || 'Test Character', worldId: 'test-world', type: overrides.type || 'npc', stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, hp: 20, maxHp: 20, ac: 10, level: 1, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides }); return charRepo.findById(id)!; } function createItem(name: string, value: number = 100) { const itemId = uuid(); const now = new Date().toISOString(); // Create item in items table db.prepare(` INSERT INTO items (id, name, description, type, weight, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(itemId, name, '', 'misc', 1, value, now, now); return itemId; } function addItemToInventory(charId: string, itemId: string, quantity: number = 1) { invRepo.addItem(charId, itemId, quantity); } // ============================================================================ // CATEGORY 1: THEFT RECORDING // ============================================================================ describe('Category 1: Theft Recording', () => { test('1.1 - recording a theft creates stolen item record', () => { const merchant = createCharacter({ name: 'Merchant' }); const thief = createCharacter({ name: 'Thief' }); const itemId = createItem('Ruby Necklace', 500); addItemToInventory(merchant.id, itemId); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id, stolenLocation: 'market-square' }); expect(record.itemId).toBe(itemId); expect(record.stolenFrom).toBe(merchant.id); expect(record.stolenBy).toBe(thief.id); expect(record.stolenLocation).toBe('market-square'); }); test('1.2 - new theft starts at burning heat level', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Gold Ring'); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); expect(record.heatLevel).toBe('burning'); }); test('1.3 - theft record includes witnesses', () => { const merchant = createCharacter(); const thief = createCharacter(); const witness1 = createCharacter({ name: 'Witness 1' }); const witness2 = createCharacter({ name: 'Witness 2' }); const itemId = createItem('Diamond'); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id, witnesses: [witness1.id, witness2.id] }); expect(record.witnesses).toHaveLength(2); expect(record.witnesses).toContain(witness1.id); expect(record.witnesses).toContain(witness2.id); }); test('1.4 - theft record not reported to guards by default', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Sword'); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); expect(record.reportedToGuards).toBe(false); expect(record.bounty).toBe(0); }); test('1.5 - can check if item is stolen', () => { const merchant = createCharacter(); const thief = createCharacter(); const stolenItem = createItem('Stolen Item'); const legitimateItem = createItem('Legitimate Item'); theftRepo.recordTheft({ itemId: stolenItem, stolenFrom: merchant.id, stolenBy: thief.id }); expect(theftRepo.isStolen(stolenItem)).toBe(true); expect(theftRepo.isStolen(legitimateItem)).toBe(false); }); test('1.6 - theft without location is valid', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Ring'); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); expect(record.stolenLocation).toBeNull(); }); test('1.7 - theft without witnesses is valid', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Coin Purse'); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); expect(record.witnesses).toEqual([]); }); // EDGE-001: Self-theft validation test('1.8 - EDGE-001: self-theft should be rejected (thief === victim)', () => { const character = createCharacter({ name: 'Self-Thief' }); const itemId = createItem('Self Item'); addItemToInventory(character.id, itemId); expect(() => { theftRepo.recordTheft({ itemId, stolenFrom: character.id, stolenBy: character.id // Same as stolenFrom - should fail! }); }).toThrow('A character cannot steal from themselves'); }); }); // ============================================================================ // CATEGORY 2: HEAT SYSTEM // ============================================================================ describe('Category 2: Heat System', () => { test('2.1 - heat levels have correct values', () => { expect(HEAT_VALUES.burning).toBe(100); expect(HEAT_VALUES.hot).toBe(50); expect(HEAT_VALUES.warm).toBe(25); expect(HEAT_VALUES.cool).toBe(10); expect(HEAT_VALUES.cold).toBe(5); }); test('2.2 - heat decay rules are correct', () => { expect(HEAT_DECAY_RULES.burning_to_hot).toBe(1); expect(HEAT_DECAY_RULES.hot_to_warm).toBe(3); expect(HEAT_DECAY_RULES.warm_to_cool).toBe(7); expect(HEAT_DECAY_RULES.cool_to_cold).toBe(14); }); test('2.3 - heat comparison works correctly', () => { expect(compareHeatLevels('burning', 'cold')).toBeGreaterThan(0); expect(compareHeatLevels('cold', 'burning')).toBeLessThan(0); expect(compareHeatLevels('hot', 'hot')).toBe(0); }); test('2.4 - can update heat level', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Gem'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.updateHeatLevel(itemId, 'hot'); const record = theftRepo.getTheftRecord(itemId); expect(record?.heatLevel).toBe('hot'); }); test('2.5 - heat decay processing advances heat levels', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Bracelet'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Advance 2 days - should decay from burning to hot const changes = theftRepo.processHeatDecay(2); expect(changes.length).toBeGreaterThanOrEqual(1); const change = changes.find(c => c.itemId === itemId); expect(change?.oldHeat).toBe('burning'); expect(change?.newHeat).toBe('hot'); }); test('2.6 - heat decay respects thresholds', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Tiara'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Advance 0 days - should not change const changes = theftRepo.processHeatDecay(0); const itemChange = changes.find(c => c.itemId === itemId); expect(itemChange).toBeUndefined(); const record = theftRepo.getTheftRecord(itemId); expect(record?.heatLevel).toBe('burning'); }); }); // ============================================================================ // CATEGORY 3: FENCE OPERATIONS // ============================================================================ describe('Category 3: Fence Operations', () => { test('3.1 - can register a fence', () => { const fenceNpc = createCharacter({ name: 'Shady Dealer' }); const fence = theftRepo.registerFence({ npcId: fenceNpc.id, buyRate: 0.4, maxHeatLevel: 'hot', dailyHeatCapacity: 100 }); expect(fence.npcId).toBe(fenceNpc.id); expect(fence.buyRate).toBe(0.4); expect(fence.maxHeatLevel).toBe('hot'); }); test('3.2 - fence has default values', () => { const fenceNpc = createCharacter(); const fence = theftRepo.registerFence({ npcId: fenceNpc.id }); expect(fence.buyRate).toBe(0.4); expect(fence.maxHeatLevel).toBe('hot'); expect(fence.dailyHeatCapacity).toBe(100); expect(fence.cooldownDays).toBe(7); expect(fence.reputation).toBe(50); }); test('3.3 - can list all fences', () => { const fence1 = createCharacter({ name: 'Fence 1' }); const fence2 = createCharacter({ name: 'Fence 2' }); theftRepo.registerFence({ npcId: fence1.id }); theftRepo.registerFence({ npcId: fence2.id }); const fences = theftRepo.listFences(); expect(fences.length).toBe(2); }); test('3.4 - can list fences by faction', () => { const guildFence = createCharacter({ name: 'Guild Fence' }); const independentFence = createCharacter({ name: 'Independent' }); theftRepo.registerFence({ npcId: guildFence.id, factionId: 'thieves-guild' }); theftRepo.registerFence({ npcId: independentFence.id }); const guildFences = theftRepo.listFences('thieves-guild'); expect(guildFences.length).toBe(1); expect(guildFences[0].npcId).toBe(guildFence.id); }); test('3.5 - fence accepts items within heat tolerance', () => { const fenceNpc = createCharacter(); const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Loot', 200); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'hot', buyRate: 0.5 }); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Burning is hotter than hot - should reject const result = theftRepo.canFenceAccept(fenceNpc.id, record, 200); expect(result.accepted).toBe(false); expect(result.reason).toContain('too hot'); }); test('3.6 - fence accepts cooled down items', () => { const fenceNpc = createCharacter(); const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Cooled Loot', 200); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'hot', buyRate: 0.5, dailyHeatCapacity: 200 }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Cool it down theftRepo.updateHeatLevel(itemId, 'warm'); const record = theftRepo.getTheftRecord(itemId)!; const result = theftRepo.canFenceAccept(fenceNpc.id, record, 200); expect(result.accepted).toBe(true); expect(result.price).toBe(100); // 200 * 0.5 }); test('3.7 - fence respects daily capacity', () => { const fenceNpc = createCharacter(); const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Heavy Item', 500); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'warm', dailyHeatCapacity: 20 }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Cool it to warm (value 25) theftRepo.updateHeatLevel(itemId, 'warm'); const record = theftRepo.getTheftRecord(itemId)!; const result = theftRepo.canFenceAccept(fenceNpc.id, record, 500); expect(result.accepted).toBe(false); expect(result.reason).toContain('capacity'); }); test('3.8 - fence transaction updates heat capacity', () => { const fenceNpc = createCharacter(); const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Small Item', 100); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold', dailyHeatCapacity: 100 }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.updateHeatLevel(itemId, 'cold'); theftRepo.recordFenceTransaction(fenceNpc.id, itemId, 'cold'); const fence = theftRepo.getFence(fenceNpc.id); expect(fence?.currentDailyHeat).toBe(5); // cold = 5 }); test('3.9 - daily capacity resets', () => { const fenceNpc = createCharacter(); theftRepo.registerFence({ npcId: fenceNpc.id, dailyHeatCapacity: 100 }); // Manually update current heat db.prepare('UPDATE fence_npcs SET current_daily_heat = 50 WHERE npc_id = ?') .run(fenceNpc.id); theftRepo.resetFenceDailyCapacity(); const fence = theftRepo.getFence(fenceNpc.id); expect(fence?.currentDailyHeat).toBe(0); }); // EDGE-006: Victim/fence conflict detection test('3.10 - EDGE-006: registering victim as fence throws error', () => { const victimMerchant = createCharacter({ name: 'Victim Merchant' }); const thief = createCharacter({ name: 'Thief' }); const itemId = createItem('Stolen Item'); addItemToInventory(victimMerchant.id, itemId); // First, steal from the merchant theftRepo.recordTheft({ itemId, stolenFrom: victimMerchant.id, stolenBy: thief.id }); // Now attempt to register the victim as a fence - should throw error expect(() => { theftRepo.registerFence({ npcId: victimMerchant.id, buyRate: 0.3 }); }).toThrow('Cannot register a theft victim as a fence'); }); test('3.11 - EDGE-006: registering non-victim as fence succeeds', () => { const normalFence = createCharacter({ name: 'Normal Fence' }); const result = theftRepo.registerFence({ npcId: normalFence.id, buyRate: 0.5 }); expect(result.npcId).toBe(normalFence.id); expect(result.buyRate).toBe(0.5); }); }); // ============================================================================ // CATEGORY 4: THEFT RESOLUTION // ============================================================================ describe('Category 4: Theft Resolution', () => { test('4.1 - can report theft to guards', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Valuable'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.reportToGuards(itemId, 100); const record = theftRepo.getTheftRecord(itemId); expect(record?.reportedToGuards).toBe(true); expect(record?.bounty).toBe(100); }); test('4.2 - can mark item as recovered', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Recovered Item'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.markRecovered(itemId); expect(theftRepo.isStolen(itemId)).toBe(false); }); test('4.3 - can mark item as fenced', () => { const merchant = createCharacter(); const thief = createCharacter(); const fenceNpc = createCharacter(); const itemId = createItem('Fenced Item'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.markFenced(itemId, fenceNpc.id); const record = theftRepo.getTheftRecord(itemId); expect(record?.fenced).toBe(true); expect(record?.fencedTo).toBe(fenceNpc.id); }); test('4.4 - can clear stolen flag completely', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Cleared Item'); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.clearStolenFlag(itemId); const record = theftRepo.getTheftRecord(itemId); expect(record).toBeNull(); }); test('4.5 - get all active thefts', () => { const merchant = createCharacter(); const thief = createCharacter(); const item1 = createItem('Item 1'); const item2 = createItem('Item 2'); const item3 = createItem('Item 3'); theftRepo.recordTheft({ itemId: item1, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.recordTheft({ itemId: item2, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.recordTheft({ itemId: item3, stolenFrom: merchant.id, stolenBy: thief.id }); // Recover one theftRepo.markRecovered(item2); const active = theftRepo.getAllActiveThefts(); expect(active.length).toBe(2); }); }); // ============================================================================ // CATEGORY 5: CORPSE CREATION // ============================================================================ describe('Category 5: Corpse Creation', () => { test('5.1 - can create corpse from character', () => { const character = createCharacter({ name: 'Fallen Hero' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'pc', { worldId: 'test-world', position: { x: 5, y: 10 } } ); expect(corpse.characterId).toBe(character.id); expect(corpse.characterName).toBe('Fallen Hero'); expect(corpse.state).toBe('fresh'); }); test('5.2 - corpse starts as fresh', () => { const character = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); expect(corpse.state).toBe('fresh'); }); test('5.3 - corpse includes position', () => { const character = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world', position: { x: 10, y: 20 } } ); expect(corpse.position?.x).toBe(10); expect(corpse.position?.y).toBe(20); }); test('5.4 - corpse can have creature type for loot lookup', () => { const goblin = createCharacter({ name: 'Goblin Scout' }); const corpse = corpseRepo.createFromDeath( goblin.id, goblin.name, 'enemy', { creatureType: 'goblin', cr: 0.25, worldId: 'test-world' } ); expect(corpse.creatureType).toBe('goblin'); expect(corpse.cr).toBe(0.25); }); test('5.5 - corpse can be linked to encounter', () => { const character = createCharacter(); const encounterId = uuid(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world', encounterId } ); expect(corpse.encounterId).toBe(encounterId); }); test('5.6 - can find corpse by ID', () => { const character = createCharacter(); const created = corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); const found = corpseRepo.findById(created.id); expect(found?.id).toBe(created.id); }); test('5.7 - can find corpse by character ID', () => { const character = createCharacter(); corpseRepo.createFromDeath( character.id, character.name, 'pc', { worldId: 'test-world' } ); const found = corpseRepo.findByCharacterId(character.id); expect(found?.characterId).toBe(character.id); }); test('5.8 - can find corpses in encounter', () => { const encounterId = uuid(); const char1 = createCharacter({ name: 'Goblin 1' }); const char2 = createCharacter({ name: 'Goblin 2' }); corpseRepo.createFromDeath( char1.id, char1.name, 'enemy', { worldId: 'test-world', encounterId } ); corpseRepo.createFromDeath( char2.id, char2.name, 'enemy', { worldId: 'test-world', encounterId } ); const corpses = corpseRepo.findByEncounterId(encounterId); expect(corpses.length).toBe(2); }); }); // ============================================================================ // CATEGORY 6: CORPSE DECAY // ============================================================================ describe('Category 6: Corpse Decay', () => { test('6.1 - decay rules are correct', () => { expect(CORPSE_DECAY_RULES.fresh_to_decaying).toBe(24); expect(CORPSE_DECAY_RULES.decaying_to_skeletal).toBe(168); expect(CORPSE_DECAY_RULES.skeletal_to_gone).toBe(720); }); test('6.2 - decay processing advances corpse state', () => { const character = createCharacter(); corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); // Advance 30 hours const changes = corpseRepo.processDecay(30); expect(changes.length).toBeGreaterThanOrEqual(1); expect(changes[0].newState).toBe('decaying'); }); test('6.3 - cleanup removes gone corpses', () => { const character = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); // Manually set state to gone db.prepare('UPDATE corpses SET state = ? WHERE id = ?').run('gone', corpse.id); const removed = corpseRepo.cleanupGoneCorpses(); expect(removed).toBe(1); const found = corpseRepo.findById(corpse.id); expect(found).toBeNull(); }); test('6.4 - fresh corpse does not decay without time', () => { const character = createCharacter(); corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); const changes = corpseRepo.processDecay(0); expect(changes.length).toBe(0); }); }); // ============================================================================ // CATEGORY 7: CORPSE LOOTING // ============================================================================ describe('Category 7: Corpse Looting', () => { test('7.1 - can add items to corpse inventory', () => { const character = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); const itemId = createItem('Goblin Sword'); corpseRepo.addToCorpseInventory(corpse.id, itemId, 1); const inventory = corpseRepo.getCorpseInventory(corpse.id); expect(inventory.length).toBe(1); expect(inventory[0].itemId).toBe(itemId); }); test('7.2 - can loot single item from corpse', () => { const character = createCharacter(); const looter = createCharacter({ name: 'Looter' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); const itemId = createItem('Gold Coins'); corpseRepo.addToCorpseInventory(corpse.id, itemId, 10); const looted = corpseRepo.lootItem(corpse.id, itemId, looter.id); expect(looted.success).toBe(true); expect(looted.quantity).toBe(10); // Item should be removed from corpse (looted flag set) const available = corpseRepo.getAvailableLoot(corpse.id); expect(available.length).toBe(0); }); test('7.3 - can loot all items from corpse', () => { const character = createCharacter(); const looter = createCharacter({ name: 'Greedy Looter' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); corpseRepo.addToCorpseInventory(corpse.id, createItem('Item 1'), 1); corpseRepo.addToCorpseInventory(corpse.id, createItem('Item 2'), 1); corpseRepo.addToCorpseInventory(corpse.id, createItem('Item 3'), 1); const looted = corpseRepo.lootAll(corpse.id, looter.id); expect(looted.length).toBe(3); const available = corpseRepo.getAvailableLoot(corpse.id); expect(available.length).toBe(0); const corpseAfter = corpseRepo.findById(corpse.id); expect(corpseAfter?.looted).toBe(true); }); test('7.4 - looting marks corpse as looted', () => { const character = createCharacter(); const looter = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); corpseRepo.addToCorpseInventory(corpse.id, createItem('Loot'), 1); corpseRepo.lootAll(corpse.id, looter.id); const updated = corpseRepo.findById(corpse.id); expect(updated?.looted).toBe(true); expect(updated?.lootedBy).toBe(looter.id); }); }); // ============================================================================ // CATEGORY 8: LOOT GENERATION // ============================================================================ describe('Category 8: Loot Generation', () => { test('8.1 - can create loot table', () => { const table = corpseRepo.createLootTable({ name: 'Test Loot', creatureTypes: ['test'], guaranteedDrops: [], randomDrops: [] }); expect(table.name).toBe('Test Loot'); expect(table.creatureTypes).toContain('test'); }); test('8.2 - can find loot table by creature type', () => { corpseRepo.createLootTable({ name: 'Goblin Loot', creatureTypes: ['goblin', 'hobgoblin'], guaranteedDrops: [], randomDrops: [] }); const found = corpseRepo.findLootTableByCreatureType('goblin'); expect(found).not.toBeNull(); expect(found?.name).toBe('Goblin Loot'); }); test('8.3 - can list all loot tables', () => { corpseRepo.createLootTable({ name: 'Table 1', creatureTypes: ['type1'], guaranteedDrops: [], randomDrops: [] }); corpseRepo.createLootTable({ name: 'Table 2', creatureTypes: ['type2'], guaranteedDrops: [], randomDrops: [] }); const tables = corpseRepo.listLootTables(); expect(tables.length).toBe(2); }); test('8.4 - loot table supports CR ranges', () => { corpseRepo.createLootTable({ name: 'Low CR Loot', creatureTypes: ['lowcr'], crRange: { min: 0, max: 2 }, guaranteedDrops: [], randomDrops: [] }); const found = corpseRepo.findLootTableByCreatureType('lowcr', 1); expect(found).not.toBeNull(); }); test('8.5 - loot generation creates inventory items', () => { const character = createCharacter({ name: 'Goblin' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { creatureType: 'goblin', cr: 0.25, worldId: 'test-world' } ); // Create a simple loot table corpseRepo.createLootTable({ name: 'Test Goblin Loot', creatureTypes: ['goblin'], guaranteedDrops: [ { itemId: null, itemTemplateId: null, itemName: 'Goblin Ear', quantity: { min: 1, max: 1 }, weight: 1 } ], randomDrops: [] }); const result = corpseRepo.generateLoot(corpse.id, 'goblin', 0.25); const updated = corpseRepo.findById(corpse.id); expect(updated?.lootGenerated).toBe(true); // Check that items were generated expect(result.itemsAdded.length).toBeGreaterThanOrEqual(1); }); }); // ============================================================================ // CATEGORY 9: HARVESTING // ============================================================================ describe('Category 9: Harvesting', () => { test('9.1 - corpse can have harvestable resources', () => { const character = createCharacter({ name: 'Wolf' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { creatureType: 'wolf', worldId: 'test-world' } ); // Add harvestable resources via direct SQL (as would be done by generateLoot) const resources = [ { resourceType: 'wolf pelt', quantity: 1, harvested: false }, { resourceType: 'wolf fang', quantity: 2, harvested: false } ]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const updated = corpseRepo.findById(corpse.id)!; expect(updated.harvestable).toBe(true); expect(updated.harvestableResources.length).toBe(2); }); test('9.2 - can harvest resources', () => { const character = createCharacter({ name: 'Dead Wolf' }); const harvester = createCharacter({ name: 'Hunter' }); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); // Add harvestable resources via direct SQL const resources = [{ resourceType: 'wolf pelt', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'wolf pelt', harvester.id); expect(result.success).toBe(true); expect(result.quantity).toBe(1); }); test('9.3 - harvesting marks resource as harvested', () => { const character = createCharacter(); const harvester = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); // Add harvestable resources via direct SQL const resources = [{ resourceType: 'hide', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); corpseRepo.harvestResource(corpse.id, 'hide', harvester.id); const updated = corpseRepo.findById(corpse.id); const resource = updated?.harvestableResources.find(r => r.resourceType === 'hide'); expect(resource?.harvested).toBe(true); }); test('9.4 - cannot harvest already harvested resource', () => { const character = createCharacter(); const harvester = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'enemy', { worldId: 'test-world' } ); // Add harvestable resources via direct SQL const resources = [{ resourceType: 'bone', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); // First harvest succeeds const first = corpseRepo.harvestResource(corpse.id, 'bone', harvester.id); expect(first.success).toBe(true); // Second harvest fails const second = corpseRepo.harvestResource(corpse.id, 'bone', harvester.id); expect(second.success).toBe(false); }); }); // ============================================================================ // CATEGORY 10: EDGE CASES // ============================================================================ describe('Category 10: Edge Cases', () => { test('10.1 - non-existent fence returns null', () => { const fence = theftRepo.getFence('non-existent'); expect(fence).toBeNull(); }); test('10.2 - non-existent theft record returns null', () => { const record = theftRepo.getTheftRecord('non-existent'); expect(record).toBeNull(); }); test('10.3 - non-existent corpse returns null', () => { const corpse = corpseRepo.findById('non-existent'); expect(corpse).toBeNull(); }); test('10.4 - empty fence list returns empty array', () => { const fences = theftRepo.listFences(); expect(fences).toEqual([]); }); test('10.5 - empty loot table list returns empty array', () => { const tables = corpseRepo.listLootTables(); expect(tables).toEqual([]); }); test('10.6 - looting empty corpse returns empty array', () => { const character = createCharacter(); const looter = createCharacter(); const corpse = corpseRepo.createFromDeath( character.id, character.name, 'npc', { worldId: 'test-world' } ); const looted = corpseRepo.lootAll(corpse.id, looter.id); expect(looted).toEqual([]); }); test('10.7 - heat decay with no stolen items returns empty array', () => { const changes = theftRepo.processHeatDecay(100); expect(changes).toEqual([]); }); test('10.8 - corpse decay with no corpses returns empty array', () => { const changes = corpseRepo.processDecay(100); expect(changes).toEqual([]); }); test('10.9 - cleanup with no gone corpses returns 0', () => { const removed = corpseRepo.cleanupGoneCorpses(); expect(removed).toBe(0); }); test('10.10 - fence specializations are stored correctly', () => { const fenceNpc = createCharacter(); const fence = theftRepo.registerFence({ npcId: fenceNpc.id, specializations: ['jewelry', 'gems', 'art'] }); expect(fence.specializations).toHaveLength(3); expect(fence.specializations).toContain('jewelry'); }); }); // ============================================================================ // CATEGORY 11: OPTIONAL ITEM TRANSFERS // ============================================================================ describe('Category 11: Optional Item Transfers', () => { test('11.1 - theft without transfer keeps item in victim inventory', () => { const merchant = createCharacter({ name: 'Merchant' }); const thief = createCharacter({ name: 'Thief' }); const itemId = createItem('Stolen Ring', 100); addItemToInventory(merchant.id, itemId); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id, transferItem: false }); expect(record.transferred).toBe(false); // Item still in merchant inventory const merchantInv = invRepo.getInventory(merchant.id); expect(merchantInv.items.some(i => i.itemId === itemId)).toBe(true); // Not in thief inventory const thiefInv = invRepo.getInventory(thief.id); expect(thiefInv.items.some(i => i.itemId === itemId)).toBe(false); }); test('11.2 - theft with transfer moves item to thief inventory', () => { const merchant = createCharacter({ name: 'Victim' }); const thief = createCharacter({ name: 'Burglar' }); const itemId = createItem('Golden Chalice', 500); addItemToInventory(merchant.id, itemId); const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id, transferItem: true }); expect(record.transferred).toBe(true); // Item removed from merchant inventory const merchantInv = invRepo.getInventory(merchant.id); expect(merchantInv.items.some(i => i.itemId === itemId)).toBe(false); // Item now in thief inventory const thiefInv = invRepo.getInventory(thief.id); expect(thiefInv.items.some(i => i.itemId === itemId)).toBe(true); }); test('11.3 - loot without transfer tracks but does not add to inventory', () => { const enemy = createCharacter({ name: 'Goblin' }); const hero = createCharacter({ name: 'Hero' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const lootItem = createItem('Goblin Dagger'); corpseRepo.addToCorpseInventory(corpse.id, lootItem, 1); const result = corpseRepo.lootItem(corpse.id, lootItem, hero.id, 1, false); expect(result.success).toBe(true); expect(result.transferred).toBe(false); // Item NOT in hero inventory (narrative only) const heroInv = invRepo.getInventory(hero.id); expect(heroInv.items.some(i => i.itemId === lootItem)).toBe(false); }); test('11.4 - loot with transfer adds item to looter inventory', () => { const enemy = createCharacter({ name: 'Orc' }); const hero = createCharacter({ name: 'Adventurer' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const lootItem = createItem('Orc Blade'); corpseRepo.addToCorpseInventory(corpse.id, lootItem, 1); const result = corpseRepo.lootItem(corpse.id, lootItem, hero.id, 1, true); expect(result.success).toBe(true); expect(result.transferred).toBe(true); // Item IS in hero inventory const heroInv = invRepo.getInventory(hero.id); expect(heroInv.items.some(i => i.itemId === lootItem)).toBe(true); }); test('11.5 - lootAll with transfer adds all items to looter', () => { const enemy = createCharacter({ name: 'Bandit' }); const hero = createCharacter({ name: 'Sheriff' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const item1 = createItem('Gold Coins'); const item2 = createItem('Silver Ring'); const item3 = createItem('Dagger'); corpseRepo.addToCorpseInventory(corpse.id, item1, 10); corpseRepo.addToCorpseInventory(corpse.id, item2, 1); corpseRepo.addToCorpseInventory(corpse.id, item3, 1); const results = corpseRepo.lootAll(corpse.id, hero.id, true); expect(results.length).toBe(3); expect(results.every(r => r.transferred)).toBe(true); // All items in hero inventory const heroInv = invRepo.getInventory(hero.id); expect(heroInv.items.some(i => i.itemId === item1)).toBe(true); expect(heroInv.items.some(i => i.itemId === item2)).toBe(true); expect(heroInv.items.some(i => i.itemId === item3)).toBe(true); }); test('11.6 - theft transfer with quantity moves correct amount', () => { const merchant = createCharacter({ name: 'Coin Vendor' }); const thief = createCharacter({ name: 'Pickpocket' }); const coinId = createItem('Gold Coins', 1); addItemToInventory(merchant.id, coinId, 100); // 100 coins const record = theftRepo.recordTheft({ itemId: coinId, stolenFrom: merchant.id, stolenBy: thief.id, transferItem: true, quantity: 25 // Steal only 25 }); expect(record.transferred).toBe(true); // Merchant has 75 left const merchantInv = invRepo.getInventory(merchant.id); const merchantCoins = merchantInv.items.find(i => i.itemId === coinId); expect(merchantCoins?.quantity).toBe(75); // Thief has 25 const thiefInv = invRepo.getInventory(thief.id); const thiefCoins = thiefInv.items.find(i => i.itemId === coinId); expect(thiefCoins?.quantity).toBe(25); }); test('11.7 - default behavior is no transfer (backwards compatible)', () => { const merchant = createCharacter(); const thief = createCharacter(); const itemId = createItem('Test Item'); addItemToInventory(merchant.id, itemId); // No transferItem parameter const record = theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Should default to no transfer expect(record.transferred).toBe(false); }); }); // ============================================================================ // CATEGORY 12: CURRENCY OPERATIONS // ============================================================================ describe('Category 12: Currency Operations', () => { test('12.1 - new character starts with zero currency', () => { const character = createCharacter({ name: 'Penniless' }); const currency = invRepo.getCurrency(character.id); expect(currency.gold).toBe(0); expect(currency.silver).toBe(0); expect(currency.copper).toBe(0); }); test('12.2 - can set currency for character', () => { const character = createCharacter({ name: 'Wealthy' }); invRepo.setCurrency(character.id, { gold: 100, silver: 50, copper: 25 }); const currency = invRepo.getCurrency(character.id); expect(currency.gold).toBe(100); expect(currency.silver).toBe(50); expect(currency.copper).toBe(25); }); test('12.3 - can add currency to character', () => { const character = createCharacter({ name: 'Earner' }); invRepo.setCurrency(character.id, { gold: 10 }); const updated = invRepo.addCurrency(character.id, { gold: 5, silver: 30 }); expect(updated.gold).toBe(15); expect(updated.silver).toBe(30); expect(updated.copper).toBe(0); }); test('12.4 - can remove currency from character', () => { const character = createCharacter({ name: 'Spender' }); invRepo.setCurrency(character.id, { gold: 50, silver: 20 }); const success = invRepo.removeCurrency(character.id, { gold: 30, silver: 10 }); expect(success).toBe(true); const currency = invRepo.getCurrency(character.id); expect(currency.gold).toBe(20); expect(currency.silver).toBe(10); }); test('12.5 - cannot remove more currency than available', () => { const character = createCharacter({ name: 'Poor' }); invRepo.setCurrency(character.id, { gold: 10 }); const success = invRepo.removeCurrency(character.id, { gold: 50 }); expect(success).toBe(false); // Currency should be unchanged const currency = invRepo.getCurrency(character.id); expect(currency.gold).toBe(10); }); test('12.6 - can transfer currency between characters', () => { const giver = createCharacter({ name: 'Giver' }); const receiver = createCharacter({ name: 'Receiver' }); invRepo.setCurrency(giver.id, { gold: 100 }); const success = invRepo.transferCurrency(giver.id, receiver.id, { gold: 40 }); expect(success).toBe(true); expect(invRepo.getCurrency(giver.id).gold).toBe(60); expect(invRepo.getCurrency(receiver.id).gold).toBe(40); }); test('12.7 - currency transfer fails if insufficient funds', () => { const giver = createCharacter({ name: 'Broke Giver' }); const receiver = createCharacter({ name: 'Hopeful Receiver' }); invRepo.setCurrency(giver.id, { gold: 10 }); const success = invRepo.transferCurrency(giver.id, receiver.id, { gold: 50 }); expect(success).toBe(false); // Neither should have changed expect(invRepo.getCurrency(giver.id).gold).toBe(10); expect(invRepo.getCurrency(receiver.id).gold).toBe(0); }); test('12.8 - hasCurrency checks correctly', () => { const character = createCharacter({ name: 'Checker' }); invRepo.setCurrency(character.id, { gold: 50, silver: 25 }); expect(invRepo.hasCurrency(character.id, { gold: 30 })).toBe(true); expect(invRepo.hasCurrency(character.id, { gold: 50, silver: 25 })).toBe(true); expect(invRepo.hasCurrency(character.id, { gold: 100 })).toBe(false); }); test('12.9 - hasCurrency works with mixed denominations', () => { const character = createCharacter({ name: 'Mixed' }); // 50 gold = 5000 copper, 25 silver = 250 copper, 10 copper = 10 copper // Total: 5260 copper invRepo.setCurrency(character.id, { gold: 50, silver: 25, copper: 10 }); // 52 gold 60 copper = 5260 copper - should have exactly enough expect(invRepo.hasCurrency(character.id, { gold: 52, copper: 60 })).toBe(true); // 53 gold = 5300 copper - should not have enough expect(invRepo.hasCurrency(character.id, { gold: 53 })).toBe(false); }); test('12.10 - inventory includes currency', () => { const character = createCharacter({ name: 'Investor' }); invRepo.setCurrency(character.id, { gold: 75, silver: 50, copper: 100 }); const inventory = invRepo.getInventory(character.id); expect(inventory.currency.gold).toBe(75); expect(inventory.currency.silver).toBe(50); expect(inventory.currency.copper).toBe(100); }); }); // ============================================================================ // CATEGORY 13: FENCE PAYMENT // ============================================================================ describe('Category 13: Fence Payment', () => { test('13.1 - fence transaction without payment (narrative only)', () => { const fenceNpc = createCharacter({ name: 'Narrative Fence' }); const thief = createCharacter({ name: 'Narrative Thief' }); const merchant = createCharacter({ name: 'Victim' }); const itemId = createItem('Stolen Goods', 200); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold', buyRate: 0.5 }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Cool the item down theftRepo.updateHeatLevel(itemId, 'cold'); const result = theftRepo.recordFenceTransaction(fenceNpc.id, itemId, 'cold'); expect(result.fenced).toBe(true); expect(result.paid).toBe(false); expect(result.amountPaid).toBeUndefined(); // Thief should have no gold expect(invRepo.getCurrency(thief.id).gold).toBe(0); }); test('13.2 - fence transaction with payment', () => { const fenceNpc = createCharacter({ name: 'Paying Fence' }); const thief = createCharacter({ name: 'Paid Thief' }); const merchant = createCharacter({ name: 'Another Victim' }); const itemId = createItem('Valuable Loot', 500); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold', buyRate: 0.4 // 40% }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); // Cool the item down theftRepo.updateHeatLevel(itemId, 'cold'); // Calculate price (500 * 0.4 = 200 gold) const result = theftRepo.recordFenceTransaction(fenceNpc.id, itemId, 'cold', { paySeller: true, sellerId: thief.id, price: 200 }); expect(result.fenced).toBe(true); expect(result.paid).toBe(true); expect(result.amountPaid).toBe(200); // Thief should have the gold expect(invRepo.getCurrency(thief.id).gold).toBe(200); }); test('13.3 - fence payment accumulates with existing gold', () => { const fenceNpc = createCharacter({ name: 'Second Fence' }); const thief = createCharacter({ name: 'Repeat Thief' }); const merchant = createCharacter({ name: 'Merchant 2' }); // Thief already has some gold invRepo.setCurrency(thief.id, { gold: 50 }); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold', buyRate: 0.5 }); const itemId = createItem('Another Item', 100); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.updateHeatLevel(itemId, 'cold'); theftRepo.recordFenceTransaction(fenceNpc.id, itemId, 'cold', { paySeller: true, sellerId: thief.id, price: 50 // 100 * 0.5 }); // Thief should have 50 + 50 = 100 gold expect(invRepo.getCurrency(thief.id).gold).toBe(100); }); test('13.4 - canFenceAccept calculates correct price', () => { const fenceNpc = createCharacter({ name: 'Price Fence' }); const merchant = createCharacter({ name: 'Price Victim' }); const thief = createCharacter({ name: 'Price Thief' }); const itemId = createItem('Priced Item', 1000); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold', buyRate: 0.35, // 35% dailyHeatCapacity: 100 }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.updateHeatLevel(itemId, 'cold'); const record = theftRepo.getTheftRecord(itemId)!; const result = theftRepo.canFenceAccept(fenceNpc.id, record, 1000); expect(result.accepted).toBe(true); expect(result.price).toBe(350); // 1000 * 0.35 }); test('13.5 - default fence transaction behavior is no payment (backwards compatible)', () => { const fenceNpc = createCharacter({ name: 'Default Fence' }); const merchant = createCharacter({ name: 'Default Victim' }); const thief = createCharacter({ name: 'Default Thief' }); const itemId = createItem('Default Item', 100); theftRepo.registerFence({ npcId: fenceNpc.id, maxHeatLevel: 'cold' }); theftRepo.recordTheft({ itemId, stolenFrom: merchant.id, stolenBy: thief.id }); theftRepo.updateHeatLevel(itemId, 'cold'); // Call without options (backwards compatible) const result = theftRepo.recordFenceTransaction(fenceNpc.id, itemId, 'cold'); expect(result.fenced).toBe(true); expect(result.paid).toBe(false); expect(invRepo.getCurrency(thief.id).gold).toBe(0); }); }); // ============================================================================ // CATEGORY 14: CORPSE CURRENCY LOOTING // ============================================================================ describe('Category 14: Corpse Currency Looting', () => { test('14.1 - new corpse has no currency by default', () => { const enemy = createCharacter({ name: 'Broke Goblin' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); expect(corpse.currency.gold).toBe(0); expect(corpse.currency.silver).toBe(0); expect(corpse.currency.copper).toBe(0); expect(corpse.currencyLooted).toBe(false); }); test('14.2 - generateLoot stores currency on corpse', () => { const enemy = createCharacter({ name: 'Rich Goblin' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { creatureType: 'goblin', cr: 0.25, worldId: 'test-world' } ); // Create loot table with guaranteed currency corpseRepo.createLootTable({ name: 'Currency Test Table', creatureTypes: ['goblin'], guaranteedDrops: [], randomDrops: [], currencyRange: { gold: { min: 5, max: 5 }, // Fixed 5 gold for testing silver: { min: 10, max: 10 }, copper: { min: 20, max: 20 } } }); const loot = corpseRepo.generateLoot(corpse.id, 'goblin', 0.25); // Check returned currency expect(loot.currency.gold).toBe(5); expect(loot.currency.silver).toBe(10); expect(loot.currency.copper).toBe(20); // Check currency stored on corpse const updated = corpseRepo.findById(corpse.id); expect(updated?.currency.gold).toBe(5); expect(updated?.currency.silver).toBe(10); expect(updated?.currency.copper).toBe(20); }); test('14.3 - lootCurrency without transfer (narrative only)', () => { const enemy = createCharacter({ name: 'Currency Enemy' }); const looter = createCharacter({ name: 'Narrative Looter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); // Manually set currency on corpse db.prepare('UPDATE corpses SET currency = ? WHERE id = ?') .run(JSON.stringify({ gold: 10, silver: 5, copper: 0 }), corpse.id); const result = corpseRepo.lootCurrency(corpse.id, looter.id, false); expect(result.success).toBe(true); expect(result.currency.gold).toBe(10); expect(result.currency.silver).toBe(5); expect(result.transferred).toBe(false); // Looter should NOT have the gold expect(invRepo.getCurrency(looter.id).gold).toBe(0); // Corpse should be marked as currency looted const updated = corpseRepo.findById(corpse.id); expect(updated?.currencyLooted).toBe(true); }); test('14.4 - lootCurrency with transfer adds to looter', () => { const enemy = createCharacter({ name: 'Treasure Enemy' }); const looter = createCharacter({ name: 'Treasure Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); // Manually set currency on corpse db.prepare('UPDATE corpses SET currency = ? WHERE id = ?') .run(JSON.stringify({ gold: 25, silver: 10, copper: 50 }), corpse.id); const result = corpseRepo.lootCurrency(corpse.id, looter.id, true); expect(result.success).toBe(true); expect(result.currency.gold).toBe(25); expect(result.transferred).toBe(true); // Looter should have the currency const currency = invRepo.getCurrency(looter.id); expect(currency.gold).toBe(25); expect(currency.silver).toBe(10); expect(currency.copper).toBe(50); }); test('14.5 - cannot loot currency twice', () => { const enemy = createCharacter({ name: 'One-time Enemy' }); const looter = createCharacter({ name: 'Greedy Looter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); db.prepare('UPDATE corpses SET currency = ? WHERE id = ?') .run(JSON.stringify({ gold: 15, silver: 0, copper: 0 }), corpse.id); // First loot succeeds const first = corpseRepo.lootCurrency(corpse.id, looter.id, true); expect(first.success).toBe(true); expect(invRepo.getCurrency(looter.id).gold).toBe(15); // Second loot fails const second = corpseRepo.lootCurrency(corpse.id, looter.id, true); expect(second.success).toBe(false); expect(second.reason).toContain('already looted'); // Looter should still only have 15 gold expect(invRepo.getCurrency(looter.id).gold).toBe(15); }); test('14.6 - lootCurrency fails on empty corpse', () => { const enemy = createCharacter({ name: 'Penniless Enemy' }); const looter = createCharacter({ name: 'Disappointed Looter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); // No currency set const result = corpseRepo.lootCurrency(corpse.id, looter.id, true); expect(result.success).toBe(false); expect(result.reason).toContain('No currency'); }); test('14.7 - lootCurrency accumulates with existing gold', () => { const enemy = createCharacter({ name: 'Bonus Enemy' }); const looter = createCharacter({ name: 'Already Rich' }); // Looter already has some gold invRepo.setCurrency(looter.id, { gold: 100, silver: 50 }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); db.prepare('UPDATE corpses SET currency = ? WHERE id = ?') .run(JSON.stringify({ gold: 30, silver: 20, copper: 10 }), corpse.id); corpseRepo.lootCurrency(corpse.id, looter.id, true); const currency = invRepo.getCurrency(looter.id); expect(currency.gold).toBe(130); // 100 + 30 expect(currency.silver).toBe(70); // 50 + 20 expect(currency.copper).toBe(10); }); test('14.8 - corpse findById includes currency info', () => { const enemy = createCharacter({ name: 'Currency Check' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); db.prepare('UPDATE corpses SET currency = ?, currency_looted = 1 WHERE id = ?') .run(JSON.stringify({ gold: 50, silver: 25, copper: 100 }), corpse.id); const found = corpseRepo.findById(corpse.id); expect(found?.currency.gold).toBe(50); expect(found?.currency.silver).toBe(25); expect(found?.currency.copper).toBe(100); expect(found?.currencyLooted).toBe(true); }); }); // ============================================================================ // CATEGORY 15: HARVEST WITH ITEM CREATION // ============================================================================ describe('Category 15: Harvest With Item Creation', () => { test('15.1 - harvest without createItem (narrative only)', () => { const enemy = createCharacter({ name: 'Wolf Corpse' }); const hunter = createCharacter({ name: 'Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); // Set harvestable resources const resources = [{ resourceType: 'wolf pelt', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'wolf pelt', hunter.id); expect(result.success).toBe(true); expect(result.quantity).toBe(1); expect(result.transferred).toBe(false); expect(result.itemId).toBeUndefined(); // Hunter should NOT have the item in inventory const inventory = invRepo.getInventory(hunter.id); expect(inventory.items.length).toBe(0); }); test('15.2 - harvest with createItem adds to harvester inventory', () => { const enemy = createCharacter({ name: 'Harvest Wolf' }); const hunter = createCharacter({ name: 'Item Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const resources = [{ resourceType: 'wolf fang', quantity: 3, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'wolf fang', hunter.id, { createItem: true }); expect(result.success).toBe(true); expect(result.quantity).toBe(3); expect(result.transferred).toBe(true); expect(result.itemId).toBeDefined(); // Hunter should have the item in inventory const inventory = invRepo.getInventory(hunter.id); expect(inventory.items.length).toBe(1); expect(inventory.items[0].itemId).toBe(result.itemId); expect(inventory.items[0].quantity).toBe(3); }); test('15.3 - harvest with createItem creates item in items table', () => { const enemy = createCharacter({ name: 'Dragon Corpse' }); const crafter = createCharacter({ name: 'Crafter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const resources = [{ resourceType: 'dragon scale', quantity: 5, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'dragon scale', crafter.id, { createItem: true }); expect(result.success).toBe(true); // Verify item exists in items table const itemRow = db.prepare('SELECT * FROM items WHERE id = ?').get(result.itemId); expect(itemRow).toBeDefined(); expect((itemRow as any).name).toBe('dragon scale'); expect((itemRow as any).type).toBe('misc'); }); test('15.4 - harvest with skill check passes', () => { const enemy = createCharacter({ name: 'Skill Wolf' }); const skilled = createCharacter({ name: 'Skilled Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const resources = [{ resourceType: 'pristine hide', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'pristine hide', skilled.id, { skillCheck: { roll: 18, dc: 15 }, createItem: true }); expect(result.success).toBe(true); expect(result.transferred).toBe(true); }); test('15.5 - harvest with skill check fails', () => { const enemy = createCharacter({ name: 'Failed Wolf' }); const unskilled = createCharacter({ name: 'Unskilled Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const resources = [{ resourceType: 'delicate organ', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); const result = corpseRepo.harvestResource(corpse.id, 'delicate organ', unskilled.id, { skillCheck: { roll: 8, dc: 15 } }); expect(result.success).toBe(false); expect(result.reason).toContain('Failed skill check'); expect(result.transferred).toBe(false); // Resource should NOT be marked as harvested const updated = corpseRepo.findById(corpse.id); const resource = updated?.harvestableResources.find(r => r.resourceType === 'delicate organ'); expect(resource?.harvested).toBe(false); }); test('15.6 - default harvest behavior is no transfer (backwards compatible)', () => { const enemy = createCharacter({ name: 'Compat Wolf' }); const compat = createCharacter({ name: 'Compat Hunter' }); const corpse = corpseRepo.createFromDeath( enemy.id, enemy.name, 'enemy', { worldId: 'test-world' } ); const resources = [{ resourceType: 'generic hide', quantity: 1, harvested: false }]; db.prepare('UPDATE corpses SET harvestable = 1, harvestable_resources = ? WHERE id = ?') .run(JSON.stringify(resources), corpse.id); // Call without options (backwards compatible) const result = corpseRepo.harvestResource(corpse.id, 'generic hide', compat.id); expect(result.success).toBe(true); expect(result.transferred).toBe(false); expect(result.itemId).toBeUndefined(); }); }); // ============================================================================ // CATEGORY 10: EDGE CASE FIXES // ============================================================================ describe('Category 10: Edge Case Fixes', () => { test('EDGE-001 - prevent self-theft at repository level', () => { const character = createCharacter({ name: 'Test Character' }); const itemId = createItem('Self-owned Item', 100); addItemToInventory(character.id, itemId); // Attempt to steal from self expect(() => { theftRepo.recordTheft({ itemId, stolenFrom: character.id, stolenBy: character.id, // Same as victim! stolenLocation: null }); }).toThrow('A character cannot steal from themselves'); }); test('EDGE-006 - prevent victim from being registered as fence', () => { const victim = createCharacter({ name: 'Victim NPC' }); const thief = createCharacter({ name: 'Thief' }); const itemId = createItem('Stolen Gold Ring', 200); addItemToInventory(victim.id, itemId); // Record a theft where victim had something stolen theftRepo.recordTheft({ itemId, stolenFrom: victim.id, stolenBy: thief.id, stolenLocation: 'tavern' }); // Attempt to register victim as a fence expect(() => { theftRepo.registerFence({ npcId: victim.id, factionId: null, buyRate: 0.4, maxHeatLevel: 'hot', dailyHeatCapacity: 100, specializations: [], cooldownDays: 7 }); }).toThrow('Cannot register a theft victim as a fence'); }); test('EDGE-006 - allow fence registration if no items stolen from them', () => { const cleanNpc = createCharacter({ name: 'Clean Fence NPC' }); // This should succeed because cleanNpc has never been a victim const fence = theftRepo.registerFence({ npcId: cleanNpc.id, factionId: null, buyRate: 0.4, maxHeatLevel: 'hot', dailyHeatCapacity: 100, specializations: [], cooldownDays: 7 }); expect(fence.npcId).toBe(cleanNpc.id); expect(fence.buyRate).toBe(0.4); }); });

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