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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 14:12:22 +00:00
parent f5c8d8661b
commit 52e94112e6
2 changed files with 544 additions and 0 deletions
@@ -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();
});
});
});
+193
View File
@@ -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<number, MemoryEvent[]> {
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<number, MemoryEvent[]>();
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,
}));
}