Files
ttrpg_manager/src/lib/assistant/director/context.ts
T
NilsBriggen 2f0700f8d3 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>
2026-06-10 03:13:47 +02:00

132 lines
5.2 KiB
TypeScript

import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas';
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). */
const DEFAULT_SPEED = 30;
function buildSystemConstraint(systemLabel: string, system: string): string {
return (
`This is a ${systemLabel} (system id: ${system}) campaign. ` +
`Only use ${systemLabel} rules, creatures, classes, feats, and options. ` +
`Never bring in content from any other game system.`
);
}
function combatantActor(system: Campaign['system'], c: Combatant): SceneActor {
const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
const badges = deriveState(system, DEFAULT_SPEED, c.conditions).badges;
return {
name: c.name,
kind: c.kind,
...(c.characterId ? { characterId: c.characterId } : {}),
combatantId: c.id,
hp: c.hp,
ac: c.ac,
conditions,
...(badges.length ? { badges } : {}),
};
}
/** 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;
return {
name: c.name,
kind: c.kind === 'npc' ? 'npc' : 'pc',
characterId: c.id,
hp: c.hp,
conditions,
...(badges.length ? { badges } : {}),
};
}
/**
* Assemble the grounded scene the director may reference. The roster + addCandidates
* are a CLOSED set: when an encounter is active the roster is its live combatants;
* otherwise it is the party (plus tracked NPCs). Pure and synchronous.
*/
export function buildDirectorScene(input: {
campaign: Campaign;
characters: Character[];
encounter?: Encounter | undefined;
persona: DirectorPersona;
narrationStyle?: string | undefined;
controlledCharacterId?: string | undefined;
transcriptWindow: DirectorScene['transcriptWindow'];
summary?: string | undefined;
addCandidates?: string[] | undefined;
}): DirectorScene {
const { campaign, characters, encounter, persona } = input;
const system = campaign.system;
const systemLabel = getSystem(system).label;
let roster: SceneActor[];
let sceneEncounter: SceneEncounter | undefined;
if (encounter && encounter.status === 'active') {
const cur = currentCombatant(encounter);
roster = encounter.combatants.map((c) => combatantActor(system, c));
sceneEncounter = {
name: encounter.name,
round: encounter.round,
...(cur ? { currentActorName: cur.name } : {}),
combatants: encounter.combatants.map((c) => ({ name: c.name, kind: c.kind, initiative: c.initiative, current: c.id === cur?.id })),
};
} else {
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;
return {
system,
systemLabel,
systemConstraint: buildSystemConstraint(systemLabel, system),
persona,
campaignName: campaign.name,
...(input.narrationStyle ? { narrationStyle: input.narrationStyle } : {}),
roster,
...(sceneEncounter ? { encounter: sceneEncounter } : {}),
...(controlled ? { controlledName: controlled.name } : {}),
addCandidates: input.addCandidates ?? [],
transcriptWindow: input.transcriptWindow,
...(input.summary ? { summary: input.summary } : {}),
};
}