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