eb639bf562
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>
290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
import { TICK_RATE, BROADCAST_EVERY_N_TICKS, ENERGY_DECAY_PER_TICK, DAY_NIGHT_RATIO } from '@dflike/shared';
|
|
import { World } from '../ecs/World.js';
|
|
import { GameMap } from '../map/GameMap.js';
|
|
import { generateMap } from '../map/mapGenerator.js';
|
|
import { needsDecaySystem } from '../systems/needsDecaySystem.js';
|
|
import { npcBrainSystem } from '../systems/npcBrainSystem.js';
|
|
import { movementSystem } from '../systems/movementSystem.js';
|
|
import { socialSystem } from '../systems/socialSystem.js';
|
|
import { statModifierSystem } from '../systems/statModifierSystem.js';
|
|
import { relationshipSystem } from '../systems/relationshipSystem.js';
|
|
import { gatheringSystem } from '../systems/gatheringSystem.js';
|
|
import { createBondRegistry } from '../systems/bondRegistry.js';
|
|
import { ItemRegistry } from '../industry/itemRegistry.js';
|
|
import { industrySystem } from '../systems/industrySystem.js';
|
|
import { craftingSystem } from '../systems/craftingSystem.js';
|
|
import { buildingSystem } from '../systems/buildingSystem.js';
|
|
import { dropoffSystem } from '../systems/dropoffSystem.js';
|
|
import { pickupSystem } from '../systems/pickupSystem.js';
|
|
import { RecipeRegistry } from '../industry/recipeRegistry.js';
|
|
import { spawnNPC } from './spawner.js';
|
|
import { createLlmService, type LlmService } from '../llm/llmService.js';
|
|
import { generateBackstory } from '../llm/backstoryGenerator.js';
|
|
import { createNarrationService, type NarrationService } from '../llm/narrationService.js';
|
|
import { narrationEmitter } from '../systems/narrationEmitter.js';
|
|
import type { EntityId, InventionSummary, Position } from '@dflike/shared';
|
|
import type { StructureData } from '../systems/buildingSystem.js';
|
|
import { createThoughtSystem, type ThoughtSystem } from '../systems/thoughtSystem.js';
|
|
import { createEventMemoryService, type EventMemoryService } from '../llm/eventMemoryService.js';
|
|
import { createInventionSystem, type InventionSystem } from '../systems/inventionSystem.js';
|
|
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;
|
|
readonly map: GameMap;
|
|
readonly llmService: LlmService;
|
|
readonly narrationService: NarrationService;
|
|
readonly eventMemoryService: EventMemoryService;
|
|
readonly thoughtSystem: ThoughtSystem;
|
|
readonly inventionSystem: InventionSystem;
|
|
public followedEntityIds: Set<EntityId> = new Set();
|
|
public onInventionCreated: ((summary: InventionSummary) => void) | null = null;
|
|
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(options?: { newWorld?: boolean; savePath?: string }) {
|
|
this.world = new World();
|
|
this.map = new GameMap();
|
|
this.llmService = createLlmService();
|
|
this.narrationService = createNarrationService(this.llmService);
|
|
this.eventMemoryService = createEventMemoryService();
|
|
this.thoughtSystem = createThoughtSystem(this.llmService, this.narrationService, this.eventMemoryService);
|
|
this.inventionSystem = createInventionSystem(
|
|
this.llmService,
|
|
this.narrationService,
|
|
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 {
|
|
const generated = generateMap(this.map.width, this.map.height);
|
|
|
|
// Load terrain and decorations
|
|
this.map.terrain = generated.terrain;
|
|
this.map.decorations = generated.decorations;
|
|
this.map.trunkDecorations = generated.trunkDecorations;
|
|
this.map.loadObstacles(generated.obstacles);
|
|
|
|
// Load resource tiles
|
|
this.map.resourceTiles = generated.resourceTiles;
|
|
|
|
// Points of interest from generator
|
|
for (const pos of generated.foodPositions) {
|
|
this.map.addPointOfInterest({ type: 'food', position: pos });
|
|
}
|
|
for (const pos of generated.restPositions) {
|
|
this.map.addPointOfInterest({ type: 'rest', position: pos });
|
|
}
|
|
}
|
|
|
|
private spawnDefaultStockpiles(): void {
|
|
const cx = Math.floor(this.map.width / 2);
|
|
const cy = Math.floor(this.map.height / 2);
|
|
|
|
// Find two walkable positions near the center for wood and stone stockpiles
|
|
const positions: Position[] = [];
|
|
outer:
|
|
for (let r = 0; positions.length < 2; r++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
if (r > 0 && Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
|
const x = cx + dx;
|
|
const y = cy + dy;
|
|
if (this.map.isWalkable(x, y) && !positions.some(p => p.x === x && p.y === y)) {
|
|
positions.push({ x, y });
|
|
if (positions.length >= 2) break outer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const subtypes = ['wood', 'stone'];
|
|
for (let i = 0; i < positions.length; i++) {
|
|
const entity = this.world.createEntity();
|
|
this.world.addComponent<Position>(entity, 'position', positions[i]);
|
|
this.world.addComponent<StructureData>(entity, 'structure', {
|
|
type: 'stockpile',
|
|
subtype: subtypes[i],
|
|
inventory: new Map(),
|
|
});
|
|
}
|
|
}
|
|
|
|
generateNpcBackstory(entityId: number): void {
|
|
generateBackstory(this.world, entityId, this.llmService);
|
|
}
|
|
|
|
private spawnInitialNPCs(count: number): void {
|
|
for (let i = 0; i < count; i++) {
|
|
const entity = spawnNPC(this.world, this.map, undefined, this.eventMemoryService);
|
|
this.generateNpcBackstory(entity);
|
|
}
|
|
}
|
|
|
|
setBroadcastHandler(handler: () => void): void {
|
|
this.onBroadcast = handler;
|
|
}
|
|
|
|
start(): void {
|
|
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 {
|
|
if (this.interval) {
|
|
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 {
|
|
this.tick++;
|
|
|
|
// Run systems in order
|
|
statModifierSystem(this.world);
|
|
needsDecaySystem(this.world, this.eventMemoryService);
|
|
npcBrainSystem(this.world, this.map, this.eventMemoryService);
|
|
socialSystem(this.world, this.eventMemoryService);
|
|
narrationEmitter(this.world, this.narrationService, this.followedEntityIds, this.eventMemoryService);
|
|
relationshipSystem(this.world, this.eventMemoryService);
|
|
industrySystem(this.world, this.map);
|
|
pickupSystem(this.world, this.tick, (entry) => this.onStockpileEvent?.(entry));
|
|
gatheringSystem(this.world, this.map);
|
|
craftingSystem(this.world);
|
|
buildingSystem(this.world, this.map);
|
|
dropoffSystem(this.world, this.map, this.tick, (entry) => this.onStockpileEvent?.(entry));
|
|
movementSystem(this.world);
|
|
this.thoughtSystem.update(this.world, this.followedEntityIds, this.tick);
|
|
this.inventionSystem.update(this.world, this.tick);
|
|
|
|
// Broadcast state periodically
|
|
if (this.tick % BROADCAST_EVERY_N_TICKS === 0 && this.onBroadcast) {
|
|
this.onBroadcast();
|
|
}
|
|
}
|
|
|
|
getTick(): number {
|
|
return this.tick;
|
|
}
|
|
|
|
getGameTime(): number {
|
|
const dayTicks = 100 / ENERGY_DECAY_PER_TICK;
|
|
const nightTicks = dayTicks / DAY_NIGHT_RATIO;
|
|
const cycleTicks = Math.round(dayTicks + nightTicks);
|
|
return (this.tick % cycleTicks) / cycleTicks;
|
|
}
|
|
}
|