test(persistence): add full save/load integration test

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 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 14:43:19 +00:00
parent 94fcafeea0
commit 2f2248cb6d
@@ -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<string>(npc1, 'name')!;
const npc2Name = world.getComponent<string>(npc2, 'name')!;
// Set up a relationship between them
const rels1 = world.getComponent<Map<EntityId, RelationshipData>>(npc1, 'relationships')!;
rels1.set(npc2, { value: 55, interactions: 10, lastInteractionTick: 400, status: 'active' });
const rels2 = world.getComponent<Map<EntityId, RelationshipData>>(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<StructureData>(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<string>(npc1, 'name')).toBe(npc1Name);
expect(world2.getComponent<string>(npc2, 'name')).toBe(npc2Name);
// Verify relationship value (55) restored
const loadedRels1 = world2.getComponent<Map<EntityId, RelationshipData>>(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<ReturnType<typeof createBondRegistry>>('bondRegistry')!;
expect(loadedBondRegistry).toBeInstanceOf(Map);
expect(hasBond(loadedBondRegistry, npc1, npc2, 'partner')).toBe(true);
// Verify structure with inventory restored
const loadedStruct = world2.getComponent<StructureData>(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();
});
});