P21: player seats + two-way live play + offline handoff

Players claim a seat (GM approves) and drive their own character live —
HP/slots/resources/conditions/dice broadcast to the table — via new seat
protocol messages and per-seat ownership enforced in RoomHub. GM persists
player edits (charactersRepo.update → liveQuery → rebroadcast). Offline dev:
"Share with player" encodes a claim link (src/lib/sync/playerLink.ts) → /player
imports the character locally for full-sheet editing, bundled back as an
offlineSnapshot on seat claim for GM review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 12:34:00 +02:00
parent c28c29a6c6
commit 360d9ff842
21 changed files with 863 additions and 25 deletions
+4
View File
@@ -45,6 +45,10 @@ export function buildServer() {
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); break;
case 'claimSeat': hub.claimSeat(sender, m.characterId, m.offlineSnapshot); break;
case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break;
case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break;
case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break;
}
});
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
+48
View File
@@ -1,15 +1,27 @@
import { describe, it, expect } from 'vitest';
import { RoomHub, type Sender } from './rooms';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
import { characterSchema, type Character } from '@/lib/schemas';
function fake(): Sender & { msgs: ServerMessage[] } {
const msgs: ServerMessage[] = [];
return { msgs, send: (m) => msgs.push(m) };
}
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
const char: Character = characterSchema.parse({
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
hp: { current: 18, max: 24, temp: 0 }, createdAt: 't', updatedAt: 't',
});
function lastOf<T extends ServerMessage['t']>(s: ReturnType<typeof fake>, t: T): Extract<ServerMessage, { t: T }> | undefined {
return [...s.msgs].reverse().find((m): m is Extract<ServerMessage, { t: T }> => m.t === t);
}
function hostAndJoin(hub: RoomHub) {
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const player = fake(); hub.join(player, hosted.joinCode);
return { gm, player, secret: hosted.gmSecret };
}
describe('RoomHub', () => {
it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => {
@@ -79,4 +91,40 @@ describe('RoomHub', () => {
hub.sweep();
expect(hub.roomCount()).toBe(0);
});
it('routes a seat claim → GM grant → player gets their sheet', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
hub.claimSeat(player, 'ch1', char);
const req = lastOf(gm, 'seatRequest')!;
expect(req).toMatchObject({ characterId: 'ch1' });
expect(req.offlineSnapshot?.name).toBe('Lia');
hub.seatGrant(gm, secret, req.playerId, char);
expect(lastOf(player, 'seatGranted')).toMatchObject({ character: { id: 'ch1' } });
});
it('forwards a seated player patch/roll, and drops them from non-seated sockets', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
// Not seated yet → patch is dropped.
hub.playerPatch(player, 'ch1', { hp: { current: 1, max: 24, temp: 0 } });
expect(lastOf(gm, 'playerPatched')).toBeUndefined();
hub.claimSeat(player, 'ch1');
hub.seatGrant(gm, secret, lastOf(gm, 'seatRequest')!.playerId, char);
hub.playerPatch(player, 'ch1', { hp: { current: 7, max: 24, temp: 0 } });
expect(lastOf(gm, 'playerPatched')).toMatchObject({ characterId: 'ch1', diff: { hp: { current: 7 } } });
hub.playerRoll(player, 'ch1', 'Sword', '1d20+5', 18, '[13]+5');
expect(lastOf(gm, 'rollBroadcast')).toMatchObject({ playerName: 'Lia', total: 18 });
// A patch for a character the player doesn't hold is rejected.
const before = gm.msgs.filter((m) => m.t === 'playerPatched').length;
hub.playerPatch(player, 'someone-else', { hp: { current: 0, max: 24, temp: 0 } });
expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before);
});
});
+78 -7
View File
@@ -1,10 +1,23 @@
import crypto from 'node:crypto';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
import type { ServerMessage, Snapshot, PartialCharacterDiff } 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;
}
interface SeatRequest {
playerId: string;
characterId: string;
offlineSnapshot?: Character;
}
interface Room {
roomId: string;
joinCode: string;
@@ -14,6 +27,10 @@ interface Room {
players: Set<Sender>;
snapshot: Snapshot | null;
images: Map<string, string>;
/** granted seats keyed by playerId */
seats: Map<string, Seat>;
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
pendingSeatRequests: SeatRequest[];
lastActivity: number;
}
@@ -30,12 +47,14 @@ function timingEqual(a: string, b: string): boolean {
/**
* 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.
* 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<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player' }>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string }>();
constructor(private now: () => number = () => Date.now()) {}
private mintJoinCode(): string {
@@ -56,8 +75,9 @@ export class RoomHub {
if (timingEqual(room.gmSecretHash, hash)) {
room.gm = socket;
room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'gm' });
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 });
return;
}
}
@@ -68,11 +88,12 @@ export class RoomHub {
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(),
gm: socket, players: new Set(), snapshot: null, images: new Map(),
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
};
this.rooms.set(roomId, room);
this.byCode.set(joinCode, roomId);
this.conns.set(socket, { roomId, role: 'gm' });
this.conns.set(socket, { roomId, role: 'gm', playerId: 'gm' });
socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
}
@@ -86,7 +107,7 @@ export class RoomHub {
}
room.players.add(socket);
room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'player' });
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() });
socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
}
@@ -114,6 +135,55 @@ export class RoomHub {
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 });
target.send({ t: 'seatGranted', character });
}
/** 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);
}
disconnect(socket: Sender): void {
const conn = this.conns.get(socket);
if (!conn) return;
@@ -121,6 +191,7 @@ export class RoomHub {
if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
room.players.delete(socket);
for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid);
}
this.conns.delete(socket);
}