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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user