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