From d6eda054bf5198dcd4ab05dcd9a79ea1a8e83f90 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 17:28:32 +0200 Subject: [PATCH] P14d: guest display names for players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Players set a display name in the session panel ("Your name"); it persists (sessionStore.guestName) and is sent via a new `setName` message on join + edit. - Server stores the name per connection and uses it in the roster + chat; the roster entry now also carries the player's character, so the GM sees "Alice · Lia". - Realtime e2e: a player names themselves and the GM's roster/share update to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e-realtime/realtime.spec.ts | 5 +++++ server/src/index.ts | 1 + server/src/rooms.test.ts | 7 +++++++ server/src/rooms.ts | 21 +++++++++++++++++---- src/features/play/SessionSidebar.tsx | 15 +++++++++++++-- src/lib/sync/messages.ts | 4 +++- src/lib/sync/wsSync.ts | 7 +++++++ src/stores/sessionStore.ts | 9 +++++++-- 8 files changed, 60 insertions(+), 9 deletions(-) diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index b251d43..64282b4 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -58,6 +58,11 @@ test('GM hosts a session; a player on another device sees live combat', async ({ await player.getByRole('button', { name: 'Open session panel' }).click(); await expect(player.getByText(/Who's here \(2\)/)).toBeVisible(); // host + player visible to everyone + // Guest name: the player names themselves; the GM's roster updates (share button is per-name). + await player.getByLabel('Your display name').fill('Alice'); + await player.getByLabel('Your display name').blur(); + await expect(gm.getByRole('button', { name: 'Share with Alice' })).toBeVisible({ timeout: 8000 }); + // Chat: a GM table message reaches the player; the player's reply reaches the GM. await gm.getByLabel('Chat message').fill('Welcome adventurers'); await gm.getByRole('button', { name: 'Send' }).click(); diff --git a/server/src/index.ts b/server/src/index.ts index 08b0dfd..7c9b6d4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -103,6 +103,7 @@ export function buildServer() { case 'gmRoll': hub.gmRoll(sender, m.gmSecret, m.label, m.expression, m.total, m.breakdown); break; case 'privateState': hub.privateState(sender, m.gmSecret, m.targetPlayerId, m.handout); break; case 'chat': hub.chat(sender, m.body, m.to); break; + case 'setName': hub.setName(sender, m.name); break; } }); socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 2898466..2d10543 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -158,6 +158,13 @@ describe('RoomHub', () => { expect(lastOf(player, 'error')?.code).toBe('forbidden'); }); + it('lets a player set a display name shown in the roster', () => { + const hub = new RoomHub(); + const { gm, player } = hostAndJoin(hub); + hub.setName(player, 'Alice'); + expect(lastOf(gm, 'roster')!.players.find((p) => p.name === 'Alice')).toBeTruthy(); + }); + it('routes chat: table to all, whisper to the target only', () => { const hub = new RoomHub(); const gm = fake(); hub.host(gm); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 507625c..a49a4bc 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -54,7 +54,7 @@ function timingEqual(a: string, b: string): boolean { 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 { @@ -175,20 +175,33 @@ export class RoomHub { /** Tell everyone (GM + players) the current roster: the GM (if present) + players. */ private sendRoster(room: Room): void { - const players: { playerId: string; name: string }[] = []; + const players: { playerId: string; name: string; character?: string }[] = []; if (room.gm) players.push({ playerId: 'gm', name: 'GM' }); const seen = new Set(); for (const s of room.players) { const conn = this.conns.get(s); if (!conn || seen.has(conn.playerId)) continue; seen.add(conn.playerId); - players.push({ playerId: conn.playerId, name: room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}` }); + const seatName = room.seats.get(conn.playerId)?.name; + const name = conn.name ?? seatName ?? `Player ${conn.playerId.slice(0, 4)}`; + players.push({ playerId: conn.playerId, name, ...(conn.name && seatName ? { character: seatName } : {}) }); } const msg = { t: 'roster', players } as const; room.gm?.send(msg); for (const p of room.players) p.send(msg); } + /** A player sets their display name (roster + chat). */ + setName(socket: Sender, name: 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 trimmed = name.trim().slice(0, 40); + if (trimmed) conn.name = trimmed; else delete conn.name; + room.lastActivity = this.now(); + this.sendRoster(room); + } + /** 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); @@ -237,7 +250,7 @@ export class RoomHub { for (const p of room.players) p.send(msg); } } else { - const from = room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`; + const from = conn.name ?? room.seats.get(conn.playerId)?.name ?? `Player ${conn.playerId.slice(0, 4)}`; if (to) { room.gm?.send({ t: 'chat', from, body, scope: 'whisper' }); } else { diff --git a/src/features/play/SessionSidebar.tsx b/src/features/play/SessionSidebar.tsx index e70563a..2eb8605 100644 --- a/src/features/play/SessionSidebar.tsx +++ b/src/features/play/SessionSidebar.tsx @@ -3,7 +3,7 @@ import { useLiveQuery } from 'dexie-react-hooks'; import { useSessionStore } from '@/stores/sessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { useUiStore } from '@/stores/uiStore'; -import { sendChat, sendPrivateHandout } from '@/lib/sync/wsSync'; +import { sendChat, sendPrivateHandout, sendSetName } from '@/lib/sync/wsSync'; import { sessionLogRepo } from '@/lib/db/repositories'; import type { SessionLogEntry } from '@/lib/schemas'; import type { RosterEntry } from '@/lib/sync/messages'; @@ -25,6 +25,8 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = const joinCode = useSessionStore((s) => s.joinCode); const intent = useSessionStore((s) => s.joinIntent); const roster = useSessionStore((s) => s.roster); + const guestName = useSessionStore((s) => s.guestName); + const setGuestName = useSessionStore((s) => s.setGuestName); const rolls = usePlayerSessionStore((s) => s.rolls); const chat = usePlayerSessionStore((s) => s.chat); const activeCampaignId = useUiStore((s) => s.activeCampaignId); @@ -34,6 +36,8 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = const inSession = role !== 'off' || !!intent; const [tab, setTab] = useState<'chat' | 'rolls' | 'recap'>('chat'); + const [nameDraft, setNameDraft] = useState(guestName); + const commitName = () => { const n = nameDraft.trim(); setGuestName(n); sendSetName(n); }; const [draft, setDraft] = useState(''); const [whisperTo, setWhisperTo] = useState(''); // GM: playerId; player: 'gm' to whisper the GM const targets = roster.filter((p) => p.playerId !== 'gm'); @@ -89,6 +93,13 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =

You're not in a live session. Host one (GM) or open a join link to play together.

)} + {inSession && !isGm && ( +
+ + setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" /> +
+ )} + {inSession && (

Who's here ({roster.length})

@@ -96,7 +107,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () = {roster.map((p) => (
  • - {p.name} + {p.name}{p.character ? · {p.character} : null} {isGm && p.playerId !== 'gm' && ( )} diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index cbe25c8..10a7ed3 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -107,7 +107,7 @@ export const privateHandoutSchema = z.object({ }); export type PrivateHandout = z.infer; -export const rosterEntrySchema = z.object({ playerId: z.string(), name: z.string() }); +export const rosterEntrySchema = z.object({ playerId: z.string(), name: z.string(), character: z.string().optional() }); export type RosterEntry = z.infer; // ---- messages ---- @@ -129,6 +129,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('privateState'), gmSecret: z.string(), targetPlayerId: z.string(), handout: privateHandoutSchema.nullable() }), // chat: table message (no `to`) or whisper. GM `to` = target playerId; player `to` set = whisper to GM. z.object({ t: z.literal('chat'), body: z.string().min(1).max(2000), to: z.string().optional() }), + // a player sets their display name (shown in the roster + chat) + z.object({ t: z.literal('setName'), name: z.string().max(40) }), ]); export type ClientMessage = z.infer; diff --git a/src/lib/sync/wsSync.ts b/src/lib/sync/wsSync.ts index 6c76eb9..4e2888d 100644 --- a/src/lib/sync/wsSync.ts +++ b/src/lib/sync/wsSync.ts @@ -71,6 +71,8 @@ function connect(): void { for (const [id, dataUrl] of sentImages) send({ t: 'image', gmSecret, id, dataUrl }); } else if (msg.t === 'joined') { session.set({ role: 'player', status: 'connected', roomId: msg.roomId, error: null }); + const guestName = useSessionStore.getState().guestName.trim(); + if (guestName) send({ t: 'setName', name: guestName }); } else if (msg.t === 'snapshot') { usePlayerSessionStore.getState().setSnapshot(msg.snapshot); // fetch the map image if we don't have it cached @@ -171,6 +173,11 @@ export function broadcastGmRoll(label: string, expression: string, total: number } } +/** Player: set/update the display name shown in the roster + chat. */ +export function sendSetName(name: string): void { + if (useSessionStore.getState().role === 'player') send({ t: 'setName', name }); +} + /** Send a chat message. `to` set = whisper (GM → that playerId; player → the GM). */ export function sendChat(body: string, to?: string): void { const text = body.trim(); diff --git a/src/stores/sessionStore.ts b/src/stores/sessionStore.ts index 0be578a..a6b283c 100644 --- a/src/stores/sessionStore.ts +++ b/src/stores/sessionStore.ts @@ -25,12 +25,15 @@ interface SessionState { seatRequests: SeatRequest[]; /** when set, the app keeps a player connection alive across views/reloads */ joinIntent: JoinIntent | null; + /** the player's chosen display name (persisted) */ + guestName: string; /** GM only: connected players (for targeting private shares) */ roster: RosterEntry[]; - set: (patch: Partial>) => void; + set: (patch: Partial>) => void; addSeatRequest: (r: SeatRequest) => void; removeSeatRequest: (playerId: string) => void; setJoinIntent: (j: JoinIntent | null) => void; + setGuestName: (n: string) => void; setRoster: (r: RosterEntry[]) => void; reset: () => void; } @@ -47,14 +50,16 @@ export const useSessionStore = create()( error: null, seatRequests: [], joinIntent: null, + guestName: '', roster: [], set: (patch) => set(patch), 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) })), setJoinIntent: (joinIntent) => set({ joinIntent }), + setGuestName: (guestName) => set({ guestName }), setRoster: (roster) => set({ roster }), reset: () => set({ role: 'off', status: 'idle', roomId: null, joinCode: null, error: null, seatRequests: [], roster: [] }), }), - { name: 'ttrpg-session', partialize: (s) => ({ joinIntent: s.joinIntent }) }, + { name: 'ttrpg-session', partialize: (s) => ({ joinIntent: s.joinIntent, guestName: s.guestName }) }, ), );