Files
dflike/server/src/network/SocketServer.ts
T
root 8c793578ae feat: wire backstory generation into NPC spawn flow
Trigger fire-and-forget backstory generation whenever an NPC spawns,
both during initial spawn and via player spawn-npc requests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:52:13 +00:00

161 lines
5.9 KiB
TypeScript

import { Server, Socket } from 'socket.io';
import type http from 'http';
import type { ClientEvents, ServerEvents, PlayerInput, Position, Movement, PlayerControlled, Appearance } from '@dflike/shared';
import { Direction, MAX_NPC_COUNT } 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';
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;
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.getTick(), this.gameLoop.getGameTime());
this.io.emit('state-update', update);
});
}
private setupConnections(): void {
this.io.on('connection', (socket) => {
const playerId = socket.id;
console.log(`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 });
// 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 >= MAX_NPC_COUNT) return;
const target = map.findNearestWalkable(data.x, data.y, 3)
?? map.getRandomWalkable();
const npcEntity = spawnNPC(world, map, target);
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 data = computeSuperlatives(this.gameLoop.world, registry);
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 disconnect
socket.on('disconnect', () => {
console.log(`Player disconnected: ${playerId}`);
this.superlativesSubscribers.delete(socket);
this.stopSuperlativesTimerIfEmpty();
world.removeEntity(entity);
this.io.emit('player-left', { playerId });
});
});
}
private broadcastSuperlatives(): void {
if (this.superlativesSubscribers.size === 0) return;
const registry = this.gameLoop.world.getSingleton<BondRegistry>('bondRegistry');
if (!registry) return;
const data = computeSuperlatives(this.gameLoop.world, registry);
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 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;
}
}
}
}