diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 729f4bc..af3b045 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -26,7 +26,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({ const player = await playerCtx.newPage(); await player.goto(`/play?room=${code}`); 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. await gm.getByLabel('Primary').getByRole('link', { name: 'Combat' }).click(); diff --git a/e2e-realtime/seats.spec.ts b/e2e-realtime/seats.spec.ts new file mode 100644 index 0000000..e89e787 --- /dev/null +++ b/e2e-realtime/seats.spec.ts @@ -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(); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 26fa897..7db1f3d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -45,6 +45,10 @@ export function buildServer() { case 'state': hub.state(sender, m.gmSecret, m.snapshot); break; case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break; case 'requestImage': hub.requestImage(sender, m.id); break; + case 'claimSeat': hub.claimSeat(sender, m.characterId, m.offlineSnapshot); break; + case 'seatGrant': hub.seatGrant(sender, m.gmSecret, m.targetPlayerId, m.character); break; + case 'playerPatch': hub.playerPatch(sender, m.characterId, m.diff); break; + case 'playerRoll': hub.playerRoll(sender, m.characterId, m.label, m.expression, m.total, m.breakdown); break; } }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 54d9f46..e5dd69d 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -1,15 +1,27 @@ import { describe, it, expect } from 'vitest'; import { RoomHub, type Sender } from './rooms'; import type { ServerMessage, Snapshot } from '@/lib/sync/messages'; +import { characterSchema, type Character } from '@/lib/schemas'; function fake(): Sender & { msgs: ServerMessage[] } { const msgs: ServerMessage[] = []; return { msgs, send: (m) => msgs.push(m) }; } const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; +const char: Character = characterSchema.parse({ + id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia', + abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 }, + hp: { current: 18, max: 24, temp: 0 }, createdAt: 't', updatedAt: 't', +}); function lastOf(s: ReturnType, t: T): Extract | undefined { return [...s.msgs].reverse().find((m): m is Extract => m.t === t); } +function hostAndJoin(hub: RoomHub) { + const gm = fake(); hub.host(gm); + const hosted = lastOf(gm, 'hosted') as Extract; + const player = fake(); hub.join(player, hosted.joinCode); + return { gm, player, secret: hosted.gmSecret }; +} describe('RoomHub', () => { it('hosts a room and lets a player join + receive snapshots; GM is authoritative', () => { @@ -79,4 +91,40 @@ describe('RoomHub', () => { hub.sweep(); expect(hub.roomCount()).toBe(0); }); + + it('routes a seat claim → GM grant → player gets their sheet', () => { + const hub = new RoomHub(); + const { gm, player, secret } = hostAndJoin(hub); + + hub.claimSeat(player, 'ch1', char); + const req = lastOf(gm, 'seatRequest')!; + expect(req).toMatchObject({ characterId: 'ch1' }); + expect(req.offlineSnapshot?.name).toBe('Lia'); + + hub.seatGrant(gm, secret, req.playerId, char); + expect(lastOf(player, 'seatGranted')).toMatchObject({ character: { id: 'ch1' } }); + }); + + it('forwards a seated player patch/roll, and drops them from non-seated sockets', () => { + const hub = new RoomHub(); + const { gm, player, secret } = hostAndJoin(hub); + + // Not seated yet → patch is dropped. + hub.playerPatch(player, 'ch1', { hp: { current: 1, max: 24, temp: 0 } }); + expect(lastOf(gm, 'playerPatched')).toBeUndefined(); + + hub.claimSeat(player, 'ch1'); + hub.seatGrant(gm, secret, lastOf(gm, 'seatRequest')!.playerId, char); + + hub.playerPatch(player, 'ch1', { hp: { current: 7, max: 24, temp: 0 } }); + expect(lastOf(gm, 'playerPatched')).toMatchObject({ characterId: 'ch1', diff: { hp: { current: 7 } } }); + + hub.playerRoll(player, 'ch1', 'Sword', '1d20+5', 18, '[13]+5'); + expect(lastOf(gm, 'rollBroadcast')).toMatchObject({ playerName: 'Lia', total: 18 }); + + // A patch for a character the player doesn't hold is rejected. + const before = gm.msgs.filter((m) => m.t === 'playerPatched').length; + hub.playerPatch(player, 'someone-else', { hp: { current: 0, max: 24, temp: 0 } }); + expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before); + }); }); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 21578d7..8a024ca 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -1,10 +1,23 @@ import crypto from 'node:crypto'; -import type { ServerMessage, Snapshot } from '@/lib/sync/messages'; +import type { ServerMessage, Snapshot, PartialCharacterDiff } from '@/lib/sync/messages'; +import type { Character } from '@/lib/schemas/character'; export interface Sender { send: (msg: ServerMessage) => void; } +interface Seat { + sender: Sender; + characterId: string; + name: string; +} + +interface SeatRequest { + playerId: string; + characterId: string; + offlineSnapshot?: Character; +} + interface Room { roomId: string; joinCode: string; @@ -14,6 +27,10 @@ interface Room { players: Set; snapshot: Snapshot | null; images: Map; + /** granted seats keyed by playerId */ + seats: Map; + /** seat requests awaiting GM approval (re-sent when the GM reconnects) */ + pendingSeatRequests: SeatRequest[]; lastActivity: number; } @@ -30,12 +47,14 @@ function timingEqual(a: string, b: string): boolean { /** * In-memory, GM-authoritative room registry. No persistence. The GM is the only - * writer; players are read-only and the hub enforces it. `now` is injectable for tests. + * writer of shared state; players are read-only EXCEPT for their own seat (HP, + * slots, conditions, rolls), which the hub forwards to the GM to persist. + * `now` is injectable for tests. */ export class RoomHub { private rooms = new Map(); private byCode = new Map(); - private conns = new Map(); + private conns = new Map(); constructor(private now: () => number = () => Date.now()) {} private mintJoinCode(): string { @@ -56,8 +75,9 @@ export class RoomHub { if (timingEqual(room.gmSecretHash, hash)) { room.gm = socket; room.lastActivity = this.now(); - this.conns.set(socket, { roomId: room.roomId, role: 'gm' }); + this.conns.set(socket, { roomId: room.roomId, role: 'gm', playerId: 'gm' }); socket.send({ t: 'hosted', roomId: room.roomId, joinCode: room.joinCode, gmSecret: resume }); + for (const req of room.pendingSeatRequests) socket.send({ t: 'seatRequest', ...req }); return; } } @@ -68,11 +88,12 @@ export class RoomHub { const room: Room = { roomId, joinCode, gmSecretHash: sha256(gmSecret), passwordHash: password ? sha256(password) : null, - gm: socket, players: new Set(), snapshot: null, images: new Map(), lastActivity: this.now(), + gm: socket, players: new Set(), snapshot: null, images: new Map(), + seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(), }; this.rooms.set(roomId, room); this.byCode.set(joinCode, roomId); - this.conns.set(socket, { roomId, role: 'gm' }); + this.conns.set(socket, { roomId, role: 'gm', playerId: 'gm' }); socket.send({ t: 'hosted', roomId, joinCode, gmSecret }); } @@ -86,7 +107,7 @@ export class RoomHub { } room.players.add(socket); room.lastActivity = this.now(); - this.conns.set(socket, { roomId: room.roomId, role: 'player' }); + this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() }); socket.send({ t: 'joined', roomId: room.roomId }); if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); } @@ -114,6 +135,55 @@ export class RoomHub { if (dataUrl) socket.send({ t: 'mapImage', id, dataUrl }); } + /** Player asks to control a character; the request is forwarded to the GM. */ + claimSeat(socket: Sender, characterId: string, offlineSnapshot?: Character): void { + const conn = this.conns.get(socket); + const room = conn ? this.rooms.get(conn.roomId) : undefined; + if (!conn || conn.role !== 'player' || !room) return; + room.lastActivity = this.now(); + const req: SeatRequest = { playerId: conn.playerId, characterId, ...(offlineSnapshot ? { offlineSnapshot } : {}) }; + room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== conn.playerId); + room.pendingSeatRequests.push(req); + room.gm?.send({ t: 'seatRequest', ...req }); + } + + /** GM approves a seat request and hands the player their authoritative sheet. */ + seatGrant(socket: Sender, gmSecret: string, targetPlayerId: string, character: Character): void { + const room = this.gmRoom(socket, gmSecret); + if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } + room.lastActivity = this.now(); + room.pendingSeatRequests = room.pendingSeatRequests.filter((r) => r.playerId !== targetPlayerId); + let target: Sender | undefined; + for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } } + if (!target) return; + room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name }); + target.send({ t: 'seatGranted', character }); + } + + /** A seated player edits their own sheet; the diff is forwarded to the GM only. */ + playerPatch(socket: Sender, characterId: string, diff: PartialCharacterDiff): void { + const conn = this.conns.get(socket); + const room = conn ? this.rooms.get(conn.roomId) : undefined; + if (!conn || conn.role !== 'player' || !room) return; + const seat = room.seats.get(conn.playerId); + if (!seat || seat.characterId !== characterId) return; // must hold the seat for this character + room.lastActivity = this.now(); + room.gm?.send({ t: 'playerPatched', characterId, diff }); + } + + /** A seated player rolls dice; broadcast to the GM and the rest of the table. */ + playerRoll(socket: Sender, _characterId: string, label: string, expression: string, total: number, breakdown: string): void { + const conn = this.conns.get(socket); + const room = conn ? this.rooms.get(conn.roomId) : undefined; + if (!conn || conn.role !== 'player' || !room) return; + const seat = room.seats.get(conn.playerId); + if (!seat) return; // only seated players broadcast rolls + room.lastActivity = this.now(); + const msg = { t: 'rollBroadcast', playerName: seat.name, label, expression, total, breakdown } as const; + room.gm?.send(msg); + for (const p of room.players) if (p !== socket) p.send(msg); + } + disconnect(socket: Sender): void { const conn = this.conns.get(socket); if (!conn) return; @@ -121,6 +191,7 @@ export class RoomHub { if (room) { if (conn.role === 'gm' && room.gm === socket) room.gm = null; room.players.delete(socket); + for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid); } this.conns.delete(socket); } diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 4e2b1f3..6087343 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -3,7 +3,8 @@ import { Link } from '@tanstack/react-router'; import type { Character } from '@/lib/schemas'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } 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 { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { rollCheck } from '@/lib/useRoll'; @@ -37,6 +38,15 @@ export function CharacterSheet({ character }: { character: Character }) { const [c, setC] = useState(character); const [levelUp, setLevelUp] = 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) => { // Persist everything except identity/timestamps (update() stamps updatedAt). const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next; @@ -90,6 +100,7 @@ export function CharacterSheet({ character }: { character: Character }) {
{profLabel} + {c.kind === 'pc' && }
diff --git a/src/features/play/SessionControl.tsx b/src/features/play/SessionControl.tsx index 535e1d4..d5feed1 100644 --- a/src/features/play/SessionControl.tsx +++ b/src/features/play/SessionControl.tsx @@ -1,16 +1,20 @@ import { useState } from 'react'; -import { useSessionStore } from '@/stores/sessionStore'; -import { hostSession, stopSession } from '@/lib/sync/wsSync'; +import { useLiveQuery } from 'dexie-react-hooks'; +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 { 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() { const campaign = useActiveCampaign(); const role = useSessionStore((s) => s.role); const status = useSessionStore((s) => s.status); const joinCode = useSessionStore((s) => s.joinCode); + const seatRequests = useSessionStore((s) => s.seatRequests); const [copied, setCopied] = useState(false); + const [showSeats, setShowSeats] = useState(false); if (role === 'player') return null; // players don't host @@ -28,7 +32,7 @@ export function SessionControl() { const link = joinCode ? `${location.origin}/play?room=${joinCode}` : ''; return ( -
+
{status === 'connected' ? 'Live' : status}: + + {seatRequests.length > 0 && ( + + )} + + + {showSeats && seatRequests.length > 0 && ( +
+
Seat requests
+
    + {seatRequests.map((r) => )} +
+
+ )}
); } + +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 ( +
  • +
    Player wants {name}
    +
    + + {req.offlineSnapshot && } + +
    +
  • + ); +} diff --git a/src/features/player/MyCharacterPanel.tsx b/src/features/player/MyCharacterPanel.tsx new file mode 100644 index 0000000..ef7234f --- /dev/null +++ b/src/features/player/MyCharacterPanel.tsx @@ -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) => 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) => 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 ( +
    +
    +

    {c.name}

    + Lv {c.level} {c.className} · {c.ancestry} +
    + +
    + {/* HP */} +
    +
    + Hit points + {c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''} +
    +
    +
    50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} /> +
    +
    + + + + + + Temp + + +
    +
    + + {/* Defenses */} +
    +
    Defenses
    + {c.system === 'pf2e' ? ( +
    + setDef({ dying })} /> + setDef({ wounded })} /> + setDef({ doomed })} /> + setDef({ heroPoints })} /> +
    + ) : ( +
    + setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} /> + setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} /> +
    + setDef({ exhaustion })} /> + +
    +
    + )} +
    + + {/* Spell slots */} + {slots.length > 0 && ( +
    +
    Spell slots
    +
    + {slots.map((s) => ( +
    + Lvl {s.level} +
    + {Array.from({ length: s.max }, (_, i) => ( +
    + {s.current}/{s.max} +
    + ))} +
    +
    + )} + + {/* Resources */} + {c.resources.length > 0 && ( +
    +
    Resources
    +
    + {c.resources.map((r) => ( +
    + {r.name} + setRes(r.id, n)} suffix={`/${r.max}`} /> +
    + ))} +
    +
    + )} + + {/* Conditions */} + + + {/* Dice */} + +
    +
    + ); +} + +function Counter({ label, value, min, max, onChange, suffix }: { label: string; value: number; min: number; max: number; onChange: (n: number) => void; suffix?: string }) { + return ( +
    + {label && {label}} + + {value}{suffix ?? ''} + +
    + ); +} + +function Pips({ label, count, filled, color, onSet }: { label: string; count: number; filled: number; color: string; onSet: (n: number) => void }) { + return ( +
    + {label} +
    + {Array.from({ length: count }, (_, i) => ( +
    +
    + ); +} + +function ConditionsBox({ character: c, onPatch }: { character: Character; onPatch: (diff: Partial) => 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 ( +
    +
    Conditions
    +
    + {c.conditions.length === 0 && None.} + {c.conditions.map((cond, i) => ( + + ))} +
    +
    + setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') add(); }} placeholder="Add condition…" aria-label="Condition name" /> + +
    +
    + ); +} + +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 ( +
    +
    Dice
    +
    + {QUICK_DICE.map((d) => )} +
    +
    + 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" /> + +
    +
    + ); +} diff --git a/src/features/player/PlayerSetupPage.tsx b/src/features/player/PlayerSetupPage.tsx new file mode 100644 index 0000000..7a5ed03 --- /dev/null +++ b/src/features/player/PlayerSetupPage.tsx @@ -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({ 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 ( + + + {status.kind === 'importing' &&

    Saving your character…

    } + {status.kind === 'error' && } + {status.kind === 'done' && ( +
    +

    {status.name} is saved on this device.

    +

    Edit it any time below. When your GM starts the session, enter the room code to play live — your offline changes come with you.

    + + + +
    + )} + + + +
    +

    Your characters

    + {(localPcs ?? []).length === 0 ? ( +

    No characters yet. Open the “Share with player” link your GM sends you.

    + ) : ( +
      + {(localPcs ?? []).map((c) => ( +
    • + + {c.name} + Lv {c.level} {c.className} + +
    • + ))} +
    + )} +
    +
    + ); +} + +function JoinBox() { + const [code, setCode] = useState(''); + const join = () => { const r = code.trim().toUpperCase(); if (r) window.location.href = `/play?room=${encodeURIComponent(r)}`; }; + return ( +
    +

    Join a live session

    +
    + setCode(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') join(); }} placeholder="Room code (e.g. AB12CD)" aria-label="Room code" /> + +
    +
    + ); +} diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index a51feaa..015ab25 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -1,8 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; -import type { Campaign } from '@/lib/schemas'; +import { useEffect, useState } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import type { Campaign, Character } from '@/lib/schemas'; import { localSync } from '@/lib/sync'; 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 { useSessionStore } from '@/stores/sessionStore'; 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 { Button } from '@/components/ui/Button'; import { PlayerBoards } from './PlayerBoards'; +import { SeatClaimScreen } from './SeatClaimScreen'; +import { MyCharacterPanel } from './MyCharacterPanel'; +import { RollFeed } from './RollFeed'; function roomParam(): string | null { return new URLSearchParams(window.location.search).get('room'); @@ -64,8 +69,14 @@ function NetworkedPlayerView({ room }: { room: string }) { const error = useSessionStore((s) => s.error); const snapshot = usePlayerSessionStore((s) => s.snapshot); const images = usePlayerSessionStore((s) => s.images); + const myCharacter = usePlayerSessionStore((s) => s.myCharacter); + const seatStatus = usePlayerSessionStore((s) => s.seatStatus); 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(() => { joinSession(room); @@ -80,6 +91,14 @@ function NetworkedPlayerView({ room }: { room: string }) { } }, [error, needPw, room]); + const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId)); + const handlePatch = (diff: Partial) => { + const cur = usePlayerSessionStore.getState().myCharacter; + if (!cur) return; + usePlayerSessionStore.getState().patchMyCharacter(diff); + sendPlayerPatch(cur.id, diff); + }; + if (!snapshot) { return ( @@ -88,10 +107,15 @@ function NetworkedPlayerView({ room }: { room: string }) { ); } const image = snapshot.mapImageId ? images[snapshot.mapImageId] : undefined; - void joined; return ( + {myCharacter && seatStatus === 'granted' ? ( + + ) : ( + + )} + ); } diff --git a/src/features/player/RollFeed.tsx b/src/features/player/RollFeed.tsx new file mode 100644 index 0000000..da073ac --- /dev/null +++ b/src/features/player/RollFeed.tsx @@ -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 ( +
    +

    Table rolls

    +
      + {rolls.map((r) => ( +
    • + {r.playerName} + {r.label || r.expression} + {r.total} + {r.breakdown} +
    • + ))} +
    +
    + ); +} diff --git a/src/features/player/SeatClaimScreen.tsx b/src/features/player/SeatClaimScreen.tsx new file mode 100644 index 0000000..6661a20 --- /dev/null +++ b/src/features/player/SeatClaimScreen.tsx @@ -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; + pending: boolean; + onClaim: (characterId: string) => void; +}) { + if (snapshot.party.length === 0) { + return ; + } + return ( +
    +

    Which character is yours?

    +

    Pick your character to manage its HP, spells, and rolls live. The GM approves the request.

    +
    + {snapshot.party.map((c) => { + const hasOffline = localChars.has(c.id); + return ( +
    +
    +
    {c.name}
    +
    Lv {c.level} · AC {c.ac} · {c.hp.current}/{c.hp.max} HP{hasOffline ? ' · offline edits ready' : ''}
    +
    + +
    + ); + })} +
    + {pending &&

    Waiting for the GM to approve…

    } +
    + ); +} diff --git a/src/lib/db/repositories.ts b/src/lib/db/repositories.ts index f7e9a40..a11686e 100644 --- a/src/lib/db/repositories.ts +++ b/src/lib/db/repositories.ts @@ -89,6 +89,13 @@ export const charactersRepo = { return db.characters.get(id); }, + /** Fetch several characters by id (missing ids are dropped). */ + async getMany(ids: string[]): Promise { + if (ids.length === 0) return []; + const rows = await db.characters.bulkGet(ids); + return rows.filter((c): c is Character => !!c); + }, + async create( campaignId: string, draft: Pick & { diff --git a/src/lib/sync/messages.test.ts b/src/lib/sync/messages.test.ts index f160189..7fd66c5 100644 --- a/src/lib/sync/messages.test.ts +++ b/src/lib/sync/messages.test.ts @@ -1,8 +1,15 @@ 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 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', () => { it('round-trips client messages and rejects malformed', () => { 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: '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); + 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', () => { const full: Snapshot = { campaignName: 'X', calendarDay: null, diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 520a064..69d253f 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -1,5 +1,6 @@ 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 @@ -63,6 +64,30 @@ export const snapshotSchema = z.object({ }); export type Snapshot = z.infer; +// ---- 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; + +export const rollBroadcastSchema = z.object({ + playerName: z.string(), + label: z.string(), + expression: z.string(), + total: int, + breakdown: z.string(), +}); +export type RollBroadcast = z.infer; + // ---- messages ---- 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('image'), gmSecret: z.string(), id: z.string(), dataUrl: 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; @@ -80,5 +111,10 @@ export const serverMessageSchema = z.discriminatedUnion('t', [ 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('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; diff --git a/src/lib/sync/playerLink.test.ts b/src/lib/sync/playerLink.test.ts new file mode 100644 index 0000000..6f4f156 --- /dev/null +++ b/src/lib/sync/playerLink.test.ts @@ -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 + }); +}); diff --git a/src/lib/sync/playerLink.ts b/src/lib/sync/playerLink.ts new file mode 100644 index 0000000..6dcf5c7 --- /dev/null +++ b/src/lib/sync/playerLink.ts @@ -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; + +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; + } +} diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 595eb9c..288ff0e 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -1,6 +1,8 @@ import { useSessionStore } from '@/stores/sessionStore'; 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 @@ -66,6 +68,23 @@ function connect(): void { if (id && !usePlayerSessionStore.getState().images[id]) send({ t: 'requestImage', id }); } else if (msg.t === 'mapImage') { 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') { session.set({ status: 'error', error: msg.message }); 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 }); } +// ---- 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 { manualStop = true; intent = null; diff --git a/src/router.tsx b/src/router.tsx index aedf1fa..1d931b5 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -13,6 +13,7 @@ import { QuestsPage } from '@/features/world/QuestsPage'; import { CalendarPage } from '@/features/world/CalendarPage'; import { MapsPage } from '@/features/world/MapsPage'; import { PlayerViewPage } from '@/features/player/PlayerViewPage'; +import { PlayerSetupPage } from '@/features/player/PlayerSetupPage'; import { HomebrewPage } from '@/features/world/HomebrewPage'; import { SettingsPage } from '@/features/settings/SettingsPage'; 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 mapsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/maps', component: MapsPage }); 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 settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage }); @@ -54,6 +56,7 @@ const routeTree = rootRoute.addChildren([ calendarRoute, mapsRoute, playRoute, + playerRoute, homebrewRoute, settingsRoute, assistantRoute, diff --git a/src/stores/playerSessionStore.ts b/src/stores/playerSessionStore.ts index ec75099..1536dad 100644 --- a/src/stores/playerSessionStore.ts +++ b/src/stores/playerSessionStore.ts @@ -1,20 +1,42 @@ 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 { snapshot: Snapshot | null; /** map image data URLs keyed by map id, cached so they don't re-stream */ images: Record; + /** 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; setImage: (id: string, dataUrl: string) => void; + setMyCharacter: (c: Character) => void; + patchMyCharacter: (diff: Partial) => void; + setSeatStatus: (s: SeatStatus) => void; + addRoll: (r: RollBroadcast) => void; reset: () => void; } +let rollSeq = 0; + /** Ephemeral store holding what a joined player currently sees (never persisted). */ export const usePlayerSessionStore = create()((set) => ({ snapshot: null, images: {}, + myCharacter: null, + seatStatus: 'none', + rolls: [], setSnapshot: (snapshot) => set({ snapshot }), 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: [] }), })); diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 8e21c4b..53b1d7e 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -1,15 +1,26 @@ import { create } from 'zustand'; +import type { Character } from '@/lib/schemas'; export type SessionRole = 'off' | 'gm' | 'player'; export type SessionStatus = 'idle' | 'connecting' | 'connected' | 'error'; +export interface SeatRequest { + playerId: string; + characterId: string; + offlineSnapshot?: Character; +} + interface SessionState { role: SessionRole; status: SessionStatus; roomId: string | null; joinCode: string | null; error: string | null; - set: (patch: Partial>) => void; + /** seat-control requests awaiting the GM's approval */ + seatRequests: SeatRequest[]; + set: (patch: Partial>) => void; + addSeatRequest: (r: SeatRequest) => void; + removeSeatRequest: (playerId: string) => void; reset: () => void; } @@ -20,6 +31,9 @@ export const useSessionStore = create()((set) => ({ roomId: null, joinCode: null, error: null, + seatRequests: [], 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: [] }), }));