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
+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);
});
});