import crypto from 'node:crypto'; import type { ServerMessage, Snapshot, PartialCharacterDiff, PrivateHandout } from '@/lib/sync/messages'; import type { Character } from '@/lib/schemas/character'; export interface Sender { send: (msg: ServerMessage) => void; } interface Seat { sender: Sender; characterId: string; name: string; /** the granted sheet, re-sent on reconnect so the player regains control seamlessly */ character: Character; /** set when the holder's socket drops; the seat survives a short grace window */ disconnectedAt?: number; } /** How long a seat survives after its player disconnects, so a reconnect can reclaim it. */ const SEAT_GRACE_MS = 5 * 60 * 1000; interface SeatRequest { playerId: string; characterId: string; offlineSnapshot?: Character; } interface Room { roomId: string; joinCode: string; gmSecretHash: string; passwordHash: string | null; gm: Sender | null; players: Set; snapshot: Snapshot | null; images: Map; /** running total of bytes in `images`, kept so the cap is O(1) to check */ imageBytes: number; /** granted seats keyed by playerId */ seats: Map; /** seat requests awaiting GM approval (re-sent when the GM reconnects) */ pendingSeatRequests: SeatRequest[]; lastActivity: number; } const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle // Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box. export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number } const DEFAULT_LIMITS: RoomLimits = { maxRooms: Number(process.env.MAX_ROOMS) || 200, maxImagesPerRoom: 40, maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room }; function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); } function timingEqual(a: string, b: string): boolean { const ab = Buffer.from(a), bb = Buffer.from(b); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); } /** * In-memory, GM-authoritative room registry. No persistence. The GM is the only * writer of shared state; players are read-only EXCEPT for their own seat (HP, * slots, conditions, rolls), which the hub forwards to the GM to persist. * `now` is injectable for tests. */ export class RoomHub { private rooms = new Map(); private byCode = new Map(); private conns = new Map(); private limits: RoomLimits; constructor(private now: () => number = () => Date.now(), limits: Partial = {}) { this.limits = { ...DEFAULT_LIMITS, ...limits }; } private mintJoinCode(): string { for (let attempt = 0; attempt < 50; attempt++) { let code = ''; const bytes = crypto.randomBytes(6); for (let i = 0; i < 6; i++) code += JOIN_ALPHABET[bytes[i]! % JOIN_ALPHABET.length]; if (!this.byCode.has(code)) return code; } return crypto.randomBytes(8).toString('hex').toUpperCase().slice(0, 6); } host(socket: Sender, password?: string, resume?: string): void { // Resume an existing room if the GM presents its secret (seamless reconnect). if (resume) { const hash = sha256(resume); for (const room of this.rooms.values()) { if (timingEqual(room.gmSecretHash, hash)) { room.gm = socket; room.lastActivity = this.now(); this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' }); socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume }); for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req }); this.sendRoster(room); return; } } } // Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out; // a sweep runs first to reclaim any that just expired before we refuse. if (this.rooms.size >= this.limits.maxRooms) { this.sweep(); if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; } } const roomId = crypto.randomUUID(); const joinCode = this.mintJoinCode(); const gmSecret = crypto.randomBytes(32).toString('base64url'); const room: Room = { roomId, joinCode, gmSecretHash: sha256(gmSecret), passwordHash: password ? sha256(password) : null, gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0, seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(), }; this.rooms.set(roomId, room); this.byCode.set(joinCode, roomId); this.conns.set(socket, { roomId, role: 'gm', playerId: 'gm' }); socket.send({ t: 'hosted', roomId, joinCode, gmSecret }); } join(socket: Sender, joinCode: string, password?: string, clientPlayerId?: string): void { const roomId = this.byCode.get(joinCode.trim().toUpperCase()); const room = roomId ? this.rooms.get(roomId) : undefined; if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; } if (room.passwordHash && (!password || !timingEqual(room.passwordHash, sha256(password)))) { socket.send({ t: 'error', code: 'bad-password', message: 'Wrong session password.' }); return; } room.players.add(socket); room.lastActivity = this.now(); // Reuse the client's stable id (so a transient drop keeps the seat) ONLY when it // isn't already live on another connection — preventing a second tab/peer from // seizing an active seat. The id is a client-generated UUID kept in localStorage. let playerId: string = crypto.randomUUID(); if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) { const liveElsewhere = [...this.conns.values()].some((c) => c.roomId === room.roomId && c.playerId === clientPlayerId); if (!liveElsewhere) playerId = clientPlayerId; } this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId }); socket.send({ t: 'joined', roomId: room.roomId }); if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); // Reconnect: if this player still owns a seat, re-bind it to the new socket and // re-grant the sheet so their HP/condition/roll messages are accepted again. const seat = room.seats.get(playerId); if (seat) { seat.sender = socket; delete seat.disconnectedAt; socket.send({ t: 'seatGranted', character: seat.character }); } this.sendRoster(room); } state(socket: Sender, gmSecret: string, snapshot: Snapshot): void { const room = this.gmRoom(socket, gmSecret); if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } room.snapshot = snapshot; room.lastActivity = this.now(); for (const p of room.players) p.send({ t: 'snapshot', snapshot }); } image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void { const room = this.gmRoom(socket, gmSecret); if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } // Bound per-room image memory: cap the count of distinct images and the total bytes. const size = Buffer.byteLength(dataUrl); const prev = room.images.get(id); const prevSize = prev ? Buffer.byteLength(prev) : 0; const isNew = prev === undefined; if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) { socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' }); return; } room.images.set(id, dataUrl); room.imageBytes += size - prevSize; room.lastActivity = this.now(); for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl }); } requestImage(socket: Sender, id: string): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; const dataUrl = room?.images.get(id); if (dataUrl) socket.send({ t: 'mapImage', id, dataUrl }); } /** Player asks to control a character; the request is forwarded to the GM. */ claimSeat(socket: Sender, characterId: string, offlineSnapshot?: Character): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; if (!conn || conn.role !== 'player' || !room) return; room.lastActivity = this.now(); const req: SeatRequest = { playerId: conn.playerId, characterId, ...(offlineSnapshot ? { offlineSnapshot } : {}) }; room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== conn.playerId); room.pendingSeatRequests.push(req); room.gm?.send({ t: 'seatRequest', ...req }); } /** GM approves a seat request and hands the player their authoritative sheet. */ seatGrant(socket: Sender, gmSecret: string, targetPlayerId: string, character: Character): void { const room = this.gmRoom(socket, gmSecret); if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } room.lastActivity = this.now(); room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== targetPlayerId); let target: Sender | undefined; for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } } if (!target) return; room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name, character }); target.send({ t: 'seatGranted', character }); this.sendRoster(room); } /** GM sends a private handout/note/image to one player; non-recipients never receive it. */ privateState(socket: Sender, gmSecret: string, targetPlayerId: string, handout: PrivateHandout | null): void { const room = this.gmRoom(socket, gmSecret); if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } room.lastActivity = this.now(); for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) s.send({ t: 'privateHandout', handout }); } } /** Tell everyone (GM + players) the current roster: the GM (if present) + players. */ private sendRoster(room: Room): void { const players: { playerId: string; name: string; character?: string }[] = []; if (room.gm) players.push({ playerId: 'gm', name: 'GM' }); const seen = new Set(); for (const s of room.players) { const conn = this.conns.get(s); if (!conn || seen.has(conn.playerId)) continue; seen.add(conn.playerId); const seatName = room.seats.get(conn.playerId)?.name; const name = conn.name ?? seatName ?? `Player ${conn.playerId.slice(0, 4)}`; players.push({ playerId: conn.playerId, name, ...(conn.name && seatName ? { character: seatName } : {}) }); } const msg = { t: 'roster', players } as const; room.gm?.send(msg); for (const p of room.players) p.send(msg); } /** A player sets their display name (roster + chat). */ setName(socket: Sender, name: string): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; if (!conn || conn.role !== 'player' || !room) return; const trimmed = name.trim().slice(0, 40); if (trimmed) conn.name = trimmed; else delete conn.name; room.lastActivity = this.now(); this.sendRoster(room); } /** A seated player edits their own sheet; the diff is forwarded to the GM only. */ playerPatch(socket: Sender, characterId: string, diff: PartialCharacterDiff): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; if (!conn || conn.role !== 'player' || !room) return; const seat = room.seats.get(conn.playerId); if (!seat || seat.characterId !== characterId) return; // must hold the seat for this character room.lastActivity = this.now(); room.gm?.send({ t: 'playerPatched', characterId, diff }); } /** A seated player rolls dice; broadcast to the GM and the rest of the table. */ playerRoll(socket: Sender, _characterId: string, label: string, expression: string, total: number, breakdown: string): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; if (!conn || conn.role !== 'player' || !room) return; const seat = room.seats.get(conn.playerId); if (!seat) return; // only seated players broadcast rolls room.lastActivity = this.now(); const msg = { t: 'rollBroadcast', playerName: seat.name, label, expression, total, breakdown } as const; room.gm?.send(msg); for (const p of room.players) if (p !== socket) p.send(msg); } /** The GM broadcasts one of their own (public) rolls to the players. */ gmRoll(socket: Sender, gmSecret: string, label: string, expression: string, total: number, breakdown: string): void { const room = this.gmRoom(socket, gmSecret); if (!room) return; room.lastActivity = this.now(); const msg = { t: 'rollBroadcast', playerName: 'GM', label, expression, total, breakdown } as const; for (const p of room.players) p.send(msg); // players only; the GM already sees it locally } /** Table chat (to all) or a whisper. GM `to` = target player; player `to` set = whisper to GM. */ chat(socket: Sender, body: string, to?: string): void { const conn = this.conns.get(socket); const room = conn ? this.rooms.get(conn.roomId) : undefined; if (!conn || !room) return; room.lastActivity = this.now(); if (conn.role === 'gm') { if (to) { const msg = { t: 'chat', from: 'GM', body, scope: 'whisper' } as const; for (const [s, c] of this.conns) if (c.roomId === room.roomId && c.playerId === to) s.send(msg); } else { const msg = { t: 'chat', from: 'GM', body, scope: 'table' } as const; for (const p of room.players) p.send(msg); } } else { const from = conn.name ?? room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`; if (to) { room.gm?.send({ t: 'chat', from, body, scope: 'whisper' }); } else { const msg = { t: 'chat', from, body, scope: 'table' } as const; room.gm?.send(msg); for (const p of room.players) if (p !== socket) p.send(msg); } } } disconnect(socket: Sender): void { const conn = this.conns.get(socket); if (!conn) return; const room = this.rooms.get(conn.roomId); if (room) { if (conn.role === 'gm' && room.gm === socket) room.gm = null; room.players.delete(socket); // Don't drop the seat on disconnect — start its grace timer so a reconnect (same // stable playerId) can reclaim it. sweep() evicts seats that stay gone too long. for (const [, seat] of room.seats) if (seat.sender === socket) seat.disconnectedAt = this.now(); // Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it). this.sendRoster(room); } this.conns.delete(socket); } /** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */ sweep(): void { const now = this.now(); const cutoff = now - ROOM_TTL_MS; for (const [id, room] of this.rooms) { if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); continue; } let dropped = false; for (const [pid, seat] of room.seats) { if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) { room.seats.delete(pid); dropped = true; } } if (dropped) this.sendRoster(room); } } roomCount(): number { return this.rooms.size; } /** Admin overview of live rooms. Deliberately excludes join codes and secrets — * the admin can see load, not enter private games. */ stats(): Array<{ players: number; seats: number; images: number; imageBytes: number; idleMs: number; hasGm: boolean }> { const now = this.now(); return [...this.rooms.values()].map((r) => ({ players: r.players.size, seats: r.seats.size, images: r.images.size, imageBytes: r.imageBytes, idleMs: Math.max(0, now - r.lastActivity), hasGm: r.gm !== null, })); } private gmRoom(socket: Sender, gmSecret: string): Room | null { const conn = this.conns.get(socket); if (!conn || conn.role !== 'gm') return null; const room = this.rooms.get(conn.roomId); if (!room || room.gm !== socket || !timingEqual(room.gmSecretHash, sha256(gmSecret))) return null; return room; } }