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);
|
useDirectorSession(campaign);
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
const setConfig = useDirectorStore((s) => s.setConfig);
|
const setConfig = useDirectorStore((s) => s.setConfig);
|
||||||
|
const shareWithPlayers = useDirectorStore((s) => s.shareWithPlayers);
|
||||||
const entries = session?.transcript ?? [];
|
const entries = session?.transcript ?? [];
|
||||||
const started = entries.length > 0;
|
const started = entries.length > 0;
|
||||||
|
|
||||||
@@ -89,8 +90,10 @@ function Director({ campaign }: { campaign: Campaign }) {
|
|||||||
persona={persona}
|
persona={persona}
|
||||||
controlledCharacterId={controlledCharacterId ?? ''}
|
controlledCharacterId={controlledCharacterId ?? ''}
|
||||||
characters={characters}
|
characters={characters}
|
||||||
|
shareWithPlayers={shareWithPlayers}
|
||||||
onPersona={(p) => setConfig({ persona: p })}
|
onPersona={(p) => setConfig({ persona: p })}
|
||||||
onSeat={(id) => setConfig({ controlledCharacterId: id })}
|
onSeat={(id) => setConfig({ controlledCharacterId: id })}
|
||||||
|
onShare={(v) => setConfig({ shareWithPlayers: v })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-3">
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
|
|||||||
@@ -7,14 +7,18 @@ export function SceneControls({
|
|||||||
persona,
|
persona,
|
||||||
controlledCharacterId,
|
controlledCharacterId,
|
||||||
characters,
|
characters,
|
||||||
|
shareWithPlayers,
|
||||||
onPersona,
|
onPersona,
|
||||||
onSeat,
|
onSeat,
|
||||||
|
onShare,
|
||||||
}: {
|
}: {
|
||||||
persona: DirectorPersona;
|
persona: DirectorPersona;
|
||||||
controlledCharacterId: string;
|
controlledCharacterId: string;
|
||||||
characters: Character[];
|
characters: Character[];
|
||||||
|
shareWithPlayers: boolean;
|
||||||
onPersona: (p: DirectorPersona) => void;
|
onPersona: (p: DirectorPersona) => void;
|
||||||
onSeat: (id: string) => void;
|
onSeat: (id: string) => void;
|
||||||
|
onShare: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||||
|
|
||||||
@@ -45,6 +49,11 @@ export function SceneControls({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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">
|
<p className="text-[11px] text-muted">
|
||||||
{persona === 'dm'
|
{persona === 'dm'
|
||||||
? 'The AI narrates the world and runs NPCs and monsters.'
|
? 'The AI narrates the world and runs NPCs and monsters.'
|
||||||
|
|||||||
@@ -16,12 +16,23 @@ import {
|
|||||||
type RollRequest,
|
type RollRequest,
|
||||||
} from '@/lib/assistant/director';
|
} from '@/lib/assistant/director';
|
||||||
import { complete } from '@/lib/llm/client';
|
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 type { Degree } from '@/lib/dice/check';
|
||||||
import { useLatestAiSession } from './hooks';
|
import { useLatestAiSession } from './hooks';
|
||||||
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
|
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
|
||||||
|
|
||||||
const nowIso = (): string => new Date().toISOString();
|
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
|
* 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
|
* 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,
|
text: res.turn.narration,
|
||||||
ts: nowIso(),
|
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 });
|
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ interface DirectorState {
|
|||||||
narrationStyle: string;
|
narrationStyle: string;
|
||||||
/** how many recent transcript entries to send each turn (token budget) */
|
/** how many recent transcript entries to send each turn (token budget) */
|
||||||
windowSize: number;
|
windowSize: number;
|
||||||
setConfig: (patch: Partial<Pick<DirectorState, 'persona' | 'controlledCharacterId' | 'narrationStyle' | 'windowSize'>>) => void;
|
/** when hosting a live session, push the director's narration to players' screens */
|
||||||
|
shareWithPlayers: boolean;
|
||||||
|
setConfig: (patch: Partial<Pick<DirectorState, 'persona' | 'controlledCharacterId' | 'narrationStyle' | 'windowSize' | 'shareWithPlayers'>>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDirectorStore = create<DirectorState>()(
|
export const useDirectorStore = create<DirectorState>()(
|
||||||
@@ -21,6 +23,7 @@ export const useDirectorStore = create<DirectorState>()(
|
|||||||
controlledCharacterId: '',
|
controlledCharacterId: '',
|
||||||
narrationStyle: '',
|
narrationStyle: '',
|
||||||
windowSize: 16,
|
windowSize: 16,
|
||||||
|
shareWithPlayers: true,
|
||||||
setConfig: (patch) => set(patch),
|
setConfig: (patch) => set(patch),
|
||||||
}),
|
}),
|
||||||
{ name: 'ttrpg-director' },
|
{ name: 'ttrpg-director' },
|
||||||
|
|||||||
Reference in New Issue
Block a user