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