diff --git a/docs/plans/2026-03-09-world-persistence-impl.md b/docs/plans/2026-03-09-world-persistence-impl.md new file mode 100644 index 0000000..8287ef2 --- /dev/null +++ b/docs/plans/2026-03-09-world-persistence-impl.md @@ -0,0 +1,2139 @@ +# World Persistence Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add SQLite-based world persistence so the server saves and restores full game state across restarts. + +**Architecture:** A persistence layer (`server/src/persistence/`) wraps the existing ECS. Event data (narration, memory, stockpile, inventions) is written immediately on occurrence. Entity state (NPCs, structures, relationships, bonds) is batch-saved every 30 seconds. Graceful shutdown saves on SIGINT/SIGTERM. On startup, loads from `saves/default.db` if present, or generates a fresh world. + +**Tech Stack:** `better-sqlite3` (synchronous SQLite driver), vitest for tests + +--- + +### Task 1: Add better-sqlite3 dependency + +**Files:** +- Modify: `server/package.json` + +**Step 1: Install better-sqlite3** + +Run: `npm -w server install better-sqlite3` + +**Step 2: Install types** + +Run: `npm -w server install -D @types/better-sqlite3` + +**Step 3: Create saves directory with gitignore** + +Create `saves/.gitkeep` (empty file) and `saves/.gitignore`: +``` +*.db +*.db-journal +*.db-wal +``` + +**Step 4: Commit** + +```bash +git add server/package.json package-lock.json saves/ +git commit -m "chore: add better-sqlite3 dependency and saves directory" +``` + +--- + +### Task 2: Database module — connection and schema + +**Files:** +- Create: `server/src/persistence/database.ts` +- Test: `server/src/persistence/__tests__/database.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// server/src/persistence/__tests__/database.test.ts +import { describe, it, expect, afterEach } from 'vitest'; +import { openDatabase, closeDatabase, getDatabase } from '../database.js'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-persistence.db'); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('database', () => { + it('creates a new database with all tables', () => { + openDatabase(TEST_DB); + const db = getDatabase(); + const tables = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ).all() as { name: string }[]; + const names = tables.map(t => t.name); + + expect(names).toContain('metadata'); + expect(names).toContain('tiles'); + expect(names).toContain('entities'); + expect(names).toContain('components'); + expect(names).toContain('relationships'); + expect(names).toContain('bonds'); + expect(names).toContain('narration_events'); + expect(names).toContain('memory_events'); + expect(names).toContain('stockpile_log'); + expect(names).toContain('inventions'); + }); + + it('sets schema_version in metadata', () => { + openDatabase(TEST_DB); + const db = getDatabase(); + const row = db.prepare("SELECT value FROM metadata WHERE key = 'schema_version'").get() as { value: string } | undefined; + expect(row).toBeDefined(); + expect(parseInt(row!.value)).toBeGreaterThanOrEqual(1); + }); + + it('returns same db on multiple getDatabase calls', () => { + openDatabase(TEST_DB); + expect(getDatabase()).toBe(getDatabase()); + }); + + it('can reopen an existing database', () => { + openDatabase(TEST_DB); + const db1 = getDatabase(); + db1.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES ('test_key', 'hello')").run(); + closeDatabase(); + + openDatabase(TEST_DB); + const db2 = getDatabase(); + const row = db2.prepare("SELECT value FROM metadata WHERE key = 'test_key'").get() as { value: string }; + expect(row.value).toBe('hello'); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/persistence/__tests__/database.test.ts` +Expected: FAIL — module not found + +**Step 3: Implement database module** + +```typescript +// server/src/persistence/database.ts +import Database from 'better-sqlite3'; +import type BetterSqlite3 from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; + +let db: BetterSqlite3.Database | null = null; + +export const CURRENT_SCHEMA_VERSION = 1; + +const SCHEMA_SQL = ` + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tiles ( + x INTEGER NOT NULL, + y INTEGER NOT NULL, + terrain INTEGER NOT NULL, + decoration INTEGER NOT NULL DEFAULT -1, + trunk_decoration INTEGER NOT NULL DEFAULT -1, + resource_type TEXT, + PRIMARY KEY (x, y) + ); + + CREATE TABLE IF NOT EXISTS entities ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + name TEXT, + x REAL NOT NULL, + y REAL NOT NULL + ); + + CREATE TABLE IF NOT EXISTS components ( + entity_id INTEGER NOT NULL, + component_name TEXT NOT NULL, + data TEXT NOT NULL, + PRIMARY KEY (entity_id, component_name) + ); + + CREATE TABLE IF NOT EXISTS relationships ( + entity_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + value REAL NOT NULL, + interactions INTEGER NOT NULL DEFAULT 0, + last_interaction_tick INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + PRIMARY KEY (entity_id, target_id) + ); + + CREATE TABLE IF NOT EXISTS bonds ( + entity_a INTEGER NOT NULL, + entity_b INTEGER NOT NULL, + type TEXT NOT NULL, + formed_tick INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + ); + + CREATE TABLE IF NOT EXISTS narration_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER NOT NULL, + type TEXT NOT NULL, + entity_ids TEXT NOT NULL, + outcome TEXT NOT NULL, + narration TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS memory_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER NOT NULL, + tick INTEGER NOT NULL, + type TEXT NOT NULL, + other_entity_id INTEGER, + other_name TEXT, + detail TEXT, + old_tier TEXT, + new_tier TEXT, + need TEXT, + old_goal TEXT, + new_goal TEXT + ); + + CREATE TABLE IF NOT EXISTS stockpile_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER NOT NULL, + npc_name TEXT NOT NULL, + action TEXT NOT NULL, + item_id TEXT NOT NULL, + quantity INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS inventions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER NOT NULL, + item_id TEXT NOT NULL, + inventor_entity_id INTEGER NOT NULL, + inventor_name TEXT NOT NULL, + day INTEGER NOT NULL, + name TEXT NOT NULL, + category TEXT NOT NULL, + inputs TEXT NOT NULL, + workshop_type TEXT, + tool_required TEXT + ); +`; + +export function openDatabase(filepath: string): void { + if (db) { + db.close(); + } + + // Ensure directory exists + const dir = path.dirname(filepath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + db = new Database(filepath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + // Create tables if they don't exist + db.exec(SCHEMA_SQL); + + // Set schema version if not present + const existing = db.prepare("SELECT value FROM metadata WHERE key = 'schema_version'").get() as { value: string } | undefined; + if (!existing) { + db.prepare("INSERT INTO metadata (key, value) VALUES ('schema_version', ?)").run(String(CURRENT_SCHEMA_VERSION)); + } +} + +export function getDatabase(): BetterSqlite3.Database { + if (!db) throw new Error('Database not initialized. Call openDatabase() first.'); + return db; +} + +export function closeDatabase(): void { + if (db) { + db.close(); + db = null; + } +} + +export function getSchemaVersion(): number { + const row = getDatabase().prepare("SELECT value FROM metadata WHERE key = 'schema_version'").get() as { value: string } | undefined; + return row ? parseInt(row.value) : 0; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/persistence/__tests__/database.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/persistence/ +git commit -m "feat(persistence): add database module with schema creation" +``` + +--- + +### Task 3: World serializer — save/load tiles and metadata + +**Files:** +- Create: `server/src/persistence/worldSerializer.ts` +- Test: `server/src/persistence/__tests__/worldSerializer.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// server/src/persistence/__tests__/worldSerializer.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { openDatabase, closeDatabase, getDatabase } from '../database.js'; +import { saveTiles, loadTiles, saveMetadata, loadMetadata } from '../worldSerializer.js'; +import { Terrain } from '@dflike/shared'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-world.db'); + +beforeEach(() => { + openDatabase(TEST_DB); +}); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('saveTiles / loadTiles', () => { + it('round-trips tile data', () => { + const width = 3; + const height = 2; + const terrain = [ + Terrain.GRASS, Terrain.WATER, Terrain.GRASS, + Terrain.STONE, Terrain.GRASS, Terrain.DIRT, + ]; + const decorations = [-1, -1, 2, -1, -1, -1]; + const trunkDecorations = [-1, -1, -1, 3, -1, -1]; + const resourceTiles = [{ x: 1, y: 0, resourceType: 'water' }]; + const obstacles = new Set(['2,0', '0,1']); + const foodPositions = [{ x: 0, y: 0 }]; + const restPositions = [{ x: 2, y: 1 }]; + + saveTiles(width, height, { + terrain, decorations, trunkDecorations, + resourceTiles, obstacles, foodPositions, restPositions, + }); + + const loaded = loadTiles(width, height); + expect(loaded).not.toBeNull(); + expect(loaded!.terrain).toEqual(terrain); + expect(loaded!.decorations).toEqual(decorations); + expect(loaded!.trunkDecorations).toEqual(trunkDecorations); + expect(loaded!.resourceTiles).toEqual(resourceTiles); + expect(loaded!.obstacles).toEqual(obstacles); + expect(loaded!.foodPositions).toEqual(foodPositions); + expect(loaded!.restPositions).toEqual(restPositions); + }); + + it('returns null when no tiles saved', () => { + const loaded = loadTiles(3, 2); + expect(loaded).toBeNull(); + }); +}); + +describe('saveMetadata / loadMetadata', () => { + it('round-trips metadata', () => { + saveMetadata({ tick: 1500, lastSavedAt: '2026-03-09T12:00:00Z' }); + const meta = loadMetadata(); + expect(meta.tick).toBe(1500); + expect(meta.lastSavedAt).toBe('2026-03-09T12:00:00Z'); + }); + + it('returns defaults when no metadata saved', () => { + const meta = loadMetadata(); + expect(meta.tick).toBe(0); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/persistence/__tests__/worldSerializer.test.ts` +Expected: FAIL + +**Step 3: Implement world serializer** + +```typescript +// server/src/persistence/worldSerializer.ts +import { getDatabase } from './database.js'; + +export interface TileData { + terrain: number[]; + decorations: number[]; + trunkDecorations: number[]; + resourceTiles: Array<{ x: number; y: number; resourceType: string }>; + obstacles: Set; + foodPositions: Array<{ x: number; y: number }>; + restPositions: Array<{ x: number; y: number }>; +} + +export function saveTiles(width: number, height: number, data: TileData): void { + const db = getDatabase(); + + // Build a lookup for resource types and obstacle/POI status + const resourceMap = new Map(); + for (const rt of data.resourceTiles) { + resourceMap.set(`${rt.x},${rt.y}`, rt.resourceType); + } + + const insertTile = db.prepare( + `INSERT OR REPLACE INTO tiles (x, y, terrain, decoration, trunk_decoration, resource_type) + VALUES (?, ?, ?, ?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + db.prepare('DELETE FROM tiles').run(); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = y * width + x; + insertTile.run( + x, y, + data.terrain[idx], + data.decorations[idx], + data.trunkDecorations[idx], + resourceMap.get(`${x},${y}`) ?? null, + ); + } + } + }); + insertAll(); + + // Save obstacles and POIs as metadata JSON + db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES ('obstacles', ?)").run( + JSON.stringify([...data.obstacles]) + ); + db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES ('food_positions', ?)").run( + JSON.stringify(data.foodPositions) + ); + db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES ('rest_positions', ?)").run( + JSON.stringify(data.restPositions) + ); +} + +export function loadTiles(width: number, height: number): TileData | null { + const db = getDatabase(); + + const count = (db.prepare('SELECT COUNT(*) as c FROM tiles').get() as { c: number }).c; + if (count === 0) return null; + + const terrain = new Array(width * height).fill(0); + const decorations = new Array(width * height).fill(-1); + const trunkDecorations = new Array(width * height).fill(-1); + const resourceTiles: Array<{ x: number; y: number; resourceType: string }> = []; + + const rows = db.prepare('SELECT x, y, terrain, decoration, trunk_decoration, resource_type FROM tiles').all() as Array<{ + x: number; y: number; terrain: number; decoration: number; trunk_decoration: number; resource_type: string | null; + }>; + + for (const row of rows) { + const idx = row.y * width + row.x; + terrain[idx] = row.terrain; + decorations[idx] = row.decoration; + trunkDecorations[idx] = row.trunk_decoration; + if (row.resource_type) { + resourceTiles.push({ x: row.x, y: row.y, resourceType: row.resource_type }); + } + } + + const obstaclesJson = (db.prepare("SELECT value FROM metadata WHERE key = 'obstacles'").get() as { value: string } | undefined)?.value; + const obstacles = new Set(obstaclesJson ? JSON.parse(obstaclesJson) : []); + + const foodJson = (db.prepare("SELECT value FROM metadata WHERE key = 'food_positions'").get() as { value: string } | undefined)?.value; + const foodPositions = foodJson ? JSON.parse(foodJson) : []; + + const restJson = (db.prepare("SELECT value FROM metadata WHERE key = 'rest_positions'").get() as { value: string } | undefined)?.value; + const restPositions = restJson ? JSON.parse(restJson) : []; + + return { terrain, decorations, trunkDecorations, resourceTiles, obstacles, foodPositions, restPositions }; +} + +export function saveMetadata(data: { tick: number; lastSavedAt: string }): void { + const db = getDatabase(); + const upsert = db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)"); + const saveAll = db.transaction(() => { + upsert.run('tick', String(data.tick)); + upsert.run('last_saved_at', data.lastSavedAt); + }); + saveAll(); +} + +export function loadMetadata(): { tick: number; lastSavedAt: string } { + const db = getDatabase(); + const get = (key: string) => { + const row = db.prepare("SELECT value FROM metadata WHERE key = ?").get(key) as { value: string } | undefined; + return row?.value ?? null; + }; + return { + tick: parseInt(get('tick') ?? '0'), + lastSavedAt: get('last_saved_at') ?? '', + }; +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/persistence/__tests__/worldSerializer.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/persistence/ +git commit -m "feat(persistence): add world serializer for tiles and metadata" +``` + +--- + +### Task 4: Entity serializer — save/load entities and components + +**Files:** +- Create: `server/src/persistence/entitySerializer.ts` +- Test: `server/src/persistence/__tests__/entitySerializer.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// server/src/persistence/__tests__/entitySerializer.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { openDatabase, closeDatabase } from '../database.js'; +import { saveEntities, loadEntities } from '../entitySerializer.js'; +import { World } from '../../ecs/World.js'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-entities.db'); + +beforeEach(() => { + openDatabase(TEST_DB); +}); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('saveEntities / loadEntities', () => { + it('round-trips an NPC with all components', () => { + const world = new World(); + const id = world.createEntity(); + world.addComponent(id, 'position', { x: 10.5, y: 20.3 }); + world.addComponent(id, 'name', 'TestNPC'); + world.addComponent(id, 'needs', { hunger: 75, energy: 50, productivity: 60 }); + world.addComponent(id, 'stats', { + strength: 12, dexterity: 10, constitution: 14, + intelligence: 8, perception: 11, + sociability: 13, courage: 9, curiosity: 15, + empathy: 10, temperament: 7, + }); + world.addComponent(id, 'inventory', new Map([['wood', 3], ['stone', 1]])); + world.addComponent(id, 'relationships', new Map()); + world.addComponent(id, 'appearance', { skinId: 'shape00_skin01', accessories: {} }); + world.addComponent(id, 'movement', { + state: 'idle', target: null, path: [], + direction: 0, moveProgress: 0, + }); + world.addComponent(id, 'npcBrain', { currentGoal: null, goalQueue: [] }); + world.addComponent(id, 'socialState', { + phase: 'none', partnerId: null, phaseTimer: 0, + outcome: null, globalCooldown: 0, + pairCooldowns: new Map(), lastOutcome: null, + proposalCooldown: 0, pendingProposal: null, + isProposalInteraction: false, + }); + world.addComponent(id, 'statModifiers', { modifiers: [] }); + world.addComponent(id, 'backstory', 'A brave warrior.'); + + saveEntities(world); + + const newWorld = new World(); + const maxId = loadEntities(newWorld); + + expect(maxId).toBe(id); + expect(newWorld.getComponent(id, 'name')).toBe('TestNPC'); + expect(newWorld.getComponent(id, 'position')).toEqual({ x: 10.5, y: 20.3 }); + expect(newWorld.getComponent(id, 'needs')).toEqual({ hunger: 75, energy: 50, productivity: 60 }); + + const inv = newWorld.getComponent>(id, 'inventory')!; + expect(inv).toBeInstanceOf(Map); + expect(inv.get('wood')).toBe(3); + + const social = newWorld.getComponent(id, 'socialState')!; + expect(social.pairCooldowns).toBeInstanceOf(Map); + }); + + it('round-trips a structure entity', () => { + const world = new World(); + const id = world.createEntity(); + world.addComponent(id, 'position', { x: 5, y: 5 }); + world.addComponent(id, 'structure', { + type: 'stockpile', subtype: 'wood', + inventory: new Map([['wood', 10]]), + }); + + saveEntities(world); + + const newWorld = new World(); + loadEntities(newWorld); + + const structure = newWorld.getComponent(id, 'structure')!; + expect(structure.type).toBe('stockpile'); + expect(structure.inventory).toBeInstanceOf(Map); + expect(structure.inventory.get('wood')).toBe(10); + }); + + it('round-trips relationships', () => { + const world = new World(); + const id1 = world.createEntity(); + const id2 = world.createEntity(); + world.addComponent(id1, 'position', { x: 0, y: 0 }); + world.addComponent(id2, 'position', { x: 1, y: 1 }); + world.addComponent(id1, 'name', 'Alice'); + world.addComponent(id2, 'name', 'Bob'); + + const rels = new Map(); + rels.set(id2, { value: 45.5, interactions: 12, lastInteractionTick: 100, status: 'active' }); + world.addComponent(id1, 'relationships', rels); + world.addComponent(id2, 'relationships', new Map()); + + saveEntities(world); + + const newWorld = new World(); + loadEntities(newWorld); + + const loadedRels = newWorld.getComponent>(id1, 'relationships')!; + expect(loadedRels).toBeInstanceOf(Map); + expect(loadedRels.get(id2)).toEqual({ + value: 45.5, interactions: 12, + lastInteractionTick: 100, status: 'active', + }); + }); + + it('round-trips bond registry', () => { + const world = new World(); + const { createBondRegistry, addBond } = await import('../../systems/bondRegistry.js'); + const registry = createBondRegistry(); + addBond(registry, 1, 2, 'partner', 500); + world.setSingleton('bondRegistry', registry); + + saveEntities(world); + + const newWorld = new World(); + loadEntities(newWorld); + + const loadedRegistry = newWorld.getSingleton('bondRegistry')!; + expect(loadedRegistry).toBeDefined(); + // Bond should be restorable + const { hasBond } = await import('../../systems/bondRegistry.js'); + expect(hasBond(loadedRegistry, 1, 2, 'partner')).toBe(true); + }); + + it('skips playerControlled entities', () => { + const world = new World(); + const npcId = world.createEntity(); + world.addComponent(npcId, 'position', { x: 0, y: 0 }); + world.addComponent(npcId, 'name', 'NPC'); + + const playerId = world.createEntity(); + world.addComponent(playerId, 'position', { x: 1, y: 1 }); + world.addComponent(playerId, 'playerControlled', { playerId: 'p1', mode: 'avatar' }); + + saveEntities(world); + + const newWorld = new World(); + loadEntities(newWorld); + + expect(newWorld.getComponent(npcId, 'name')).toBe('NPC'); + expect(newWorld.getComponent(playerId, 'playerControlled')).toBeUndefined(); + }); + + it('sets nextId correctly after loading', () => { + const world = new World(); + // Create entities to advance the ID counter + world.createEntity(); // 1 + world.createEntity(); // 2 + const id3 = world.createEntity(); // 3 + world.addComponent(id3, 'position', { x: 0, y: 0 }); + + saveEntities(world); + + const newWorld = new World(); + const maxId = loadEntities(newWorld); + expect(maxId).toBe(3); + + // Next entity should get id 4+ (caller sets nextId) + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/persistence/__tests__/entitySerializer.test.ts` +Expected: FAIL + +**Step 3: Implement entity serializer** + +The key complexity is handling Map types (inventory, pairCooldowns, structure.inventory, relationships) and skipping player entities. + +Components that need Map→Object conversion on save and Object→Map on load: +- `inventory` — `Map` +- `socialState.pairCooldowns` — `Map` +- `structure.inventory` — `Map` +- `relationships` — saved to dedicated table, not components table + +Components skipped entirely: +- `movement.path` — recalculated +- `playerControlled` — players reconnect fresh +- `relationships` — stored in dedicated table + +```typescript +// server/src/persistence/entitySerializer.ts +import { getDatabase } from './database.js'; +import type { World } from '../ecs/World.js'; +import type { EntityId, Position, Relationships, RelationshipData } from '@dflike/shared'; +import { createBondRegistry, type BondRegistry, type Bond } from '../systems/bondRegistry.js'; + +// Components stored as JSON in the components table +const SERIALIZED_COMPONENTS = [ + 'needs', 'stats', 'appearance', 'npcBrain', 'socialState', + 'statModifiers', 'backstory', 'inventory', 'structure', + 'movement', 'name', +] as const; + +// Components with Map fields that need conversion +const MAP_FIELDS: Record = { + inventory: ['__root__'], // The component itself is a Map + socialState: ['pairCooldowns'], + structure: ['inventory'], +}; + +function serializeComponent(name: string, data: unknown): string { + if (name === 'inventory') { + // inventory is a Map directly + return JSON.stringify(Object.fromEntries(data as Map)); + } + if (name === 'name' || name === 'backstory') { + return JSON.stringify(data); + } + + // Deep clone and convert Maps within the object + const obj = { ...(data as Record) }; + + if (name === 'socialState' && obj.pairCooldowns instanceof Map) { + obj.pairCooldowns = Object.fromEntries(obj.pairCooldowns as Map); + } + if (name === 'structure' && obj.inventory instanceof Map) { + obj.inventory = Object.fromEntries(obj.inventory as Map); + } + + // Strip path from movement (will be recalculated) + if (name === 'movement') { + obj.path = []; + obj.state = 'idle'; + obj.target = null; + obj.moveProgress = 0; + } + + // Reset active social interaction state + if (name === 'socialState') { + obj.phase = 'none'; + obj.partnerId = null; + obj.phaseTimer = 0; + obj.outcome = null; + obj.lastOutcome = null; + obj.pendingProposal = null; + obj.isProposalInteraction = false; + } + + // Strip active building state from npcBrain + if (name === 'npcBrain') { + obj.currentGoal = null; + obj.goalQueue = []; + } + + return JSON.stringify(obj); +} + +function deserializeComponent(name: string, json: string): unknown { + const data = JSON.parse(json); + + if (name === 'inventory') { + return new Map(Object.entries(data)); + } + if (name === 'name' || name === 'backstory') { + return data; + } + + if (name === 'socialState' && data.pairCooldowns && !(data.pairCooldowns instanceof Map)) { + data.pairCooldowns = new Map( + Object.entries(data.pairCooldowns).map(([k, v]) => [Number(k), v]) + ); + } + if (name === 'structure' && data.inventory && !(data.inventory instanceof Map)) { + data.inventory = new Map(Object.entries(data.inventory)); + } + + return data; +} + +export function saveEntities(world: World): void { + const db = getDatabase(); + + const insertEntity = db.prepare( + 'INSERT INTO entities (id, type, name, x, y) VALUES (?, ?, ?, ?, ?)' + ); + const insertComponent = db.prepare( + 'INSERT INTO components (entity_id, component_name, data) VALUES (?, ?, ?)' + ); + const insertRelationship = db.prepare( + `INSERT INTO relationships (entity_id, target_id, value, interactions, last_interaction_tick, status) + VALUES (?, ?, ?, ?, ?, ?)` + ); + const insertBond = db.prepare( + 'INSERT INTO bonds (entity_a, entity_b, type, formed_tick, status) VALUES (?, ?, ?, ?, ?)' + ); + + const saveAll = db.transaction(() => { + // Clear existing data + db.prepare('DELETE FROM entities').run(); + db.prepare('DELETE FROM components').run(); + db.prepare('DELETE FROM relationships').run(); + db.prepare('DELETE FROM bonds').run(); + + const allEntities = world.getAllEntities(); + for (const entityId of allEntities) { + // Skip player-controlled entities + if (world.getComponent(entityId, 'playerControlled')) continue; + + const pos = world.getComponent(entityId, 'position'); + if (!pos) continue; + + const name = world.getComponent(entityId, 'name') ?? null; + const structure = world.getComponent(entityId, 'structure'); + const type = structure ? 'structure' : 'npc'; + + insertEntity.run(entityId, type, name, pos.x, pos.y); + + // Save each component as JSON + for (const compName of SERIALIZED_COMPONENTS) { + if (compName === 'name') continue; // already in entities table + const comp = world.getComponent(entityId, compName); + if (comp === undefined) continue; + insertComponent.run(entityId, compName, serializeComponent(compName, comp)); + } + + // Save relationships to dedicated table + const rels = world.getComponent(entityId, 'relationships'); + if (rels) { + for (const [targetId, relData] of rels) { + insertRelationship.run( + entityId, targetId, + relData.value, relData.interactions, + relData.lastInteractionTick, relData.status, + ); + } + } + } + + // Save bond registry + const registry = world.getSingleton('bondRegistry'); + if (registry) { + for (const [key, bonds] of registry) { + const [a, b] = key.split(':').map(Number); + for (const bond of bonds) { + insertBond.run(a, b, bond.type, bond.formedAtTick, bond.status); + } + } + } + }); + + saveAll(); +} + +export function loadEntities(world: World): number { + const db = getDatabase(); + let maxId = 0; + + // Load entities + const entities = db.prepare('SELECT id, type, name, x, y FROM entities ORDER BY id').all() as Array<{ + id: number; type: string; name: string | null; x: number; y: number; + }>; + + for (const entity of entities) { + // Create entity with specific ID by advancing the counter + // We need World to support this — for now, create and track + while (world.createEntity() < entity.id) { /* advance counter */ } + // The entity was just created with the right ID since World uses sequential IDs + + if (entity.id > maxId) maxId = entity.id; + + world.addComponent(entity.id, 'position', { x: entity.x, y: entity.y }); + if (entity.name) { + world.addComponent(entity.id, 'name', entity.name); + } + } + + // Load components + const components = db.prepare('SELECT entity_id, component_name, data FROM components').all() as Array<{ + entity_id: number; component_name: string; data: string; + }>; + + for (const comp of components) { + if (comp.component_name === 'name') continue; // already loaded + world.addComponent( + comp.entity_id, + comp.component_name, + deserializeComponent(comp.component_name, comp.data), + ); + } + + // Load relationships + const rels = db.prepare( + 'SELECT entity_id, target_id, value, interactions, last_interaction_tick, status FROM relationships' + ).all() as Array<{ + entity_id: number; target_id: number; value: number; + interactions: number; last_interaction_tick: number; status: string; + }>; + + // Group by entity_id + const relsByEntity = new Map>(); + for (const rel of rels) { + if (!relsByEntity.has(rel.entity_id)) { + relsByEntity.set(rel.entity_id, new Map()); + } + relsByEntity.get(rel.entity_id)!.set(rel.target_id, { + value: rel.value, + interactions: rel.interactions, + lastInteractionTick: rel.last_interaction_tick, + status: rel.status as 'active' | 'memory', + }); + } + + for (const [entityId, relMap] of relsByEntity) { + world.addComponent(entityId, 'relationships', relMap); + } + + // Load bond registry + const bonds = db.prepare('SELECT entity_a, entity_b, type, formed_tick, status FROM bonds').all() as Array<{ + entity_a: number; entity_b: number; type: string; formed_tick: number; status: string; + }>; + + const registry = createBondRegistry(); + for (const bond of bonds) { + const key = bond.entity_a < bond.entity_b + ? `${bond.entity_a}:${bond.entity_b}` + : `${bond.entity_b}:${bond.entity_a}`; + if (!registry.has(key)) registry.set(key, []); + registry.get(key)!.push({ + type: bond.type, + formedAtTick: bond.formed_tick, + status: bond.status as 'active' | 'former', + }); + } + world.setSingleton('bondRegistry', registry); + + return maxId; +} +``` + +**Important:** The World class uses sequential IDs starting at 1. Loading entities with specific IDs requires a way to set the ID. We need to add a `createEntityWithId(id)` method to World. + +**Step 4: Add `createEntityWithId` to World** + +Modify `server/src/ecs/World.ts` to add: + +```typescript +createEntityWithId(id: EntityId): void { + this.entities.add(id); + if (id >= this.nextId) { + this.nextId = id + 1; + } +} +``` + +This replaces the hacky "advance counter" loop in loadEntities. Update `loadEntities` to use `world.createEntityWithId(entity.id)` instead of the while loop. + +**Step 5: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/persistence/__tests__/entitySerializer.test.ts` +Expected: PASS + +**Step 6: Run all existing tests to verify no regressions** + +Run: `npm -w server run test` +Expected: All 338+ tests pass + +**Step 7: Commit** + +```bash +git add server/src/persistence/ server/src/ecs/World.ts +git commit -m "feat(persistence): add entity serializer with component/relationship/bond support" +``` + +--- + +### Task 5: Event serializer — save/load narration, memory, stockpile, inventions + +**Files:** +- Create: `server/src/persistence/eventSerializer.ts` +- Test: `server/src/persistence/__tests__/eventSerializer.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// server/src/persistence/__tests__/eventSerializer.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { openDatabase, closeDatabase } from '../database.js'; +import { + saveNarrationEvent, loadRecentNarrationEvents, + saveMemoryEvent, loadRecentMemoryEvents, + saveStockpileEntry, loadRecentStockpileEntries, + saveInvention, loadAllInventions, +} from '../eventSerializer.js'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-events.db'); + +beforeEach(() => { + openDatabase(TEST_DB); +}); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('narration events', () => { + it('saves and loads narration events', () => { + saveNarrationEvent({ + id: 1, tick: 100, type: 'social', + entityIds: [1, 2], outcome: 'positive', + narration: 'They became friends.', + }); + saveNarrationEvent({ + id: 2, tick: 200, type: 'proposal', + entityIds: [3, 4], outcome: 'proposal_accepted', + narration: 'A partnership was formed.', + }); + + const events = loadRecentNarrationEvents(50); + expect(events).toHaveLength(2); + expect(events[0].tick).toBe(100); + expect(events[1].tick).toBe(200); + expect(events[0].entityIds).toEqual([1, 2]); + }); + + it('respects limit', () => { + for (let i = 0; i < 5; i++) { + saveNarrationEvent({ + id: i + 1, tick: i * 100, type: 'social', + entityIds: [1, 2], outcome: 'positive', + narration: `Event ${i}`, + }); + } + const events = loadRecentNarrationEvents(3); + expect(events).toHaveLength(3); + // Should return the 3 most recent, in chronological order + expect(events[0].narration).toBe('Event 2'); + expect(events[2].narration).toBe('Event 4'); + }); +}); + +describe('memory events', () => { + it('saves and loads per-entity memory events', () => { + saveMemoryEvent(1, { + id: 1, type: 'social_positive', tick: 50, + otherEntityId: 2, otherName: 'Bob', + detail: 'Had a nice chat', + }); + saveMemoryEvent(1, { + id: 2, type: 'tier_change', tick: 100, + otherEntityId: 2, otherName: 'Bob', + detail: 'Became friends', oldTier: 'Stranger', newTier: 'Friend', + }); + saveMemoryEvent(2, { + id: 3, type: 'spawned', tick: 0, + detail: 'Bob arrived', + }); + + const entity1Events = loadRecentMemoryEvents(1, 50); + expect(entity1Events).toHaveLength(2); + expect(entity1Events[0].type).toBe('social_positive'); + + const entity2Events = loadRecentMemoryEvents(2, 50); + expect(entity2Events).toHaveLength(1); + }); +}); + +describe('stockpile log', () => { + it('saves and loads stockpile entries', () => { + saveStockpileEntry({ + npcName: 'Alice', action: 'dropoff', + itemId: 'wood', quantity: 5, tick: 300, + }); + + const entries = loadRecentStockpileEntries(50); + expect(entries).toHaveLength(1); + expect(entries[0].npcName).toBe('Alice'); + expect(entries[0].action).toBe('dropoff'); + }); +}); + +describe('inventions', () => { + it('saves and loads inventions', () => { + saveInvention({ + itemId: 'wooden_plank', inventorEntityId: 1, + inventorName: 'Alice', tick: 500, day: 3, + name: 'Wooden Plank', category: 'material', + inputs: [{ itemId: 'wood', quantity: 2 }], + workshopType: 'workbench', toolRequired: null, + }); + + const inventions = loadAllInventions(); + expect(inventions).toHaveLength(1); + expect(inventions[0].itemId).toBe('wooden_plank'); + expect(inventions[0].inputs).toEqual([{ itemId: 'wood', quantity: 2 }]); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/persistence/__tests__/eventSerializer.test.ts` +Expected: FAIL + +**Step 3: Implement event serializer** + +```typescript +// server/src/persistence/eventSerializer.ts +import { getDatabase } from './database.js'; +import type { NarrationEvent } from '@dflike/shared'; +import type { 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 * FROM ( + SELECT id, tick, type, entity_ids, outcome, narration + FROM narration_events ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC` + ).all(limit) as Array<{ + id: number; tick: number; type: string; + entity_ids: string; outcome: string; narration: string; + }>; + + return rows.map(row => ({ + id: row.id, + tick: row.tick, + type: row.type as NarrationEvent['type'], + entityIds: JSON.parse(row.entity_ids), + outcome: row.outcome as NarrationEvent['outcome'], + narration: row.narration, + })); +} + +// --- 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 ?? null, + event.oldTier ?? null, + event.newTier ?? null, + event.need ?? null, + event.oldGoal ?? null, + event.newGoal ?? null, + ); +} + +export function loadRecentMemoryEvents(entityId: number, limit: number): MemoryEvent[] { + const db = getDatabase(); + const rows = db.prepare( + `SELECT * FROM ( + 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 ? + ) ORDER BY id ASC` + ).all(entityId, limit) as Array<{ + id: number; tick: number; type: string; + other_entity_id: number | null; other_name: string | null; + detail: string | null; old_tier: string | null; new_tier: string | null; + need: string | null; old_goal: string | null; new_goal: string | null; + }>; + + return rows.map(row => { + const event: MemoryEvent = { + id: row.id, + type: row.type as MemoryEvent['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 as MemoryEvent['need']; + if (row.old_goal !== null) event.oldGoal = row.old_goal; + if (row.new_goal !== null) event.newGoal = row.new_goal; + return event; + }); +} + +/** Load ALL memory events for all entities, grouped by entityId */ +export function loadAllRecentMemoryEvents(limit: number): Map { + const db = getDatabase(); + // Get the most recent `limit` events per entity using window function + const rows = db.prepare( + `SELECT entity_id, id, tick, type, other_entity_id, other_name, detail, + old_tier, new_tier, need, old_goal, new_goal + FROM memory_events ORDER BY entity_id, id ASC` + ).all() as Array<{ + entity_id: number; id: number; tick: number; type: string; + other_entity_id: number | null; other_name: string | null; + detail: string | null; old_tier: string | null; new_tier: string | null; + need: string | null; old_goal: string | null; new_goal: string | null; + }>; + + const result = new Map(); + for (const row of rows) { + if (!result.has(row.entity_id)) result.set(row.entity_id, []); + const events = result.get(row.entity_id)!; + const event: MemoryEvent = { + id: row.id, + type: row.type as MemoryEvent['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 as MemoryEvent['need']; + if (row.old_goal !== null) event.oldGoal = row.old_goal; + if (row.new_goal !== null) event.newGoal = row.new_goal; + events.push(event); + } + + // Trim to last N per entity + for (const [entityId, events] of result) { + if (events.length > limit) { + result.set(entityId, events.slice(-limit)); + } + } + + return result; +} + +// --- 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 * FROM ( + SELECT tick, npc_name, action, item_id, quantity + FROM stockpile_log ORDER BY id DESC LIMIT ? + ) ORDER BY id ASC` + ).all(limit) as Array<{ + tick: number; npc_name: string; action: string; + item_id: string; quantity: number; + }>; + + return rows.map(row => ({ + tick: row.tick, + npcName: row.npc_name, + action: row.action as 'dropoff' | 'pickup', + itemId: row.item_id, + quantity: row.quantity, + })); +} + +// --- 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 * FROM inventions ORDER BY id ASC' + ).all() as Array<{ + tick: number; item_id: string; inventor_entity_id: number; + inventor_name: string; day: number; name: string; + category: string; inputs: string; + workshop_type: string | null; tool_required: string | null; + }>; + + return rows.map(row => ({ + tick: row.tick, + itemId: row.item_id, + inventorEntityId: row.inventor_entity_id, + inventorName: row.inventor_name, + day: row.day, + name: row.name, + category: row.category as InventionEntry['category'], + inputs: JSON.parse(row.inputs), + workshopType: row.workshop_type, + toolRequired: row.tool_required, + })); +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/persistence/__tests__/eventSerializer.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/persistence/ +git commit -m "feat(persistence): add event serializer for narration, memory, stockpile, inventions" +``` + +--- + +### Task 6: SaveManager — orchestration, periodic saves, shutdown + +**Files:** +- Create: `server/src/persistence/saveManager.ts` +- Test: `server/src/persistence/__tests__/saveManager.test.ts` + +**Step 1: Write the failing tests** + +```typescript +// server/src/persistence/__tests__/saveManager.test.ts +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { SaveManager } from '../saveManager.js'; +import { openDatabase, closeDatabase, getDatabase } from '../database.js'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { createBondRegistry } from '../../systems/bondRegistry.js'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-savemanager.db'); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('SaveManager', () => { + it('initializes a new world save', () => { + const world = new World(); + world.setSingleton('bondRegistry', createBondRegistry()); + const map = new GameMap(4, 4); + map.terrain = new Array(16).fill(0); + map.decorations = new Array(16).fill(-1); + map.trunkDecorations = new Array(16).fill(-1); + + const manager = new SaveManager(TEST_DB); + manager.initNewWorld(world, map, { + terrain: map.terrain, + decorations: map.decorations, + trunkDecorations: map.trunkDecorations, + resourceTiles: [], + obstacles: new Set(), + foodPositions: [{ x: 1, y: 1 }], + restPositions: [{ x: 2, y: 2 }], + }); + + // Should have saved tiles + const db = getDatabase(); + const count = (db.prepare('SELECT COUNT(*) as c FROM tiles').get() as { c: number }).c; + expect(count).toBe(16); + }); + + it('saves and restores entity state', () => { + const world = new World(); + world.setSingleton('bondRegistry', createBondRegistry()); + const id = world.createEntity(); + world.addComponent(id, 'position', { x: 5, y: 10 }); + world.addComponent(id, 'name', 'Tester'); + world.addComponent(id, 'needs', { hunger: 50, energy: 70, productivity: 80 }); + + const manager = new SaveManager(TEST_DB); + // Init with minimal map data + const map = new GameMap(4, 4); + manager.initNewWorld(world, map, { + terrain: new Array(16).fill(0), + decorations: new Array(16).fill(-1), + trunkDecorations: new Array(16).fill(-1), + resourceTiles: [], obstacles: new Set(), + foodPositions: [], restPositions: [], + }); + manager.saveEntityState(world, 100); + + // Load into fresh world + const newWorld = new World(); + manager.loadEntityState(newWorld); + expect(newWorld.getComponent(id, 'name')).toBe('Tester'); + expect(newWorld.getComponent(id, 'needs')).toEqual({ hunger: 50, energy: 70, productivity: 80 }); + }); + + it('detects existing save', () => { + const manager = new SaveManager(TEST_DB); + expect(manager.saveExists()).toBe(false); + + const world = new World(); + world.setSingleton('bondRegistry', createBondRegistry()); + const map = new GameMap(4, 4); + manager.initNewWorld(world, map, { + terrain: new Array(16).fill(0), + decorations: new Array(16).fill(-1), + trunkDecorations: new Array(16).fill(-1), + resourceTiles: [], obstacles: new Set(), + foodPositions: [], restPositions: [], + }); + manager.close(); + + const manager2 = new SaveManager(TEST_DB); + expect(manager2.saveExists()).toBe(true); + manager2.close(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +Run: `npm -w server run test -- --run src/persistence/__tests__/saveManager.test.ts` +Expected: FAIL + +**Step 3: Implement SaveManager** + +```typescript +// server/src/persistence/saveManager.ts +import fs from 'fs'; +import { openDatabase, closeDatabase, getDatabase, getSchemaVersion, CURRENT_SCHEMA_VERSION } from './database.js'; +import { saveTiles, loadTiles, saveMetadata, loadMetadata, type TileData } from './worldSerializer.js'; +import { saveEntities, loadEntities } from './entitySerializer.js'; +import type { World } from '../ecs/World.js'; +import type { GameMap } from '../map/GameMap.js'; + +export class SaveManager { + private filepath: string; + + constructor(filepath: string) { + this.filepath = filepath; + } + + saveExists(): boolean { + return fs.existsSync(this.filepath); + } + + /** Initialize database for a brand new world */ + initNewWorld(world: World, map: GameMap, tileData: TileData): void { + openDatabase(this.filepath); + saveTiles(map.width, map.height, tileData); + } + + /** Open existing save and verify schema */ + openExistingSave(): void { + openDatabase(this.filepath); + const version = getSchemaVersion(); + if (version < CURRENT_SCHEMA_VERSION) { + this.runMigrations(version); + } + } + + /** Load map tiles into a GameMap instance */ + loadMapState(map: GameMap): boolean { + const tileData = loadTiles(map.width, map.height); + if (!tileData) return false; + + map.terrain = tileData.terrain; + map.decorations = tileData.decorations; + map.trunkDecorations = tileData.trunkDecorations; + map.resourceTiles = tileData.resourceTiles; + map.loadObstacles(tileData.obstacles); + + for (const pos of tileData.foodPositions) { + map.addPointOfInterest({ type: 'food', position: pos }); + } + for (const pos of tileData.restPositions) { + map.addPointOfInterest({ type: 'rest', position: pos }); + } + + return true; + } + + /** Save all entity state (NPCs, structures, relationships, bonds) */ + saveEntityState(world: World, tick: number): void { + saveEntities(world); + saveMetadata({ + tick, + lastSavedAt: new Date().toISOString(), + }); + } + + /** Load entity state into world, returns saved tick */ + loadEntityState(world: World): number { + loadEntities(world); + const meta = loadMetadata(); + return meta.tick; + } + + close(): void { + closeDatabase(); + } + + private runMigrations(fromVersion: number): void { + // Migration functions will be added here as schema evolves + // For now, just update the version + const db = getDatabase(); + db.prepare("INSERT OR REPLACE INTO metadata (key, value) VALUES ('schema_version', ?)").run( + String(CURRENT_SCHEMA_VERSION) + ); + } +} +``` + +**Step 4: Run tests to verify they pass** + +Run: `npm -w server run test -- --run src/persistence/__tests__/saveManager.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server/src/persistence/ +git commit -m "feat(persistence): add SaveManager orchestration class" +``` + +--- + +### Task 7: Wire event services to write to database + +**Files:** +- Modify: `server/src/llm/narrationService.ts` +- Modify: `server/src/llm/eventMemoryService.ts` +- Modify: `server/src/industry/stockpileLog.ts` +- Modify: `server/src/industry/inventionTimeline.ts` + +This task modifies the existing event services to optionally write events to the database when a persistence callback is configured. We keep changes minimal — each service gets a `setPersistence` method that accepts a save callback, and each `record` method calls it after buffering. + +**Step 1: Modify narrationService.ts** + +Add to the `NarrationService` interface: +```typescript +setPersistence(saveFn: (event: NarrationEvent) => void): void; +loadFromPersistence(events: NarrationEvent[]): void; +``` + +In `createNarrationService`, add: +```typescript +let persistFn: ((event: NarrationEvent) => void) | null = null; +``` + +In `recordInteraction`, after the event is pushed to the buffer, add: +```typescript +if (persistFn) persistFn(event); +``` + +Implement: +```typescript +setPersistence(saveFn) { persistFn = saveFn; }, +loadFromPersistence(events) { + buffer.length = 0; + buffer.push(...events); + if (events.length > 0) { + nextId = Math.max(...events.map(e => e.id)) + 1; + } +}, +``` + +**Step 2: Modify eventMemoryService.ts** + +Add to the `EventMemoryService` interface: +```typescript +setPersistence(saveFn: (entityId: EntityId, event: MemoryEvent) => void): void; +loadFromPersistence(allEvents: Map): void; +``` + +In `createEventMemoryService`, add: +```typescript +let persistFn: ((entityId: EntityId, event: MemoryEvent) => void) | null = null; +``` + +In `record`, after the event is pushed, add: +```typescript +if (persistFn) persistFn(entityId, event); +``` + +Implement: +```typescript +setPersistence(saveFn) { persistFn = saveFn; }, +loadFromPersistence(allEvents) { + for (const [entityId, events] of allEvents) { + buffers.set(entityId, [...events]); + for (const e of events) { + if (e.id >= nextId) nextId = e.id + 1; + } + } +}, +``` + +**Step 3: Modify stockpileLog.ts** + +Add to the `StockpileLog` interface: +```typescript +setPersistence(saveFn: (entry: StockpileLogEntry) => void): void; +loadFromPersistence(entries: StockpileLogEntry[]): void; +``` + +In `createStockpileLog`, add persistence callback field and wire into `record`. + +Implement `loadFromPersistence` to replace the entries array. + +**Step 4: Modify inventionTimeline.ts** + +Add to the `InventionTimeline` interface: +```typescript +setPersistence(saveFn: (entry: InventionEntry) => void): void; +loadFromPersistence(entries: InventionEntry[]): void; +``` + +Same pattern — callback on `record`, bulk load on init. + +**Step 5: Run all existing tests** + +Run: `npm -w server run test` +Expected: All tests pass (the new methods are optional — existing code doesn't call them) + +**Step 6: Commit** + +```bash +git add server/src/llm/narrationService.ts server/src/llm/eventMemoryService.ts \ + server/src/industry/stockpileLog.ts server/src/industry/inventionTimeline.ts +git commit -m "feat(persistence): add persistence hooks to event services" +``` + +--- + +### Task 8: Integrate persistence into GameLoop + +**Files:** +- Modify: `server/src/game/GameLoop.ts` +- Modify: `server/src/main.ts` + +This is the integration task that wires everything together. + +**Step 1: Modify GameLoop constructor and add persistence methods** + +Add imports at top of GameLoop.ts: +```typescript +import { SaveManager } from '../persistence/saveManager.js'; +import { saveNarrationEvent, loadRecentNarrationEvents, saveMemoryEvent, loadAllRecentMemoryEvents, saveStockpileEntry, loadRecentStockpileEntries, saveInvention, loadAllInventions } from '../persistence/eventSerializer.js'; +``` + +Add fields: +```typescript +private saveManager: SaveManager | null = null; +private saveInterval: ReturnType | null = null; +``` + +Refactor the constructor to accept an options object: +```typescript +constructor(options?: { newWorld?: boolean; savePath?: string }) { + const savePath = options?.savePath ?? 'saves/default.db'; + const forceNew = options?.newWorld ?? false; + + this.world = new World(); + this.map = new GameMap(); + + // Initialize services (LLM, narration, etc.) — same as current code + // ... + + this.saveManager = new SaveManager(savePath); + + if (!forceNew && this.saveManager.saveExists()) { + this.loadFromSave(); + } else { + this.initNewWorld(); + } +} +``` + +Add `initNewWorld` method (extracts current constructor logic): +```typescript +private initNewWorld(): void { + // Set singletons (bondRegistry, itemRegistry, recipeRegistry, inventionTimeline, stockpileLog) + this.world.setSingleton('bondRegistry', createBondRegistry()); + this.world.setSingleton('itemRegistry', ItemRegistry.createDefault()); + this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + this.world.setSingleton('inventionTimeline', createInventionTimeline()); + this.world.setSingleton('stockpileLog', createStockpileLog()); + + this.setupMap(); + this.spawnDefaultStockpiles(); + this.spawnInitialNPCs(8); + + // Save initial world state + if (this.saveManager) { + const generated = this.lastGeneratedMap!; // Store generateMap result + this.saveManager.initNewWorld(this.world, this.map, generated); + this.saveManager.saveEntityState(this.world, 0); + this.wirePersistenceCallbacks(); + } +} +``` + +Add `loadFromSave` method: +```typescript +private loadFromSave(): void { + this.saveManager!.openExistingSave(); + + // Load map + this.saveManager!.loadMapState(this.map); + + // Set singletons that aren't entity-based + this.world.setSingleton('itemRegistry', ItemRegistry.createDefault()); + this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault()); + + // Load entities (this also restores bondRegistry) + this.tick = this.saveManager!.loadEntityState(this.world); + + // Create inventionTimeline and stockpileLog, load from DB + const inventionTimeline = createInventionTimeline(); + const inventions = loadAllInventions(); + inventionTimeline.loadFromPersistence(inventions); + this.world.setSingleton('inventionTimeline', inventionTimeline); + + const stockpileLog = createStockpileLog(); + const stockEntries = loadRecentStockpileEntries(50); + stockpileLog.loadFromPersistence(stockEntries); + this.world.setSingleton('stockpileLog', stockpileLog); + + // Load event history into services + const narrationEvents = loadRecentNarrationEvents(50); + this.narrationService.loadFromPersistence(narrationEvents); + + const memoryEvents = loadAllRecentMemoryEvents(50); + this.eventMemoryService.loadFromPersistence(memoryEvents); + + this.wirePersistenceCallbacks(); + + console.log(`Loaded save: tick ${this.tick}, ${this.world.getAllEntities().length} entities`); +} +``` + +Add `wirePersistenceCallbacks`: +```typescript +private wirePersistenceCallbacks(): void { + this.narrationService.setPersistence(saveNarrationEvent); + this.eventMemoryService.setPersistence(saveMemoryEvent); + + const stockpileLog = this.world.getSingleton('stockpileLog')!; + stockpileLog.setPersistence(saveStockpileEntry); + + const inventionTimeline = this.world.getSingleton('inventionTimeline')!; + inventionTimeline.setPersistence(saveInvention); +} +``` + +**Step 2: Add periodic save and shutdown hooks** + +In `start()`: +```typescript +start(): void { + // ... existing interval setup ... + + // Periodic save every 30 seconds + this.saveInterval = setInterval(() => { + if (this.saveManager) { + this.saveManager.saveEntityState(this.world, this.tick); + } + }, 30_000); +} +``` + +In `stop()`: +```typescript +stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + if (this.saveInterval) { + clearInterval(this.saveInterval); + this.saveInterval = null; + } + // Final save + if (this.saveManager) { + this.saveManager.saveEntityState(this.world, this.tick); + this.saveManager.close(); + } +} +``` + +**Step 3: Modify main.ts for shutdown handling and CLI args** + +```typescript +// server/src/main.ts +import 'dotenv/config'; +import http from 'http'; +import { GameLoop } from './game/GameLoop.js'; +import { SocketServer } from './network/SocketServer.js'; + +const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001; +const newWorld = process.argv.includes('--new-world'); + +const gameLoop = new GameLoop({ newWorld }); +const httpServer = http.createServer(); +new SocketServer(httpServer, gameLoop); + +httpServer.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); + gameLoop.start(); +}); + +// Graceful shutdown +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.on(signal, () => { + console.log(`\nReceived ${signal}, saving and shutting down...`); + gameLoop.stop(); + httpServer.close(); + process.exit(0); + }); +} +``` + +**Step 4: Store the generateMap result for persistence** + +In `setupMap()`, store the generated map data so `initNewWorld` can pass it to `SaveManager.initNewWorld()`. Add a field: +```typescript +private lastGeneratedMap: TileData | null = null; +``` + +In `setupMap()`, store the result: +```typescript +private setupMap(): void { + const generated = generateMap(this.map.width, this.map.height); + this.lastGeneratedMap = { + terrain: generated.terrain, + decorations: generated.decorations, + trunkDecorations: generated.trunkDecorations, + resourceTiles: generated.resourceTiles, + obstacles: generated.obstacles, + foodPositions: generated.foodPositions, + restPositions: generated.restPositions, + }; + // ... rest of existing setupMap logic ... +} +``` + +**Step 5: Run all tests** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 6: Manual smoke test** + +1. `npm -w server run dev` — starts, creates `saves/default.db` +2. Wait for NPCs to interact, build things +3. Ctrl-C — should print "saving and shutting down" +4. `npm -w server run dev` — should load from save, print entity count +5. `npm -w server run dev -- --new-world` — should create fresh world + +**Step 7: Commit** + +```bash +git add server/src/game/GameLoop.ts server/src/main.ts +git commit -m "feat(persistence): integrate save/load into GameLoop with periodic autosave and graceful shutdown" +``` + +--- + +### Task 9: Add index for memory_events entity_id lookup + +**Files:** +- Modify: `server/src/persistence/database.ts` + +**Step 1: Add index to schema SQL** + +Add after the `memory_events` table creation: +```sql +CREATE INDEX IF NOT EXISTS idx_memory_events_entity ON memory_events(entity_id); +CREATE INDEX IF NOT EXISTS idx_narration_events_tick ON narration_events(tick); +``` + +**Step 2: Run all tests** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add server/src/persistence/database.ts +git commit -m "feat(persistence): add database indexes for query performance" +``` + +--- + +### Task 10: Integration test — full save/load cycle + +**Files:** +- Create: `server/src/persistence/__tests__/integration.test.ts` + +**Step 1: Write integration test** + +```typescript +// server/src/persistence/__tests__/integration.test.ts +import { describe, it, expect, afterEach } from 'vitest'; +import { SaveManager } from '../saveManager.js'; +import { closeDatabase } from '../database.js'; +import { + saveNarrationEvent, loadRecentNarrationEvents, + saveMemoryEvent, loadAllRecentMemoryEvents, + saveStockpileEntry, loadRecentStockpileEntries, + saveInvention, loadAllInventions, +} from '../eventSerializer.js'; +import { World } from '../../ecs/World.js'; +import { GameMap } from '../../map/GameMap.js'; +import { createBondRegistry, addBond, hasBond } from '../../systems/bondRegistry.js'; +import { generateMap } from '../../map/mapGenerator.js'; +import { spawnNPC } from '../../game/spawner.js'; +import fs from 'fs'; +import path from 'path'; + +const TEST_DB = path.join(import.meta.dirname, 'test-integration.db'); + +afterEach(() => { + closeDatabase(); + try { fs.unlinkSync(TEST_DB); } catch { /* ignore */ } +}); + +describe('full save/load cycle', () => { + it('saves a world with NPCs, structures, and events, then restores it', () => { + // === SETUP: Create a world with real game data === + const world = new World(); + const map = new GameMap(); + const generated = generateMap(map.width, map.height); + + // Init singletons + const bondRegistry = createBondRegistry(); + world.setSingleton('bondRegistry', bondRegistry); + + // Apply map + map.terrain = generated.terrain; + map.decorations = generated.decorations; + map.trunkDecorations = generated.trunkDecorations; + map.loadObstacles(generated.obstacles); + map.resourceTiles = generated.resourceTiles; + + // Spawn NPCs + const npc1 = spawnNPC(world, map); + const npc2 = spawnNPC(world, map); + const name1 = world.getComponent(npc1, 'name')!; + const name2 = world.getComponent(npc2, 'name')!; + + // Set up a relationship + const rels1 = world.getComponent>(npc1, 'relationships')!; + rels1.set(npc2, { value: 55, interactions: 10, lastInteractionTick: 200, status: 'active' }); + + // Add a bond + addBond(bondRegistry, npc1, npc2, 'partner', 150); + + // Create a structure + const structId = world.createEntity(); + world.addComponent(structId, 'position', { x: 10, y: 10 }); + world.addComponent(structId, 'structure', { + type: 'stockpile', subtype: 'wood', + inventory: new Map([['wood', 25]]), + }); + + // === SAVE === + const manager = new SaveManager(TEST_DB); + manager.initNewWorld(world, map, { + terrain: generated.terrain, + decorations: generated.decorations, + trunkDecorations: generated.trunkDecorations, + resourceTiles: generated.resourceTiles, + obstacles: generated.obstacles, + foodPositions: generated.foodPositions, + restPositions: generated.restPositions, + }); + manager.saveEntityState(world, 500); + + // Save some events + saveNarrationEvent({ + id: 1, tick: 100, type: 'social', + entityIds: [npc1, npc2], outcome: 'positive', + narration: `${name1} and ${name2} chatted.`, + }); + saveMemoryEvent(npc1, { + id: 1, type: 'social_positive', tick: 100, + otherEntityId: npc2, otherName: name2, + detail: 'Had a pleasant conversation', + }); + saveStockpileEntry({ + npcName: name1, action: 'dropoff', + itemId: 'wood', quantity: 5, tick: 300, + }); + + manager.close(); + + // === LOAD === + const newWorld = new World(); + const newMap = new GameMap(); + const manager2 = new SaveManager(TEST_DB); + manager2.openExistingSave(); + + // Load map + const mapLoaded = manager2.loadMapState(newMap); + expect(mapLoaded).toBe(true); + expect(newMap.terrain).toEqual(map.terrain); + + // Load entities + const tick = manager2.loadEntityState(newWorld); + expect(tick).toBe(500); + + // Verify NPCs + expect(newWorld.getComponent(npc1, 'name')).toBe(name1); + expect(newWorld.getComponent(npc2, 'name')).toBe(name2); + + // Verify relationship + const loadedRels = newWorld.getComponent>(npc1, 'relationships')!; + expect(loadedRels.get(npc2)?.value).toBe(55); + + // Verify bond + const loadedRegistry = newWorld.getSingleton('bondRegistry')!; + expect(hasBond(loadedRegistry, npc1, npc2, 'partner')).toBe(true); + + // Verify structure + const loadedStructure = newWorld.getComponent(structId, 'structure')!; + expect(loadedStructure.type).toBe('stockpile'); + expect(loadedStructure.inventory.get('wood')).toBe(25); + + // Verify events + const narrationEvents = loadRecentNarrationEvents(50); + expect(narrationEvents).toHaveLength(1); + + const memoryEvents = loadAllRecentMemoryEvents(50); + expect(memoryEvents.get(npc1)).toHaveLength(1); + + const stockEntries = loadRecentStockpileEntries(50); + expect(stockEntries).toHaveLength(1); + + manager2.close(); + }); +}); +``` + +**Step 2: Run integration test** + +Run: `npm -w server run test -- --run src/persistence/__tests__/integration.test.ts` +Expected: PASS + +**Step 3: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 4: Commit** + +```bash +git add server/src/persistence/__tests__/integration.test.ts +git commit -m "test(persistence): add full save/load integration test" +``` + +--- + +### Task 11: Final verification and cleanup + +**Step 1: Run full test suite** + +Run: `npm -w server run test` +Expected: All tests pass + +**Step 2: Start server and verify save/load cycle manually** + +```bash +npm -w server run dev +# Wait ~35 seconds for first autosave +# Ctrl-C — should see "saving and shutting down" +ls -la saves/ # Should see default.db +npm -w server run dev # Should load from save +# Verify NPCs are same as before, structures intact +npm -w server run dev -- --new-world # Fresh world +``` + +**Step 3: Verify .gitignore prevents db files from being committed** + +```bash +git status # saves/default.db should NOT appear +``` + +**Step 4: Final commit if any cleanup needed** + +```bash +git add -A +git commit -m "chore: persistence system cleanup and verification" +```