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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { currentCombatant } from '@/lib/combat/engine';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import type { DirectorPersona, DirectorScene, SceneActor, SceneEncounter } from './types';
|
||||
|
||||
/** 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 {
|
||||
const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
? roster.find((a) => a.characterId === input.controlledCharacterId)
|
||||
: undefined;
|
||||
|
||||
@@ -3,10 +3,11 @@ import { directorTurnSchema } from './schema';
|
||||
import { sanitizeTurn, runDirectorTurn } from './engine';
|
||||
import { fallbackDirectorTurn } from './fallback';
|
||||
import { buildDirectorScene } from './context';
|
||||
import { buildDirectorPrompt, cueFor } from './prompt';
|
||||
import type { DirectorScene } from './types';
|
||||
import type { LlmConfig } from '@/lib/llm/types';
|
||||
import type { Campaign, Character, Encounter } from '@/lib/schemas';
|
||||
import { characterSchema, encounterSchema } from '@/lib/schemas';
|
||||
import { characterSchema, encounterSchema, newSpellEntry } from '@/lib/schemas';
|
||||
|
||||
// ---- fixtures ----
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
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 PC’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,19 +3,22 @@ import { persist } from 'zustand/middleware';
|
||||
import type { DirectorPersona } from '@/lib/schemas';
|
||||
|
||||
interface DirectorState {
|
||||
/** which role the director plays by default */
|
||||
/** which role the director plays */
|
||||
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") */
|
||||
narrationStyle: string;
|
||||
/** how many recent transcript entries to send each turn (token budget) */
|
||||
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>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
persona: 'dm',
|
||||
controlledCharacterId: '',
|
||||
narrationStyle: '',
|
||||
windowSize: 16,
|
||||
setConfig: (patch) => set(patch),
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user