feat(server): integrate persistence into GameLoop and main.ts

Wire SaveManager into game startup/shutdown: constructor now checks for
existing save file and loads or creates new world accordingly. Add
autosave every 30s, final save on stop, and graceful shutdown signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-09 14:34:03 +00:00
parent ca79868061
commit eb639bf562
2 changed files with 117 additions and 7 deletions
+105 -6
View File
@@ -30,6 +30,10 @@ import { createInventionSystem, type InventionSystem } from '../systems/inventio
import { createInventionTimeline } from '../industry/inventionTimeline.js';
import { createStockpileLog } from '../industry/stockpileLog.js';
import type { StockpileLogEntry } from '@dflike/shared';
import { SaveManager } from '../persistence/saveManager.js';
import { saveNarrationEvent, loadRecentNarrationEvents, saveMemoryEvent, loadAllRecentMemoryEvents, saveStockpileEntry, loadRecentStockpileEntries, saveInvention, loadAllInventions } from '../persistence/eventSerializer.js';
import type { StockpileLog } from '../industry/stockpileLog.js';
import type { InventionTimeline } from '../industry/inventionTimeline.js';
export class GameLoop {
readonly world: World;
@@ -44,15 +48,12 @@ export class GameLoop {
public onStockpileEvent: ((entry: StockpileLogEntry) => void) | null = null;
private tick = 0;
private interval: ReturnType<typeof setInterval> | null = null;
private saveManager: SaveManager | null = null;
private saveInterval: ReturnType<typeof setInterval> | null = null;
private onBroadcast: (() => void) | null = null;
constructor() {
constructor(options?: { newWorld?: boolean; savePath?: string }) {
this.world = new World();
this.world.setSingleton('bondRegistry', createBondRegistry());
this.world.setSingleton('itemRegistry', ItemRegistry.createDefault());
this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
this.world.setSingleton('inventionTimeline', createInventionTimeline());
this.world.setSingleton('stockpileLog', createStockpileLog());
this.map = new GameMap();
this.llmService = createLlmService();
this.narrationService = createNarrationService(this.llmService);
@@ -64,9 +65,89 @@ export class GameLoop {
this.eventMemoryService,
(summary) => this.onInventionCreated?.(summary),
);
this.saveManager = new SaveManager(options?.savePath ?? 'saves/default.db');
if (!options?.newWorld && this.saveManager.saveExists()) {
this.loadFromSave();
} else {
this.initNewWorld();
}
}
private initNewWorld(): void {
this.world.setSingleton('bondRegistry', createBondRegistry());
this.world.setSingleton('itemRegistry', ItemRegistry.createDefault());
this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
this.world.setSingleton('inventionTimeline', createInventionTimeline());
this.world.setSingleton('stockpileLog', createStockpileLog());
this.setupMap();
this.spawnDefaultStockpiles();
this.spawnInitialNPCs(8);
if (this.saveManager) {
this.saveManager.initNewWorld(this.world, this.map, {
terrain: this.map.terrain,
decorations: this.map.decorations,
trunkDecorations: this.map.trunkDecorations,
resourceTiles: this.map.resourceTiles,
obstacles: new Set(this.map.getObstacles().map(p => `${p.x},${p.y}`)),
foodPositions: this.map.getPointsOfInterest('food').map(p => p.position),
restPositions: this.map.getPointsOfInterest('rest').map(p => p.position),
});
this.saveManager.saveEntityState(this.world, 0);
this.wirePersistenceCallbacks();
}
console.log('Created new world');
}
private loadFromSave(): void {
this.saveManager!.openExistingSave();
// Load map
this.saveManager!.loadMapState(this.map);
// Set non-entity singletons
this.world.setSingleton('itemRegistry', ItemRegistry.createDefault());
this.world.setSingleton('recipeRegistry', RecipeRegistry.createDefault());
// Load entities (also restores bondRegistry singleton)
this.tick = this.saveManager!.loadEntityState(this.world);
// Create and load inventionTimeline
const inventionTimeline = createInventionTimeline();
const inventions = loadAllInventions();
inventionTimeline.loadFromPersistence(inventions);
this.world.setSingleton('inventionTimeline', inventionTimeline);
// Create and load stockpileLog
const stockpileLog = createStockpileLog();
const stockEntries = loadRecentStockpileEntries(50);
stockpileLog.loadFromPersistence(stockEntries);
this.world.setSingleton('stockpileLog', stockpileLog);
// Load event history into services
const narrationEvents = loadRecentNarrationEvents(50);
this.narrationService.loadFromPersistence(narrationEvents);
const memoryEvents = loadAllRecentMemoryEvents(50);
this.eventMemoryService.loadFromPersistence(memoryEvents);
this.wirePersistenceCallbacks();
console.log(`Loaded save: tick ${this.tick}, ${this.world.getAllEntities().length} entities`);
}
private wirePersistenceCallbacks(): void {
this.narrationService.setPersistence(saveNarrationEvent);
this.eventMemoryService.setPersistence(saveMemoryEvent);
const stockpileLog = this.world.getSingleton<StockpileLog>('stockpileLog')!;
stockpileLog.setPersistence(saveStockpileEntry);
const inventionTimeline = this.world.getSingleton<InventionTimeline>('inventionTimeline')!;
inventionTimeline.setPersistence(saveInvention);
}
private setupMap(): void {
@@ -142,6 +223,14 @@ export class GameLoop {
const tickInterval = 1000 / TICK_RATE;
this.interval = setInterval(() => this.update(), tickInterval);
console.log(`Game loop started at ${TICK_RATE} ticks/sec`);
// Periodic save every 30 seconds
this.saveInterval = setInterval(() => {
if (this.saveManager) {
this.saveManager.saveEntityState(this.world, this.tick);
console.log(`Autosaved at tick ${this.tick}`);
}
}, 30_000);
}
stop(): void {
@@ -149,6 +238,16 @@ export class GameLoop {
clearInterval(this.interval);
this.interval = null;
}
if (this.saveInterval) {
clearInterval(this.saveInterval);
this.saveInterval = null;
}
// Final save on shutdown
if (this.saveManager) {
this.saveManager.saveEntityState(this.world, this.tick);
this.saveManager.close();
console.log(`Final save at tick ${this.tick}`);
}
}
private update(): void {
+12 -1
View File
@@ -4,8 +4,9 @@ import { GameLoop } from './game/GameLoop.js';
import { SocketServer } from './network/SocketServer.js';
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3001;
const newWorld = process.argv.includes('--new-world');
const gameLoop = new GameLoop();
const gameLoop = new GameLoop({ newWorld });
const httpServer = http.createServer();
new SocketServer(httpServer, gameLoop);
@@ -13,3 +14,13 @@ httpServer.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
gameLoop.start();
});
// Graceful shutdown
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
console.log(`\nReceived ${signal}, saving and shutting down...`);
gameLoop.stop();
httpServer.close();
process.exit(0);
});
}