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:
2026-06-10 03:13:47 +02:00
parent ce9bbc8220
commit 2f0700f8d3
7 changed files with 156 additions and 13 deletions
@@ -3,7 +3,10 @@ import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex'; import { Badge } from '@/components/ui/Codex';
import { useDirectorStore } from '@/stores/directorStore';
import { useCharacters } from '@/features/characters/hooks';
import { useDirectorSession } from './useDirectorSession'; import { useDirectorSession } from './useDirectorSession';
import { SceneControls } from './SceneControls';
import { TranscriptPane } from './TranscriptPane'; import { TranscriptPane } from './TranscriptPane';
import { RollRequestCard } from './RollRequestCard'; import { RollRequestCard } from './RollRequestCard';
import { ActionCard } from './ActionCard'; import { ActionCard } from './ActionCard';
@@ -14,8 +17,10 @@ export function DirectorPage() {
} }
function Director({ campaign }: { campaign: Campaign }) { 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); useDirectorSession(campaign);
const characters = useCharacters(campaign.id);
const setConfig = useDirectorStore((s) => s.setConfig);
const entries = session?.transcript ?? []; const entries = session?.transcript ?? [];
const started = entries.length > 0; const started = entries.length > 0;
@@ -80,16 +85,26 @@ function Director({ campaign }: { campaign: Campaign }) {
</section> </section>
<aside className="space-y-4"> <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="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 smallcaps">Your move</div> <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()}> <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> </Button>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<Button className="w-full" disabled={busy} onClick={() => void run()}> <Button className="w-full" disabled={busy} onClick={() => void run()}>
{busy ? 'Thinking…' : 'Continue'} {busy ? 'Thinking…' : persona === 'player' ? 'Take turn' : 'Continue'}
</Button> </Button>
<SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} /> <SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} />
</div> </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. */ * touch the roll seam via the cards), never the pure engine. */
export function useDirectorSession(campaign: Campaign) { export function useDirectorSession(campaign: Campaign) {
const persona = useDirectorStore((s) => s.persona); const persona = useDirectorStore((s) => s.persona);
const controlledId = useDirectorStore((s) => s.controlledCharacterId);
const narrationStyle = useDirectorStore((s) => s.narrationStyle); const narrationStyle = useDirectorStore((s) => s.narrationStyle);
const windowSize = useDirectorStore((s) => s.windowSize); const windowSize = useDirectorStore((s) => s.windowSize);
const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey); 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 session = useLatestAiSession(campaign.id, persona);
const characters = useCharacters(campaign.id); 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). // Roster for name-resolution when applying actions (rebuilt from live data).
const roster = useMemo( const roster = useMemo(
() => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, transcriptWindow: [] }).roster, () => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, controlledCharacterId, transcriptWindow: [] }).roster,
[campaign, characters, activeEnc, persona], [campaign, characters, activeEnc, persona, controlledCharacterId],
); );
const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters }); const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters });
@@ -71,6 +74,7 @@ export function useDirectorSession(campaign: Campaign) {
characters, characters,
encounter: activeEnc, encounter: activeEnc,
persona, persona,
controlledCharacterId,
narrationStyle, narrationStyle,
transcriptWindow, transcriptWindow,
summary: fresh?.summary, summary: fresh?.summary,
@@ -94,7 +98,7 @@ export function useDirectorSession(campaign: Campaign) {
setBusy(false); setBusy(false);
} }
}, },
[session?.id, campaign, persona, characters, activeEnc, narrationStyle, windowSize], [session?.id, campaign, persona, controlledCharacterId, characters, activeEnc, narrationStyle, windowSize],
); );
const recordRoll = useCallback( const recordRoll = useCallback(
@@ -134,5 +138,7 @@ export function useDirectorSession(campaign: Campaign) {
setMessage(null); setMessage(null);
}, [session]); }, [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;
} }
+32 -1
View File
@@ -1,7 +1,8 @@
import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas'; import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem, type CharacterRulesInput } from '@/lib/rules';
import { deriveState } from '@/lib/mechanics'; import { deriveState } from '@/lib/mechanics';
import { currentCombatant } from '@/lib/combat/engine'; import { currentCombatant } from '@/lib/combat/engine';
import { formatModifier } from '@/lib/format';
import type { DirectorPersona, DirectorScene, SceneActor, SceneEncounter } from './types'; import type { DirectorPersona, DirectorScene, SceneActor, SceneEncounter } from './types';
/** Default base speed used for a bare combatant (it carries no speed field). */ /** Default base speed used for a bare combatant (it carries no speed field). */
@@ -30,6 +31,28 @@ function combatantActor(system: Campaign['system'], c: Combatant): SceneActor {
}; };
} }
/** Full sheet detail (attacks with derived to-hit/damage, spells, resources, slots)
* for the PC the director pilots — so the AI player acts from real numbers. */
function pcDetail(c: Character): Pick<SceneActor, 'attacks' | 'spells' | 'resources' | 'slots'> {
const sys = getSystem(c.system);
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
const attacks = c.attacks.map((a) => {
const r = sys.weaponAttack(rulesInput, {
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus, damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
});
return { name: a.name, expression: `1d20${formatModifier(r.toHit)}`, damage: r.damage, damageType: a.damageType };
});
const spells = c.spellcasting.spells.map((s) => ({ name: s.name, level: s.level, concentration: s.concentration }));
const resources = c.resources.map((r) => ({ name: r.name, current: r.current, max: r.max }));
const slots = c.spellcasting.slots.map((s) => ({ level: s.level, current: s.current, max: s.max }));
return {
...(attacks.length ? { attacks } : {}),
...(spells.length ? { spells } : {}),
...(resources.length ? { resources } : {}),
...(slots.length ? { slots } : {}),
};
}
function pcActor(c: Character): SceneActor { function pcActor(c: Character): SceneActor {
const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name)); const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
const badges = deriveState(c.system, c.speed, c.conditions).badges; const badges = deriveState(c.system, c.speed, c.conditions).badges;
@@ -79,6 +102,14 @@ export function buildDirectorScene(input: {
roster = characters.filter((c) => c.kind === 'pc' || c.kind === 'npc').map(pcActor); roster = characters.filter((c) => c.kind === 'pc' || c.kind === 'npc').map(pcActor);
} }
// Inject full sheet detail for the PC the director pilots (AI seat / player persona).
const detailChar = input.controlledCharacterId
? characters.find((x) => x.id === input.controlledCharacterId)
: undefined;
if (detailChar) {
roster = roster.map((a) => (a.characterId === detailChar.id ? { ...a, ...pcDetail(detailChar) } : a));
}
const controlled = input.controlledCharacterId const controlled = input.controlledCharacterId
? roster.find((a) => a.characterId === input.controlledCharacterId) ? roster.find((a) => a.characterId === input.controlledCharacterId)
: undefined; : undefined;
+34 -1
View File
@@ -3,10 +3,11 @@ import { directorTurnSchema } from './schema';
import { sanitizeTurn, runDirectorTurn } from './engine'; import { sanitizeTurn, runDirectorTurn } from './engine';
import { fallbackDirectorTurn } from './fallback'; import { fallbackDirectorTurn } from './fallback';
import { buildDirectorScene } from './context'; import { buildDirectorScene } from './context';
import { buildDirectorPrompt, cueFor } from './prompt';
import type { DirectorScene } from './types'; import type { DirectorScene } from './types';
import type { LlmConfig } from '@/lib/llm/types'; import type { LlmConfig } from '@/lib/llm/types';
import type { Campaign, Character, Encounter } from '@/lib/schemas'; import type { Campaign, Character, Encounter } from '@/lib/schemas';
import { characterSchema, encounterSchema } from '@/lib/schemas'; import { characterSchema, encounterSchema, newSpellEntry } from '@/lib/schemas';
// ---- fixtures ---- // ---- fixtures ----
const campaign5e: Campaign = { id: 'c1', name: 'Test', system: '5e', description: '', createdAt: 't', updatedAt: 't' }; const campaign5e: Campaign = { id: 'c1', name: 'Test', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
@@ -166,3 +167,35 @@ describe('buildDirectorScene — grounding', () => {
expect(s.roster.map((a) => a.name).sort()).toEqual(['Borin', 'Lia']); expect(s.roster.map((a) => a.name).sort()).toEqual(['Borin', 'Lia']);
}); });
}); });
describe('AI Player (player persona)', () => {
function caster(): Character {
return characterSchema.parse({
id: 'pc-Mira', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira', className: 'Cleric', level: 5,
abilities: { str: 12, dex: 12, con: 14, int: 10, wis: 16, cha: 10 },
hp: { current: 30, max: 30, temp: 0 },
attacks: [{ id: 'a1', name: 'Mace', ability: 'str', rank: 'trained', damageDice: '1d6', damageType: 'bludgeoning', itemBonus: 0, addAbilityToDamage: true }],
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }], spells: [newSpellEntry({ id: 's1', name: 'Bless', level: 1, concentration: true })] },
resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }],
createdAt: 't', updatedAt: 't',
});
}
it('injects the controlled PCs attacks, spells and resources into the scene', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
expect(s.controlledName).toBe('Mira');
const me = s.roster.find((a) => a.name === 'Mira')!;
expect(me.attacks?.[0]).toMatchObject({ name: 'Mace', damageType: 'bludgeoning' });
expect(me.attacks?.[0]!.expression).toMatch(/^1d20[+-]\d+$/); // derived to-hit
expect(me.spells?.map((sp) => sp.name)).toContain('Bless');
expect(me.resources?.map((r) => r.name)).toContain('Channel Divinity');
});
it('prompts in first person as the controlled character and cues its turn', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
const { system } = buildDirectorPrompt(s);
expect(system).toMatch(/role-playing Mira|playing Mira/i);
expect(system).toMatch(/first person/i);
expect(cueFor(s)).toMatch(/your turn/i);
});
});
+5 -2
View File
@@ -3,19 +3,22 @@ import { persist } from 'zustand/middleware';
import type { DirectorPersona } from '@/lib/schemas'; import type { DirectorPersona } from '@/lib/schemas';
interface DirectorState { interface DirectorState {
/** which role the director plays by default */ /** which role the director plays */
persona: DirectorPersona; persona: DirectorPersona;
/** the PC the director pilots when persona === 'player' (the "AI seat") */
controlledCharacterId: string;
/** free-text tone hint fed to the narrator (e.g. "grim, terse") */ /** free-text tone hint fed to the narrator (e.g. "grim, terse") */
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' | 'narrationStyle' | 'windowSize'>>) => void; setConfig: (patch: Partial<Pick<DirectorState, 'persona' | 'controlledCharacterId' | 'narrationStyle' | 'windowSize'>>) => void;
} }
export const useDirectorStore = create<DirectorState>()( export const useDirectorStore = create<DirectorState>()(
persist( persist(
(set) => ({ (set) => ({
persona: 'dm', persona: 'dm',
controlledCharacterId: '',
narrationStyle: '', narrationStyle: '',
windowSize: 16, windowSize: 16,
setConfig: (patch) => set(patch), setConfig: (patch) => set(patch),
File diff suppressed because one or more lines are too long