P14d: guest display names for players

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:28:32 +02:00
parent 4e13a5bdf4
commit d6eda054bf
8 changed files with 60 additions and 9 deletions
+5
View File
@@ -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();
+1
View File
@@ -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); });
+7
View File
@@ -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);
+17 -4
View File
@@ -54,7 +54,7 @@ function timingEqual(a: string, b: string): boolean {
export class RoomHub {
private rooms = new Map<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string }>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
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<string>();
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 {
+13 -2
View File
@@ -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: () =
<p className="p-3 text-sm text-muted">You're not in a live session. Host one (GM) or open a join link to play together.</p>
)}
{inSession && !isGm && (
<div className="border-b border-line p-3">
<label className="text-xs font-semibold uppercase tracking-wide text-muted">Your name</label>
<Input value={nameDraft} onChange={(e) => 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" />
</div>
)}
{inSession && (
<div className="border-b border-line p-3">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
@@ -96,7 +107,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
{roster.map((p) => (
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
{p.name}
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
{isGm && p.playerId !== 'gm' && (
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
)}
+3 -1
View File
@@ -107,7 +107,7 @@ export const privateHandoutSchema = z.object({
});
export type PrivateHandout = z.infer<typeof privateHandoutSchema>;
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<typeof rosterEntrySchema>;
// ---- 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<typeof clientMessageSchema>;
+7
View File
@@ -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();
+7 -2
View File
@@ -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<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest' | 'setJoinIntent' | 'setRoster'>>) => void;
set: (patch: Partial<Omit<SessionState, 'set' | 'reset' | 'addSeatRequest' | 'removeSeatRequest' | 'setJoinIntent' | 'setRoster' | 'setGuestName'>>) => 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<SessionState>()(
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 }) },
),
);