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