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