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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user