b2ac0e7a00
Allows resetting the LLM stats aggregation window without deleting historical data. Stores a reset timestamp in metadata and filters the aggregation query to only include calls after that point. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
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<ClientEvents, ServerEvents>;
|
|
private gameLoop: GameLoop;
|
|
private superlativesSubscribers: Set<Socket<ClientEvents, ServerEvents>> = new Set();
|
|
private superlativesTimer: ReturnType<typeof setInterval> | null = null;
|
|
private followedEntities: Map<string, EntityId | null> = new Map();
|
|
private logSubscribers: Map<string, () => void> = new Map();
|
|
private authenticatedSockets: Set<string> = 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<Position>(entity, 'position', startPos);
|
|
world.addComponent<Movement>(entity, 'movement', {
|
|
state: 'idle', target: null, path: [], direction: Direction.DOWN, moveProgress: 0,
|
|
});
|
|
world.addComponent<PlayerControlled>(entity, 'playerControlled', {
|
|
playerId, mode: 'camera',
|
|
});
|
|
world.addComponent<Appearance>(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>('inventionTimeline');
|
|
if (inventionTimeline) {
|
|
socket.emit('invention-history', inventionTimeline.getSummaries());
|
|
}
|
|
|
|
// Send stockpile history and summary
|
|
const stockpileLog = this.gameLoop.world.getSingleton<StockpileLog>('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>('bondRegistry');
|
|
if (registry) {
|
|
const inventionTimeline = this.gameLoop.world.getSingleton<InventionTimeline>('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<string, number> {
|
|
const summary: Record<string, number> = {};
|
|
for (const entity of this.gameLoop.world.query('structure')) {
|
|
const s = this.gameLoop.world.getComponent<StructureData>(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>('bondRegistry');
|
|
if (!registry) return;
|
|
const inventionTimeline = this.gameLoop.world.getSingleton<InventionTimeline>('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<PlayerControlled>(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<Position>(entityId, 'position');
|
|
const mov = world.getComponent<Movement>(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;
|
|
}
|
|
}
|
|
}
|
|
}
|