961fe8655a
The first user-visible slice of the AI DM / AI Player "director": a new
/director page where the AI narrates the scene, voices NPCs/monsters, and runs
enemies — grounded entirely in the campaign's real party and active encounter,
and degrading to a deterministic narrator when no AI key is set.
Engine (pure, framework-free) in src/lib/assistant/director/:
- schema.ts: DeepSeek-tolerant directorTurnSchema (z.preprocess structural
repair + z.coerce + .catch() enums + per-element safeParse-drop) → a single
validated turn { narration, rollRequests[], actions[], suggestions[] }.
- context.ts: buildDirectorScene assembles a CLOSED roster from the active
encounter (or the party) with deriveState badges — the only entities the
director may name.
- prompt.ts: persona-aware system prompt (DM/player) leading with the
systemConstraint; multi-turn message mapping from the transcript.
- engine.ts: runDirectorTurn + sanitizeTurn — the anti-hallucination gate that
drops any action referencing an off-roster entity (and ungrounded
addCombatant), mirroring the encounter advisor's candidate filter.
- fallback.ts: deterministic director that still surfaces roll buttons.
Hard rules, enforced:
- NEVER auto-roll: a roll fires only from RollRequestCard's onClick (rollAndShow).
A unit test asserts the engine layer never imports the roll seam, making
auto-roll structurally impossible.
- Approve-each: D1 is read-only (actions render as previews; the Apply bridge
lands in D2). The director never writes game state.
- Always-on fallback: works with no API key.
Also: Dexie v18 `aiSessions` table + aiSessionsRepo (+ cascade delete), a small
directorStore, a Settings card, and the /director route + nav entry.
Verified in-app on the sample 5e campaign: grounded narration named the real
current combatant; on an enemy's turn a "Aller Rosk attack vs Pip Underbough"
roll card appeared with diceRolls UNCHANGED until the button was clicked, which
then fired exactly one roll and recorded the result back into the transcript.
381 unit tests green; lint clean (3 pre-existing); build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
3.6 KiB
TypeScript
101 lines
3.6 KiB
TypeScript
import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas';
|
|
import { getSystem } from '@/lib/rules';
|
|
import { deriveState } from '@/lib/mechanics';
|
|
import { currentCombatant } from '@/lib/combat/engine';
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 } : {}),
|
|
};
|
|
}
|