Skip to main content
Glama
corpse.repo.ts27.8 kB
import Database from 'better-sqlite3'; import { v4 as uuid } from 'uuid'; import { Corpse, CorpseState, LootTable, CORPSE_DECAY_RULES, DEFAULT_LOOT_TABLES } from '../../schema/corpse.js'; import { InventoryRepository } from './inventory.repo.js'; /** * FAILED-004: Corpse Repository * Manages corpses, loot generation, and harvesting */ interface CorpseRow { id: string; character_id: string; character_name: string; character_type: string; creature_type: string | null; cr: number | null; world_id: string | null; region_id: string | null; position_x: number | null; position_y: number | null; encounter_id: string | null; state: string; state_updated_at: string; loot_generated: number; looted: number; looted_by: string | null; looted_at: string | null; currency: string | null; currency_looted: number; harvestable: number; harvestable_resources: string; created_at: string; updated_at: string; } interface CorpseInventoryRow { corpse_id: string; item_id: string; quantity: number; looted: number; } interface LootTableRow { id: string; name: string; creature_types: string; cr_min: number | null; cr_max: number | null; guaranteed_drops: string; random_drops: string; currency_range: string | null; harvestable_resources: string | null; created_at: string; updated_at: string; } export class CorpseRepository { private inventoryRepo: InventoryRepository; constructor(private db: Database.Database) { this.inventoryRepo = new InventoryRepository(db); } /** * Create a corpse when a character dies */ createFromDeath(characterId: string, characterName: string, characterType: 'pc' | 'npc' | 'enemy' | 'neutral', options: { encounterId?: string; position?: { x: number; y: number }; worldId?: string; regionId?: string; creatureType?: string; cr?: number; } = {}): Corpse { const now = new Date().toISOString(); const id = uuid(); const stmt = this.db.prepare(` INSERT INTO corpses ( id, character_id, character_name, character_type, creature_type, cr, world_id, region_id, position_x, position_y, encounter_id, state, state_updated_at, harvestable, harvestable_resources, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, 0, '[]', ?, ?) `); stmt.run( id, characterId, characterName, characterType, options.creatureType ?? null, options.cr ?? null, options.worldId ?? null, options.regionId ?? null, options.position?.x ?? null, options.position?.y ?? null, options.encounterId ?? null, now, now, now ); return this.findById(id)!; } /** * Get corpse by ID */ findById(id: string): Corpse | null { const stmt = this.db.prepare(`SELECT * FROM corpses WHERE id = ?`); const row = stmt.get(id) as CorpseRow | undefined; if (!row) return null; return this.rowToCorpse(row); } /** * Get corpse for a specific character */ findByCharacterId(characterId: string): Corpse | null { const stmt = this.db.prepare(` SELECT * FROM corpses WHERE character_id = ? AND state != 'gone' ORDER BY created_at DESC LIMIT 1 `); const row = stmt.get(characterId) as CorpseRow | undefined; if (!row) return null; return this.rowToCorpse(row); } /** * Get all corpses in an encounter */ findByEncounterId(encounterId: string): Corpse[] { const stmt = this.db.prepare(` SELECT * FROM corpses WHERE encounter_id = ? AND state != 'gone' `); const rows = stmt.all(encounterId) as CorpseRow[]; return rows.map(r => this.rowToCorpse(r)); } /** * Get corpses in a region */ findByRegion(worldId: string, regionId: string): Corpse[] { const stmt = this.db.prepare(` SELECT * FROM corpses WHERE world_id = ? AND region_id = ? AND state != 'gone' `); const rows = stmt.all(worldId, regionId) as CorpseRow[]; return rows.map(r => this.rowToCorpse(r)); } /** * Get corpses at or near a specific position */ findNearPosition(worldId: string, x: number, y: number, radius: number = 3): Corpse[] { const stmt = this.db.prepare(` SELECT * FROM corpses WHERE world_id = ? AND state != 'gone' AND position_x IS NOT NULL AND position_y IS NOT NULL AND ABS(position_x - ?) <= ? AND ABS(position_y - ?) <= ? `); const rows = stmt.all(worldId, x, radius, y, radius) as CorpseRow[]; return rows.map(r => this.rowToCorpse(r)); } /** * Add item to corpse inventory */ addToCorpseInventory(corpseId: string, itemId: string, quantity: number = 1): void { const stmt = this.db.prepare(` INSERT INTO corpse_inventory (corpse_id, item_id, quantity, looted) VALUES (?, ?, ?, 0) ON CONFLICT(corpse_id, item_id) DO UPDATE SET quantity = quantity + ? `); stmt.run(corpseId, itemId, quantity, quantity); } /** * Get items in corpse inventory */ getCorpseInventory(corpseId: string): Array<{ itemId: string; quantity: number; looted: boolean }> { const stmt = this.db.prepare(` SELECT * FROM corpse_inventory WHERE corpse_id = ? `); const rows = stmt.all(corpseId) as CorpseInventoryRow[]; return rows.map(r => ({ itemId: r.item_id, quantity: r.quantity, looted: r.looted === 1 })); } /** * Get unlootable items from corpse */ getAvailableLoot(corpseId: string): Array<{ itemId: string; quantity: number }> { const stmt = this.db.prepare(` SELECT * FROM corpse_inventory WHERE corpse_id = ? AND looted = 0 `); const rows = stmt.all(corpseId) as CorpseInventoryRow[]; return rows.map(r => ({ itemId: r.item_id, quantity: r.quantity })); } /** * Loot an item from a corpse * @param transferToLooter - If true, adds item to looter's inventory */ lootItem(corpseId: string, itemId: string, looterId: string, quantity?: number, transferToLooter?: boolean): { success: boolean; itemId: string; quantity: number; transferred: boolean; reason?: string; } { const corpse = this.findById(corpseId); if (!corpse) { return { success: false, itemId, quantity: 0, transferred: false, reason: 'Corpse not found' }; } if (corpse.state === 'gone') { return { success: false, itemId, quantity: 0, transferred: false, reason: 'Corpse has decayed completely' }; } const inventory = this.getAvailableLoot(corpseId); const item = inventory.find(i => i.itemId === itemId); if (!item) { return { success: false, itemId, quantity: 0, transferred: false, reason: 'Item not on corpse or already looted' }; } const toLoot = quantity ?? item.quantity; if (toLoot > item.quantity) { return { success: false, itemId, quantity: 0, transferred: false, reason: `Only ${item.quantity} available` }; } const now = new Date().toISOString(); if (toLoot === item.quantity) { // Mark as fully looted const stmt = this.db.prepare(` UPDATE corpse_inventory SET looted = 1 WHERE corpse_id = ? AND item_id = ? `); stmt.run(corpseId, itemId); } else { // Reduce quantity const stmt = this.db.prepare(` UPDATE corpse_inventory SET quantity = quantity - ? WHERE corpse_id = ? AND item_id = ? `); stmt.run(toLoot, corpseId, itemId); } // Update corpse const updateStmt = this.db.prepare(` UPDATE corpses SET looted_by = ?, looted_at = ?, updated_at = ? WHERE id = ? `); updateStmt.run(looterId, now, now, corpseId); // Check if all items looted const remaining = this.getAvailableLoot(corpseId); if (remaining.length === 0) { const lootedStmt = this.db.prepare(` UPDATE corpses SET looted = 1, updated_at = ? WHERE id = ? `); lootedStmt.run(now, corpseId); } // Optionally transfer to looter's inventory let transferred = false; if (transferToLooter) { this.inventoryRepo.addItem(looterId, itemId, toLoot); transferred = true; } return { success: true, itemId, quantity: toLoot, transferred }; } /** * Loot all items from a corpse * @param transferToLooter - If true, adds all items to looter's inventory */ lootAll(corpseId: string, looterId: string, transferToLooter?: boolean): Array<{ itemId: string; quantity: number; transferred: boolean }> { const available = this.getAvailableLoot(corpseId); const looted: Array<{ itemId: string; quantity: number; transferred: boolean }> = []; for (const item of available) { const result = this.lootItem(corpseId, item.itemId, looterId, item.quantity, transferToLooter); if (result.success) { looted.push({ itemId: result.itemId, quantity: result.quantity, transferred: result.transferred }); } } return looted; } /** * Loot currency from a corpse * @param transferToLooter - If true, adds currency to looter's inventory */ lootCurrency(corpseId: string, looterId: string, transferToLooter?: boolean): { success: boolean; currency: { gold: number; silver: number; copper: number }; transferred: boolean; reason?: string; } { const corpse = this.findById(corpseId); if (!corpse) { return { success: false, currency: { gold: 0, silver: 0, copper: 0 }, transferred: false, reason: 'Corpse not found' }; } if (corpse.currencyLooted) { return { success: false, currency: { gold: 0, silver: 0, copper: 0 }, transferred: false, reason: 'Currency already looted' }; } const currency = corpse.currency; if (currency.gold === 0 && currency.silver === 0 && currency.copper === 0) { return { success: false, currency: { gold: 0, silver: 0, copper: 0 }, transferred: false, reason: 'No currency on corpse' }; } // Mark currency as looted const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE corpses SET currency_looted = 1, updated_at = ? WHERE id = ? `); stmt.run(now, corpseId); // Optionally transfer to looter's inventory let transferred = false; if (transferToLooter) { this.inventoryRepo.addCurrency(looterId, currency); transferred = true; } return { success: true, currency, transferred }; } /** * Generate loot for a corpse based on creature type */ generateLoot(corpseId: string, creatureType: string, cr?: number): { itemsAdded: Array<{ name: string; quantity: number }>; currency: { gold: number; silver: number; copper: number }; harvestable: Array<{ resourceType: string; quantity: number }>; } { const corpse = this.findById(corpseId); if (!corpse || corpse.lootGenerated) { return { itemsAdded: [], currency: { gold: 0, silver: 0, copper: 0 }, harvestable: [] }; } // Find matching loot table const lootTable = this.findLootTableByCreatureType(creatureType, cr); if (!lootTable) { // Mark as generated but empty this.markLootGenerated(corpseId); return { itemsAdded: [], currency: { gold: 0, silver: 0, copper: 0 }, harvestable: [] }; } const itemsAdded: Array<{ name: string; quantity: number }> = []; const harvestable: Array<{ resourceType: string; quantity: number }> = []; // Process guaranteed drops for (const drop of lootTable.guaranteedDrops) { const qty = this.rollQuantity(drop.quantity.min, drop.quantity.max); if (qty > 0 && drop.itemName) { itemsAdded.push({ name: drop.itemName, quantity: qty }); // Would need to create item in items table and add to corpse_inventory } } // Process random drops for (const drop of lootTable.randomDrops) { if (Math.random() <= drop.weight) { const qty = this.rollQuantity(drop.quantity.min, drop.quantity.max); if (qty > 0 && drop.itemName) { itemsAdded.push({ name: drop.itemName, quantity: qty }); } } } // Process currency let gold = 0, silver = 0, copper = 0; if (lootTable.currencyRange) { gold = this.rollQuantity(lootTable.currencyRange.gold.min, lootTable.currencyRange.gold.max); if (lootTable.currencyRange.silver) { silver = this.rollQuantity(lootTable.currencyRange.silver.min, lootTable.currencyRange.silver.max); } if (lootTable.currencyRange.copper) { copper = this.rollQuantity(lootTable.currencyRange.copper.min, lootTable.currencyRange.copper.max); } } // Process harvestable resources if (lootTable.harvestableResources) { for (const resource of lootTable.harvestableResources) { const qty = this.rollQuantity(resource.quantity.min, resource.quantity.max); if (qty > 0) { harvestable.push({ resourceType: resource.resourceType, quantity: qty }); } } } // Update corpse with harvestable resources if (harvestable.length > 0) { const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE corpses SET harvestable = 1, harvestable_resources = ?, updated_at = ? WHERE id = ? `); stmt.run( JSON.stringify(harvestable.map(h => ({ ...h, harvested: false }))), now, corpseId ); } this.markLootGenerated(corpseId, { gold, silver, copper }); return { itemsAdded, currency: { gold, silver, copper }, harvestable }; } /** * Harvest a resource from a corpse * @param createItem - If true, creates an item in the items table and adds to harvester inventory */ harvestResource(corpseId: string, resourceType: string, harvesterId: string, options?: { skillCheck?: { roll: number; dc: number }; createItem?: boolean; }): { success: boolean; quantity: number; resourceType: string; itemId?: string; transferred: boolean; reason?: string; } { const corpse = this.findById(corpseId); if (!corpse) { return { success: false, quantity: 0, resourceType, transferred: false, reason: 'Corpse not found' }; } if (!corpse.harvestable) { return { success: false, quantity: 0, resourceType, transferred: false, reason: 'Corpse has no harvestable resources' }; } if (corpse.state === 'skeletal' || corpse.state === 'gone') { return { success: false, quantity: 0, resourceType, transferred: false, reason: 'Corpse too decayed to harvest' }; } const resources = corpse.harvestableResources; const resource = resources.find(r => r.resourceType === resourceType && !r.harvested); if (!resource) { return { success: false, quantity: 0, resourceType, transferred: false, reason: 'Resource not available or already harvested' }; } // Check DC if required if (options?.skillCheck) { if (options.skillCheck.roll < options.skillCheck.dc) { return { success: false, quantity: 0, resourceType, transferred: false, reason: `Failed skill check (${options.skillCheck.roll} vs DC ${options.skillCheck.dc})` }; } } // Mark as harvested resource.harvested = true; const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE corpses SET harvestable_resources = ?, updated_at = ? WHERE id = ? `); stmt.run(JSON.stringify(resources), now, corpseId); // Optionally create item and add to harvester inventory let itemId: string | undefined; let transferred = false; if (options?.createItem) { // Create the item in items table itemId = uuid(); const createStmt = this.db.prepare(` INSERT INTO items (id, name, description, type, weight, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); createStmt.run( itemId, resourceType, // Use resource type as item name (e.g., "wolf pelt") `Harvested ${resourceType} from a corpse`, 'misc', 1, 10, // Default value, could be parameterized now, now ); // Add to harvester inventory this.inventoryRepo.addItem(harvesterId, itemId, resource.quantity); transferred = true; } return { success: true, quantity: resource.quantity, resourceType, itemId, transferred }; } /** * Process corpse decay based on time passed */ processDecay(hoursAdvanced: number): { corpseId: string; oldState: CorpseState; newState: CorpseState }[] { const changes: { corpseId: string; oldState: CorpseState; newState: CorpseState }[] = []; const stmt = this.db.prepare(`SELECT * FROM corpses WHERE state != 'gone'`); const corpses = stmt.all() as CorpseRow[]; const now = new Date(); for (const row of corpses) { const stateUpdated = new Date(row.state_updated_at); const hoursSinceUpdate = Math.floor((now.getTime() - stateUpdated.getTime()) / (1000 * 60 * 60)) + hoursAdvanced; let currentState = row.state as CorpseState; let newState = currentState; if (currentState === 'fresh' && hoursSinceUpdate >= CORPSE_DECAY_RULES.fresh_to_decaying) { newState = 'decaying'; } else if (currentState === 'decaying' && hoursSinceUpdate >= CORPSE_DECAY_RULES.decaying_to_skeletal) { newState = 'skeletal'; } else if (currentState === 'skeletal' && hoursSinceUpdate >= CORPSE_DECAY_RULES.skeletal_to_gone) { newState = 'gone'; } if (newState !== currentState) { this.updateState(row.id, newState); changes.push({ corpseId: row.id, oldState: currentState, newState }); } } return changes; } /** * Update corpse state */ updateState(corpseId: string, newState: CorpseState): void { const now = new Date().toISOString(); const stmt = this.db.prepare(` UPDATE corpses SET state = ?, state_updated_at = ?, updated_at = ? WHERE id = ? `); stmt.run(newState, now, now, corpseId); } /** * Clean up gone corpses */ cleanupGoneCorpses(): number { // Delete corpse inventory first const deleteInventory = this.db.prepare(` DELETE FROM corpse_inventory WHERE corpse_id IN (SELECT id FROM corpses WHERE state = 'gone') `); deleteInventory.run(); // Delete corpses const deleteCorpses = this.db.prepare(`DELETE FROM corpses WHERE state = 'gone'`); const result = deleteCorpses.run(); return result.changes; } /** * Mark corpse as loot generated */ private markLootGenerated(corpseId: string, currency?: { gold: number; silver: number; copper: number }): void { const now = new Date().toISOString(); if (currency && (currency.gold > 0 || currency.silver > 0 || currency.copper > 0)) { const stmt = this.db.prepare(` UPDATE corpses SET loot_generated = 1, currency = ?, updated_at = ? WHERE id = ? `); stmt.run(JSON.stringify(currency), now, corpseId); } else { const stmt = this.db.prepare(` UPDATE corpses SET loot_generated = 1, updated_at = ? WHERE id = ? `); stmt.run(now, corpseId); } } // ============================================================ // LOOT TABLE OPERATIONS // ============================================================ /** * Create a loot table */ createLootTable(table: Omit<LootTable, 'id' | 'createdAt' | 'updatedAt'>): LootTable { const now = new Date().toISOString(); const id = uuid(); const stmt = this.db.prepare(` INSERT INTO loot_tables ( id, name, creature_types, cr_min, cr_max, guaranteed_drops, random_drops, currency_range, harvestable_resources, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( id, table.name, JSON.stringify(table.creatureTypes), table.crRange?.min ?? null, table.crRange?.max ?? null, JSON.stringify(table.guaranteedDrops), JSON.stringify(table.randomDrops), table.currencyRange ? JSON.stringify(table.currencyRange) : null, table.harvestableResources ? JSON.stringify(table.harvestableResources) : null, now, now ); return this.findLootTableById(id)!; } /** * Find loot table by ID */ findLootTableById(id: string): LootTable | null { const stmt = this.db.prepare(`SELECT * FROM loot_tables WHERE id = ?`); const row = stmt.get(id) as LootTableRow | undefined; if (!row) return null; return this.rowToLootTable(row); } /** * Find loot table by creature type */ findLootTableByCreatureType(creatureType: string, cr?: number): LootTable | null { // First try to find in database const stmt = this.db.prepare(`SELECT * FROM loot_tables`); const rows = stmt.all() as LootTableRow[]; for (const row of rows) { const table = this.rowToLootTable(row); if (table.creatureTypes.includes(creatureType.toLowerCase())) { if (cr !== undefined && table.crRange) { if (cr >= table.crRange.min && cr <= table.crRange.max) { return table; } } else { return table; } } } // Fall back to default loot tables for (const defaultTable of DEFAULT_LOOT_TABLES) { if (defaultTable.creatureTypes.includes(creatureType.toLowerCase())) { if (cr !== undefined && defaultTable.crRange) { if (cr >= defaultTable.crRange.min && cr <= defaultTable.crRange.max) { return { id: `default-${defaultTable.name.toLowerCase().replace(/\s+/g, '-')}`, ...defaultTable, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; } } else { return { id: `default-${defaultTable.name.toLowerCase().replace(/\s+/g, '-')}`, ...defaultTable, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; } } } return null; } /** * List all loot tables */ listLootTables(): LootTable[] { const stmt = this.db.prepare(`SELECT * FROM loot_tables`); const rows = stmt.all() as LootTableRow[]; return rows.map(r => this.rowToLootTable(r)); } // ============================================================ // HELPER METHODS // ============================================================ private rollQuantity(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } private rowToCorpse(row: CorpseRow): Corpse { let currency = { gold: 0, silver: 0, copper: 0 }; if (row.currency) { try { const parsed = JSON.parse(row.currency); currency = { gold: parsed.gold ?? 0, silver: parsed.silver ?? 0, copper: parsed.copper ?? 0 }; } catch { // Keep default } } return { id: row.id, characterId: row.character_id, characterName: row.character_name, characterType: row.character_type as 'pc' | 'npc' | 'enemy' | 'neutral', creatureType: row.creature_type ?? undefined, cr: row.cr ?? undefined, worldId: row.world_id, regionId: row.region_id, position: row.position_x !== null && row.position_y !== null ? { x: row.position_x, y: row.position_y } : null, encounterId: row.encounter_id, state: row.state as CorpseState, stateUpdatedAt: row.state_updated_at, lootGenerated: row.loot_generated === 1, looted: row.looted === 1, lootedBy: row.looted_by, lootedAt: row.looted_at, currency, currencyLooted: row.currency_looted === 1, harvestable: row.harvestable === 1, harvestableResources: JSON.parse(row.harvestable_resources), createdAt: row.created_at, updatedAt: row.updated_at }; } private rowToLootTable(row: LootTableRow): LootTable { return { id: row.id, name: row.name, creatureTypes: JSON.parse(row.creature_types), crRange: row.cr_min !== null && row.cr_max !== null ? { min: row.cr_min, max: row.cr_max } : undefined, guaranteedDrops: JSON.parse(row.guaranteed_drops), randomDrops: JSON.parse(row.random_drops), currencyRange: row.currency_range ? JSON.parse(row.currency_range) : undefined, harvestableResources: row.harvestable_resources ? JSON.parse(row.harvestable_resources) : undefined, createdAt: row.created_at, updatedAt: row.updated_at }; } }

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