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:
@@ -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); });
|
||||
|
||||
@@ -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
@@ -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