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
+32 -1
View File
@@ -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;
+34 -1
View File
@@ -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 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);
});
});