From 514a18ab54212046fb91acbee23c65c73c3adac5 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 14:14:05 +0000 Subject: [PATCH] feat(server): add entity serializer for save/load persistence Add createEntityWithId to World for restoring entities with original IDs. Implement saveEntities/loadEntities to persist NPCs, structures, relationships, and bonds to SQLite, with proper Map serialization and transient state resets. Co-Authored-By: Claude Opus 4.6 --- server/src/ecs/World.ts | 7 + .../__tests__/entitySerializer.test.ts | 287 ++++++++++++++++++ server/src/persistence/database.ts | 142 +++++++++ server/src/persistence/entitySerializer.ts | 259 ++++++++++++++++ 4 files changed, 695 insertions(+) create mode 100644 server/src/persistence/__tests__/entitySerializer.test.ts create mode 100644 server/src/persistence/database.ts create mode 100644 server/src/persistence/entitySerializer.ts diff --git a/server/src/ecs/World.ts b/server/src/ecs/World.ts index c1a6ee0..41e85cf 100644 --- a/server/src/ecs/World.ts +++ b/server/src/ecs/World.ts @@ -14,6 +14,13 @@ export class World { return id; } + createEntityWithId(id: EntityId): void { + this.entities.add(id); + if (id >= this.nextId) { + this.nextId = id + 1; + } + } + removeEntity(entity: EntityId): void { this.entities.delete(entity); for (const store of this.components.values()) { diff --git a/server/src/persistence/__tests__/entitySerializer.test.ts b/server/src/persistence/__tests__/entitySerializer.test.ts new file mode 100644 index 0000000..c68f096 --- /dev/null +++ b/server/src/persistence/__tests__/entitySerializer.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { World } from '../../ecs/World.js'; +import { openDatabase, closeDatabase } from '../database.js'; +import { saveEntities, loadEntities } from '../entitySerializer.js'; +import type { + Position, Needs, Stats, Movement, NPCBrain, SocialState, + StatModifiers, Appearance, RelationshipData, EntityId, +} from '@dflike/shared'; +import type { StructureData } from '../../systems/buildingSystem.js'; +import type { BondRegistry } from '../../systems/bondRegistry.js'; +import { createBondRegistry } from '../../systems/bondRegistry.js'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +function tempDbPath(): string { + return path.join(os.tmpdir(), `dflike-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe('entitySerializer', () => { + const dbPaths: string[] = []; + let world: World; + let dbPath: string; + + function createTempDb(): string { + const p = tempDbPath(); + dbPaths.push(p); + return p; + } + + beforeEach(() => { + dbPath = createTempDb(); + openDatabase(dbPath); + world = new World(); + }); + + 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; + }); + + it('round-trips an NPC with all components', () => { + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 10.5, y: 20.5 }); + world.addComponent(e, 'name', 'Gwendolyn'); + world.addComponent(e, 'needs', { hunger: 75, energy: 60, productivity: 40 }); + world.addComponent(e, 'stats', { + strength: 12, dexterity: 14, constitution: 10, intelligence: 8, perception: 11, + sociability: 15, courage: 9, curiosity: 13, empathy: 16, temperament: 7, + }); + world.addComponent>(e, 'inventory', new Map([['log', 3], ['stone', 1]])); + world.addComponent(e, 'appearance', { + skinId: 'shape00_skin02', + accessories: { torso: 'shirt01' }, + portraitFeatures: { eyes: 'eyes01' }, + }); + world.addComponent(e, 'movement', { + state: 'walking', target: { x: 5, y: 5 }, path: [{ x: 5, y: 5 }], direction: 2, moveProgress: 0.5, + }); + world.addComponent(e, 'npcBrain', { + currentGoal: 'gather', goalQueue: ['wander'], + gatherTarget: { x: 3, y: 4, resourceType: 'tree' }, + }); + world.addComponent(e, 'socialState', { + phase: 'facing', partnerId: 99, phaseTimer: 5, + outcome: 'positive', globalCooldown: 10, + pairCooldowns: new Map([[2, 50], [3, 30]]), + lastOutcome: { partnerId: 99, outcome: 'positive', tick: 100 }, + proposalCooldown: 0, + pendingProposal: { targetId: 99, type: 'partner' }, + isProposalInteraction: true, + }); + world.addComponent(e, 'statModifiers', { + modifiers: [{ stat: 'strength', value: 2, remaining: 10 }], + }); + world.addComponent(e, 'backstory', 'A brave adventurer from the north.'); + + saveEntities(world); + + // Load into a fresh world + const world2 = new World(); + const maxId = loadEntities(world2); + + const entities = world2.getAllEntities(); + expect(entities).toHaveLength(1); + expect(entities[0]).toBe(e); + + // Position + const pos = world2.getComponent(e, 'position')!; + expect(pos.x).toBe(10.5); + expect(pos.y).toBe(20.5); + + // Name + expect(world2.getComponent(e, 'name')).toBe('Gwendolyn'); + + // Needs + const needs = world2.getComponent(e, 'needs')!; + expect(needs.hunger).toBe(75); + expect(needs.energy).toBe(60); + + // Stats + const stats = world2.getComponent(e, 'stats')!; + expect(stats.strength).toBe(12); + expect(stats.empathy).toBe(16); + + // Inventory (Map) + const inv = world2.getComponent>(e, 'inventory')!; + expect(inv).toBeInstanceOf(Map); + expect(inv.get('log')).toBe(3); + expect(inv.get('stone')).toBe(1); + + // Appearance + const app = world2.getComponent(e, 'appearance')!; + expect(app.skinId).toBe('shape00_skin02'); + expect(app.accessories.torso).toBe('shirt01'); + + // Movement (reset on load) + const mov = world2.getComponent(e, 'movement')!; + expect(mov.state).toBe('idle'); + expect(mov.target).toBeNull(); + expect(mov.path).toEqual([]); + expect(mov.moveProgress).toBe(0); + + // NPCBrain (reset on load) + const brain = world2.getComponent(e, 'npcBrain')!; + expect(brain.currentGoal).toBeNull(); + expect(brain.goalQueue).toEqual([]); + + // SocialState (reset on load) + const social = world2.getComponent(e, 'socialState')!; + expect(social.phase).toBe('none'); + expect(social.partnerId).toBeNull(); + expect(social.phaseTimer).toBe(0); + expect(social.outcome).toBeNull(); + expect(social.lastOutcome).toBeNull(); + expect(social.pendingProposal).toBeNull(); + expect(social.isProposalInteraction).toBe(false); + // pairCooldowns should be preserved + expect(social.pairCooldowns).toBeInstanceOf(Map); + expect(social.pairCooldowns.get(2)).toBe(50); + // globalCooldown preserved + expect(social.globalCooldown).toBe(10); + + // StatModifiers + const mods = world2.getComponent(e, 'statModifiers')!; + expect(mods.modifiers).toHaveLength(1); + expect(mods.modifiers[0].stat).toBe('strength'); + + // Backstory + expect(world2.getComponent(e, 'backstory')).toBe('A brave adventurer from the north.'); + + expect(maxId).toBe(e); + }); + + it('round-trips a structure entity', () => { + const e = world.createEntity(); + world.addComponent(e, 'position', { x: 5, y: 10 }); + world.addComponent(e, 'name', 'Stockpile'); + world.addComponent(e, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map([['log', 10], ['stone', 5]]), + buildProgress: undefined, + }); + + saveEntities(world); + + const world2 = new World(); + loadEntities(world2); + + const entities = world2.getAllEntities(); + expect(entities).toHaveLength(1); + + const struct = world2.getComponent(e, 'structure')!; + expect(struct.type).toBe('stockpile'); + expect(struct.subtype).toBe('general'); + expect(struct.inventory).toBeInstanceOf(Map); + expect(struct.inventory.get('log')).toBe(10); + expect(struct.inventory.get('stone')).toBe(5); + }); + + it('round-trips relationships between entities', () => { + const e1 = world.createEntity(); + const e2 = world.createEntity(); + world.addComponent(e1, 'position', { x: 0, y: 0 }); + world.addComponent(e2, 'position', { x: 1, y: 1 }); + world.addComponent(e1, 'name', 'Alice'); + world.addComponent(e2, 'name', 'Bob'); + + const rels1 = new Map(); + rels1.set(e2, { value: 50, interactions: 10, lastInteractionTick: 100, status: 'active' }); + world.addComponent(e1, 'relationships', rels1); + + const rels2 = new Map(); + rels2.set(e1, { value: 30, interactions: 10, lastInteractionTick: 100, status: 'active' }); + world.addComponent(e2, 'relationships', rels2); + + saveEntities(world); + + const world2 = new World(); + loadEntities(world2); + + const loadedRels1 = world2.getComponent>(e1, 'relationships')!; + expect(loadedRels1).toBeInstanceOf(Map); + expect(loadedRels1.get(e2)!.value).toBe(50); + expect(loadedRels1.get(e2)!.interactions).toBe(10); + expect(loadedRels1.get(e2)!.status).toBe('active'); + + const loadedRels2 = world2.getComponent>(e2, 'relationships')!; + expect(loadedRels2.get(e1)!.value).toBe(30); + }); + + it('round-trips bond registry', () => { + const e1 = world.createEntity(); + const e2 = world.createEntity(); + world.addComponent(e1, 'position', { x: 0, y: 0 }); + world.addComponent(e2, 'position', { x: 1, y: 1 }); + + const registry = createBondRegistry(); + registry.set(`${e1}:${e2}`, [ + { type: 'partner', formedAtTick: 50, status: 'active' }, + { type: 'friend', formedAtTick: 20, status: 'former' }, + ]); + world.setSingleton('bondRegistry', registry); + + saveEntities(world); + + const world2 = new World(); + loadEntities(world2); + + const loadedRegistry = world2.getSingleton('bondRegistry')!; + expect(loadedRegistry).toBeInstanceOf(Map); + + const bonds = loadedRegistry.get(`${e1}:${e2}`)!; + expect(bonds).toHaveLength(2); + expect(bonds[0].type).toBe('partner'); + expect(bonds[0].formedAtTick).toBe(50); + expect(bonds[0].status).toBe('active'); + expect(bonds[1].type).toBe('friend'); + expect(bonds[1].status).toBe('former'); + }); + + it('skips playerControlled entities', () => { + const npc = world.createEntity(); + world.addComponent(npc, 'position', { x: 0, y: 0 }); + world.addComponent(npc, 'name', 'NPC'); + + const player = world.createEntity(); + world.addComponent(player, 'position', { x: 5, y: 5 }); + world.addComponent(player, 'playerControlled', { playerId: 'p1', mode: 'avatar' }); + world.addComponent(player, 'name', 'Player'); + + saveEntities(world); + + const world2 = new World(); + loadEntities(world2); + + const entities = world2.getAllEntities(); + expect(entities).toHaveLength(1); + expect(entities[0]).toBe(npc); + expect(world2.getComponent(npc, 'name')).toBe('NPC'); + }); + + it('sets maxId correctly after loading', () => { + const e1 = world.createEntity(); // 1 + const e2 = world.createEntity(); // 2 + const e3 = world.createEntity(); // 3 + world.addComponent(e1, 'position', { x: 0, y: 0 }); + world.addComponent(e2, 'position', { x: 1, y: 1 }); + world.addComponent(e3, 'position', { x: 2, y: 2 }); + + saveEntities(world); + + const world2 = new World(); + const maxId = loadEntities(world2); + expect(maxId).toBe(3); + + // New entity should get id 4 + const newEntity = world2.createEntity(); + expect(newEntity).toBe(4); + }); +}); diff --git a/server/src/persistence/database.ts b/server/src/persistence/database.ts new file mode 100644 index 0000000..2f067b1 --- /dev/null +++ b/server/src/persistence/database.ts @@ -0,0 +1,142 @@ +import Database from 'better-sqlite3'; + +export const CURRENT_SCHEMA_VERSION = 1; + +let db: Database.Database | null = null; + +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, + y INTEGER, + terrain INTEGER, + decoration INTEGER DEFAULT -1, + trunk_decoration INTEGER 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, + component_name TEXT, + data TEXT NOT NULL, + PRIMARY KEY (entity_id, component_name) +); + +CREATE TABLE IF NOT EXISTS relationships ( + entity_id INTEGER, + target_id INTEGER, + value REAL, + interactions INTEGER DEFAULT 0, + last_interaction_tick INTEGER DEFAULT 0, + status TEXT DEFAULT 'active', + PRIMARY KEY (entity_id, target_id) +); + +CREATE TABLE IF NOT EXISTS bonds ( + entity_a INTEGER, + entity_b INTEGER, + type TEXT, + formed_tick INTEGER, + status TEXT DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS narration_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER, + type TEXT, + entity_ids TEXT, + outcome TEXT, + narration TEXT +); + +CREATE TABLE IF NOT EXISTS memory_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER, + tick INTEGER, + type TEXT, + 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, + npc_name TEXT, + action TEXT, + item_id TEXT, + quantity INTEGER +); + +CREATE TABLE IF NOT EXISTS inventions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tick INTEGER, + item_id TEXT, + inventor_entity_id INTEGER, + inventor_name TEXT, + day INTEGER, + name TEXT, + category TEXT, + inputs TEXT, + workshop_type TEXT, + tool_required TEXT +); +`; + +export function openDatabase(filepath: string): void { + if (db) { + db.close(); + db = null; + } + + db = new Database(filepath); + db.pragma('journal_mode = WAL'); + 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(): Database.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 d = getDatabase(); + const row = d.prepare("SELECT value FROM metadata WHERE key = 'schema_version'").get() as { value: string } | undefined; + if (!row) { + throw new Error('schema_version not found in metadata table'); + } + return Number(row.value); +} diff --git a/server/src/persistence/entitySerializer.ts b/server/src/persistence/entitySerializer.ts new file mode 100644 index 0000000..7a95d3a --- /dev/null +++ b/server/src/persistence/entitySerializer.ts @@ -0,0 +1,259 @@ +import type { EntityId, RelationshipData } from '@dflike/shared'; +import type { World } from '../ecs/World.js'; +import type { Bond, BondRegistry } from '../systems/bondRegistry.js'; +import { createBondRegistry } from '../systems/bondRegistry.js'; +import { getDatabase } from './database.js'; + +/** Components to serialize (excluding name, relationships, position which are handled separately) */ +const SERIALIZABLE_COMPONENTS = [ + 'needs', 'stats', 'appearance', 'npcBrain', 'socialState', + 'statModifiers', 'backstory', 'inventory', 'structure', 'movement', +] as const; + +function serializeComponent(name: string, data: unknown): string { + switch (name) { + case 'inventory': { + // Map -> plain object + const map = data as Map; + return JSON.stringify(Object.fromEntries(map)); + } + case 'socialState': { + const social = data as Record; + // Convert pairCooldowns Map, reset transient fields + const serialized = { + ...social, + phase: 'none', + partnerId: null, + phaseTimer: 0, + outcome: null, + lastOutcome: null, + pendingProposal: null, + isProposalInteraction: false, + pairCooldowns: Object.fromEntries( + (social.pairCooldowns as Map) ?? new Map(), + ), + }; + return JSON.stringify(serialized); + } + case 'structure': { + const struct = data as Record; + const serialized = { + ...struct, + inventory: Object.fromEntries( + (struct.inventory as Map) ?? new Map(), + ), + }; + return JSON.stringify(serialized); + } + case 'movement': { + const mov = data as Record; + const serialized = { + ...mov, + path: [], + state: 'idle', + target: null, + moveProgress: 0, + }; + return JSON.stringify(serialized); + } + case 'npcBrain': { + const brain = data as Record; + const serialized = { + ...brain, + currentGoal: null, + goalQueue: [], + }; + return JSON.stringify(serialized); + } + case 'backstory': + // backstory is a string, wrap directly + return JSON.stringify(data); + default: + return JSON.stringify(data); + } +} + +function deserializeComponent(name: string, json: string): unknown { + const parsed = JSON.parse(json); + + switch (name) { + case 'inventory': { + return new Map(Object.entries(parsed)); + } + case 'socialState': { + return { + ...parsed, + pairCooldowns: new Map( + Object.entries(parsed.pairCooldowns ?? {}).map( + ([k, v]) => [Number(k), v] as [number, number], + ), + ), + }; + } + case 'structure': { + return { + ...parsed, + inventory: new Map(Object.entries(parsed.inventory ?? {})), + }; + } + case 'backstory': + case 'name': + // These are strings directly + return parsed; + default: + return parsed; + } +} + +export function saveEntities(world: World): void { + const db = getDatabase(); + + const transaction = db.transaction(() => { + // Clear existing entity data + db.exec('DELETE FROM entities'); + db.exec('DELETE FROM components'); + db.exec('DELETE FROM relationships'); + db.exec('DELETE FROM bonds'); + + 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 (?, ?, ?, ?, ?)', + ); + + for (const entityId of world.getAllEntities()) { + // Skip player-controlled entities + if (world.getComponent(entityId, 'playerControlled')) continue; + + const position = world.getComponent<{ x: number; y: number }>(entityId, 'position'); + if (!position) continue; + + // Determine entity type + const hasStructure = !!world.getComponent(entityId, 'structure'); + const entityType = hasStructure ? 'structure' : 'npc'; + const name = world.getComponent(entityId, 'name') ?? null; + + insertEntity.run(entityId, entityType, name, position.x, position.y); + + // Serialize components + for (const compName of SERIALIZABLE_COMPONENTS) { + const compData = world.getComponent(entityId, compName); + if (compData !== undefined) { + insertComponent.run(entityId, compName, serializeComponent(compName, compData)); + } + } + + // Relationships go to dedicated table + const relationships = world.getComponent>( + entityId, 'relationships', + ); + if (relationships) { + for (const [targetId, relData] of relationships) { + insertRelationship.run( + entityId, targetId, relData.value, + relData.interactions, relData.lastInteractionTick, relData.status, + ); + } + } + } + + // Save bond registry + const bondRegistry = world.getSingleton('bondRegistry'); + if (bondRegistry) { + for (const [key, bonds] of bondRegistry) { + const [a, b] = key.split(':').map(Number); + for (const bond of bonds) { + insertBond.run(a, b, bond.type, bond.formedAtTick, bond.status); + } + } + } + }); + + transaction(); +} + +export function loadEntities(world: World): number { + const db = getDatabase(); + let maxId = 0; + + // Load entities + const entityRows = 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 row of entityRows) { + world.createEntityWithId(row.id); + world.addComponent(row.id, 'position', { x: row.x, y: row.y }); + if (row.name !== null) { + world.addComponent(row.id, 'name', row.name); + } + if (row.id > maxId) maxId = row.id; + } + + // Load components + const componentRows = db.prepare('SELECT entity_id, component_name, data FROM components').all() as Array<{ + entity_id: number; component_name: string; data: string; + }>; + + for (const row of componentRows) { + const data = deserializeComponent(row.component_name, row.data); + world.addComponent(row.entity_id, row.component_name, data); + } + + // Load relationships, grouped by entity_id + const relRows = 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; + }>; + + const relMap = new Map>(); + for (const row of relRows) { + if (!relMap.has(row.entity_id)) { + relMap.set(row.entity_id, new Map()); + } + relMap.get(row.entity_id)!.set(row.target_id, { + value: row.value, + interactions: row.interactions, + lastInteractionTick: row.last_interaction_tick, + status: row.status as 'active' | 'memory', + }); + } + for (const [entityId, rels] of relMap) { + world.addComponent(entityId, 'relationships', rels); + } + + // Load bonds + const bondRows = 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 bondRegistry: BondRegistry = createBondRegistry(); + for (const row of bondRows) { + const key = `${row.entity_a}:${row.entity_b}`; + if (!bondRegistry.has(key)) { + bondRegistry.set(key, []); + } + bondRegistry.get(key)!.push({ + type: row.type, + formedAtTick: row.formed_tick, + status: row.status as 'active' | 'former', + }); + } + if (bondRows.length > 0) { + world.setSingleton('bondRegistry', bondRegistry); + } + + return maxId; +}