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:
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
||||
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
|
||||
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
@@ -31,6 +31,7 @@ const NAV: NavItem[] = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
||||
{ to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' },
|
||||
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
||||
{ to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' },
|
||||
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
||||
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
||||
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import type { DirectorAction } from '@/lib/assistant/director';
|
||||
import { actionSummary } from './actionSummary';
|
||||
|
||||
/**
|
||||
* D1: a passive preview of a proposed change. The one-click Apply (routed through
|
||||
* the rules kernel, approve-each) arrives in phase D2.
|
||||
*/
|
||||
export function ActionCard({ action }: { action: DirectorAction }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-dashed border-line bg-panel/60 p-2 text-sm">
|
||||
<Badge tone="arcane">proposed</Badge>
|
||||
<span className="text-muted">{actionSummary(action)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { useDirectorSession } from './useDirectorSession';
|
||||
import { TranscriptPane } from './TranscriptPane';
|
||||
import { RollRequestCard } from './RollRequestCard';
|
||||
import { ActionCard } from './ActionCard';
|
||||
import { SuggestionChips } from './SuggestionChips';
|
||||
|
||||
export function DirectorPage() {
|
||||
return <RequireCampaign>{(c) => <Director campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function Director({ campaign }: { campaign: Campaign }) {
|
||||
const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } =
|
||||
useDirectorSession(campaign);
|
||||
const entries = session?.transcript ?? [];
|
||||
const started = entries.length > 0;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="AI Director"
|
||||
subtitle={`${campaign.name} · ${persona === 'dm' ? 'Dungeon Master' : 'Player'}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{source && <Badge tone={source === 'llm' ? 'arcane' : 'default'}>{source === 'llm' ? 'AI' : 'deterministic'}</Badge>}
|
||||
{started && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void restart()}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!llmReady && (
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
No AI key configured — the director runs in deterministic mode. Add a key in{' '}
|
||||
<Link to="/settings" className="text-accent underline-offset-2 hover:underline">
|
||||
Settings
|
||||
</Link>{' '}
|
||||
for full narration.
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="mb-3 text-sm text-muted" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{activeEnc && (
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Running combat: <span className="text-ink">{activeEnc.name}</span> (round {activeEnc.round}).
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_330px]">
|
||||
<section className="space-y-4">
|
||||
<TranscriptPane entries={entries} />
|
||||
|
||||
{lastTurn && lastTurn.rollRequests.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="smallcaps">Rolls — you roll these</div>
|
||||
{lastTurn.rollRequests.map((r) => (
|
||||
<RollRequestCard key={r.id} req={r} system={campaign.system} onRolled={recordRoll} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastTurn && lastTurn.actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="smallcaps">Proposed changes</div>
|
||||
{lastTurn.actions.map((a, i) => (
|
||||
<ActionCard key={i} action={a} />
|
||||
))}
|
||||
<p className="text-[11px] text-muted">
|
||||
One-click Apply (routed through the rules engine) arrives in the next update — for now, resolve these on
|
||||
your sheet and tracker.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-lg border border-line bg-panel p-3">
|
||||
<div className="mb-2 smallcaps">Your move</div>
|
||||
{!started ? (
|
||||
<Button className="w-full" disabled={busy} onClick={() => void run()}>
|
||||
{busy ? 'Starting…' : 'Begin the scene'}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Button className="w-full" disabled={busy} onClick={() => void run()}>
|
||||
{busy ? 'Thinking…' : 'Continue'}
|
||||
</Button>
|
||||
<SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react';
|
||||
import { Dices } from 'lucide-react';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import { rollAndShow } from '@/lib/useRoll';
|
||||
import type { Degree } from '@/lib/dice/check';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import type { RollRequest } from '@/lib/assistant/director';
|
||||
|
||||
/**
|
||||
* The ONLY place the director feature touches the roll seam. A roll fires solely
|
||||
* from this button's onClick — never automatically — honoring the hard no-auto-roll
|
||||
* rule. The result is reported back so the director can adjudicate next turn.
|
||||
*/
|
||||
export function RollRequestCard({
|
||||
req,
|
||||
system,
|
||||
onRolled,
|
||||
}: {
|
||||
req: RollRequest;
|
||||
system: SystemId;
|
||||
onRolled: (req: RollRequest, result: { total: number; degree?: Degree }) => void;
|
||||
}) {
|
||||
const [done, setDone] = useState<{ total: number; degree?: Degree } | null>(null);
|
||||
|
||||
const roll = () => {
|
||||
const result = rollAndShow({
|
||||
expression: req.expression,
|
||||
label: req.label,
|
||||
...(req.dc !== undefined ? { dc: req.dc, system } : {}),
|
||||
});
|
||||
setDone(result);
|
||||
onRolled(req, result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-line bg-surface-2 p-2.5">
|
||||
<Dices size={16} className="shrink-0 text-accent" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-ink">
|
||||
{req.label}
|
||||
{req.against ? <span className="text-muted"> vs {req.against}</span> : null}
|
||||
</div>
|
||||
<div className="font-mono text-xs text-muted">
|
||||
{req.expression}
|
||||
{req.dc !== undefined ? ` · DC ${req.dc}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{done ? (
|
||||
<Badge tone={done.degree ? (done.degree.includes('success') ? 'success' : 'ember') : 'gold'}>
|
||||
{done.total}
|
||||
{done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button size="sm" onClick={roll}>Roll</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Textarea } from '@/components/ui/Input';
|
||||
|
||||
/** Quick-reply suggestion chips + a free-text box for the human's action/intent. */
|
||||
export function SuggestionChips({
|
||||
suggestions,
|
||||
disabled,
|
||||
onPick,
|
||||
}: {
|
||||
suggestions: string[];
|
||||
disabled: boolean;
|
||||
onPick: (text: string) => void;
|
||||
}) {
|
||||
const [text, setText] = useState('');
|
||||
const send = () => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
setText('');
|
||||
onPick(t);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{suggestions.map((s, i) => (
|
||||
<Button key={i} size="sm" variant="subtle" disabled={disabled} onClick={() => onPick(s)}>
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
placeholder="Say or do something… (Ctrl/Cmd+Enter to send)"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button className="w-full" disabled={disabled || !text.trim()} onClick={send}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Dices } from 'lucide-react';
|
||||
import type { DirectorTranscriptEntry } from '@/lib/schemas';
|
||||
|
||||
/** The narrative feed — narration, the human's inputs, roll results, and system notes. */
|
||||
export function TranscriptPane({ entries }: { entries: DirectorTranscriptEntry[] }) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [entries.length]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-line bg-panel/50 p-8 text-center text-sm text-muted">
|
||||
The story begins when you start the scene.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map((e) => (
|
||||
<TranscriptLine key={e.id} entry={e} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptLine({ entry }: { entry: DirectorTranscriptEntry }) {
|
||||
switch (entry.role) {
|
||||
case 'narration':
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-3">
|
||||
{entry.speaker && <span className="font-display text-sm font-semibold text-accent">{entry.speaker}. </span>}
|
||||
<span className="text-sm leading-relaxed text-ink">{entry.text}</span>
|
||||
</div>
|
||||
);
|
||||
case 'player-input':
|
||||
return (
|
||||
<div className="ml-6 rounded-lg border border-accent/30 bg-accent-glow/40 p-2.5">
|
||||
<span className="text-[11px] uppercase tracking-wide text-accent-deep">{entry.speaker ?? 'You'}</span>
|
||||
<p className="text-sm text-ink">{entry.text}</p>
|
||||
</div>
|
||||
);
|
||||
case 'roll-result':
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 pl-1 font-mono text-xs text-accent-deep">
|
||||
<Dices size={13} aria-hidden /> {entry.text}
|
||||
</p>
|
||||
);
|
||||
case 'system':
|
||||
return <p className="pl-1 text-xs italic text-muted">{entry.text}</p>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DirectorAction } from '@/lib/assistant/director';
|
||||
|
||||
/** Human-readable one-liner for a proposed director action. Shared by the preview
|
||||
* card and (phase D2) the apply bridge. */
|
||||
export function actionSummary(a: DirectorAction): string {
|
||||
switch (a.kind) {
|
||||
case 'damage':
|
||||
return `${a.target} takes ${a.amount}${a.damageType ? ` ${a.damageType}` : ''} damage`;
|
||||
case 'heal':
|
||||
return `${a.target} heals ${a.amount}`;
|
||||
case 'tempHp':
|
||||
return `${a.target} gains ${a.amount} temp HP`;
|
||||
case 'condition':
|
||||
return `${a.op === 'add' ? 'Add' : 'Remove'} ${a.name}${a.value ? ` ${a.value}` : ''} ${a.op === 'add' ? 'on' : 'from'} ${a.target}`;
|
||||
case 'castSpell':
|
||||
return `${a.caster} casts ${a.spell}${a.atLevel ? ` (level ${a.atLevel})` : ''}`;
|
||||
case 'spendResource':
|
||||
return `${a.actor} spends ${a.amount} ${a.resource}`;
|
||||
case 'advanceTurn':
|
||||
return 'Advance to the next turn';
|
||||
case 'addCombatant':
|
||||
return `Add ${a.name} to combat`;
|
||||
case 'log':
|
||||
return a.text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { aiSessionsRepo } from '@/lib/db/repositories';
|
||||
import type { AiSession, DirectorPersona } from '@/lib/schemas';
|
||||
|
||||
export function useAiSessions(campaignId: string): AiSession[] {
|
||||
return useLiveQuery(() => aiSessionsRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
|
||||
/** The most-recently-updated director session for a campaign + persona. */
|
||||
export function useLatestAiSession(campaignId: string, persona: DirectorPersona): AiSession | undefined {
|
||||
return useLiveQuery(() => aiSessionsRepo.latest(campaignId, persona), [campaignId, persona], undefined);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { aiSessionsRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { buildDirectorScene, runDirectorTurn, type DirectorAction, type RollRequest } from '@/lib/assistant/director';
|
||||
import type { Degree } from '@/lib/dice/check';
|
||||
import { useLatestAiSession } from './hooks';
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
const DEGREE_LABEL: Record<Degree, string> = {
|
||||
'critical-success': 'Critical Success',
|
||||
success: 'Success',
|
||||
failure: 'Failure',
|
||||
'critical-failure': 'Critical Failure',
|
||||
};
|
||||
|
||||
export interface LatestTurn {
|
||||
rollRequests: RollRequest[];
|
||||
actions: DirectorAction[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
/** Orchestrates one director session: building the grounded scene, running turns,
|
||||
* recording the human's rolls, and persisting the transcript. UI-layer (it may
|
||||
* touch the roll seam via the cards), never the pure engine. */
|
||||
export function useDirectorSession(campaign: Campaign) {
|
||||
const persona = useDirectorStore((s) => s.persona);
|
||||
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
|
||||
const windowSize = useDirectorStore((s) => s.windowSize);
|
||||
const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey);
|
||||
|
||||
const session = useLatestAiSession(campaign.id, persona);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const activeEnc = encounters.find((e) => e.status === 'active');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [source, setSource] = useState<'llm' | 'fallback' | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [lastTurn, setLastTurn] = useState<LatestTurn | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
async (input?: string) => {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
let sid = session?.id;
|
||||
if (!sid) sid = (await aiSessionsRepo.create(campaign.id, persona)).id;
|
||||
|
||||
if (input && input.trim()) {
|
||||
await aiSessionsRepo.appendEntries(sid, [{ id: newId(), role: 'player-input', text: input.trim(), ts: nowIso() }]);
|
||||
}
|
||||
|
||||
const fresh = await aiSessionsRepo.get(sid);
|
||||
const transcriptWindow = (fresh?.transcript ?? []).slice(-windowSize);
|
||||
const scene = buildDirectorScene({
|
||||
campaign,
|
||||
characters,
|
||||
encounter: activeEnc,
|
||||
persona,
|
||||
narrationStyle,
|
||||
transcriptWindow,
|
||||
summary: fresh?.summary,
|
||||
});
|
||||
|
||||
const res = await runDirectorTurn(getLlmConfig(), scene);
|
||||
setSource(res.source);
|
||||
if (res.message) setMessage(res.message);
|
||||
|
||||
if (res.turn.narration.trim()) {
|
||||
await aiSessionsRepo.appendEntries(sid, [{
|
||||
id: newId(),
|
||||
role: 'narration',
|
||||
...(res.turn.speaker ? { speaker: res.turn.speaker } : {}),
|
||||
text: res.turn.narration,
|
||||
ts: nowIso(),
|
||||
}]);
|
||||
}
|
||||
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session?.id, campaign, persona, characters, activeEnc, narrationStyle, windowSize],
|
||||
);
|
||||
|
||||
const recordRoll = useCallback(
|
||||
async (req: RollRequest, result: { total: number; degree?: Degree }) => {
|
||||
if (!session) return;
|
||||
const dc = req.dc !== undefined ? ` (DC ${req.dc})` : '';
|
||||
const deg = result.degree ? ` — ${DEGREE_LABEL[result.degree]}` : '';
|
||||
const text = `${req.label}${dc}: ${result.total}${deg}`;
|
||||
await aiSessionsRepo.appendEntries(session.id, [{
|
||||
id: newId(), role: 'roll-result', ...(req.actor ? { speaker: req.actor } : {}), text, ts: nowIso(),
|
||||
}]);
|
||||
setLastTurn((prev) => (prev ? { ...prev, rollRequests: prev.rollRequests.filter((r) => r.id !== req.id) } : prev));
|
||||
},
|
||||
[session],
|
||||
);
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
if (!session) return;
|
||||
await aiSessionsRepo.mutate(session.id, (s) => ({ ...s, transcript: [], summary: '', summarizedThrough: 0 }));
|
||||
setLastTurn(null);
|
||||
setSource(null);
|
||||
setMessage(null);
|
||||
}, [session]);
|
||||
|
||||
return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } as const;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { useDirectorStore } from '@/stores/directorStore';
|
||||
import { Input, Field } from '@/components/ui/Input';
|
||||
|
||||
/** Tuning for the AI DM / AI Player. It reuses the assistant's AI key (Assistant
|
||||
* section above) and never rolls dice or edits a sheet without your approval. */
|
||||
export function DirectorSettings() {
|
||||
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
|
||||
const windowSize = useDirectorStore((s) => s.windowSize);
|
||||
const setConfig = useDirectorStore((s) => s.setConfig);
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-line bg-panel p-4 sm:p-5">
|
||||
<h2 className="smallcaps text-[11px] text-muted">AI Director</h2>
|
||||
<hr className="gilt-rule mt-2 mb-1" />
|
||||
<div className="flex flex-wrap items-center gap-3 py-4">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
|
||||
<Wand2 size={18} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-display text-[15px] font-semibold text-ink">AI Dungeon Master & Player</div>
|
||||
<div className="mt-0.5 text-xs text-faint">
|
||||
Runs scenes and pilots characters, grounded in your campaign data. It uses the Assistant AI key above; without
|
||||
one it falls back to a deterministic mode. It never auto-rolls dice and never changes a sheet without your
|
||||
approval.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Narration style">
|
||||
<Input
|
||||
value={narrationStyle}
|
||||
placeholder="e.g. grim and terse; whimsical; cinematic"
|
||||
onChange={(e) => setConfig({ narrationStyle: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Transcript memory (turns)">
|
||||
<Input
|
||||
type="number"
|
||||
min={4}
|
||||
max={60}
|
||||
value={windowSize}
|
||||
onChange={(e) => setConfig({ windowSize: Math.max(4, Math.min(60, Number(e.target.value) || 16)) })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { pickTextFile } from '@/lib/io/file';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
|
||||
import { AssistantSettings } from './AssistantSettings';
|
||||
import { DirectorSettings } from './DirectorSettings';
|
||||
import { CloudCampaigns } from './CloudCampaigns';
|
||||
import { Page, PageHeader } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -125,6 +126,8 @@ export function SettingsPage() {
|
||||
|
||||
<AssistantSettings />
|
||||
|
||||
<DirectorSettings />
|
||||
|
||||
<CloudSync />
|
||||
|
||||
<CloudCampaigns />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PlayerSetupPage } from '@/features/player/PlayerSetupPage';
|
||||
import { HomebrewPage } from '@/features/world/HomebrewPage';
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage';
|
||||
import { AssistantPage } from '@/features/assistant/AssistantPage';
|
||||
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
|
||||
|
||||
const rootRoute = createRootRoute({ component: RootLayout });
|
||||
|
||||
@@ -42,6 +43,7 @@ const playerRoute = createRoute({ getParentRoute: () => rootRoute, path: '/playe
|
||||
const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/homebrew', component: HomebrewPage });
|
||||
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
|
||||
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
|
||||
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
@@ -61,6 +63,7 @@ const routeTree = rootRoute.addChildren([
|
||||
homebrewRoute,
|
||||
settingsRoute,
|
||||
assistantRoute,
|
||||
directorRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { DirectorPersona } from '@/lib/schemas';
|
||||
|
||||
interface DirectorState {
|
||||
/** which role the director plays by default */
|
||||
persona: DirectorPersona;
|
||||
/** free-text tone hint fed to the narrator (e.g. "grim, terse") */
|
||||
narrationStyle: string;
|
||||
/** how many recent transcript entries to send each turn (token budget) */
|
||||
windowSize: number;
|
||||
setConfig: (patch: Partial<Pick<DirectorState, 'persona' | 'narrationStyle' | 'windowSize'>>) => void;
|
||||
}
|
||||
|
||||
export const useDirectorStore = create<DirectorState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
persona: 'dm',
|
||||
narrationStyle: '',
|
||||
windowSize: 16,
|
||||
setConfig: (patch) => set(patch),
|
||||
}),
|
||||
{ name: 'ttrpg-director' },
|
||||
),
|
||||
);
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user