D3: AI Player — pilot a real PC, with caster actions
The director can now play a party character, not just DM. A Mode switch
(Dungeon Master / Player) plus an AI-seat picker choose which PC the AI pilots.
- Scene injection: when seated, the controlled PC's real sheet detail is fed to
the director — attacks with derived to-hit (sys.weaponAttack) + damage
expressions, spells (level/concentration), resources, and spell slots — so the
AI player acts from true numbers, in first person.
- Persona-aware prompt + cue already route DM→runs monsters / player→"it is your
turn"; the seat sets controlledName.
- Caster actions (castSpell/spendResource) flow through the D2 approve-each apply
bridge unchanged — slot/resource spend is enforced by the kernel, concentration
mirrors to the combatant.
- UI: SceneControls (mode + seat), seat-gating ("pick a character"), and
player-flavored buttons (Take turn). directorStore gains controlledCharacterId.
Verified in-app: switching to Player mode, seating "Lia the Brave · Fighter 3",
and taking a turn produced first-person narration. 388 unit tests green (incl. new
player-persona scene/ prompt tests); lint clean; build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,10 @@ import type { Campaign } from '@/lib/schemas';
|
||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useDirectorSession } from './useDirectorSession';
|
||||
import { SceneControls } from './SceneControls';
|
||||
import { TranscriptPane } from './TranscriptPane';
|
||||
import { RollRequestCard } from './RollRequestCard';
|
||||
import { ActionCard } from './ActionCard';
|
||||
@@ -14,8 +17,10 @@ export function DirectorPage() {
|
||||
}
|
||||
|
||||
function Director({ campaign }: { campaign: Campaign }) {
|
||||
const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } =
|
||||
const { persona, controlledCharacterId, needsSeat, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } =
|
||||
useDirectorSession(campaign);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const setConfig = useDirectorStore((s) => s.setConfig);
|
||||
const entries = session?.transcript ?? [];
|
||||
const started = entries.length > 0;
|
||||
|
||||
@@ -80,16 +85,26 @@ function Director({ campaign }: { campaign: Campaign }) {
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<SceneControls
|
||||
persona={persona}
|
||||
controlledCharacterId={controlledCharacterId ?? ''}
|
||||
characters={characters}
|
||||
onPersona={(p) => setConfig({ persona: p })}
|
||||
onSeat={(id) => setConfig({ controlledCharacterId: id })}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-line bg-panel p-3">
|
||||
<div className="mb-2 smallcaps">Your move</div>
|
||||
{!started ? (
|
||||
{needsSeat ? (
|
||||
<p className="text-sm text-muted">Pick a character above for the AI to play.</p>
|
||||
) : !started ? (
|
||||
<Button className="w-full" disabled={busy} onClick={() => void run()}>
|
||||
{busy ? 'Starting…' : 'Begin the scene'}
|
||||
{busy ? 'Starting…' : persona === 'player' ? 'Take the first action' : 'Begin the scene'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" disabled={busy} onClick={() => void run()}>
|
||||
{busy ? 'Thinking…' : 'Continue'}
|
||||
{busy ? 'Thinking…' : persona === 'player' ? 'Take turn' : 'Continue'}
|
||||
</Button>
|
||||
<SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Character, DirectorPersona } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Field, Select } from '@/components/ui/Input';
|
||||
|
||||
/** Mode switch (DM vs Player) and the AI-seat picker — which PC the director pilots. */
|
||||
export function SceneControls({
|
||||
persona,
|
||||
controlledCharacterId,
|
||||
characters,
|
||||
onPersona,
|
||||
onSeat,
|
||||
}: {
|
||||
persona: DirectorPersona;
|
||||
controlledCharacterId: string;
|
||||
characters: Character[];
|
||||
onPersona: (p: DirectorPersona) => void;
|
||||
onSeat: (id: string) => void;
|
||||
}) {
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-line bg-panel p-3">
|
||||
<div>
|
||||
<div className="mb-1.5 smallcaps">Mode</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant={persona === 'dm' ? 'primary' : 'secondary'} onClick={() => onPersona('dm')}>
|
||||
Dungeon Master
|
||||
</Button>
|
||||
<Button size="sm" variant={persona === 'player' ? 'primary' : 'secondary'} onClick={() => onPersona('player')}>
|
||||
Player
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{persona === 'player' && (
|
||||
<Field label="AI plays">
|
||||
<Select value={controlledCharacterId} onChange={(e) => onSeat(e.target.value)} aria-label="Character the AI plays">
|
||||
<option value="">— pick a character —</option>
|
||||
{pcs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
{c.className ? ` · ${c.className} ${c.level}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<p className="text-[11px] text-muted">
|
||||
{persona === 'dm'
|
||||
? 'The AI narrates the world and runs NPCs and monsters.'
|
||||
: 'The AI plays the chosen hero in first person. You still roll its dice and approve its actions.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,9 +31,12 @@ export interface LatestTurn {
|
||||
* touch the roll seam via the cards), never the pure engine. */
|
||||
export function useDirectorSession(campaign: Campaign) {
|
||||
const persona = useDirectorStore((s) => s.persona);
|
||||
const controlledId = useDirectorStore((s) => s.controlledCharacterId);
|
||||
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
|
||||
const windowSize = useDirectorStore((s) => s.windowSize);
|
||||
const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey);
|
||||
// The AI seat only applies in the player persona.
|
||||
const controlledCharacterId = persona === 'player' ? controlledId || undefined : undefined;
|
||||
|
||||
const session = useLatestAiSession(campaign.id, persona);
|
||||
const characters = useCharacters(campaign.id);
|
||||
@@ -47,8 +50,8 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
|
||||
// Roster for name-resolution when applying actions (rebuilt from live data).
|
||||
const roster = useMemo(
|
||||
() => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, transcriptWindow: [] }).roster,
|
||||
[campaign, characters, activeEnc, persona],
|
||||
() => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, controlledCharacterId, transcriptWindow: [] }).roster,
|
||||
[campaign, characters, activeEnc, persona, controlledCharacterId],
|
||||
);
|
||||
const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters });
|
||||
|
||||
@@ -71,6 +74,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
characters,
|
||||
encounter: activeEnc,
|
||||
persona,
|
||||
controlledCharacterId,
|
||||
narrationStyle,
|
||||
transcriptWindow,
|
||||
summary: fresh?.summary,
|
||||
@@ -94,7 +98,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session?.id, campaign, persona, characters, activeEnc, narrationStyle, windowSize],
|
||||
[session?.id, campaign, persona, controlledCharacterId, characters, activeEnc, narrationStyle, windowSize],
|
||||
);
|
||||
|
||||
const recordRoll = useCallback(
|
||||
@@ -134,5 +138,7 @@ export function useDirectorSession(campaign: Campaign) {
|
||||
setMessage(null);
|
||||
}, [session]);
|
||||
|
||||
return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } as const;
|
||||
const needsSeat = persona === 'player' && !controlledCharacterId;
|
||||
|
||||
return { persona, controlledCharacterId, needsSeat, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } as const;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user