D1: AI Director (MVP) — grounded AI DM narration + no-auto-roll dice buttons
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>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { directorTurnSchema } from './schema';
|
||||
import { sanitizeTurn, runDirectorTurn } from './engine';
|
||||
import { fallbackDirectorTurn } from './fallback';
|
||||
import { buildDirectorScene } from './context';
|
||||
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';
|
||||
|
||||
// ---- fixtures ----
|
||||
const campaign5e: Campaign = { id: 'c1', name: 'Test', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
|
||||
|
||||
function pc(name: string, hp = 20): Character {
|
||||
return characterSchema.parse({
|
||||
id: `pc-${name}`, campaignId: 'c1', system: '5e', kind: 'pc', name,
|
||||
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 10 },
|
||||
hp: { current: hp, max: 20, temp: 0 }, createdAt: 't', updatedAt: 't',
|
||||
});
|
||||
}
|
||||
|
||||
function scene(overrides: Partial<DirectorScene> = {}): DirectorScene {
|
||||
return {
|
||||
system: '5e', systemLabel: 'D&D 5e', systemConstraint: 'x', persona: 'dm',
|
||||
campaignName: 'Test',
|
||||
roster: [
|
||||
{ name: 'Lia', kind: 'pc', conditions: [], hp: { current: 8, max: 20, temp: 0 } },
|
||||
{ name: 'Goblin', kind: 'monster', conditions: [], hp: { current: 7, max: 7, temp: 0 } },
|
||||
],
|
||||
addCandidates: ['Orc'],
|
||||
transcriptWindow: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('directorTurnSchema — DeepSeek tolerance', () => {
|
||||
it('parses a clean turn', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
narration: 'You enter.', rollRequests: [{ actor: 'Lia', label: 'Stealth', kind: 'check', expression: '1d20+5', dc: 15 }],
|
||||
actions: [{ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' }],
|
||||
suggestions: ['Sneak', 'Charge'],
|
||||
});
|
||||
expect(t.narration).toBe('You enter.');
|
||||
expect(t.rollRequests[0]).toMatchObject({ actor: 'Lia', kind: 'check', dc: 15 });
|
||||
expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' });
|
||||
expect(t.suggestions).toEqual(['Sneak', 'Charge']);
|
||||
});
|
||||
|
||||
it('recovers a renamed actions key, type→kind, and stringified numbers', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
text: 'The goblin lunges.', // narration alias
|
||||
effects: [{ type: 'applyDamage', creature: 'Lia', value: '5' }], // renamed key + verb + alias + string number
|
||||
rolls: [{ dice: '1d20+3', name: 'Scimitar', type: 'attack', who: 'Goblin' }],
|
||||
});
|
||||
expect(t.narration).toBe('The goblin lunges.');
|
||||
expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Lia', amount: 5 });
|
||||
expect(t.rollRequests[0]).toMatchObject({ expression: '1d20+3', label: 'Scimitar', kind: 'attack', actor: 'Goblin' });
|
||||
});
|
||||
|
||||
it('parses a code-fenced object is handled upstream; here drops one bad action among good ones', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
narration: 'x',
|
||||
actions: [
|
||||
{ kind: 'damage', target: 'Goblin', amount: 3 },
|
||||
{ kind: 'totally-unknown-verb', foo: 1 }, // unmappable → dropped
|
||||
{ kind: 'advanceTurn' },
|
||||
],
|
||||
});
|
||||
expect(t.actions).toHaveLength(2);
|
||||
expect(t.actions.map((a) => a.kind)).toEqual(['damage', 'advanceTurn']);
|
||||
});
|
||||
|
||||
it('tolerates a completely empty/garbage object', () => {
|
||||
expect(directorTurnSchema.parse({}).narration).toBe('');
|
||||
expect(directorTurnSchema.parse('just a string').narration).toBe('just a string');
|
||||
const t = directorTurnSchema.parse({ narration: 'x' });
|
||||
expect(t.rollRequests).toEqual([]);
|
||||
expect(t.actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTurn — anti-hallucination gate', () => {
|
||||
it('drops actions targeting entities not in the roster, keeps grounded ones', () => {
|
||||
const t = directorTurnSchema.parse({
|
||||
narration: 'x',
|
||||
actions: [
|
||||
{ kind: 'damage', target: 'Goblin', amount: 4 }, // in roster → keep
|
||||
{ kind: 'damage', target: 'Gandalf', amount: 99 }, // not in roster → drop
|
||||
{ kind: 'condition', target: 'Lia', op: 'add', name: 'prone' }, // in roster → keep
|
||||
{ kind: 'advanceTurn' }, // always keep
|
||||
],
|
||||
});
|
||||
const out = sanitizeTurn(t, scene());
|
||||
expect(out.actions.map((a) => (a.kind === 'damage' || a.kind === 'condition' ? a.target : a.kind))).toEqual(['Goblin', 'Lia', 'advanceTurn']);
|
||||
});
|
||||
|
||||
it('drops addCombatant unless the monster is in addCandidates', () => {
|
||||
const t = directorTurnSchema.parse({ narration: 'x', actions: [
|
||||
{ kind: 'addCombatant', name: 'Orc' }, // candidate → keep
|
||||
{ kind: 'addCombatant', name: 'Tarrasque' }, // not a candidate → drop
|
||||
] });
|
||||
const out = sanitizeTurn(t, scene());
|
||||
expect(out.actions).toHaveLength(1);
|
||||
expect(out.actions[0]).toMatchObject({ kind: 'addCombatant', name: 'Orc' });
|
||||
});
|
||||
|
||||
it('mints roll ids and blanks an off-board roll target', () => {
|
||||
const t = directorTurnSchema.parse({ narration: 'x', rollRequests: [
|
||||
{ actor: 'Goblin', label: 'Attack', kind: 'attack', expression: '1d20+4', against: 'Sauron' },
|
||||
] });
|
||||
const out = sanitizeTurn(t, scene());
|
||||
expect(out.rollRequests[0]!.id).toBeTruthy();
|
||||
expect(out.rollRequests[0]!.against).toBe(''); // off-board target blanked, roll kept
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDirectorTurn — fallback', () => {
|
||||
const off: LlmConfig = { enabled: false, provider: 'anthropic', model: 'm', baseUrl: 'b', apiKey: '', rememberKey: false };
|
||||
|
||||
it('returns a deterministic turn when the LLM is disabled', async () => {
|
||||
const r = await runDirectorTurn(off, scene());
|
||||
expect(r.source).toBe('fallback');
|
||||
expect(r.turn.narration.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("surfaces an attack roll on an enemy's turn (no auto-roll)", async () => {
|
||||
const s = scene({
|
||||
encounter: { name: 'Ambush', round: 1, currentActorName: 'Goblin', combatants: [
|
||||
{ name: 'Lia', kind: 'pc', initiative: 12, current: false },
|
||||
{ name: 'Goblin', kind: 'monster', initiative: 15, current: true },
|
||||
] },
|
||||
});
|
||||
const r = await runDirectorTurn(off, s);
|
||||
expect(r.turn.rollRequests.length).toBeGreaterThanOrEqual(1);
|
||||
expect(r.turn.rollRequests[0]!.kind).toBe('attack');
|
||||
expect(r.turn.actions).toEqual([]); // proposes nothing destructive before the human rolls
|
||||
});
|
||||
|
||||
it('fallback never references an entity outside the roster', () => {
|
||||
const t = fallbackDirectorTurn(scene());
|
||||
const names = new Set(['Lia', 'Goblin']);
|
||||
for (const a of t.actions) {
|
||||
if (a.kind === 'damage' || a.kind === 'heal' || a.kind === 'tempHp' || a.kind === 'condition') expect(names.has(a.target)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDirectorScene — grounding', () => {
|
||||
it('uses the active encounter combatants as the roster', () => {
|
||||
const enc: Encounter = encounterSchema.parse({
|
||||
id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 2, turnIndex: 1,
|
||||
combatants: [
|
||||
{ id: 'a', name: 'Lia', kind: 'pc', initiative: 18, ac: 15, hp: { current: 8, max: 20, temp: 0 } },
|
||||
{ id: 'b', name: 'Goblin', kind: 'monster', initiative: 14, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
|
||||
],
|
||||
createdAt: 't', updatedAt: 't',
|
||||
});
|
||||
const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia')], encounter: enc, persona: 'dm', transcriptWindow: [] });
|
||||
expect(s.roster.map((a) => a.name)).toEqual(['Lia', 'Goblin']);
|
||||
expect(s.encounter?.currentActorName).toBe('Goblin'); // turnIndex 1
|
||||
expect(s.systemConstraint).toContain('5e');
|
||||
});
|
||||
|
||||
it('falls back to the party PCs out of combat', () => {
|
||||
const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia'), pc('Borin')], persona: 'dm', transcriptWindow: [] });
|
||||
expect(s.roster.map((a) => a.name).sort()).toEqual(['Borin', 'Lia']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { newId } from '@/lib/ids';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import type { LlmConfig } from '@/lib/llm/types';
|
||||
import { directorTurnSchema, type DirectorTurn } from './schema';
|
||||
import { buildDirectorPrompt, buildDirectorMessages } from './prompt';
|
||||
import { fallbackDirectorTurn } from './fallback';
|
||||
import { inRoster } from './resolve';
|
||||
import type { DirectorScene } from './types';
|
||||
|
||||
export interface DirectorTurnResult {
|
||||
turn: DirectorTurn;
|
||||
source: 'llm' | 'fallback';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The anti-hallucination gate (the second of three). Mint missing roll ids, blank
|
||||
* roll targets that aren't on the board, and DROP any state-changing action that
|
||||
* references an entity outside the closed roster (or an ungrounded monster for
|
||||
* addCombatant). Roll requests are kept (they never mutate state — a human still
|
||||
* clicks to roll) but cleaned. The third gate is `useDirectorAction`, which
|
||||
* re-resolves names before touching the kernel.
|
||||
*/
|
||||
export function sanitizeTurn(turn: DirectorTurn, scene: DirectorScene): DirectorTurn {
|
||||
const candidates = new Set(scene.addCandidates.map((c) => c.trim().toLowerCase()));
|
||||
|
||||
const rollRequests = turn.rollRequests.map((r) => ({
|
||||
...r,
|
||||
id: r.id || newId(),
|
||||
...(r.against && !inRoster(scene.roster, r.against) ? { against: '' } : {}),
|
||||
}));
|
||||
|
||||
const actions = turn.actions.filter((a) => {
|
||||
switch (a.kind) {
|
||||
case 'damage':
|
||||
case 'heal':
|
||||
case 'tempHp':
|
||||
case 'condition':
|
||||
return inRoster(scene.roster, a.target);
|
||||
case 'castSpell':
|
||||
return inRoster(scene.roster, a.caster);
|
||||
case 'spendResource':
|
||||
return inRoster(scene.roster, a.actor);
|
||||
case 'addCombatant':
|
||||
return candidates.has(a.name.trim().toLowerCase()) || (!!a.monsterRef && candidates.has(a.monsterRef.trim().toLowerCase()));
|
||||
case 'advanceTurn':
|
||||
case 'log':
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return { ...turn, rollRequests, actions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one director turn. Uses the LLM when configured (grounded, multi-turn), and
|
||||
* always degrades to a deterministic turn otherwise. The returned turn is already
|
||||
* sanitized against the scene's closed roster.
|
||||
*/
|
||||
export async function runDirectorTurn(cfg: LlmConfig, scene: DirectorScene): Promise<DirectorTurnResult> {
|
||||
if (cfg.enabled && cfg.apiKey.trim()) {
|
||||
const { system, user } = buildDirectorPrompt(scene);
|
||||
const messages = buildDirectorMessages(scene);
|
||||
const res = await complete<DirectorTurn>(cfg, { system, user, messages, schema: directorTurnSchema, maxTokens: 2048 });
|
||||
if (res.ok && 'data' in res) return { turn: sanitizeTurn(res.data, scene), source: 'llm' };
|
||||
const message = res.ok
|
||||
? 'The AI returned an unexpected response. Showing a deterministic turn.'
|
||||
: `AI unavailable (${res.error}). Showing a deterministic turn.`;
|
||||
return { turn: fallbackDirectorTurn(scene), source: 'fallback', message };
|
||||
}
|
||||
return { turn: fallbackDirectorTurn(scene), source: 'fallback' };
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { newId } from '@/lib/ids';
|
||||
import type { DirectorScene, SceneActor } from './types';
|
||||
import type { DirectorTurn, RollRequest } from './schema';
|
||||
|
||||
const roll = (r: Omit<RollRequest, 'id'>): RollRequest => ({ id: newId(), ...r });
|
||||
|
||||
function lowestHpPc(roster: SceneActor[]): SceneActor | undefined {
|
||||
return roster
|
||||
.filter((a) => a.kind === 'pc' && a.hp)
|
||||
.sort((x, y) => (x.hp!.current - y.hp!.current))[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic director used when the LLM is off/keyless/errored. It still honors
|
||||
* every constraint: it surfaces rolls as buttons (never rolls), and proposes no
|
||||
* state changes on its own. Intentionally plain — it keeps the loop usable offline.
|
||||
*/
|
||||
export function fallbackDirectorTurn(scene: DirectorScene): DirectorTurn {
|
||||
// Player persona: declare a simple action with the controlled PC's first attack.
|
||||
if (scene.persona === 'player') {
|
||||
const me = scene.roster.find((a) => a.name === scene.controlledName);
|
||||
const atk = me?.attacks?.[0];
|
||||
if (atk) {
|
||||
return {
|
||||
narration: `${me!.name} steps up and strikes with ${atk.name}.`,
|
||||
speaker: me!.name,
|
||||
rollRequests: [roll({ actor: me!.name, label: `${atk.name} attack`, kind: 'attack', expression: atk.expression })],
|
||||
actions: [],
|
||||
suggestions: ['Roll damage if it hits', 'Take a different action', 'Hold and assess'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
narration: `${scene.controlledName ?? 'Your character'} sizes up the situation, ready to act.`,
|
||||
rollRequests: [],
|
||||
actions: [],
|
||||
suggestions: ['Attack', 'Cast a spell', 'Move and take cover'],
|
||||
};
|
||||
}
|
||||
|
||||
// DM persona, in combat, on an enemy's turn: surface an attack roll for the GM.
|
||||
const cur = scene.encounter?.currentActorName
|
||||
? scene.roster.find((a) => a.name === scene.encounter!.currentActorName)
|
||||
: undefined;
|
||||
if (cur && cur.kind !== 'pc') {
|
||||
const target = lowestHpPc(scene.roster);
|
||||
return {
|
||||
narration: target
|
||||
? `${cur.name} bears down on ${target.name}, weapon raised.`
|
||||
: `${cur.name} looks for an opening.`,
|
||||
speaker: cur.name,
|
||||
rollRequests: [
|
||||
roll({
|
||||
actor: cur.name,
|
||||
label: `${cur.name} attack`,
|
||||
kind: 'attack',
|
||||
expression: '1d20',
|
||||
...(target ? { against: target.name } : {}),
|
||||
}),
|
||||
],
|
||||
actions: [],
|
||||
suggestions: ['Roll the attack, then damage on a hit', "Advance to the next turn"],
|
||||
};
|
||||
}
|
||||
|
||||
// DM persona, a PC's turn or out of combat.
|
||||
if (scene.encounter) {
|
||||
return {
|
||||
narration: cur ? `The moment hangs — it is ${cur.name}'s turn to act.` : 'The fight pauses, tension thick in the air.',
|
||||
rollRequests: [],
|
||||
actions: [],
|
||||
suggestions: ['Describe your action', 'Make an attack', 'Cast a spell', 'Move'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
narration: 'The scene waits on you. The air is still, and the path ahead is yours to choose.',
|
||||
rollRequests: [],
|
||||
actions: [],
|
||||
suggestions: ['Look around', 'Talk to someone here', 'Search the area', 'Travel onward'],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { directorTurnSchema, rollRequestSchema, directorActionSchema } from './schema';
|
||||
export type { DirectorTurn, RollRequest, DirectorAction, RollKind } from './schema';
|
||||
export type { DirectorScene, SceneActor, SceneEncounter, DirectorPersona, DirectorTranscriptEntry } from './types';
|
||||
export { buildDirectorScene } from './context';
|
||||
export { buildDirectorPrompt, buildDirectorMessages, cueFor } from './prompt';
|
||||
export { fallbackDirectorTurn } from './fallback';
|
||||
export { runDirectorTurn, sanitizeTurn, type DirectorTurnResult } from './engine';
|
||||
export { resolveCombatant, resolveActor, resolveSpellByName, resolveResourceByName, inRoster } from './resolve';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* The hard no-auto-roll rule, enforced structurally: the pure director engine must
|
||||
* never reach the roll seam. A roll may fire ONLY from a user click in
|
||||
* RollRequestCard. If any engine module imports `rollAndShow`/`rollDice`/`createRng`
|
||||
* (directly or transitively via `@/lib/useRoll` / `@/lib/dice/notation`), this fails —
|
||||
* making automatic dice rolls impossible in this layer, not merely avoided.
|
||||
*/
|
||||
const sources = import.meta.glob('./*.ts', { query: '?raw', eager: true, import: 'default' }) as Record<string, string>;
|
||||
|
||||
const FORBIDDEN = ['useRoll', 'rollAndShow', 'rollDice', 'createRng', '@/lib/dice/notation'];
|
||||
|
||||
describe('director engine — no-auto-roll import boundary', () => {
|
||||
const engineFiles = Object.entries(sources).filter(([path]) => !path.endsWith('.test.ts'));
|
||||
|
||||
it('covers the engine modules', () => {
|
||||
expect(engineFiles.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
for (const [path, src] of engineFiles) {
|
||||
it(`${path} never imports the roll seam`, () => {
|
||||
for (const token of FORBIDDEN) {
|
||||
expect(src.includes(token), `${path} must not reference ${token}`).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { DirectorScene, SceneActor } from './types';
|
||||
|
||||
/** The exact JSON shape we want back, described for the model (DeepSeek-proofing). */
|
||||
const SHAPE = `Respond with ONE JSON object, no prose, no markdown fences:
|
||||
{
|
||||
"narration": string, // vivid prose for the table (2-4 sentences)
|
||||
"speaker": string, // optional: the NPC/PC name speaking, if any
|
||||
"rollRequests": [ // rolls a HUMAN must make — you NEVER roll
|
||||
{ "actor": string, "label": string, "kind": "check"|"save"|"attack"|"damage"|"custom",
|
||||
"expression": string, // dice notation, e.g. "1d20+5"
|
||||
"dc": number, // optional target number
|
||||
"against": string } // optional target name
|
||||
],
|
||||
"actions": [ // typed state changes a human will APPROVE — you never apply them
|
||||
{ "kind": "damage", "target": string, "amount": number, "damageType": string },
|
||||
{ "kind": "condition", "target": string, "op": "add"|"remove", "name": string, "value": number },
|
||||
{ "kind": "advanceTurn" },
|
||||
{ "kind": "log", "text": string }
|
||||
],
|
||||
"suggestions": [string] // 2-4 short next-step options for the human
|
||||
}
|
||||
Numbers are plain integers (5, not "5"). Omit arrays you don't need (use []).`;
|
||||
|
||||
function actorLine(a: SceneActor): string {
|
||||
const bits: string[] = [`${a.name} (${a.kind})`];
|
||||
if (a.hp) bits.push(`HP ${a.hp.current}/${a.hp.max}${a.hp.temp ? ` +${a.hp.temp} temp` : ''}`);
|
||||
if (a.ac !== undefined) bits.push(`AC ${a.ac}`);
|
||||
if (a.conditions.length) bits.push(`conditions: ${a.conditions.join(', ')}`);
|
||||
if (a.badges?.length) bits.push(`[${a.badges.join('; ')}]`);
|
||||
return `- ${bits.join(' · ')}`;
|
||||
}
|
||||
|
||||
function controlledDetail(a: SceneActor): string {
|
||||
const lines: string[] = [];
|
||||
if (a.attacks?.length) lines.push(`Attacks: ${a.attacks.map((w) => `${w.name} (${w.expression} to hit, ${w.damage} ${w.damageType})`).join('; ')}`);
|
||||
if (a.spells?.length) lines.push(`Spells: ${a.spells.map((s) => `${s.name} (lvl ${s.level}${s.concentration ? ', concentration' : ''})`).join('; ')}`);
|
||||
if (a.slots?.length) lines.push(`Spell slots: ${a.slots.filter((s) => s.max > 0).map((s) => `L${s.level} ${s.current}/${s.max}`).join(', ') || 'none'}`);
|
||||
if (a.resources?.length) lines.push(`Resources: ${a.resources.map((r) => `${r.name} ${r.current}/${r.max}`).join(', ')}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Serialize the closed, grounded scene for the system prompt. */
|
||||
function serializeScene(scene: DirectorScene): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`Campaign: ${scene.campaignName} (${scene.systemLabel}).`);
|
||||
if (scene.encounter) {
|
||||
const e = scene.encounter;
|
||||
parts.push(`Active combat: "${e.name}", round ${e.round}.${e.currentActorName ? ` It is ${e.currentActorName}'s turn.` : ''}`);
|
||||
parts.push(`Initiative: ${e.combatants.map((c) => `${c.name}${c.current ? ' ←' : ''}`).join(', ')}.`);
|
||||
}
|
||||
parts.push('Roster (the ONLY entities you may name — use these exact names):');
|
||||
parts.push(scene.roster.map(actorLine).join('\n') || '- (no one present)');
|
||||
if (scene.controlledName) {
|
||||
const c = scene.roster.find((a) => a.name === scene.controlledName);
|
||||
if (c) {
|
||||
const detail = controlledDetail(c);
|
||||
parts.push(`You control ${c.name}. ${detail ? `\n${detail}` : ''}`);
|
||||
}
|
||||
}
|
||||
if (scene.addCandidates.length) {
|
||||
parts.push(`Monsters you MAY add to combat (choose only from these, by exact name): ${scene.addCandidates.join(', ')}.`);
|
||||
}
|
||||
if (scene.summary) parts.push(`Story so far: ${scene.summary}`);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
const RULES = [
|
||||
'You NEVER roll dice. When a roll is due, emit a rollRequest with the dice expression and DC; a human rolls it and reports the result back to you.',
|
||||
'You NEVER change game state directly. Propose typed actions; a human approves each one before it takes effect.',
|
||||
'Only reference creatures, PCs, NPCs, and monsters listed in the roster, by their EXACT names. Do not invent entities, locations as characters, or stats. To add a monster, use an addCombatant action with a name from the candidate list only.',
|
||||
].join(' ');
|
||||
|
||||
function personaRole(scene: DirectorScene): string {
|
||||
if (scene.persona === 'player') {
|
||||
return (
|
||||
`You are role-playing ${scene.controlledName ?? 'a party member'} — a single player character in this ${scene.systemLabel} party. ` +
|
||||
`Speak and act in the first person AS this character. You control ONLY this character; never act for, narrate, or decide for anyone else. ` +
|
||||
`Use only this character's listed attacks, spells, and resources.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`You are the Dungeon Master for this ${scene.systemLabel} game. Narrate the world vividly, voice the NPCs and monsters, ` +
|
||||
`set scenes, and run enemies in combat. Adjudicate fairly using ${scene.systemLabel} rules.`
|
||||
);
|
||||
}
|
||||
|
||||
/** The closing cue (also the last user turn). */
|
||||
export function cueFor(scene: DirectorScene): string {
|
||||
if (scene.persona === 'player') return `It is your turn. Decide what ${scene.controlledName ?? 'your character'} does and say it in character.`;
|
||||
if (scene.encounter?.currentActorName) {
|
||||
const cur = scene.roster.find((a) => a.name === scene.encounter!.currentActorName);
|
||||
if (cur && cur.kind !== 'pc') return `It is ${cur.name}'s turn (an enemy you control). Take its turn — describe it and surface any attack rolls.`;
|
||||
}
|
||||
return 'Continue the scene, responding to the latest input.';
|
||||
}
|
||||
|
||||
/** Build the system + user (cue) prompt for a director turn. */
|
||||
export function buildDirectorPrompt(scene: DirectorScene): { system: string; user: string } {
|
||||
const style = scene.narrationStyle ? `\nNarration style: ${scene.narrationStyle}` : '';
|
||||
const system = [
|
||||
scene.systemConstraint,
|
||||
'',
|
||||
personaRole(scene),
|
||||
'',
|
||||
RULES,
|
||||
style,
|
||||
'',
|
||||
serializeScene(scene),
|
||||
'',
|
||||
SHAPE,
|
||||
].join('\n');
|
||||
return { system, user: cueFor(scene) };
|
||||
}
|
||||
|
||||
/** Map the transcript window to provider turns, ending with the cue as the final user turn. */
|
||||
export function buildDirectorMessages(scene: DirectorScene): { role: 'user' | 'assistant'; content: string }[] {
|
||||
const msgs = scene.transcriptWindow.map((e) => {
|
||||
if (e.role === 'narration') return { role: 'assistant' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
|
||||
if (e.role === 'roll-result') return { role: 'user' as const, content: `[Roll] ${e.text}` };
|
||||
if (e.role === 'system') return { role: 'user' as const, content: `[Update] ${e.text}` };
|
||||
return { role: 'user' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
|
||||
});
|
||||
msgs.push({ role: 'user', content: cueFor(scene) });
|
||||
return msgs;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Character, CharacterResource, Combatant, Encounter, SpellEntry } from '@/lib/schemas';
|
||||
import type { SceneActor } from './types';
|
||||
|
||||
const norm = (s: string): string => s.trim().toLowerCase();
|
||||
|
||||
/** Exact (case-insensitive) match first, then a unique prefix match either direction. */
|
||||
function match<T>(items: T[], name: string, nameOf: (t: T) => string): T | undefined {
|
||||
const n = norm(name);
|
||||
if (!n) return undefined;
|
||||
const exact = items.find((t) => norm(nameOf(t)) === n);
|
||||
if (exact) return exact;
|
||||
const partial = items.filter((t) => norm(nameOf(t)).startsWith(n) || n.startsWith(norm(nameOf(t))));
|
||||
return partial.length === 1 ? partial[0] : undefined;
|
||||
}
|
||||
|
||||
export function resolveCombatant(enc: Encounter, name: string): Combatant | undefined {
|
||||
return match(enc.combatants, name, (c) => c.name);
|
||||
}
|
||||
|
||||
export function resolveActor(roster: SceneActor[], name: string): SceneActor | undefined {
|
||||
return match(roster, name, (a) => a.name);
|
||||
}
|
||||
|
||||
export function resolveSpellByName(c: Character, spell: string): SpellEntry | undefined {
|
||||
return match(c.spellcasting.spells, spell, (s) => s.name);
|
||||
}
|
||||
|
||||
export function resolveResourceByName(c: Character, resource: string): CharacterResource | undefined {
|
||||
return match(c.resources, resource, (r) => r.name);
|
||||
}
|
||||
|
||||
/** True when `name` is in the roster (the closed set the director may reference). */
|
||||
export function inRoster(roster: SceneActor[], name: string): boolean {
|
||||
return resolveActor(roster, name) !== undefined;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* DeepSeek-tolerant schema for a director "turn". The model is asked for a single
|
||||
* JSON object, but real-world models (DeepSeek especially) rename keys, stringify
|
||||
* numbers, and wrap arrays oddly. We follow the same pattern as
|
||||
* `normalizeBalanceShape` in prompts.ts: `z.preprocess` to repair the shape
|
||||
* structurally, `z.coerce` on every number, `.catch()` on enums, and per-element
|
||||
* `safeParse`+drop so one malformed action never discards the whole turn.
|
||||
*/
|
||||
|
||||
const str = z.coerce.string();
|
||||
const coerceInt = z.coerce.number().int();
|
||||
const coerceAmount = z.coerce.number().int().min(0).max(9999);
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | undefined {
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function looksLikeRoll(v: unknown): boolean {
|
||||
const o = asRecord(v);
|
||||
return !!o && ('expression' in o || 'dice' in o || 'roll' in o || 'notation' in o);
|
||||
}
|
||||
function looksLikeAction(v: unknown): boolean {
|
||||
const o = asRecord(v);
|
||||
// Use 'kind'/'action'/'op' — NOT 'type', which roll requests use for their roll kind.
|
||||
return !!o && ('kind' in o || 'action' in o || 'op' in o);
|
||||
}
|
||||
|
||||
/** First top-level array under a named key, else the first array whose elements match `pred`. */
|
||||
function findArrayOf(o: Record<string, unknown>, pred: (v: unknown) => boolean, names: string[]): unknown[] | undefined {
|
||||
for (const n of names) if (Array.isArray(o[n])) return o[n] as unknown[];
|
||||
for (const v of Object.values(o)) if (Array.isArray(v) && v.some(pred)) return v;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function coerceStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v
|
||||
.map((x) => {
|
||||
if (typeof x === 'string') return x;
|
||||
const o = asRecord(x);
|
||||
return o && typeof o.text === 'string' ? o.text : o && typeof o.label === 'string' ? o.label : '';
|
||||
})
|
||||
.filter((s) => s.trim() !== '');
|
||||
}
|
||||
|
||||
/** Map free-form action verbs to our discriminant. */
|
||||
const KIND_SYNONYMS: Record<string, string> = {
|
||||
damage: 'damage', dealdamage: 'damage', applydamage: 'damage', hurt: 'damage', hit: 'damage',
|
||||
heal: 'heal', healing: 'heal', restore: 'heal', cure: 'heal',
|
||||
temphp: 'tempHp', temporaryhp: 'tempHp', temphitpoints: 'tempHp', temphealth: 'tempHp',
|
||||
condition: 'condition', applycondition: 'condition', addcondition: 'condition', setcondition: 'condition', status: 'condition',
|
||||
castspell: 'castSpell', cast: 'castSpell', spell: 'castSpell',
|
||||
spendresource: 'spendResource', useresource: 'spendResource', spend: 'spendResource', use: 'spendResource',
|
||||
advanceturn: 'advanceTurn', nextturn: 'advanceTurn', endturn: 'advanceTurn', next: 'advanceTurn', advance: 'advanceTurn',
|
||||
addcombatant: 'addCombatant', spawn: 'addCombatant', summon: 'addCombatant', addmonster: 'addCombatant',
|
||||
log: 'log', note: 'log', message: 'log',
|
||||
};
|
||||
|
||||
function normalizeRoll(v: unknown): unknown {
|
||||
const r = asRecord(v);
|
||||
if (!r) return { expression: typeof v === 'string' ? v : '1d20', label: 'Roll', kind: 'custom', actor: '' };
|
||||
const out = { ...r };
|
||||
if (out.expression == null) out.expression = out.dice ?? out.roll ?? out.notation ?? '1d20';
|
||||
if (out.label == null) out.label = out.name ?? out.description ?? out.check ?? 'Roll';
|
||||
if (out.kind == null) out.kind = out.type ?? 'custom';
|
||||
if (out.actor == null) out.actor = out.who ?? out.by ?? out.source ?? out.roller ?? '';
|
||||
if (out.dc == null && out.difficulty != null) out.dc = out.difficulty;
|
||||
if (out.against == null && out.vs != null) out.against = out.vs;
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeAction(v: unknown): unknown {
|
||||
const a = asRecord(v);
|
||||
if (!a) return null;
|
||||
const out = { ...a };
|
||||
let kind = out.kind ?? out.action ?? out.type;
|
||||
if (typeof kind === 'string') {
|
||||
const mapped = KIND_SYNONYMS[kind.trim().toLowerCase().replace(/[\s_-]/g, '')];
|
||||
kind = mapped ?? kind.trim();
|
||||
}
|
||||
out.kind = kind;
|
||||
// Field aliasing (best-effort; the discriminated schema keeps only the right ones).
|
||||
if (out.target == null) out.target = out.creature ?? out.combatant ?? out.enemy ?? out.recipient ?? out.who;
|
||||
if (out.amount == null) out.amount = out.value ?? out.hp ?? out.points ?? out.damage;
|
||||
if (out.caster == null) out.caster = out.by ?? out.who ?? out.actor;
|
||||
if (out.actor == null) out.actor = out.by ?? out.who;
|
||||
if (out.resource == null) out.resource = out.resourceName ?? out.pool;
|
||||
if (out.name == null) out.name = out.condition ?? out.monster ?? out.creature;
|
||||
if (out.text == null) out.text = out.message ?? out.note;
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeTurn(raw: unknown): unknown {
|
||||
const o = asRecord(raw);
|
||||
if (!o) return { narration: typeof raw === 'string' ? raw : '' };
|
||||
const out = { ...o };
|
||||
if (out.narration == null) out.narration = out.text ?? out.story ?? out.description ?? out.message ?? out.content ?? '';
|
||||
if (out.speaker == null && out.character != null) out.speaker = out.character;
|
||||
|
||||
const rolls = Array.isArray(out.rollRequests)
|
||||
? out.rollRequests
|
||||
: findArrayOf(out, looksLikeRoll, ['rollRequests', 'rolls', 'checks', 'requiredRolls', 'rollsRequired']);
|
||||
const actions = Array.isArray(out.actions)
|
||||
? out.actions
|
||||
: findArrayOf(out, looksLikeAction, ['actions', 'effects', 'mutations', 'updates', 'changes']);
|
||||
|
||||
out.rollRequests = (rolls ?? []).map(normalizeRoll);
|
||||
out.actions = (actions ?? []).map(normalizeAction).filter((x) => x !== null);
|
||||
out.suggestions = coerceStringArray(out.suggestions ?? out.options ?? out.choices ?? out.nextSteps);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const rollRequestSchema = z.object({
|
||||
/** stable within a turn; minted by the engine when the model omits it */
|
||||
id: str.default(''),
|
||||
actor: str.default(''),
|
||||
label: str.default('Roll'),
|
||||
kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'),
|
||||
expression: str.default('1d20'),
|
||||
dc: coerceInt.optional(),
|
||||
against: str.optional(),
|
||||
});
|
||||
export type RollRequest = z.infer<typeof rollRequestSchema>;
|
||||
export type RollKind = RollRequest['kind'];
|
||||
|
||||
export const directorActionSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }),
|
||||
z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }),
|
||||
z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }),
|
||||
z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coerceInt.optional() }),
|
||||
z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceInt.optional() }),
|
||||
z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceInt.default(1) }),
|
||||
z.object({ kind: z.literal('advanceTurn') }),
|
||||
z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }),
|
||||
z.object({ kind: z.literal('log'), text: str }),
|
||||
]);
|
||||
export type DirectorAction = z.infer<typeof directorActionSchema>;
|
||||
|
||||
export const directorTurnSchema = z.preprocess(
|
||||
normalizeTurn,
|
||||
z.object({
|
||||
narration: str.default(''),
|
||||
speaker: str.optional(),
|
||||
rollRequests: z
|
||||
.array(z.unknown())
|
||||
.default([])
|
||||
.transform((xs) => xs.flatMap((x) => {
|
||||
const p = rollRequestSchema.safeParse(x);
|
||||
return p.success ? [p.data] : [];
|
||||
})),
|
||||
actions: z
|
||||
.array(z.unknown())
|
||||
.default([])
|
||||
.transform((xs) => xs.flatMap((x) => {
|
||||
const p = directorActionSchema.safeParse(x);
|
||||
return p.success ? [p.data] : [];
|
||||
})),
|
||||
suggestions: z.array(str).default([]),
|
||||
}),
|
||||
);
|
||||
export type DirectorTurn = z.infer<typeof directorTurnSchema>;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { DirectorPersona, DirectorTranscriptEntry } from '@/lib/schemas';
|
||||
|
||||
export type { DirectorPersona, DirectorTranscriptEntry };
|
||||
export type { RollRequest, DirectorAction, DirectorTurn, RollKind } from './schema';
|
||||
|
||||
/** A single actor the director is allowed to reference. Names are the resolution keys. */
|
||||
export interface SceneActor {
|
||||
name: string;
|
||||
kind: 'pc' | 'npc' | 'monster';
|
||||
/** links to a Character sheet (full detail for an AI-piloted PC) */
|
||||
characterId?: string;
|
||||
/** links to a live Encounter combatant (HP/conditions) */
|
||||
combatantId?: string;
|
||||
hp?: { current: number; max: number; temp: number };
|
||||
ac?: number;
|
||||
conditions: string[];
|
||||
/** derived mechanical badges (Speed 0, Disadv. attacks, …) from deriveState */
|
||||
badges?: string[];
|
||||
/** PC detail injected for the actor the director pilots (player persona / AI seat) */
|
||||
attacks?: { name: string; expression: string; damage: string; damageType: string }[];
|
||||
spells?: { name: string; level: number; concentration: boolean }[];
|
||||
resources?: { name: string; current: number; max: number }[];
|
||||
slots?: { level: number; current: number; max: number }[];
|
||||
}
|
||||
|
||||
export interface SceneEncounter {
|
||||
name: string;
|
||||
round: number;
|
||||
currentActorName?: string;
|
||||
combatants: { name: string; kind: string; initiative: number; current: boolean }[];
|
||||
}
|
||||
|
||||
/** Everything the director needs for one grounded turn. The roster + addCandidates
|
||||
* form a CLOSED set: the director may reference no entity outside it. */
|
||||
export interface DirectorScene {
|
||||
system: SystemId;
|
||||
systemLabel: string;
|
||||
/** anti-cross-system anchor — leads the prompt */
|
||||
systemConstraint: string;
|
||||
persona: DirectorPersona;
|
||||
campaignName: string;
|
||||
/** optional tone hint from settings */
|
||||
narrationStyle?: string;
|
||||
roster: SceneActor[];
|
||||
encounter?: SceneEncounter;
|
||||
/** the PC the director pilots (player persona, or a DM auto-running an NPC turn) */
|
||||
controlledName?: string;
|
||||
/** grounded monster names the director may ADD via addCombatant (DM only) */
|
||||
addCandidates: string[];
|
||||
/** recent transcript turns (token-budgeted window) */
|
||||
transcriptWindow: DirectorTranscriptEntry[];
|
||||
/** rolling summary of older, evicted turns */
|
||||
summary?: string;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { Encounter } from '@/lib/schemas/encounter';
|
||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
|
||||
import type { SessionLogEntry } from '@/lib/schemas/sessionLog';
|
||||
import type { AiSession } from '@/lib/schemas/aiSession';
|
||||
import { characterDefaults } from '@/lib/schemas/character';
|
||||
|
||||
/**
|
||||
@@ -27,6 +28,7 @@ export class TtrpgDatabase extends Dexie {
|
||||
maps!: EntityTable<BattleMap, 'id'>;
|
||||
homebrew!: EntityTable<Homebrew, 'id'>;
|
||||
sessionLog!: EntityTable<SessionLogEntry, 'id'>;
|
||||
aiSessions!: EntityTable<AiSession, 'id'>;
|
||||
|
||||
constructor() {
|
||||
super('ttrpg-manager');
|
||||
@@ -188,6 +190,10 @@ export class TtrpgDatabase extends Dexie {
|
||||
if (c.choices === undefined) c.choices = [];
|
||||
});
|
||||
});
|
||||
|
||||
// v18 — AI DM / AI Player "director" sessions: a structured transcript per
|
||||
// campaign + persona. New table, so no backfill is needed.
|
||||
this.version(18).stores({ aiSessions: 'id, campaignId, [campaignId+persona]' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
battleMapSchema,
|
||||
homebrewSchema,
|
||||
sessionLogEntrySchema,
|
||||
aiSessionSchema,
|
||||
type AiSession,
|
||||
type DirectorPersona,
|
||||
type DirectorTranscriptEntry,
|
||||
type SessionLogEntry,
|
||||
type HomebrewKind,
|
||||
type Homebrew,
|
||||
@@ -65,7 +69,7 @@ export const campaignsRepo = {
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.transaction(
|
||||
'rw',
|
||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog],
|
||||
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog, db.aiSessions],
|
||||
async () => {
|
||||
await db.characters.where('campaignId').equals(id).delete();
|
||||
await db.encounters.where('campaignId').equals(id).delete();
|
||||
@@ -76,6 +80,7 @@ export const campaignsRepo = {
|
||||
await db.maps.where('campaignId').equals(id).delete();
|
||||
await db.homebrew.where('campaignId').equals(id).delete();
|
||||
await db.sessionLog.where('campaignId').equals(id).delete();
|
||||
await db.aiSessions.where('campaignId').equals(id).delete();
|
||||
await db.calendars.delete(id);
|
||||
await db.campaigns.delete(id);
|
||||
},
|
||||
@@ -240,6 +245,56 @@ export const sessionLogRepo = {
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- AI director sessions ----------------
|
||||
export const aiSessionsRepo = {
|
||||
listByCampaign(campaignId: string): Promise<AiSession[]> {
|
||||
return db.aiSessions.where('campaignId').equals(campaignId).toArray();
|
||||
},
|
||||
get(id: string): Promise<AiSession | undefined> {
|
||||
return db.aiSessions.get(id);
|
||||
},
|
||||
/** The most-recently-updated session for a campaign + persona, if any. */
|
||||
async latest(campaignId: string, persona: DirectorPersona): Promise<AiSession | undefined> {
|
||||
const rows = await db.aiSessions.where('[campaignId+persona]').equals([campaignId, persona]).toArray();
|
||||
return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
||||
},
|
||||
async create(campaignId: string, persona: DirectorPersona, opts?: Partial<Pick<AiSession, 'title' | 'controlledCharacterId' | 'encounterId'>>): Promise<AiSession> {
|
||||
const ts = now();
|
||||
const session = aiSessionSchema.parse({
|
||||
id: newId(), campaignId, persona,
|
||||
title: opts?.title ?? (persona === 'dm' ? 'AI Dungeon Master' : 'AI Player'),
|
||||
...(opts?.controlledCharacterId ? { controlledCharacterId: opts.controlledCharacterId } : {}),
|
||||
...(opts?.encounterId ? { encounterId: opts.encounterId } : {}),
|
||||
transcript: [], summary: '', summarizedThrough: 0,
|
||||
createdAt: ts, updatedAt: ts,
|
||||
});
|
||||
await db.aiSessions.add(session);
|
||||
return session;
|
||||
},
|
||||
/** Transactional read-modify-write (mirrors encountersRepo.mutate). */
|
||||
async mutate(id: string, fn: (s: AiSession) => AiSession): Promise<void> {
|
||||
await db.transaction('rw', db.aiSessions, async () => {
|
||||
const current = await db.aiSessions.get(id);
|
||||
if (!current) return;
|
||||
await db.aiSessions.put(aiSessionSchema.parse({ ...fn(current), updatedAt: now() }));
|
||||
});
|
||||
},
|
||||
/** Append durable transcript entries in one transaction. */
|
||||
async appendEntries(id: string, entries: DirectorTranscriptEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
await this.mutate(id, (s) => ({ ...s, transcript: [...s.transcript, ...entries] }));
|
||||
},
|
||||
async setSummary(id: string, summary: string, summarizedThrough: number): Promise<void> {
|
||||
await this.mutate(id, (s) => ({ ...s, summary, summarizedThrough }));
|
||||
},
|
||||
async update(id: string, patch: Partial<Omit<AiSession, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
|
||||
await db.aiSessions.update(id, { ...patch, updatedAt: now() });
|
||||
},
|
||||
async remove(id: string): Promise<void> {
|
||||
await db.aiSessions.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------- Notes ----------------
|
||||
export const notesRepo = {
|
||||
listByCampaign(campaignId: string): Promise<Note[]> {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
import { int } from './common';
|
||||
|
||||
/** Whether the director is running the world (DM) or piloting one PC (player). */
|
||||
export const directorPersonaSchema = z.enum(['dm', 'player']);
|
||||
export type DirectorPersona = z.infer<typeof directorPersonaSchema>;
|
||||
|
||||
export const directorTranscriptRoleSchema = z.enum(['narration', 'player-input', 'roll-result', 'system']);
|
||||
export type DirectorTranscriptRole = z.infer<typeof directorTranscriptRoleSchema>;
|
||||
|
||||
/** One durable line of the director transcript (narration, a human input, a roll result, a system note). */
|
||||
export const directorTranscriptEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
role: directorTranscriptRoleSchema,
|
||||
speaker: z.string().max(120).optional(),
|
||||
text: z.string().max(8000),
|
||||
ts: z.string(),
|
||||
});
|
||||
export type DirectorTranscriptEntry = z.infer<typeof directorTranscriptEntrySchema>;
|
||||
|
||||
/**
|
||||
* A persisted AI DM / AI Player ("director") session for a campaign. Holds the
|
||||
* structured transcript plus a rolling `summary` of evicted turns (token budget,
|
||||
* filled in a later phase). One natural document per campaign + persona.
|
||||
*/
|
||||
export const aiSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
persona: directorPersonaSchema.default('dm'),
|
||||
/** AI-seat PC when persona === 'player' (or a DM piloting an absent player's PC). */
|
||||
controlledCharacterId: z.string().optional(),
|
||||
/** Bound encounter, if the session is running a specific fight. */
|
||||
encounterId: z.string().optional(),
|
||||
title: z.string().max(120).default('Session'),
|
||||
transcript: z.array(directorTranscriptEntrySchema).default([]),
|
||||
/** Rolling summary of turns folded out of the live window (token budget; phase D4). */
|
||||
summary: z.string().max(8000).default(''),
|
||||
/** Number of transcript entries already represented by `summary`. */
|
||||
summarizedThrough: int.nonnegative().default(0),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
export type AiSession = z.infer<typeof aiSessionSchema>;
|
||||
@@ -5,3 +5,4 @@ export * from './encounter';
|
||||
export * from './dice';
|
||||
export * from './world';
|
||||
export * from './sessionLog';
|
||||
export * from './aiSession';
|
||||
|
||||
Reference in New Issue
Block a user