feat(persistence): add SaveManager orchestration class
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user