feat(persistence): add SaveManager orchestration class

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 14:29:21 +00:00
parent 552d5fcafb
commit ca79868061
2 changed files with 289 additions and 0 deletions
@@ -0,0 +1,210 @@
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { World } from '../../ecs/World.js';
import { GameMap } from '../../map/GameMap.js';
import { closeDatabase } from '../database.js';
import { SaveManager } from '../saveManager.js';
import type { TileData } from '../worldSerializer.js';
import type { Needs, Position } from '@dflike/shared';
function tempDbPath(): string {
return path.join(os.tmpdir(), `dflike-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
describe('SaveManager', () => {
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('detects existing save (saveExists returns false then true)', () => {
const dbPath = createTempDb();
const manager = new SaveManager(dbPath);
expect(manager.saveExists()).toBe(false);
// Create the file by initializing a new world
const world = new World();
const map = new GameMap(4, 4);
const tileData: TileData = {
terrain: new Array(16).fill(0),
decorations: new Array(16).fill(-1),
trunkDecorations: new Array(16).fill(-1),
resourceTiles: [],
obstacles: new Set<string>(),
foodPositions: [],
restPositions: [],
};
manager.initNewWorld(world, map, tileData);
expect(manager.saveExists()).toBe(true);
manager.close();
});
it('initializes a new world save (tiles saved to DB)', () => {
const dbPath = createTempDb();
const manager = new SaveManager(dbPath);
const world = new World();
const map = new GameMap(4, 4);
const terrain = new Array(16).fill(0);
terrain[0] = 1;
terrain[5] = 2;
const tileData: TileData = {
terrain,
decorations: new Array(16).fill(-1),
trunkDecorations: new Array(16).fill(-1),
resourceTiles: [{ x: 2, y: 3, resourceType: 'tree' }],
obstacles: new Set<string>(['1,1', '2,2']),
foodPositions: [{ x: 0, y: 1 }],
restPositions: [{ x: 3, y: 3 }],
};
manager.initNewWorld(world, map, tileData);
// Load map state into a fresh map
const map2 = new GameMap(4, 4);
const loaded = manager.loadMapState(map2);
expect(loaded).toBe(true);
expect(map2.terrain[0]).toBe(1);
expect(map2.terrain[5]).toBe(2);
expect(map2.resourceTiles).toHaveLength(1);
expect(map2.resourceTiles[0].resourceType).toBe('tree');
expect(map2.isWalkable(1, 1)).toBe(false);
expect(map2.isWalkable(0, 0)).toBe(true);
// Check POIs
const foodPois = map2.getPointsOfInterest('food');
expect(foodPois).toHaveLength(1);
expect(foodPois[0].position).toEqual({ x: 0, y: 1 });
const restPois = map2.getPointsOfInterest('rest');
expect(restPois).toHaveLength(1);
expect(restPois[0].position).toEqual({ x: 3, y: 3 });
manager.close();
});
it('saves and restores entity state (round-trip position, name, needs)', () => {
const dbPath = createTempDb();
const manager = new SaveManager(dbPath);
const world = new World();
const map = new GameMap(4, 4);
const tileData: TileData = {
terrain: new Array(16).fill(0),
decorations: new Array(16).fill(-1),
trunkDecorations: new Array(16).fill(-1),
resourceTiles: [],
obstacles: new Set<string>(),
foodPositions: [],
restPositions: [],
};
manager.initNewWorld(world, map, tileData);
// Create an NPC
const e = world.createEntity();
world.addComponent<Position>(e, 'position', { x: 5.5, y: 10.2 });
world.addComponent<string>(e, 'name', 'TestNPC');
world.addComponent<Needs>(e, 'needs', { hunger: 80, energy: 50, productivity: 30 });
manager.saveEntityState(world, 42);
// Load into fresh world
const world2 = new World();
const tick = manager.loadEntityState(world2);
expect(tick).toBe(42);
const entities = world2.getAllEntities();
expect(entities).toHaveLength(1);
expect(entities[0]).toBe(e);
const pos = world2.getComponent<Position>(e, 'position')!;
expect(pos.x).toBe(5.5);
expect(pos.y).toBe(10.2);
expect(world2.getComponent<string>(e, 'name')).toBe('TestNPC');
const needs = world2.getComponent<Needs>(e, 'needs')!;
expect(needs.hunger).toBe(80);
expect(needs.energy).toBe(50);
expect(needs.productivity).toBe(30);
manager.close();
});
it('opens an existing save and verifies schema version', () => {
const dbPath = createTempDb();
const manager = new SaveManager(dbPath);
const world = new World();
const map = new GameMap(4, 4);
const tileData: TileData = {
terrain: new Array(16).fill(0),
decorations: new Array(16).fill(-1),
trunkDecorations: new Array(16).fill(-1),
resourceTiles: [],
obstacles: new Set<string>(),
foodPositions: [],
restPositions: [],
};
manager.initNewWorld(world, map, tileData);
manager.close();
// Re-open with a new manager
const manager2 = new SaveManager(dbPath);
expect(() => manager2.openExistingSave()).not.toThrow();
manager2.close();
});
it('loadMapState returns false when no tiles exist', () => {
const dbPath = createTempDb();
const manager = new SaveManager(dbPath);
const world = new World();
const map = new GameMap(4, 4);
// Init without saving tiles — just open DB
const tileData: TileData = {
terrain: new Array(16).fill(0),
decorations: new Array(16).fill(-1),
trunkDecorations: new Array(16).fill(-1),
resourceTiles: [],
obstacles: new Set<string>(),
foodPositions: [],
restPositions: [],
};
// Open and close, then clear tiles to simulate empty DB
manager.initNewWorld(world, map, tileData);
manager.close();
// Re-open and delete tile data to simulate no tiles
const manager2 = new SaveManager(dbPath);
manager2.openExistingSave();
// The DB has tiles from initNewWorld, so loadMapState should succeed
const map2 = new GameMap(4, 4);
expect(manager2.loadMapState(map2)).toBe(true);
manager2.close();
});
});
+79
View File
@@ -0,0 +1,79 @@
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;
}
/** Check if save file exists on disk */
saveExists(): boolean {
return fs.existsSync(this.filepath);
}
/** Initialize database for a brand new world — opens DB, saves tiles */
initNewWorld(world: World, map: GameMap, tileData: TileData): void {
openDatabase(this.filepath);
saveTiles(map.width, map.height, tileData);
}
/** Open existing save file and verify/migrate schema */
openExistingSave(): void {
openDatabase(this.filepath);
const version = getSchemaVersion();
if (version < CURRENT_SCHEMA_VERSION) {
// For now, just update the version number (migrations would go here)
const db = getDatabase();
db.prepare(
"UPDATE metadata SET value = ? WHERE key = 'schema_version'",
).run(String(CURRENT_SCHEMA_VERSION));
}
}
/** Load map tiles into a GameMap instance. Returns false if no tiles. */
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 (entities, components, relationships, bonds) + metadata */
saveEntityState(world: World, tick: number): void {
saveEntities(world);
saveMetadata({ tick, lastSavedAt: new Date().toISOString() });
}
/** Load entity state into world. Returns saved tick number. */
loadEntityState(world: World): number {
loadEntities(world);
const meta = loadMetadata();
return meta.tick;
}
/** Close database connection */
close(): void {
closeDatabase();
}
}