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
+1 -1
View File
@@ -26,7 +26,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
const player = await playerCtx.newPage(); const player = await playerCtx.newPage();
await player.goto(`/play?room=${code}`); await player.goto(`/play?room=${code}`);
await expect(player.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); await expect(player.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await expect(player.getByText('Lia the Brave')).toBeVisible(); // party synced await expect(player.getByText('Lia the Brave').first()).toBeVisible(); // party synced (also shown on the seat-claim screen)
// GM starts the seeded encounter → player sees initiative live, enemy HP masked. // GM starts the seeded encounter → player sees initiative live, enemy HP masked.
await gm.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click(); await gm.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click();
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, type BrowserContext } from '@playwright/test';
async function fresh(ctx: BrowserContext) {
const page = await ctx.newPage();
await page.goto('/');
await page.evaluate(() => { indexedDB.deleteDatabase('ttrpg-manager'); localStorage.clear(); });
await page.reload();
return page;
}
test('a player claims a seat and manages their character; edits round-trip to the GM', async ({ browser }) => {
const gmCtx = await browser.newContext();
const playerCtx = await browser.newContext();
// GM: sample campaign + host.
const gm = await fresh(gmCtx);
gm.on('dialog', (d) => d.dismiss()); // optional-password prompt
await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await gm.getByRole('button', { name: '📡 Host' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
// Player: join → seat-claim screen → claim Lia.
const player = await playerCtx.newPage();
await player.goto(`/play?room=${code}`);
await expect(player.getByRole('heading', { name: 'Which character is yours?' })).toBeVisible();
await player.locator('div.rounded-lg', { hasText: 'Lia the Brave' }).getByRole('button', { name: 'This is me' }).click();
// GM: approve the seat request.
await gm.getByTestId('seat-requests').click();
await gm.getByRole('button', { name: 'Grant', exact: true }).click();
// Player: now drives an interactive sheet.
await expect(player.getByTestId('my-character')).toBeVisible();
const hp = await player.getByTestId('my-hp').textContent();
const cur = Number((hp ?? '0/0').split('/')[0]);
expect(cur).toBeGreaterThan(5);
// Spend HP → optimistic update on the panel…
await player.getByRole('button', { name: '5' }).click();
await expect(player.getByTestId('my-hp')).toHaveText(new RegExp(`^${cur - 5}/`));
// …and it round-trips: GM persists it and re-broadcasts to the party board.
const party = player.locator('section').filter({ has: player.getByRole('heading', { name: 'Party' }) });
await expect(party.getByText(`${cur - 5}/`)).toBeVisible({ timeout: 8000 });
await gmCtx.close();
await playerCtx.close();
});
+4
View File
@@ -45,6 +45,10 @@ export function buildServer() {
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break; case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break; case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); 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); }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
+48
View File
@@ -1,15 +1,27 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { RoomHub, type Sender } from './rooms'; import { RoomHub, type Sender } from './rooms';
import type { ServerMessage, Snapshot } from '@/lib/sync/messages'; import type { ServerMessage, Snapshot } from '@/lib/sync/messages';
import { characterSchema, type Character } from '@/lib/schemas';
function fake(): Sender & { msgs: ServerMessage[] } { function fake(): Sender & { msgs: ServerMessage[] } {
const msgs: ServerMessage[] = []; const msgs: ServerMessage[] = [];
return { msgs, send: (m) => msgs.push(m) }; return { msgs, send: (m) => msgs.push(m) };
} }
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; 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 { 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); 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', () => { describe('RoomHub', () => {
it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => { it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => {
@@ -79,4 +91,40 @@ describe('RoomHub', () => {
hub.sweep(); hub.sweep();
expect(hub.roomCount()).toBe(0); 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 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 { export interface Sender {
send: (msg: ServerMessage) => void; send: (msg: ServerMessage) => void;
} }
interface Seat {
sender: Sender;
characterId: string;
name: string;
}
interface SeatRequest {
playerId: string;
characterId: string;
offlineSnapshot?: Character;
}
interface Room { interface Room {
roomId: string; roomId: string;
joinCode: string; joinCode: string;
@@ -14,6 +27,10 @@ interface Room {
players: Set<Sender>; players: Set<Sender>;
snapshot: Snapshot | null; snapshot: Snapshot | null;
images: Map<string, string>; 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; 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 * 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 { export class RoomHub {
private rooms = new Map<string, Room>(); private rooms = new Map<string, Room>();
private byCode = new Map<string, string>(); 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()) {} constructor(private now: () => number = () => Date.now()) {}
private mintJoinCode(): string { private mintJoinCode(): string {
@@ -56,8 +75,9 @@ export class RoomHub {
if (timingEqual(room.gmSecretHash, hash)) { if (timingEqual(room.gmSecretHash, hash)) {
room.gm = socket; room.gm = socket;
room.lastActivity = this.now(); 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 }); socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume });
for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req });
return; return;
} }
} }
@@ -68,11 +88,12 @@ export class RoomHub {
const room: Room = { const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret), roomId, joinCode, gmSecretHash: sha256(gmSecret),
passwordHash: password ? sha256(password) : null, 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.rooms.set(roomId, room);
this.byCode.set(joinCode, roomId); 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 }); socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
} }
@@ -86,7 +107,7 @@ export class RoomHub {
} }
room.players.add(socket); room.players.add(socket);
room.lastActivity = this.now(); 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 }); socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); 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 }); 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 { disconnect(socket: Sender): void {
const conn = this.conns.get(socket); const conn = this.conns.get(socket);
if (!conn) return; if (!conn) return;
@@ -121,6 +191,7 @@ export class RoomHub {
if (room) { if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null; if (conn.role === 'gm' && room.gm === socket) room.gm = null;
room.players.delete(socket); room.players.delete(socket);
for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid);
} }
this.conns.delete(socket); this.conns.delete(socket);
} }
+12 -1
View File
@@ -3,7 +3,8 @@ import { Link } from '@tanstack/react-router';
import type { Character } from '@/lib/schemas'; import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills'; import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { rollCheck } from '@/lib/useRoll'; import { rollCheck } from '@/lib/useRoll';
@@ -37,6 +38,15 @@ export function CharacterSheet({ character }: { character: Character }) {
const [c, setC] = useState<Character>(character); const [c, setC] = useState<Character>(character);
const [levelUp, setLevelUp] = useState(false); const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false); const [genScores, setGenScores] = useState(false);
const [shared, setShared] = useState(false);
const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ }
setShared(true);
setTimeout(() => setShared(false), 1500);
};
const save = useDebouncedCallback((next: Character) => { const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt). // Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next; const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
@@ -90,6 +100,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</span> </span>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="text-sm text-muted">{profLabel}</span> <span className="text-sm text-muted">{profLabel}</span>
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button> <Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button> <Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div> </div>
+50 -4
View File
@@ -1,16 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSessionStore } from '@/stores/sessionStore'; import { useLiveQuery } from 'dexie-react-hooks';
import { hostSession, stopSession } from '@/lib/sync/wsSync'; import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
import { useActiveCampaign } from '@/features/campaigns/hooks'; import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
/** Header control: GM hosts a live session and shares the join code/link. */ /** Header control: GM hosts a live session, shares the code, and approves seats. */
export function SessionControl() { export function SessionControl() {
const campaign = useActiveCampaign(); const campaign = useActiveCampaign();
const role = useSessionStore((s) => s.role); const role = useSessionStore((s) => s.role);
const status = useSessionStore((s) => s.status); const status = useSessionStore((s) => s.status);
const joinCode = useSessionStore((s) => s.joinCode); const joinCode = useSessionStore((s) => s.joinCode);
const seatRequests = useSessionStore((s) => s.seatRequests);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showSeats, setShowSeats] = useState(false);
if (role === 'player') return null; // players don't host if (role === 'player') return null; // players don't host
@@ -28,7 +32,7 @@ export function SessionControl() {
const link = joinCode ? `${location.origin}/play?room=${joinCode}` : ''; const link = joinCode ? `${location.origin}/play?room=${joinCode}` : '';
return ( return (
<div className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs"> <div className="relative flex items-center gap-1 rounded-md border border-accent/40 bg-accent/5 px-2 py-1 text-xs">
<span className="text-muted">{status === 'connected' ? 'Live' : status}:</span> <span className="text-muted">{status === 'connected' ? 'Live' : status}:</span>
<button <button
data-testid="join-code" data-testid="join-code"
@@ -36,7 +40,49 @@ export function SessionControl() {
title="Copy player link" title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }} onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button> >{joinCode}{copied ? ' ✓' : ''}</button>
{seatRequests.length > 0 && (
<button data-testid="seat-requests" className="ml-1 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 text-[10px] font-bold text-white"
title="Pending seat requests" onClick={() => setShowSeats((v) => !v)}>{seatRequests.length}</button>
)}
<Button size="sm" variant="ghost" className="h-6 px-1 text-danger" onClick={() => stopSession()} title="Stop session"></Button> <Button size="sm" variant="ghost" className="h-6 px-1 text-danger" onClick={() => stopSession()} title="Stop session"></Button>
{showSeats && seatRequests.length > 0 && (
<div className="absolute right-0 top-full z-50 mt-1 w-72 rounded-lg border border-line bg-panel p-2 shadow-lg">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Seat requests</div>
<ul className="space-y-2">
{seatRequests.map((r) => <SeatRequestRow key={r.playerId} req={r} />)}
</ul>
</div>
)}
</div> </div>
); );
} }
function SeatRequestRow({ req }: { req: SeatRequest }) {
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
const grant = async (applyOffline: boolean) => {
if (applyOffline && req.offlineSnapshot) {
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
await charactersRepo.update(req.characterId, rest);
}
const current = await charactersRepo.get(req.characterId);
if (current) grantSeat(req.playerId, current);
else removeSeatRequest(req.playerId);
};
return (
<li className="rounded-md border border-line bg-surface p-2 text-sm">
<div className="mb-1 text-ink">Player wants <span className="font-semibold">{name}</span></div>
<div className="flex flex-wrap gap-1">
<Button size="sm" variant="primary" onClick={() => void grant(false)}>Grant</Button>
{req.offlineSnapshot && <Button size="sm" variant="secondary" onClick={() => void grant(true)}>Apply offline & grant</Button>}
<Button size="sm" variant="ghost" onClick={() => removeSeatRequest(req.playerId)}>Deny</Button>
</div>
</li>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useState } from 'react';
import type { Character, Defenses } from '@/lib/schemas';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
/** Interactive in-session sheet a seated player drives themselves. */
export function MyCharacterPanel({ character: c, onPatch }: { character: Character; onPatch: (diff: Partial<Character>) => void }) {
const setHp = (current: number, temp = c.hp.temp) => onPatch({ hp: { current: clamp(current, 0, c.hp.max), max: c.hp.max, temp: Math.max(0, temp) } });
const setSlot = (level: number, current: number) => onPatch({ spellcasting: { ...c.spellcasting, slots: c.spellcasting.slots.map((s) => (s.level === level ? { ...s, current } : s)) } });
const setRes = (id: string, current: number) => onPatch({ resources: c.resources.map((r) => (r.id === id ? { ...r, current: clamp(current, 0, r.max) } : r)) });
const setDef = (patch: Partial<Defenses>) => onPatch({ defenses: { ...c.defenses, ...patch } });
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
return (
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
<div className="mb-3 flex items-baseline justify-between">
<h2 className="font-display text-2xl font-bold text-accent">{c.name}</h2>
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* HP */}
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-1 flex items-center justify-between text-sm font-semibold text-ink">
<span>Hit points</span>
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
</div>
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} />
</div>
<div className="flex flex-wrap items-center gap-1">
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>5</Button>
<Button size="sm" variant="secondary" onClick={() => setHp(c.hp.current - 1)}>1</Button>
<Button size="sm" variant="secondary" onClick={() => setHp(c.hp.current + 1)}>+1</Button>
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
<span className="mx-1 h-4 w-px bg-line" />
<span className="text-xs text-muted">Temp</span>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}></Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}>+</Button>
</div>
</div>
{/* Defenses */}
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Defenses</div>
{c.system === 'pf2e' ? (
<div className="grid grid-cols-2 gap-2 text-sm">
<Counter label="Dying" value={c.defenses.dying} min={0} max={4} onChange={(dying) => setDef({ dying })} />
<Counter label="Wounded" value={c.defenses.wounded} min={0} max={6} onChange={(wounded) => setDef({ wounded })} />
<Counter label="Doomed" value={c.defenses.doomed} min={0} max={4} onChange={(doomed) => setDef({ doomed })} />
<Counter label="Hero pts" value={c.defenses.heroPoints} min={0} max={9} onChange={(heroPoints) => setDef({ heroPoints })} />
</div>
) : (
<div className="space-y-2 text-sm">
<Pips label="Death saves ✓" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
</div>
</div>
)}
</div>
{/* Spell slots */}
{slots.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Spell slots</div>
<div className="space-y-1.5">
{slots.map((s) => (
<div key={s.level} className="flex items-center gap-2">
<span className="w-14 text-xs text-muted">Lvl {s.level}</span>
<div className="flex flex-wrap gap-1">
{Array.from({ length: s.max }, (_, i) => (
<button key={i} aria-label={`Level ${s.level} slot ${i + 1}`}
onClick={() => setSlot(s.level, i < s.current ? i : i + 1)}
className={cn('h-5 w-5 rounded-full border', i < s.current ? 'border-accent bg-accent' : 'border-line bg-surface')} />
))}
</div>
<span className="ml-auto text-xs tabular-nums text-muted">{s.current}/{s.max}</span>
</div>
))}
</div>
</div>
)}
{/* Resources */}
{c.resources.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Resources</div>
<div className="space-y-1.5">
{c.resources.map((r) => (
<div key={r.id} className="flex items-center gap-2 text-sm">
<span className="flex-1 truncate text-ink">{r.name}</span>
<Counter label="" value={r.current} min={0} max={r.max} onChange={(n) => setRes(r.id, n)} suffix={`/${r.max}`} />
</div>
))}
</div>
</div>
)}
{/* Conditions */}
<ConditionsBox character={c} onPatch={onPatch} />
{/* Dice */}
<DiceBox characterId={c.id} />
</div>
</section>
);
}
function Counter({ label, value, min, max, onChange, suffix }: { label: string; value: number; min: number; max: number; onChange: (n: number) => void; suffix?: string }) {
return (
<div className="flex items-center gap-1">
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}></Button>
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}>+</Button>
</div>
);
}
function Pips({ label, count, filled, color, onSet }: { label: string; count: number; filled: number; color: string; onSet: (n: number) => void }) {
return (
<div className="flex items-center gap-2">
<span className="w-24 text-xs text-muted">{label}</span>
<div className="flex gap-1">
{Array.from({ length: count }, (_, i) => (
<button key={i} aria-label={`${label} ${i + 1}`} onClick={() => onSet(i < filled ? i : i + 1)}
className={cn('h-5 w-5 rounded-full border', i < filled ? `border-transparent ${color}` : 'border-line bg-surface')} />
))}
</div>
</div>
);
}
function ConditionsBox({ character: c, onPatch }: { character: Character; onPatch: (diff: Partial<Character>) => void }) {
const [name, setName] = useState('');
const add = () => {
const n = name.trim();
if (!n) return;
onPatch({ conditions: [...c.conditions, { name: n }] });
setName('');
};
const remove = (i: number) => onPatch({ conditions: c.conditions.filter((_, idx) => idx !== i) });
return (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Conditions</div>
<div className="mb-2 flex flex-wrap gap-1">
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
{c.conditions.map((cond, i) => (
<button key={i} onClick={() => remove(i)} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
{cond.name}{cond.value ? ` ${cond.value}` : ''}
</button>
))}
</div>
<div className="flex gap-1">
<Input className="h-8" value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') add(); }} placeholder="Add condition…" aria-label="Condition name" />
<Button size="sm" variant="secondary" onClick={add}>Add</Button>
</div>
</div>
);
}
const QUICK_DICE = ['1d20', '1d4', '1d6', '1d8', '1d10', '1d12', '1d100'];
function DiceBox({ characterId }: { characterId: string }) {
const [expr, setExpr] = useState('');
const roll = (expression: string, label: string) => {
const mode = useRollStore.getState().mode;
const applied = applyRollMode(expression, mode);
const result = rollDice(applied, createRng());
const tag = mode === 'advantage' ? ' (adv)' : mode === 'disadvantage' ? ' (dis)' : '';
useRollStore.getState().push({ label: label + tag, result });
sendPlayerRoll(characterId, label + tag, applied, result.total, result.breakdown);
};
return (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Dice</div>
<div className="mb-2 flex flex-wrap gap-1">
{QUICK_DICE.map((d) => <Button key={d} size="sm" variant="secondary" onClick={() => roll(d, d)}>{d}</Button>)}
</div>
<div className="flex gap-1">
<Input className="h-8" value={expr} onChange={(e) => setExpr(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && expr.trim()) { roll(expr.trim(), expr.trim()); setExpr(''); } }} placeholder="e.g. 2d6+3" aria-label="Dice expression" />
<Button size="sm" variant="primary" disabled={!expr.trim()} onClick={() => { roll(expr.trim(), expr.trim()); setExpr(''); }}>Roll</Button>
</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { campaignSchema, characterSchema, type Character } from '@/lib/schemas';
import { decodeClaim } from '@/lib/sync/playerLink';
import { useUiStore } from '@/stores/uiStore';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
type Status = { kind: 'idle' } | { kind: 'importing' } | { kind: 'error' } | { kind: 'done'; characterId: string; name: string };
/** Player home: claim a shared character, develop it offline, and join a game. */
export function PlayerSetupPage() {
const claimRaw = new URLSearchParams(window.location.search).get('c');
const [status, setStatus] = useState<Status>({ kind: claimRaw ? 'importing' : 'idle' });
const setActiveCampaign = useUiStore((s) => s.setActiveCampaign);
const localPcs = useLiveQuery(() => db.characters.where('kind').equals('pc').toArray(), [], [] as Character[]);
useEffect(() => {
if (!claimRaw) return;
const payload = decodeClaim(claimRaw);
if (!payload) { setStatus({ kind: 'error' }); return; }
void (async () => {
try {
const ts = new Date().toISOString();
const existing = await db.campaigns.get(payload.character.campaignId);
const campaign = campaignSchema.parse({
id: payload.character.campaignId,
name: payload.campaignName,
system: payload.campaignSystem,
description: '',
createdAt: existing?.createdAt ?? ts,
updatedAt: ts,
});
await db.campaigns.put(campaign);
await db.characters.put(characterSchema.parse(payload.character));
setActiveCampaign(campaign.id);
setStatus({ kind: 'done', characterId: payload.character.id, name: payload.character.name });
} catch {
setStatus({ kind: 'error' });
}
})();
}, [claimRaw, setActiveCampaign]);
return (
<Page>
<PageHeader title="Player" subtitle="Develop your character and join your table" />
{status.kind === 'importing' && <p className="text-sm text-muted">Saving your character</p>}
{status.kind === 'error' && <EmptyState title="That link didn't work" hint="Ask your GM to share the character link again." />}
{status.kind === 'done' && (
<div className="mb-6 rounded-lg border border-accent/40 bg-accent/5 p-4">
<p className="text-ink"><span className="font-semibold">{status.name}</span> is saved on this device.</p>
<p className="mt-1 text-sm text-muted">Edit it any time below. When your GM starts the session, enter the room code to play live your offline changes come with you.</p>
<Link to="/characters/$characterId" params={{ characterId: status.characterId }} className="mt-3 inline-block">
<Button variant="primary">Develop your character </Button>
</Link>
</div>
)}
<JoinBox />
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Your characters</h2>
{(localPcs ?? []).length === 0 ? (
<p className="text-sm text-muted">No characters yet. Open the Share with player link your GM sends you.</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
{(localPcs ?? []).map((c) => (
<li key={c.id}>
<Link to="/characters/$characterId" params={{ characterId: c.id }} className="flex items-center justify-between rounded-lg border border-line bg-panel p-3 hover:border-accent">
<span className="font-display text-lg font-semibold text-ink">{c.name}</span>
<span className="text-xs text-muted">Lv {c.level} {c.className}</span>
</Link>
</li>
))}
</ul>
)}
</section>
</Page>
);
}
function JoinBox() {
const [code, setCode] = useState('');
const join = () => { const r = code.trim().toUpperCase(); if (r) window.location.href = `/play?room=${encodeURIComponent(r)}`; };
return (
<div className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Join a live session</h2>
<div className="flex max-w-sm gap-2">
<Input value={code} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') join(); }} placeholder="Room code (e.g. AB12CD)" aria-label="Room code" />
<Button variant="primary" disabled={!code.trim()} onClick={join}>Join</Button>
</div>
</div>
);
}
+29 -5
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Campaign } from '@/lib/schemas'; import { useLiveQuery } from 'dexie-react-hooks';
import type { Campaign, Character } from '@/lib/schemas';
import { localSync } from '@/lib/sync'; import { localSync } from '@/lib/sync';
import { buildSnapshot } from '@/lib/sync/snapshot'; import { buildSnapshot } from '@/lib/sync/snapshot';
import { joinSession, stopSession } from '@/lib/sync/wsSync'; import { joinSession, stopSession, claimSeat, sendPlayerPatch } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
@@ -12,6 +14,9 @@ import { useCalendar, useMaps } from '@/features/world/hooks';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PlayerBoards } from './PlayerBoards'; import { PlayerBoards } from './PlayerBoards';
import { SeatClaimScreen } from './SeatClaimScreen';
import { MyCharacterPanel } from './MyCharacterPanel';
import { RollFeed } from './RollFeed';
function roomParam(): string | null { function roomParam(): string | null {
return new URLSearchParams(window.location.search).get('room'); return new URLSearchParams(window.location.search).get('room');
@@ -64,8 +69,14 @@ function NetworkedPlayerView({ room }: { room: string }) {
const error = useSessionStore((s) => s.error); const error = useSessionStore((s) => s.error);
const snapshot = usePlayerSessionStore((s) => s.snapshot); const snapshot = usePlayerSessionStore((s) => s.snapshot);
const images = usePlayerSessionStore((s) => s.images); const images = usePlayerSessionStore((s) => s.images);
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
const [needPw, setNeedPw] = useState(false); const [needPw, setNeedPw] = useState(false);
const joined = useRef(false);
// Locally-developed copies of party characters (for offline-edit handoff).
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
useEffect(() => { useEffect(() => {
joinSession(room); joinSession(room);
@@ -80,6 +91,14 @@ function NetworkedPlayerView({ room }: { room: string }) {
} }
}, [error, needPw, room]); }, [error, needPw, room]);
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
const handlePatch = (diff: Partial<Character>) => {
const cur = usePlayerSessionStore.getState().myCharacter;
if (!cur) return;
usePlayerSessionStore.getState().patchMyCharacter(diff);
sendPlayerPatch(cur.id, diff);
};
if (!snapshot) { if (!snapshot) {
return ( return (
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}> <Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
@@ -88,10 +107,15 @@ function NetworkedPlayerView({ room }: { room: string }) {
); );
} }
const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined; const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined;
void joined;
return ( return (
<Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}> <Shell title={snapshot.campaignName} subtitle={`Player View · live${snapshot.calendarDay !== null ? ` · in-world day ${snapshot.calendarDay}` : ''}`}>
{myCharacter && seatStatus === 'granted' ? (
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
) : (
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
)}
<PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} /> <PlayerBoards snapshot={snapshot} {...(image ? { image } : {})} />
<RollFeed />
</Shell> </Shell>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
/** Live feed of dice rolls broadcast by seated players (and oneself). */
export function RollFeed() {
const rolls = usePlayerSessionStore((s) => s.rolls);
if (rolls.length === 0) return null;
return (
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Table rolls</h2>
<ul className="space-y-1">
{rolls.map((r) => (
<li key={r.id} className="flex items-baseline gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="font-semibold text-accent">{r.playerName}</span>
<span className="flex-1 truncate text-ink">{r.label || r.expression}</span>
<span className="font-display text-lg font-bold text-ink">{r.total}</span>
<span className="hidden truncate text-xs text-muted sm:inline">{r.breakdown}</span>
</li>
))}
</ul>
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { Snapshot } from '@/lib/sync/messages';
import type { Character } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/Page';
/**
* Shown to a joined player before they control a character: pick "you" from the
* party. If a locally-developed copy exists, its offline edits ride along for
* the GM to review.
*/
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
snapshot: Snapshot;
localChars: Map<string, Character>;
pending: boolean;
onClaim: (characterId: string) => void;
}) {
if (snapshot.party.length === 0) {
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
}
return (
<section className="mb-6">
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted">Which character is yours?</h2>
<p className="mb-3 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
<div className="grid gap-2 sm:grid-cols-2">
{snapshot.party.map((c) => {
const hasOffline = localChars.has(c.id);
return (
<div key={c.id} className="flex items-center gap-3 rounded-lg border border-line bg-panel p-3">
<div className="flex-1">
<div className="font-display text-lg font-semibold text-ink">{c.name}</div>
<div className="text-xs text-muted">Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP{hasOffline ? ' · offline edits ready' : ''}</div>
</div>
<Button size="sm" variant="primary" disabled={pending} onClick={() => onClaim(c.id)}>This is me</Button>
</div>
);
})}
</div>
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve</p>}
</section>
);
}
+7
View File
@@ -89,6 +89,13 @@ export const charactersRepo = {
return db.characters.get(id); return db.characters.get(id);
}, },
/** Fetch several characters by id (missing ids are dropped). */
async getMany(ids: string[]): Promise<Character[]> {
if (ids.length === 0) return [];
const rows = await db.characters.bulkGet(ids);
return rows.filter((c): c is Character => !!c);
},
async create( async create(
campaignId: string, campaignId: string,
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & { draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
+31 -1
View File
@@ -1,8 +1,15 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { clientMessageSchema, serverMessageSchema, snapshotSchema, type Snapshot } from './messages'; import { clientMessageSchema, serverMessageSchema, snapshotSchema, partialCharacterDiffSchema, type Snapshot } from './messages';
import { characterSchema, type Character } from '@/lib/schemas';
const snap: Snapshot = { campaignName: 'C', calendarDay: 3, party: [], encounter: null, map: null, mapImageId: null }; const snap: Snapshot = { campaignName: 'C', calendarDay: 3, 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',
});
describe('sync protocol', () => { describe('sync protocol', () => {
it('round-trips client messages and rejects malformed', () => { it('round-trips client messages and rejects malformed', () => {
expect(clientMessageSchema.safeParse({ t: 'join', joinCode: 'ABC123' }).success).toBe(true); expect(clientMessageSchema.safeParse({ t: 'join', joinCode: 'ABC123' }).success).toBe(true);
@@ -14,6 +21,29 @@ describe('sync protocol', () => {
expect(serverMessageSchema.safeParse({ t: 'hosted', roomId: 'r', joinCode: 'C', gmSecret: 's' }).success).toBe(true); expect(serverMessageSchema.safeParse({ t: 'hosted', roomId: 'r', joinCode: 'C', gmSecret: 's' }).success).toBe(true);
expect(serverMessageSchema.safeParse({ t: 'snapshot', snapshot: snap }).success).toBe(true); expect(serverMessageSchema.safeParse({ t: 'snapshot', snapshot: snap }).success).toBe(true);
}); });
it('round-trips two-way play messages', () => {
expect(clientMessageSchema.safeParse({ t: 'claimSeat', characterId: 'ch1' }).success).toBe(true);
expect(clientMessageSchema.safeParse({ t: 'claimSeat', characterId: 'ch1', offlineSnapshot: char }).success).toBe(true);
expect(clientMessageSchema.safeParse({ t: 'playerPatch', characterId: 'ch1', diff: { hp: { current: 5, max: 24, temp: 0 } } }).success).toBe(true);
expect(clientMessageSchema.safeParse({ t: 'playerRoll', characterId: 'ch1', label: 'd20', expression: '1d20', total: 14, breakdown: '[14]' }).success).toBe(true);
expect(clientMessageSchema.safeParse({ t: 'seatGrant', gmSecret: 's', targetPlayerId: 'p1', character: char }).success).toBe(true);
expect(serverMessageSchema.safeParse({ t: 'seatRequest', playerId: 'p1', characterId: 'ch1' }).success).toBe(true);
expect(serverMessageSchema.safeParse({ t: 'seatGranted', character: char }).success).toBe(true);
expect(serverMessageSchema.safeParse({ t: 'playerPatched', characterId: 'ch1', diff: { conditions: [{ name: 'prone' }] } }).success).toBe(true);
expect(serverMessageSchema.safeParse({ t: 'rollBroadcast', playerName: 'Lia', label: 'd20', expression: '1d20', total: 9, breakdown: '[9]' }).success).toBe(true);
});
it('strips identity fields from a player diff (players can never rewrite name/abilities)', () => {
const parsed = partialCharacterDiffSchema.parse({
hp: { current: 1, max: 24, temp: 0 },
name: 'Hacker', abilities: { str: 99, dex: 99, con: 99, int: 99, wis: 99, cha: 99 }, level: 20,
} as Record<string, unknown>);
expect(parsed.hp).toEqual({ current: 1, max: 24, temp: 0 });
expect('name' in parsed).toBe(false);
expect('abilities' in parsed).toBe(false);
expect('level' in parsed).toBe(false);
});
it('validates a full snapshot with masked enemy', () => { it('validates a full snapshot with masked enemy', () => {
const full: Snapshot = { const full: Snapshot = {
campaignName: 'X', calendarDay: null, campaignName: 'X', calendarDay: null,
+37 -1
View File
@@ -1,5 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { int, num, hpSchema } from '@/lib/schemas/common'; import { int, num, hpSchema, conditionSchema } from '@/lib/schemas/common';
import { characterSchema, spellcastingSchema, resourceSchema, defensesSchema } from '@/lib/schemas/character';
/** /**
* Wire protocol shared by the client and the realtime server. Everything is * Wire protocol shared by the client and the realtime server. Everything is
@@ -63,6 +64,30 @@ export const snapshotSchema = z.object({
}); });
export type Snapshot = z.infer<typeof snapshotSchema>; export type Snapshot = z.infer<typeof snapshotSchema>;
// ---- two-way play: seat claiming + player-owned mutations ----
/**
* The only character fields a seated player may mutate live. Unknown keys (name,
* abilities, class, …) are stripped by Zod, so a player can never rewrite identity.
*/
export const partialCharacterDiffSchema = z.object({
hp: hpSchema.optional(),
conditions: z.array(conditionSchema).optional(),
spellcasting: spellcastingSchema.optional(),
resources: z.array(resourceSchema).optional(),
defenses: defensesSchema.optional(),
});
export type PartialCharacterDiff = z.infer<typeof partialCharacterDiffSchema>;
export const rollBroadcastSchema = z.object({
playerName: z.string(),
label: z.string(),
expression: z.string(),
total: int,
breakdown: z.string(),
});
export type RollBroadcast = z.infer<typeof rollBroadcastSchema>;
// ---- messages ---- // ---- messages ----
export const clientMessageSchema = z.discriminatedUnion('t', [ export const clientMessageSchema = z.discriminatedUnion('t', [
@@ -71,6 +96,12 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }), z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }),
z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }), z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }),
z.object({ t: z.literal('requestImage'), id: z.string() }), z.object({ t: z.literal('requestImage'), id: z.string() }),
// two-way play (player → server)
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
z.object({ t: z.literal('playerRoll'), characterId: z.string(), label: z.string().max(120), expression: z.string().max(200), total: int, breakdown: z.string().max(600) }),
// two-way play (GM → server)
z.object({ t: z.literal('seatGrant'), gmSecret: z.string(), targetPlayerId: z.string(), character: characterSchema }),
]); ]);
export type ClientMessage = z.infer<typeof clientMessageSchema>; export type ClientMessage = z.infer<typeof clientMessageSchema>;
@@ -80,5 +111,10 @@ export const serverMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('snapshot'), snapshot: snapshotSchema }), z.object({ t: z.literal('snapshot'), snapshot: snapshotSchema }),
z.object({ t: z.literal('mapImage'), id: z.string(), dataUrl: z.string() }), z.object({ t: z.literal('mapImage'), id: z.string(), dataUrl: z.string() }),
z.object({ t: z.literal('error'), code: z.string(), message: z.string() }), z.object({ t: z.literal('error'), code: z.string(), message: z.string() }),
// two-way play
z.object({ t: z.literal('seatRequest'), playerId: z.string(), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
z.object({ t: z.literal('seatGranted'), character: characterSchema }),
z.object({ t: z.literal('playerPatched'), characterId: z.string(), diff: partialCharacterDiffSchema }),
z.object({ t: z.literal('rollBroadcast'), ...rollBroadcastSchema.shape }),
]); ]);
export type ServerMessage = z.infer<typeof serverMessageSchema>; export type ServerMessage = z.infer<typeof serverMessageSchema>;
+25
View File
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { characterSchema, type Character } from '@/lib/schemas';
import { encodeClaim, decodeClaim } from './playerLink';
const char: Character = characterSchema.parse({
id: 'ch1', campaignId: 'cmp1', system: 'pf2e', name: 'Pïp Ünderbough',
abilities: { str: 8, dex: 16, con: 12, int: 10, wis: 14, cha: 13 },
hp: { current: 22, max: 22, temp: 0 }, createdAt: 't', updatedAt: 't',
});
describe('player claim link', () => {
it('round-trips a payload through base64url (incl. unicode names)', () => {
const encoded = encodeClaim({ character: char, campaignName: 'Lost Mine', campaignSystem: 'pf2e' });
expect(encoded).not.toMatch(/[+/=]/); // url-safe
const decoded = decodeClaim(encoded);
expect(decoded?.character.name).toBe('Pïp Ünderbough');
expect(decoded?.campaignName).toBe('Lost Mine');
expect(decoded?.character.id).toBe('ch1');
});
it('returns null for garbage', () => {
expect(decodeClaim('not-base64!!')).toBeNull();
expect(decodeClaim(encodeClaim.length ? 'eyJ4IjoxfQ' : '')).toBeNull(); // valid b64 but wrong shape
});
});
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { characterSchema } from '@/lib/schemas/character';
import { systemIdSchema } from '@/lib/schemas/common';
/**
* Self-contained payload a GM shares with a player ("Share with player"). It
* carries the character plus enough campaign identity to mirror it locally, so
* the player can develop the character offline and hand changes back at session
* start. No server-side token storage — the security gate is the GM's seat grant.
*/
export const claimPayloadSchema = z.object({
character: characterSchema,
campaignName: z.string(),
campaignSystem: systemIdSchema,
});
export type ClaimPayload = z.infer<typeof claimPayloadSchema>;
function toB64url(json: string): string {
const bytes = new TextEncoder().encode(json);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromB64url(s: string): string {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
export function encodeClaim(p: ClaimPayload): string {
return toB64url(JSON.stringify(p));
}
export function decodeClaim(s: string): ClaimPayload | null {
try {
const parsed = claimPayloadSchema.safeParse(JSON.parse(fromB64url(s)));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
+46 -1
View File
@@ -1,6 +1,8 @@
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot } from './messages'; import { charactersRepo } from '@/lib/db/repositories';
import type { Character } from '@/lib/schemas';
import { clientMessageSchema, serverMessageSchema, type ClientMessage, type Snapshot, type PartialCharacterDiff } from './messages';
/** /**
* WebSocket transport for live sessions. The GM hosts and pushes player-safe * WebSocket transport for live sessions. The GM hosts and pushes player-safe
@@ -66,6 +68,23 @@ function connect(): void {
if (id && !usePlayerSessionStore.getState().images[id]) send({ t: 'requestImage', id }); if (id && !usePlayerSessionStore.getState().images[id]) send({ t: 'requestImage', id });
} else if (msg.t === 'mapImage') { } else if (msg.t === 'mapImage') {
usePlayerSessionStore.getState().setImage(msg.id, msg.dataUrl); usePlayerSessionStore.getState().setImage(msg.id, msg.dataUrl);
} else if (msg.t === 'seatRequest') {
if (session.role === 'gm') useSessionStore.getState().addSeatRequest({ playerId: msg.playerId, characterId: msg.characterId, ...(msg.offlineSnapshot ? { offlineSnapshot: msg.offlineSnapshot } : {}) });
} else if (msg.t === 'seatGranted') {
usePlayerSessionStore.getState().setMyCharacter(msg.character);
} else if (msg.t === 'playerPatched') {
if (session.role === 'gm') {
const d = msg.diff;
void charactersRepo.update(msg.characterId, {
...(d.hp ? { hp: d.hp } : {}),
...(d.conditions ? { conditions: d.conditions } : {}),
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
...(d.resources ? { resources: d.resources } : {}),
...(d.defenses ? { defenses: d.defenses } : {}),
});
}
} else if (msg.t === 'rollBroadcast') {
usePlayerSessionStore.getState().addRoll({ playerName: msg.playerName, label: msg.label, expression: msg.expression, total: msg.total, breakdown: msg.breakdown });
} else if (msg.t === 'error') { } else if (msg.t === 'error') {
session.set({ status: 'error', error: msg.message }); session.set({ status: 'error', error: msg.message });
if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true; if (msg.code === 'no-room' || msg.code === 'bad-password') manualStop = true;
@@ -106,6 +125,32 @@ export function pushImage(id: string, dataUrl: string): void {
if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl }); if (useSessionStore.getState().role === 'gm') send({ t: 'image', gmSecret, id, dataUrl });
} }
// ---- two-way play ----
/** Player: request to control a character (optionally bundling offline edits). */
export function claimSeat(characterId: string, offlineSnapshot?: Character): void {
usePlayerSessionStore.getState().setSeatStatus('pending');
send({ t: 'claimSeat', characterId, ...(offlineSnapshot ? { offlineSnapshot } : {}) });
}
/** Player: push a live edit to their own sheet (GM persists it). */
export function sendPlayerPatch(characterId: string, diff: PartialCharacterDiff): void {
send({ t: 'playerPatch', characterId, diff });
}
/** Player: broadcast a dice roll to the whole table. */
export function sendPlayerRoll(characterId: string, label: string, expression: string, total: number, breakdown: string): void {
send({ t: 'playerRoll', characterId, label, expression, total, breakdown });
}
/** GM: approve a seat request, handing the player their authoritative sheet. */
export function grantSeat(targetPlayerId: string, character: Character): void {
if (useSessionStore.getState().role === 'gm') {
send({ t: 'seatGrant', gmSecret, targetPlayerId, character });
useSessionStore.getState().removeSeatRequest(targetPlayerId);
}
}
export function stopSession(): void { export function stopSession(): void {
manualStop = true; manualStop = true;
intent = null; intent = null;
+3
View File
@@ -13,6 +13,7 @@ import { QuestsPage } from '@/features/world/QuestsPage';
import { CalendarPage } from '@/features/world/CalendarPage'; import { CalendarPage } from '@/features/world/CalendarPage';
import { MapsPage } from '@/features/world/MapsPage'; import { MapsPage } from '@/features/world/MapsPage';
import { PlayerViewPage } from '@/features/player/PlayerViewPage'; import { PlayerViewPage } from '@/features/player/PlayerViewPage';
import { PlayerSetupPage } from '@/features/player/PlayerSetupPage';
import { HomebrewPage } from '@/features/world/HomebrewPage'; import { HomebrewPage } from '@/features/world/HomebrewPage';
import { SettingsPage } from '@/features/settings/SettingsPage'; import { SettingsPage } from '@/features/settings/SettingsPage';
import { AssistantPage } from '@/features/assistant/AssistantPage'; import { AssistantPage } from '@/features/assistant/AssistantPage';
@@ -36,6 +37,7 @@ const questsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/quest
const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage }); const calendarRoute = createRoute({ getParentRoute: () => rootRoute, path: '/calendar', component: CalendarPage });
const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage }); const mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage });
const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage }); const playRoute = createRoute({ getParentRoute: () => rootRoute, path: '/play', component: PlayerViewPage });
const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/player', component: PlayerSetupPage });
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage }); const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage }); const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
@@ -54,6 +56,7 @@ const routeTree = rootRoute.addChildren([
calendarRoute, calendarRoute,
mapsRoute, mapsRoute,
playRoute, playRoute,
playerRoute,
homebrewRoute, homebrewRoute,
settingsRoute, settingsRoute,
assistantRoute, assistantRoute,
+24 -2
View File
@@ -1,20 +1,42 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Snapshot } from '@/lib/sync/messages'; import type { Snapshot, RollBroadcast } from '@/lib/sync/messages';
import type { Character } from '@/lib/schemas';
export type SeatStatus = 'none' | 'pending' | 'granted';
export interface RollEntry extends RollBroadcast { id: number }
interface PlayerSessionState { interface PlayerSessionState {
snapshot: Snapshot | null; snapshot: Snapshot | null;
/** map image data URLs keyed by map id, cached so they don't re-stream */ /** map image data URLs keyed by map id, cached so they don't re-stream */
images: Record<string, string>; images: Record<string, string>;
/** the seated player's authoritative character (granted by the GM) */
myCharacter: Character | null;
seatStatus: SeatStatus;
/** recent dice-roll broadcasts from the whole table */
rolls: RollEntry[];
setSnapshot: (s: Snapshot) => void; setSnapshot: (s: Snapshot) => void;
setImage: (id: string, dataUrl: string) => void; setImage: (id: string, dataUrl: string) => void;
setMyCharacter: (c: Character) => void;
patchMyCharacter: (diff: Partial<Character>) => void;
setSeatStatus: (s: SeatStatus) => void;
addRoll: (r: RollBroadcast) => void;
reset: () => void; reset: () => void;
} }
let rollSeq = 0;
/** Ephemeral store holding what a joined player currently sees (never persisted). */ /** Ephemeral store holding what a joined player currently sees (never persisted). */
export const usePlayerSessionStore = create<PlayerSessionState>()((set) => ({ export const usePlayerSessionStore = create<PlayerSessionState>()((set) => ({
snapshot: null, snapshot: null,
images: {}, images: {},
myCharacter: null,
seatStatus: 'none',
rolls: [],
setSnapshot: (snapshot) => set({ snapshot }), setSnapshot: (snapshot) => set({ snapshot }),
setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })), setImage: (id, dataUrl) => set((s) => ({ images: { ...s.images, [id]: dataUrl } })),
reset: () => set({ snapshot: null, images: {} }), setMyCharacter: (c) => set({ myCharacter: c, seatStatus: 'granted' }),
patchMyCharacter: (diff) => set((s) => (s.myCharacter ? { myCharacter: { ...s.myCharacter, ...diff } } : {})),
setSeatStatus: (seatStatus) => set({ seatStatus }),
addRoll: (r) => set((s) => ({ rolls: [{ ...r, id: ++rollSeq }, ...s.rolls].slice(0, 30) })),
reset: () => set({ snapshot: null, images: {}, myCharacter: null, seatStatus: 'none', rolls: [] }),
})); }));
+16 -2
View File
@@ -1,15 +1,26 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Character } from '@/lib/schemas';
export type SessionRole = 'off' | 'gm' | 'player'; export type SessionRole = 'off' | 'gm' | 'player';
export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'; export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error';
export interface SeatRequest {
playerId: string;
characterId: string;
offlineSnapshot?: Character;
}
interface SessionState { interface SessionState {
role: SessionRole; role: SessionRole;
status: SessionStatus; status: SessionStatus;
roomId: string | null; roomId: string | null;
joinCode: string | null; joinCode: string | null;
error: string | null; error: string | null;
set: (patch: Partial<Omit<SessionState, 'set' | 'reset'>>) => void; /** seat-control requests awaiting the GM's approval */
seatRequests: SeatRequest[];
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest'>>) => void;
addSeatRequest: (r: SeatRequest) => void;
removeSeatRequest: (playerId: string) => void;
reset: () => void; reset: () => void;
} }
@@ -20,6 +31,9 @@ export const useSessionStore = create<SessionState>()((set) => ({
roomId: null, roomId: null,
joinCode: null, joinCode: null,
error: null, error: null,
seatRequests: [],
set: (patch) => set(patch), set: (patch) => set(patch),
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null }), addSeatRequest: (r) => set((s) => ({ seatRequests: [...s.seatRequests.filter((x) => x.playerId !== r.playerId), r] })),
removeSeatRequest: (playerId) => set((s) => ({ seatRequests: s.seatRequests.filter((x) => x.playerId !== playerId) })),
reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [] }),
})); }));