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