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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user