import { Server, Socket } from 'socket.io'; import type http from 'http'; import type { ClientEvents, ServerEvents, PlayerInput, Position, Movement, PlayerControlled, Appearance, EntityId } from '@dflike/shared'; import { Direction } from '@dflike/shared'; import { generateRandomAppearance } from '../spawner/appearanceGenerator.js'; import type { GameLoop } from '../game/GameLoop.js'; import { spawnNPC } from '../game/spawner.js'; import { serializeWorldState, serializeStateUpdate } from './stateSerializer.js'; import { computeSuperlatives } from '../systems/superlativesComputer.js'; import type { BondRegistry } from '../systems/bondRegistry.js'; import type { InventionTimeline } from '../industry/inventionTimeline.js'; import type { StockpileLog } from '../industry/stockpileLog.js'; import type { StructureData } from '../systems/buildingSystem.js'; export class SocketServer { private io: Server; private gameLoop: GameLoop; private superlativesSubscribers: Set> = new Set(); private superlativesTimer: ReturnType | null = null; private followedEntities: Map = new Map(); private logSubscribers: Map void> = new Map(); private authenticatedSockets: Set = new Set(); constructor(httpServer: http.Server, gameLoop: GameLoop) { this.io = new Server(httpServer, { cors: { origin: '*' }, }); this.gameLoop = gameLoop; this.setupBroadcast(); this.setupConnections(); } private setupBroadcast(): void { this.gameLoop.setBroadcastHandler(() => { const update = serializeStateUpdate(this.gameLoop.world, this.gameLoop.map, this.gameLoop.getTick(), this.gameLoop.getGameTime(), this.gameLoop.getDayNumber()); this.io.emit('state-update', update); }); const narrationService = this.gameLoop.narrationService; narrationService.onEventCreated = (event) => { this.io.emit('narration-event', event); }; narrationService.onEventUpdated = (event) => { this.io.emit('narration-update', { id: event.id, narration: event.narration }); }; this.gameLoop.thoughtSystem.onThought = (data) => { this.io.emit('npc-thought', data); }; this.gameLoop.onInventionCreated = (summary) => { this.io.emit('invention-event', summary); }; this.gameLoop.onStockpileEvent = (entry) => { this.io.emit('stockpile-event', entry); this.io.emit('stockpile-summary', this.computeStockpileSummary()); }; this.gameLoop.eventMemoryService.onEventRecorded = (entityId, event) => { // Only send to clients following this NPC for (const [socketId, followedId] of this.followedEntities) { if (followedId === entityId) { const sock = this.io.sockets.sockets.get(socketId); sock?.emit('memory-event', { entityId, event }); } } }; } private setupConnections(): void { this.io.on('connection', (socket) => { const playerId = socket.id; console.log(`Player connected: ${playerId}`); this.gameLoop.logService.log('info', 'Network', `Player connected: ${playerId}`); // Create player entity (starts in camera mode — no avatar) const world = this.gameLoop.world; const map = this.gameLoop.map; const entity = world.createEntity(); const startPos = map.getRandomWalkable(); world.addComponent(entity, 'position', startPos); world.addComponent(entity, 'movement', { state: 'idle', target: null, path: [], direction: Direction.DOWN, moveProgress: 0, }); world.addComponent(entity, 'playerControlled', { playerId, mode: 'camera', }); world.addComponent(entity, 'appearance', generateRandomAppearance()); // Send full world state socket.emit('world-state', serializeWorldState(world, map)); socket.emit('player-joined', { playerId, entityId: entity }); // Send narration history socket.emit('narration-history', this.gameLoop.narrationService.getRecentEvents()); // Send invention history const inventionTimeline = this.gameLoop.world.getSingleton('inventionTimeline'); if (inventionTimeline) { socket.emit('invention-history', inventionTimeline.getSummaries()); } // Send stockpile history and summary const stockpileLog = this.gameLoop.world.getSingleton('stockpileLog'); if (stockpileLog) { socket.emit('stockpile-history', stockpileLog.getRecent()); } socket.emit('stockpile-summary', this.computeStockpileSummary()); // Notify others socket.broadcast.emit('player-joined', { playerId, entityId: entity }); // Handle inputs socket.on('player-input', (input: PlayerInput) => { this.handleInput(playerId, entity, input); }); // Handle NPC spawn requests socket.on('spawn-npc', (data: { x: number; y: number }) => { const npcCount = world.query('npcBrain').length; if (npcCount >= this.gameLoop.runtimeConstants.get('MAX_NPC_COUNT')) return; const target = map.findNearestWalkable(data.x, data.y, 3) ?? map.getRandomWalkable(); const npcEntity = spawnNPC(world, map, target, this.gameLoop.eventMemoryService); this.gameLoop.generateNpcBackstory(npcEntity); }); // Handle superlatives subscription socket.on('superlatives-subscribe', () => { this.superlativesSubscribers.add(socket); // Send immediately const registry = this.gameLoop.world.getSingleton('bondRegistry'); if (registry) { const inventionTimeline = this.gameLoop.world.getSingleton('inventionTimeline'); const data = computeSuperlatives(this.gameLoop.world, registry, inventionTimeline); socket.emit('superlatives-update', data); } // Start timer if first subscriber if (!this.superlativesTimer && this.superlativesSubscribers.size === 1) { this.superlativesTimer = setInterval(() => { this.broadcastSuperlatives(); }, 10000); } }); socket.on('superlatives-unsubscribe', () => { this.superlativesSubscribers.delete(socket); this.stopSuperlativesTimerIfEmpty(); }); // Handle follow-npc socket.on('follow-npc', (data: { entityId: EntityId | null }) => { this.followedEntities.set(socket.id, data.entityId); this.rebuildFollowedEntityIds(); // Send memory history when following a new NPC if (data.entityId !== null) { const events = this.gameLoop.eventMemoryService.getEvents(data.entityId); if (events.length > 0) { socket.emit('memory-history', { entityId: data.entityId, events }); } } }); // Admin authentication const ADMIN_PASSWORD = 'dwarf'; socket.on('admin-auth', (data: { password: string }) => { if (data.password === ADMIN_PASSWORD) { this.authenticatedSockets.add(socket.id); socket.emit('admin-auth-result', { success: true, constants: this.gameLoop.runtimeConstants.getAll(), }); } else { socket.emit('admin-auth-result', { success: false }); } }); socket.on('admin-update-constant', (data: { key: any; value: number }) => { if (!this.authenticatedSockets.has(socket.id)) return; const success = this.gameLoop.runtimeConstants.update(data.key, data.value); if (success) { this.io.emit('admin-constants-updated', this.gameLoop.runtimeConstants.getAll()); this.gameLoop.logService.log('info', 'Game', `Admin updated ${data.key} to ${data.value}`); } }); socket.on('admin-reset-defaults', () => { if (!this.authenticatedSockets.has(socket.id)) return; this.gameLoop.runtimeConstants.resetDefaults(); this.io.emit('admin-constants-updated', this.gameLoop.runtimeConstants.getAll()); this.gameLoop.logService.log('info', 'Game', 'Admin reset all constants to defaults'); }); socket.on('admin-llm-stats', () => { if (!this.authenticatedSockets.has(socket.id)) return; const stats = this.gameLoop.llmStatsService.getStats(); socket.emit('admin-llm-stats-result', stats); }); socket.on('admin-reset-llm-stats', () => { if (!this.authenticatedSockets.has(socket.id)) return; this.gameLoop.llmStatsService.resetStats(); const stats = this.gameLoop.llmStatsService.getStats(); socket.emit('admin-llm-stats-result', stats); this.gameLoop.logService.log('info', 'Game', 'Admin reset LLM stats'); }); socket.on('admin-toggle-llm-tracking', (data: { enabled: boolean }) => { if (!this.authenticatedSockets.has(socket.id)) return; this.gameLoop.llmStatsService.setTrackingEnabled(data.enabled); this.io.emit('admin-llm-tracking-toggled', { enabled: data.enabled }); this.gameLoop.logService.log('info', 'Game', `Admin ${data.enabled ? 'enabled' : 'disabled'} LLM tracking`); }); // Log streaming socket.on('log-subscribe', () => { // Send history first socket.emit('log-history', this.gameLoop.logService.getRecent()); // Subscribe to new entries const unsub = this.gameLoop.logService.subscribe((entry) => { socket.emit('log-entry', entry); }); this.logSubscribers.set(socket.id, unsub); }); socket.on('log-unsubscribe', () => { const unsub = this.logSubscribers.get(socket.id); if (unsub) { unsub(); this.logSubscribers.delete(socket.id); } }); // Handle disconnect socket.on('disconnect', () => { console.log(`Player disconnected: ${playerId}`); this.gameLoop.logService.log('info', 'Network', `Player disconnected: ${playerId}`); this.authenticatedSockets.delete(socket.id); this.superlativesSubscribers.delete(socket); this.stopSuperlativesTimerIfEmpty(); this.followedEntities.delete(socket.id); this.rebuildFollowedEntityIds(); const logUnsub = this.logSubscribers.get(socket.id); if (logUnsub) { logUnsub(); this.logSubscribers.delete(socket.id); } world.removeEntity(entity); this.io.emit('player-left', { playerId }); }); }); } private computeStockpileSummary(): Record { const summary: Record = {}; for (const entity of this.gameLoop.world.query('structure')) { const s = this.gameLoop.world.getComponent(entity, 'structure')!; if (s.type !== 'stockpile' || s.buildProgress !== undefined) continue; for (const [itemId, qty] of s.inventory) { summary[itemId] = (summary[itemId] ?? 0) + qty; } } return summary; } private broadcastSuperlatives(): void { if (this.superlativesSubscribers.size === 0) return; const registry = this.gameLoop.world.getSingleton('bondRegistry'); if (!registry) return; const inventionTimeline = this.gameLoop.world.getSingleton('inventionTimeline'); const data = computeSuperlatives(this.gameLoop.world, registry, inventionTimeline); for (const socket of this.superlativesSubscribers) { socket.emit('superlatives-update', data); } } private stopSuperlativesTimerIfEmpty(): void { if (this.superlativesSubscribers.size === 0 && this.superlativesTimer) { clearInterval(this.superlativesTimer); this.superlativesTimer = null; } } private rebuildFollowedEntityIds(): void { this.gameLoop.followedEntityIds.clear(); for (const [, entityId] of this.followedEntities) { if (entityId !== null) this.gameLoop.followedEntityIds.add(entityId); } } private handleInput(playerId: string, entityId: number, input: PlayerInput): void { const world = this.gameLoop.world; const pc = world.getComponent(entityId, 'playerControlled'); if (!pc) return; switch (input.type) { case 'toggle-mode': { pc.mode = pc.mode === 'avatar' ? 'camera' : 'avatar'; break; } case 'move': { if (pc.mode !== 'avatar' || !input.direction) break; const pos = world.getComponent(entityId, 'position'); const mov = world.getComponent(entityId, 'movement'); if (!pos || !mov) break; const newX = pos.x + input.direction.dx; const newY = pos.y + input.direction.dy; if (this.gameLoop.map.isWalkable(newX, newY)) { pos.x = newX; pos.y = newY; const { dx, dy } = input.direction; if (Math.abs(dx) > Math.abs(dy)) { mov.direction = dx > 0 ? Direction.RIGHT : Direction.LEFT; } else { mov.direction = dy > 0 ? Direction.DOWN : Direction.UP; } } break; } } } }