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:
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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); });
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'> & {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: [] }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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: [] }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user