Phase 18: internet realtime collaboration (GM-authoritative) + server

- Shared Zod wire protocol (src/lib/sync/messages.ts): hosted/joined/snapshot/
  mapImage/error; player-safe projections only (enemy HP masked on the GM before
  broadcast via src/lib/combat/playerProjection.ts).
- wsSync adapter (src/lib/sync/wsSync.ts) behind the existing SyncAdapter seam:
  GM hostSession + debounced snapshot broadcast (useSessionBroadcaster), player
  joinSession into an ephemeral playerSessionStore, exponential-backoff reconnect
  with seamless room resume (GM secret). localSync remains the offline default.
- buildSnapshot (src/lib/sync/snapshot.ts) reuses the same projection as the local
  /play view, guaranteeing parity. Player view refactored into shared PlayerBoards;
  /play?room=CODE = networked read-only mode.
- SessionControl in the header: Host (optional password) → shareable room code/link.
- Server (server/): Fastify + @fastify/websocket + @fastify/static, in-memory
  GM-authoritative rooms (crypto room id/secret hashed, optional join password,
  TTL sweep, players strictly read-only), origin allowlist, per-frame size cap +
  rate limit, Zod validation. esbuild bundle (server/build.mjs), tsconfig.server.json.
- Dockerfile (multi-stage) + deploy/ttrpg.compose.yml (own project on external
  'proxy' net, Traefik labels for ttrpg.briggen.dev, non-root, no-new-privileges).
  CSP connect-src now allows same-origin wss:.

Tests: protocol round-trip, room hub (auth/password/resume/TTL/read-only), enemy
masking, Fastify+ws integration (host→join→snapshot, player push rejected), and a
two-context Playwright realtime spec (separate config) — GM hosts, player device
sees live combat with masked enemy HP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:49:31 +02:00
parent fb459ad92c
commit a8cb7d65f4
31 changed files with 1304 additions and 118 deletions
+145
View File
@@ -0,0 +1,145 @@
import crypto from 'node:crypto';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
export interface Sender {
send: (msg: ServerMessage) => void;
}
interface Room {
roomId: string;
joinCode: string;
gmSecretHash: string;
passwordHash: string | null;
gm: Sender | null;
players: Set<Sender>;
snapshot: Snapshot | null;
images: Map<string, string>;
lastActivity: number;
}
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
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; players are read-only and the hub enforces it. `now` is injectable for tests.
*/
export class RoomHub {
private rooms = new Map<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player' }>();
constructor(private now: () => number = () => Date.now()) {}
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' });
socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
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(), lastActivity: this.now(),
};
this.rooms.set(roomId, room);
this.byCode.set(joinCode, roomId);
this.conns.set(socket, { roomId, role: 'gm' });
socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
}
join(socket: Sender, joinCode: string, password?: 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();
this.conns.set(socket, { roomId: room.roomId, role: 'player' });
socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
}
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; }
room.images.set(id, dataUrl);
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 });
}
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);
}
this.conns.delete(socket);
}
/** Evict rooms idle past the TTL. Call periodically. */
sweep(): void {
const cutoff = this.now() - ROOM_TTL_MS;
for (const [id, room] of this.rooms) {
if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); }
}
}
roomCount(): number { return this.rooms.size; }
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;
}
}