D5: broadcast the AI Director to players at the table

For live co-GM play, the AI's narration now reaches the players' screens, and its
roll buttons already broadcast their results to the table (via rollAndShow →
broadcastGmRoll).

- When hosting a session (role gm + connected) and "Share narration with players"
  is on, each director narration is pushed over the table chat channel (sendChat)
  so players see the AI DM speak in their session feed. A no-op when not hosting.
- SceneControls gains the share toggle (default on); directorStore persists it.

Reuses the existing, e2e-tested realtime chat primitive — strictly additive, no
protocol/server change. GM stays authoritative. 395 tests green; lint clean;
build OK; page + controls verified rendering on a clean load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 03:21:52 +02:00
parent 35e0dc4fc1
commit ab8956f0cb
4 changed files with 28 additions and 1 deletions
@@ -21,6 +21,7 @@ function Director({ campaign }: { campaign: Campaign }) {
useDirectorSession(campaign);
const characters = useCharacters(campaign.id);
const setConfig = useDirectorStore((s) => s.setConfig);
const shareWithPlayers = useDirectorStore((s) => s.shareWithPlayers);
const entries = session?.transcript ?? [];
const started = entries.length > 0;
@@ -89,8 +90,10 @@ function Director({ campaign }: { campaign: Campaign }) {
persona={persona}
controlledCharacterId={controlledCharacterId ?? ''}
characters={characters}
shareWithPlayers={shareWithPlayers}
onPersona={(p) => setConfig({ persona: p })}
onSeat={(id) => setConfig({ controlledCharacterId: id })}
onShare={(v) => setConfig({ shareWithPlayers: v })}
/>
<div className="rounded-lg border border-line bg-panel p-3">
@@ -7,14 +7,18 @@ export function SceneControls({
persona,
controlledCharacterId,
characters,
shareWithPlayers,
onPersona,
onSeat,
onShare,
}: {
persona: DirectorPersona;
controlledCharacterId: string;
characters: Character[];
shareWithPlayers: boolean;
onPersona: (p: DirectorPersona) => void;
onSeat: (id: string) => void;
onShare: (v: boolean) => void;
}) {
const pcs = characters.filter((c) => c.kind === 'pc');
@@ -45,6 +49,11 @@ export function SceneControls({
</Select>
</Field>
)}
<label className="flex items-center gap-2 text-xs text-muted">
<input type="checkbox" checked={shareWithPlayers} onChange={(e) => onShare(e.target.checked)} className="accent-[var(--app-accent)]" />
Share narration with players (when hosting a live session)
</label>
<p className="text-[11px] text-muted">
{persona === 'dm'
? 'The AI narrates the world and runs NPCs and monsters.'
@@ -16,12 +16,23 @@ import {
type RollRequest,
} from '@/lib/assistant/director';
import { complete } from '@/lib/llm/client';
import { sendChat } from '@/lib/sync/wsSync';
import { useSessionStore } from '@/stores/sessionStore';
import type { Degree } from '@/lib/dice/check';
import { useLatestAiSession } from './hooks';
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
const nowIso = (): string => new Date().toISOString();
/** When hosting a live session and sharing is on, push the director's narration to
* the players' screens via the table chat channel. A no-op when not hosting. */
function broadcastNarration(text: string): void {
const { role, status } = useSessionStore.getState();
if (role === 'gm' && status === 'connected' && useDirectorStore.getState().shareWithPlayers && text.trim()) {
sendChat(text.trim());
}
}
/**
* Fold older transcript turns into the session's rolling summary when the live
* window has grown past its budget. Uses the LLM when configured, else a
@@ -122,6 +133,7 @@ export function useDirectorSession(campaign: Campaign) {
text: res.turn.narration,
ts: nowIso(),
}]);
broadcastNarration(res.turn.speaker ? `${res.turn.speaker}: ${res.turn.narration}` : res.turn.narration);
}
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });