From 2f2248cb6d3a6dae1869ca5bccc2a002e1673091 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 14:43:19 +0000 Subject: [PATCH] test(persistence): add full save/load integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies complete save → close → reopen → load cycle with NPCs, relationships, bonds, structures, and all event types (narration, memory, stockpile, invention). Co-Authored-By: Claude Opus 4.6 --- .../persistence/__tests__/integration.test.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 server/src/persistence/__tests__/integration.test.ts diff --git a/server/src/persistence/__tests__/integration.test.ts b/server/src/persistence/__tests__/integration.test.ts new file mode 100644 index 0000000..6122841 --- /dev/null +++ b/server/src/persistence/__tests__/integration.test.ts @@ -0,0 +1,219 @@ +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 type { EntityId, RelationshipData } from '@dflike/shared'; +import type { StructureData } from '../../systems/buildingSystem.js'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +function tempDbPath(): string { + return path.join(os.tmpdir(), `dflike-integration-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); +} + +describe('integration: full save/load cycle', () => { + const dbPaths: string[] = []; + + function createTempDb(): string { + const p = tempDbPath(); + dbPaths.push(p); + return p; + } + + 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('saves a world with NPCs, structures, and events, then restores it', () => { + const dbPath = createTempDb(); + + // ---- Setup phase ---- + const world = new World(); + const map = new GameMap(); + const generated = generateMap(map.width, map.height, 12345); + + // Apply generated map data to the GameMap + map.terrain = generated.terrain; + map.decorations = generated.decorations; + map.trunkDecorations = generated.trunkDecorations; + map.resourceTiles = generated.resourceTiles; + map.loadObstacles(generated.obstacles); + for (const pos of generated.foodPositions) { + map.addPointOfInterest({ type: 'food', position: pos }); + } + for (const pos of generated.restPositions) { + map.addPointOfInterest({ type: 'rest', position: pos }); + } + + // Create bond registry singleton + const bondRegistry = createBondRegistry(); + world.setSingleton('bondRegistry', bondRegistry); + + // Spawn 2 NPCs + const npc1 = spawnNPC(world, map); + const npc2 = spawnNPC(world, map); + const npc1Name = world.getComponent(npc1, 'name')!; + const npc2Name = world.getComponent(npc2, 'name')!; + + // Set up a relationship between them + const rels1 = world.getComponent>(npc1, 'relationships')!; + rels1.set(npc2, { value: 55, interactions: 10, lastInteractionTick: 400, status: 'active' }); + + const rels2 = world.getComponent>(npc2, 'relationships')!; + rels2.set(npc1, { value: 55, interactions: 10, lastInteractionTick: 400, status: 'active' }); + + // Add a partner bond between them + addBond(bondRegistry, npc1, npc2, 'partner', 300); + + // Create a structure entity (stockpile with wood inventory) + const structEntity = world.createEntity(); + world.addComponent(structEntity, 'position', { x: 10, y: 10 }); + world.addComponent(structEntity, 'name', 'Stockpile'); + world.addComponent(structEntity, 'structure', { + type: 'stockpile', + subtype: 'general', + inventory: new Map([['wood', 25], ['stone', 8]]), + buildProgress: undefined, + }); + + // ---- Save phase ---- + const manager = new SaveManager(dbPath); + const tileData = { + terrain: generated.terrain, + decorations: generated.decorations, + trunkDecorations: generated.trunkDecorations, + resourceTiles: generated.resourceTiles, + obstacles: generated.obstacles, + foodPositions: generated.foodPositions, + restPositions: generated.restPositions, + }; + manager.initNewWorld(world, map, tileData); + manager.saveEntityState(world, 500); + + // Save some events + saveNarrationEvent({ + id: 0, + tick: 450, + type: 'social', + entityIds: [npc1, npc2], + names: [npc1Name, npc2Name], + outcome: 'positive', + narration: `${npc1Name} and ${npc2Name} shared a hearty laugh.`, + isLlmGenerated: false, + }); + + saveMemoryEvent(npc1, { + id: 0, + type: 'social_positive', + tick: 450, + otherEntityId: npc2, + otherName: npc2Name, + detail: 'Had a great conversation', + }); + + saveStockpileEntry({ + npcName: npc1Name, + action: 'dropoff', + itemId: 'wood', + quantity: 5, + tick: 480, + }); + + saveInvention({ + itemId: 'wooden_axe', + inventorEntityId: npc1, + inventorName: npc1Name, + tick: 400, + day: 2, + name: 'Wooden Axe', + category: 'tool', + inputs: [{ itemId: 'wood', quantity: 2 }, { itemId: 'stone', quantity: 1 }], + workshopType: 'workbench', + toolRequired: null, + }); + + // ---- Close and reopen ---- + manager.close(); + + const manager2 = new SaveManager(dbPath); + manager2.openExistingSave(); + + // ---- Load and verify ---- + + // Load map state + const map2 = new GameMap(); + const mapLoaded = manager2.loadMapState(map2); + expect(mapLoaded).toBe(true); + expect(map2.terrain).toEqual(generated.terrain); + expect(map2.decorations).toEqual(generated.decorations); + expect(map2.resourceTiles.length).toBe(generated.resourceTiles.length); + + // Load entity state + const world2 = new World(); + const restoredTick = manager2.loadEntityState(world2); + expect(restoredTick).toBe(500); + + // Verify NPC names restored + expect(world2.getComponent(npc1, 'name')).toBe(npc1Name); + expect(world2.getComponent(npc2, 'name')).toBe(npc2Name); + + // Verify relationship value (55) restored + const loadedRels1 = world2.getComponent>(npc1, 'relationships')!; + expect(loadedRels1).toBeInstanceOf(Map); + expect(loadedRels1.get(npc2)!.value).toBe(55); + expect(loadedRels1.get(npc2)!.interactions).toBe(10); + + // Verify bond exists + const loadedBondRegistry = world2.getSingleton>('bondRegistry')!; + expect(loadedBondRegistry).toBeInstanceOf(Map); + expect(hasBond(loadedBondRegistry, npc1, npc2, 'partner')).toBe(true); + + // Verify structure with inventory restored + const loadedStruct = world2.getComponent(structEntity, 'structure')!; + expect(loadedStruct.type).toBe('stockpile'); + expect(loadedStruct.subtype).toBe('general'); + expect(loadedStruct.inventory).toBeInstanceOf(Map); + expect(loadedStruct.inventory.get('wood')).toBe(25); + expect(loadedStruct.inventory.get('stone')).toBe(8); + + // Verify events loadable from DB + const narrationEvents = loadRecentNarrationEvents(10); + expect(narrationEvents).toHaveLength(1); + expect(narrationEvents[0].narration).toContain('shared a hearty laugh'); + + const memoryEvents = loadAllRecentMemoryEvents(10); + expect(memoryEvents.size).toBeGreaterThanOrEqual(1); + const npc1Memories = memoryEvents.get(npc1)!; + expect(npc1Memories).toHaveLength(1); + expect(npc1Memories[0].detail).toBe('Had a great conversation'); + + const stockpileEntries = loadRecentStockpileEntries(10); + expect(stockpileEntries).toHaveLength(1); + expect(stockpileEntries[0].npcName).toBe(npc1Name); + expect(stockpileEntries[0].itemId).toBe('wood'); + + const inventions = loadAllInventions(); + expect(inventions).toHaveLength(1); + expect(inventions[0].itemId).toBe('wooden_axe'); + expect(inventions[0].inventorName).toBe(npc1Name); + + manager2.close(); + }); +});