From 52e94112e6ee5abcfcb5abda468e28d6448dcd92 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 14:12:22 +0000 Subject: [PATCH] feat(persistence): add event serializer for narration, memory, stockpile, and inventions Save/load functions for all four event tables with proper JSON serialization, chronological ordering, per-entity grouping, and optional field handling. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/eventSerializer.test.ts | 351 ++++++++++++++++++ server/src/persistence/eventSerializer.ts | 193 ++++++++++ 2 files changed, 544 insertions(+) create mode 100644 server/src/persistence/__tests__/eventSerializer.test.ts create mode 100644 server/src/persistence/eventSerializer.ts diff --git a/server/src/persistence/__tests__/eventSerializer.test.ts b/server/src/persistence/__tests__/eventSerializer.test.ts new file mode 100644 index 0000000..88bc555 --- /dev/null +++ b/server/src/persistence/__tests__/eventSerializer.test.ts @@ -0,0 +1,351 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { openDatabase, closeDatabase } from '../database.js'; +import { + saveNarrationEvent, + loadRecentNarrationEvents, + saveMemoryEvent, + loadRecentMemoryEvents, + loadAllRecentMemoryEvents, + saveStockpileEntry, + loadRecentStockpileEntries, + saveInvention, + loadAllInventions, +} from '../eventSerializer.js'; +import type { NarrationEvent, MemoryEvent, StockpileLogEntry } from '@dflike/shared'; +import type { InventionEntry } from '../../industry/inventionTimeline.js'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +function tempDbPath(): string { + return path.join(os.tmpdir(), `dflike-event-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe('eventSerializer', () => { + const dbPaths: string[] = []; + + function createTempDb(): string { + const p = tempDbPath(); + dbPaths.push(p); + openDatabase(p); + return p; + } + + beforeEach(() => { + createTempDb(); + }); + + afterEach(() => { + try { closeDatabase(); } catch { /* ignore */ } + for (const p of dbPaths) { + try { fs.unlinkSync(p); } catch { /* ignore */ } + try { fs.unlinkSync(p + '-wal'); } catch { /* ignore */ } + try { fs.unlinkSync(p + '-shm'); } catch { /* ignore */ } + } + dbPaths.length = 0; + }); + + describe('narration events', () => { + it('saves and loads narration events', () => { + const event: NarrationEvent = { + id: 0, + tick: 100, + type: 'social', + entityIds: [1, 2], + names: ['Alice', 'Bob'], + outcome: 'positive', + narration: 'Alice and Bob had a pleasant chat.', + isLlmGenerated: false, + }; + + saveNarrationEvent(event); + const loaded = loadRecentNarrationEvents(10); + + expect(loaded).toHaveLength(1); + expect(loaded[0].tick).toBe(100); + expect(loaded[0].type).toBe('social'); + expect(loaded[0].entityIds).toEqual([1, 2]); + expect(loaded[0].outcome).toBe('positive'); + expect(loaded[0].narration).toBe('Alice and Bob had a pleasant chat.'); + // DB assigns autoincrement id + expect(loaded[0].id).toBe(1); + }); + + it('respects limit and returns in chronological order', () => { + for (let i = 0; i < 5; i++) { + saveNarrationEvent({ + id: 0, + tick: i * 10, + type: 'social', + entityIds: [1, 2], + names: ['A', 'B'], + outcome: 'positive', + narration: `Event ${i}`, + isLlmGenerated: false, + }); + } + + const loaded = loadRecentNarrationEvents(3); + expect(loaded).toHaveLength(3); + // Should be the 3 most recent, in chronological order + expect(loaded[0].narration).toBe('Event 2'); + expect(loaded[1].narration).toBe('Event 3'); + expect(loaded[2].narration).toBe('Event 4'); + }); + + it('handles proposal type with proposal_accepted outcome', () => { + saveNarrationEvent({ + id: 0, + tick: 50, + type: 'proposal', + entityIds: [3, 4], + names: ['X', 'Y'], + outcome: 'proposal_accepted', + narration: 'A proposal was accepted.', + isLlmGenerated: true, + }); + + const loaded = loadRecentNarrationEvents(1); + expect(loaded[0].type).toBe('proposal'); + expect(loaded[0].outcome).toBe('proposal_accepted'); + }); + }); + + describe('memory events', () => { + it('saves and loads per-entity memory events', () => { + const event: MemoryEvent = { + id: 0, + type: 'social_positive', + tick: 200, + otherEntityId: 2, + otherName: 'Bob', + detail: 'Had a nice conversation', + }; + + saveMemoryEvent(1, event); + const loaded = loadRecentMemoryEvents(1, 10); + + expect(loaded).toHaveLength(1); + expect(loaded[0].type).toBe('social_positive'); + expect(loaded[0].tick).toBe(200); + expect(loaded[0].otherEntityId).toBe(2); + expect(loaded[0].otherName).toBe('Bob'); + expect(loaded[0].detail).toBe('Had a nice conversation'); + expect(loaded[0].id).toBe(1); + }); + + it('handles optional fields correctly', () => { + const event: MemoryEvent = { + id: 0, + type: 'tier_change', + tick: 300, + otherEntityId: 5, + otherName: 'Charlie', + detail: 'Became friends', + oldTier: 'Acquaintance', + newTier: 'Friend', + }; + + saveMemoryEvent(10, event); + const loaded = loadRecentMemoryEvents(10, 10); + + expect(loaded[0].oldTier).toBe('Acquaintance'); + expect(loaded[0].newTier).toBe('Friend'); + // Fields not set should be absent + expect(loaded[0]).not.toHaveProperty('need'); + expect(loaded[0]).not.toHaveProperty('oldGoal'); + expect(loaded[0]).not.toHaveProperty('newGoal'); + }); + + it('handles need and goal fields', () => { + const event: MemoryEvent = { + id: 0, + type: 'goal_change', + tick: 400, + detail: 'Changed goal due to hunger', + need: 'hunger', + oldGoal: 'wander', + newGoal: 'eat', + }; + + saveMemoryEvent(7, event); + const loaded = loadRecentMemoryEvents(7, 10); + + expect(loaded[0].need).toBe('hunger'); + expect(loaded[0].oldGoal).toBe('wander'); + expect(loaded[0].newGoal).toBe('eat'); + expect(loaded[0]).not.toHaveProperty('otherEntityId'); + expect(loaded[0]).not.toHaveProperty('otherName'); + }); + + it('respects limit for memory events', () => { + for (let i = 0; i < 5; i++) { + saveMemoryEvent(1, { + id: 0, + type: 'spawned', + tick: i * 10, + detail: `Event ${i}`, + }); + } + + const loaded = loadRecentMemoryEvents(1, 2); + expect(loaded).toHaveLength(2); + expect(loaded[0].detail).toBe('Event 3'); + expect(loaded[1].detail).toBe('Event 4'); + }); + + it('loadAllRecentMemoryEvents groups by entity and trims', () => { + // Entity 1: 4 events + for (let i = 0; i < 4; i++) { + saveMemoryEvent(1, { + id: 0, + type: 'social_positive', + tick: i, + detail: `E1-${i}`, + }); + } + // Entity 2: 3 events + for (let i = 0; i < 3; i++) { + saveMemoryEvent(2, { + id: 0, + type: 'social_negative', + tick: i, + detail: `E2-${i}`, + }); + } + + const map = loadAllRecentMemoryEvents(2); + expect(map.size).toBe(2); + + const e1 = map.get(1)!; + expect(e1).toHaveLength(2); + expect(e1[0].detail).toBe('E1-2'); + expect(e1[1].detail).toBe('E1-3'); + + const e2 = map.get(2)!; + expect(e2).toHaveLength(2); + expect(e2[0].detail).toBe('E2-1'); + expect(e2[1].detail).toBe('E2-2'); + }); + }); + + describe('stockpile entries', () => { + it('saves and loads stockpile entries', () => { + const entry: StockpileLogEntry = { + npcName: 'Thorin', + action: 'dropoff', + itemId: 'wood', + quantity: 5, + tick: 500, + }; + + saveStockpileEntry(entry); + const loaded = loadRecentStockpileEntries(10); + + expect(loaded).toHaveLength(1); + expect(loaded[0].npcName).toBe('Thorin'); + expect(loaded[0].action).toBe('dropoff'); + expect(loaded[0].itemId).toBe('wood'); + expect(loaded[0].quantity).toBe(5); + expect(loaded[0].tick).toBe(500); + }); + + it('respects limit and returns chronological order', () => { + for (let i = 0; i < 5; i++) { + saveStockpileEntry({ + npcName: `NPC${i}`, + action: 'pickup', + itemId: 'stone', + quantity: i + 1, + tick: i * 100, + }); + } + + const loaded = loadRecentStockpileEntries(3); + expect(loaded).toHaveLength(3); + expect(loaded[0].npcName).toBe('NPC2'); + expect(loaded[1].npcName).toBe('NPC3'); + expect(loaded[2].npcName).toBe('NPC4'); + }); + }); + + describe('inventions', () => { + it('saves and loads inventions with JSON inputs', () => { + const entry: InventionEntry = { + itemId: 'wooden_axe', + inventorEntityId: 42, + inventorName: 'Gimli', + tick: 1000, + day: 3, + name: 'Wooden Axe', + category: 'tool', + inputs: [ + { itemId: 'wood', quantity: 2 }, + { itemId: 'stone', quantity: 1 }, + ], + workshopType: 'workbench', + toolRequired: null, + }; + + saveInvention(entry); + const loaded = loadAllInventions(); + + expect(loaded).toHaveLength(1); + expect(loaded[0].itemId).toBe('wooden_axe'); + expect(loaded[0].inventorEntityId).toBe(42); + expect(loaded[0].inventorName).toBe('Gimli'); + expect(loaded[0].tick).toBe(1000); + expect(loaded[0].day).toBe(3); + expect(loaded[0].name).toBe('Wooden Axe'); + expect(loaded[0].category).toBe('tool'); + expect(loaded[0].inputs).toEqual([ + { itemId: 'wood', quantity: 2 }, + { itemId: 'stone', quantity: 1 }, + ]); + expect(loaded[0].workshopType).toBe('workbench'); + expect(loaded[0].toolRequired).toBeNull(); + }); + + it('loads all inventions in chronological order', () => { + for (let i = 0; i < 3; i++) { + saveInvention({ + itemId: `item_${i}`, + inventorEntityId: i, + inventorName: `Inventor${i}`, + tick: i * 100, + day: i, + name: `Item ${i}`, + category: 'resource', + inputs: [], + workshopType: null, + toolRequired: null, + }); + } + + const loaded = loadAllInventions(); + expect(loaded).toHaveLength(3); + expect(loaded[0].itemId).toBe('item_0'); + expect(loaded[1].itemId).toBe('item_1'); + expect(loaded[2].itemId).toBe('item_2'); + }); + + it('handles null workshopType and toolRequired', () => { + saveInvention({ + itemId: 'raw_stone', + inventorEntityId: 1, + inventorName: 'Urist', + tick: 50, + day: 1, + name: 'Raw Stone', + category: 'resource', + inputs: [], + workshopType: null, + toolRequired: null, + }); + + const loaded = loadAllInventions(); + expect(loaded[0].workshopType).toBeNull(); + expect(loaded[0].toolRequired).toBeNull(); + }); + }); +}); diff --git a/server/src/persistence/eventSerializer.ts b/server/src/persistence/eventSerializer.ts new file mode 100644 index 0000000..6c16b10 --- /dev/null +++ b/server/src/persistence/eventSerializer.ts @@ -0,0 +1,193 @@ +import { getDatabase } from './database.js'; +import type { NarrationEvent, MemoryEvent, StockpileLogEntry } from '@dflike/shared'; +import type { InventionEntry } from '../industry/inventionTimeline.js'; + +// --- Narration Events --- + +export function saveNarrationEvent(event: NarrationEvent): void { + const db = getDatabase(); + db.prepare( + `INSERT INTO narration_events (tick, type, entity_ids, outcome, narration) + VALUES (?, ?, ?, ?, ?)` + ).run( + event.tick, + event.type, + JSON.stringify(event.entityIds), + event.outcome, + event.narration + ); +} + +export function loadRecentNarrationEvents(limit: number): NarrationEvent[] { + const db = getDatabase(); + const rows = db.prepare( + `SELECT id, tick, type, entity_ids, outcome, narration + FROM narration_events ORDER BY id DESC LIMIT ?` + ).all(limit) as Array<{ + id: number; + tick: number; + type: string; + entity_ids: string; + outcome: string; + narration: string; + }>; + + rows.reverse(); + + return rows.map(row => ({ + id: row.id, + tick: row.tick, + type: row.type as NarrationEvent['type'], + entityIds: JSON.parse(row.entity_ids), + names: ['', ''] as [string, string], + outcome: row.outcome as NarrationEvent['outcome'], + narration: row.narration, + isLlmGenerated: false, + })); +} + +// --- Memory Events --- + +export function saveMemoryEvent(entityId: number, event: MemoryEvent): void { + const db = getDatabase(); + db.prepare( + `INSERT INTO memory_events (entity_id, tick, type, other_entity_id, other_name, detail, old_tier, new_tier, need, old_goal, new_goal) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + entityId, + event.tick, + event.type, + event.otherEntityId ?? null, + event.otherName ?? null, + event.detail, + event.oldTier ?? null, + event.newTier ?? null, + event.need ?? null, + event.oldGoal ?? null, + event.newGoal ?? null + ); +} + +function rowToMemoryEvent(row: any): MemoryEvent { + const event: MemoryEvent = { + id: row.id, + type: row.type, + tick: row.tick, + detail: row.detail, + }; + if (row.other_entity_id != null) event.otherEntityId = row.other_entity_id; + if (row.other_name != null) event.otherName = row.other_name; + if (row.old_tier != null) event.oldTier = row.old_tier; + if (row.new_tier != null) event.newTier = row.new_tier; + if (row.need != null) event.need = row.need; + if (row.old_goal != null) event.oldGoal = row.old_goal; + if (row.new_goal != null) event.newGoal = row.new_goal; + return event; +} + +export function loadRecentMemoryEvents(entityId: number, limit: number): MemoryEvent[] { + const db = getDatabase(); + const rows = db.prepare( + `SELECT id, tick, type, other_entity_id, other_name, detail, old_tier, new_tier, need, old_goal, new_goal + FROM memory_events WHERE entity_id = ? ORDER BY id DESC LIMIT ?` + ).all(entityId, limit) as any[]; + + rows.reverse(); + return rows.map(rowToMemoryEvent); +} + +export function loadAllRecentMemoryEvents(limit: number): Map { + const db = getDatabase(); + const rows = db.prepare( + `SELECT id, entity_id, tick, type, other_entity_id, other_name, detail, old_tier, new_tier, need, old_goal, new_goal + FROM memory_events ORDER BY id ASC` + ).all() as any[]; + + const map = new Map(); + for (const row of rows) { + const entityId = row.entity_id as number; + if (!map.has(entityId)) { + map.set(entityId, []); + } + map.get(entityId)!.push(rowToMemoryEvent(row)); + } + + // Trim each entity's events to last `limit` entries + for (const [entityId, events] of map) { + if (events.length > limit) { + map.set(entityId, events.slice(-limit)); + } + } + + return map; +} + +// --- Stockpile Log --- + +export function saveStockpileEntry(entry: StockpileLogEntry): void { + const db = getDatabase(); + db.prepare( + `INSERT INTO stockpile_log (tick, npc_name, action, item_id, quantity) + VALUES (?, ?, ?, ?, ?)` + ).run(entry.tick, entry.npcName, entry.action, entry.itemId, entry.quantity); +} + +export function loadRecentStockpileEntries(limit: number): StockpileLogEntry[] { + const db = getDatabase(); + const rows = db.prepare( + `SELECT tick, npc_name, action, item_id, quantity + FROM stockpile_log ORDER BY id DESC LIMIT ?` + ).all(limit) as any[]; + + rows.reverse(); + + return rows.map(row => ({ + npcName: row.npc_name, + action: row.action, + itemId: row.item_id, + quantity: row.quantity, + tick: row.tick, + })); +} + +// --- Inventions --- + +export function saveInvention(entry: InventionEntry): void { + const db = getDatabase(); + db.prepare( + `INSERT INTO inventions (tick, item_id, inventor_entity_id, inventor_name, day, name, category, inputs, workshop_type, tool_required) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + entry.tick, + entry.itemId, + entry.inventorEntityId, + entry.inventorName, + entry.day, + entry.name, + entry.category, + JSON.stringify(entry.inputs), + entry.workshopType, + entry.toolRequired + ); +} + +export function loadAllInventions(): InventionEntry[] { + const db = getDatabase(); + const rows = db.prepare( + `SELECT tick, item_id, inventor_entity_id, inventor_name, day, name, category, inputs, workshop_type, tool_required + FROM inventions ORDER BY id ASC` + ).all() as any[]; + + return rows.map(row => ({ + itemId: row.item_id, + inventorEntityId: row.inventor_entity_id, + inventorName: row.inventor_name, + tick: row.tick, + day: row.day, + name: row.name, + category: row.category, + inputs: JSON.parse(row.inputs), + workshopType: row.workshop_type ?? null, + toolRequired: row.tool_required ?? null, + })); +}