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 = new Set(); public onInventionCreated: ((summary: InventionSummary) => void) | null = null; 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(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.setPersistence(saveStockpileEntry); const inventionTimeline = this.world.getSingleton('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(entity, 'position', positions[i]); this.world.addComponent(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; } }