From eb639bf562d218906f1fbef0f3cb8e2201f152a7 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 14:34:03 +0000 Subject: [PATCH] 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 --- server/src/game/GameLoop.ts | 111 ++++++++++++++++++++++++++++++++++-- server/src/main.ts | 13 ++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/server/src/game/GameLoop.ts b/server/src/game/GameLoop.ts index 748c583..8306193 100644 --- a/server/src/game/GameLoop.ts +++ b/server/src/game/GameLoop.ts @@ -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 | null = null; + private saveManager: SaveManager | null = null; + private saveInterval: ReturnType | 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.setPersistence(saveStockpileEntry); + + const inventionTimeline = this.world.getSingleton('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 { diff --git a/server/src/main.ts b/server/src/main.ts index 1837cb9..c9e9c7a 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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); + }); +}