2f0700f8d3
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>
132 lines
5.2 KiB
TypeScript
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 } : {}),
|
|
};
|
|
}
|