D2: AI Director apply bridge — approve-each, routed through the rules kernel

The director's proposed state changes are now one-click "Apply" actions. Nothing
mutates a Character or Encounter until the human clicks Apply — the AI never
writes game state on its own.

- useDirectorAction (the 3rd anti-hallucination gate): re-resolves every action's
  target against the live roster/encounter and rejects unknowns, then routes
  through the pure kernel + combat engine — applyDamage/applyHealing/setTempHp/
  updateCombatant (damage/heal/tempHp/condition), nextTurn (advanceTurn),
  logEvent (log), and castSpell/spendResource for caster actions. Persists via the
  transactional encountersRepo.mutate / charactersRepo.update.
- No-auto-roll preserved: damaging a concentrating creature SURFACES a
  concentration save (concentrationDC) as a roll button — it is never auto-rolled.
  Massive-damage death is flagged. castSpell mirrors new concentration onto the
  combatant so later saves surface.
- ActionCard gains a working Apply button (idempotent: disables after applying,
  shows the result/skip reason); applied actions append a system transcript entry
  so the next turn sees the new ground truth.

8 integration tests against a real Dexie cover each action→kernel patch, the
concentration-save surfacing, and rejection of off-roster targets. 386 unit tests
green; lint clean; build OK; verified the page renders + runs a turn on a clean load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 03:07:16 +02:00
parent 961fe8655a
commit ce9bbc8220
6 changed files with 348 additions and 16 deletions
+39 -6
View File
@@ -1,16 +1,49 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import type { DirectorAction } from '@/lib/assistant/director';
import { actionSummary } from './actionSummary';
import type { ApplyResult } from './useDirectorAction';
/**
* D1: a passive preview of a proposed change. The one-click Apply (routed through
* the rules kernel, approve-each) arrives in phase D2.
* A proposed state change. Approve-each: it only mutates the game when the human
* clicks Apply, which routes through the rules kernel. If no `onApply` is given it
* renders as a passive preview.
*/
export function ActionCard({ action }: { action: DirectorAction }) {
export function ActionCard({
action,
onApply,
}: {
action: DirectorAction;
onApply?: (a: DirectorAction) => Promise<ApplyResult>;
}) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ApplyResult | null>(null);
const apply = async () => {
if (!onApply) return;
setBusy(true);
try {
setResult(await onApply(action));
} finally {
setBusy(false);
}
};
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 className="rounded-md border border-line bg-panel/60 p-2 text-sm">
<div className="flex items-center gap-2">
<Badge tone={result ? (result.ok ? 'success' : 'ember') : 'arcane'}>
{result ? (result.ok ? 'applied' : 'skipped') : 'change'}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted">{actionSummary(action)}</span>
{onApply && !result && (
<Button size="sm" disabled={busy} onClick={apply}>
{busy ? '…' : 'Apply'}
</Button>
)}
</div>
{result && !result.ok && <p className="mt-1 pl-1 text-xs text-danger">{result.message}</p>}
</div>
);
}
@@ -14,7 +14,7 @@ export function DirectorPage() {
}
function Director({ campaign }: { campaign: Campaign }) {
const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } =
const { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } =
useDirectorSession(campaign);
const entries = session?.transcript ?? [];
const started = entries.length > 0;
@@ -71,14 +71,10 @@ function Director({ campaign }: { campaign: Campaign }) {
{lastTurn && lastTurn.actions.length > 0 && (
<div className="space-y-2">
<div className="smallcaps">Proposed changes</div>
<div className="smallcaps">Proposed changes you approve each</div>
{lastTurn.actions.map((a, i) => (
<ActionCard key={i} action={a} />
<ActionCard key={i} action={a} onApply={applyAction} />
))}
<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>
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { db } from '@/lib/db/db';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import { buildDirectorScene, type SceneActor } from '@/lib/assistant/director';
import { characterSchema, encounterSchema, newSpellEntry, type Campaign, type Character, type Encounter } from '@/lib/schemas';
import { useDirectorAction } from './useDirectorAction';
const campaign: Campaign = { id: 'c1', name: 'T', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
function seedCaster(): Character {
return characterSchema.parse({
id: 'ch1', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira',
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 16, cha: 12 },
hp: { current: 20, max: 20, temp: 0 },
spellcasting: { slots: [{ level: 1, max: 2, current: 2 }], spells: [newSpellEntry({ id: 'sp1', name: 'Bless', level: 1, concentration: true })] },
resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }],
createdAt: 't', updatedAt: 't',
});
}
function seedEncounter(): Encounter {
return encounterSchema.parse({
id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 1, turnIndex: 0,
combatants: [
{ id: 'cb-mira', name: 'Mira', kind: 'pc', characterId: 'ch1', initiative: 15, ac: 14, hp: { current: 20, max: 20, temp: 0 }, concentrating: 'Bless' },
{ id: 'cb-gob', name: 'Goblin', kind: 'monster', initiative: 12, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
],
createdAt: 't', updatedAt: 't',
});
}
let roster: SceneActor[];
let characters: Character[];
beforeEach(async () => {
await db.characters.clear();
await db.encounters.clear();
const caster = seedCaster();
const enc = seedEncounter();
await charactersRepo.insert(caster);
await encountersRepo.save(enc);
characters = [caster];
roster = buildDirectorScene({ campaign, characters, encounter: enc, persona: 'dm', transcriptWindow: [] }).roster;
});
function hook() {
return renderHook(() => useDirectorAction({ system: '5e', encounterId: 'e1', roster, characters })).result;
}
describe('useDirectorAction — approve-each apply bridge', () => {
it('applies damage through the engine and reports the new HP', async () => {
const r = await hook().current({ kind: 'damage', target: 'Goblin', amount: 5, damageType: 'slashing' });
expect(r.ok).toBe(true);
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.hp.current).toBe(2);
});
it('surfaces a concentration save (never auto-rolls) when damaging a concentrating creature', async () => {
const r = await hook().current({ kind: 'damage', target: 'Mira', amount: 10 });
expect(r.ok).toBe(true);
expect(r.rollRequests).toHaveLength(1);
expect(r.rollRequests![0]).toMatchObject({ kind: 'save', dc: 10 }); // concentrationDC(10) = max(10, 5)
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-mira')!.hp.current).toBe(10);
});
it('rejects an action targeting an entity not on the board', async () => {
const r = await hook().current({ kind: 'damage', target: 'Smaug', amount: 99 });
expect(r.ok).toBe(false);
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.every((c) => c.hp.current === c.hp.max)).toBe(true); // nothing changed
});
it('adds and removes conditions', async () => {
await hook().current({ kind: 'condition', target: 'Goblin', op: 'add', name: 'prone' });
let enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).toContain('prone');
await hook().current({ kind: 'condition', target: 'Goblin', op: 'remove', name: 'prone' });
enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).not.toContain('prone');
});
it('advances the turn through the engine', async () => {
const r = await hook().current({ kind: 'advanceTurn' });
expect(r.ok).toBe(true);
const enc = await encountersRepo.get('e1');
expect(enc!.turnIndex).toBe(1);
});
it('casts a known spell, consuming a slot and mirroring concentration to the combatant', async () => {
const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Bless', atLevel: 1 });
expect(r.ok).toBe(true);
const c = await charactersRepo.get('ch1');
expect(c!.spellcasting.slots[0]!.current).toBe(1); // one slot spent
expect(c!.concentration?.spellName).toBe('Bless');
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((x) => x.id === 'cb-mira')!.concentrating).toBe('Bless');
});
it("rejects a spell the caster doesn't have", async () => {
const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Meteor Swarm' });
expect(r.ok).toBe(false);
});
it('spends a resource through the kernel', async () => {
const r = await hook().current({ kind: 'spendResource', actor: 'Mira', resource: 'Channel Divinity', amount: 1 });
expect(r.ok).toBe(true);
const c = await charactersRepo.get('ch1');
expect(c!.resources[0]!.current).toBe(0);
});
});
@@ -0,0 +1,168 @@
import { useCallback } from 'react';
import { newId } from '@/lib/ids';
import type { SystemId } from '@/lib/rules';
import type { Character } from '@/lib/schemas';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import {
applyDamage,
applyHealing,
setTempHp,
updateCombatant,
nextTurn,
logEvent,
isMassiveDamageDeath,
} from '@/lib/combat/engine';
import { castSpell, spendResource, concentrationDC } from '@/lib/mechanics';
import {
resolveCombatant,
resolveActor,
resolveSpellByName,
resolveResourceByName,
type DirectorAction,
type RollRequest,
type SceneActor,
} from '@/lib/assistant/director';
export interface ApplyResult {
ok: boolean;
message: string;
/** follow-up rolls a human must make (e.g. a concentration save) — surfaced, never auto-rolled */
rollRequests?: RollRequest[];
}
function concentrationSave(name: string, spellName: string, damage: number): RollRequest {
return { id: newId(), actor: name, label: `Concentration save (${spellName})`, kind: 'save', expression: '1d20', dc: concentrationDC(damage) };
}
/**
* The third anti-hallucination gate and the only writer of game state for the
* director. Every action re-resolves its target against the live roster/encounter
* and routes through the pure rules kernel + combat engine — approve-each, so it
* runs solely from an ActionCard's Apply click. Damage to a concentrating creature
* SURFACES a save (it never auto-resolves it).
*/
export function useDirectorAction(opts: {
system: SystemId;
encounterId?: string | undefined;
roster: SceneActor[];
characters: Character[];
}) {
const { system, encounterId, roster, characters } = opts;
return useCallback(
async (action: DirectorAction): Promise<ApplyResult> => {
const noCombat: ApplyResult = { ok: false, message: 'No active encounter to apply this to.' };
const pcFor = (name: string): Character | undefined => {
const actor = resolveActor(roster, name);
return actor?.characterId ? characters.find((c) => c.id === actor.characterId) : undefined;
};
switch (action.kind) {
case 'damage': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
const followUps: RollRequest[] = [];
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = applyDamage(c, action.amount, action.damageType);
const massive = isMassiveDamageDeath(c.hp.max, after.hp.current);
out = {
ok: true,
message: `${c.name}: ${action.amount}${action.damageType ? ` ${action.damageType}` : ''} damage → ${after.hp.current}/${after.hp.max} HP${massive ? ' (massive damage — instant death!)' : ''}`,
};
if (system === '5e' && c.concentrating) followUps.push(concentrationSave(c.name, c.concentrating, action.amount));
return updateCombatant(e, c.id, { hp: after.hp });
});
return followUps.length ? { ...out, rollRequests: followUps } : out;
}
case 'heal': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = applyHealing(c, action.amount);
out = { ok: true, message: `${c.name} healed ${action.amount}${after.hp.current}/${after.hp.max} HP` };
return updateCombatant(e, c.id, { hp: after.hp });
});
return out;
}
case 'tempHp': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = setTempHp(c, action.amount);
out = { ok: true, message: `${c.name} gains ${action.amount} temporary HP` };
return updateCombatant(e, c.id, { hp: after.hp });
});
return out;
}
case 'condition': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const others = c.conditions.filter((x) => x.name.trim().toLowerCase() !== action.name.trim().toLowerCase());
const conditions =
action.op === 'add'
? [...others, { name: action.name, ...(action.value && action.value > 0 ? { value: action.value } : {}) }]
: others;
out = { ok: true, message: `${action.op === 'add' ? 'Added' : 'Removed'} ${action.name}${action.value ? ` ${action.value}` : ''} ${action.op === 'add' ? 'on' : 'from'} ${c.name}` };
return updateCombatant(e, c.id, { conditions });
});
return out;
}
case 'advanceTurn': {
if (!encounterId) return noCombat;
await encountersRepo.mutate(encounterId, (e) => nextTurn(e, system));
return { ok: true, message: 'Advanced to the next turn.' };
}
case 'log': {
if (encounterId) await encountersRepo.mutate(encounterId, (e) => logEvent(e, action.text));
return { ok: true, message: action.text };
}
case 'castSpell': {
const c = pcFor(action.caster);
if (!c) return { ok: false, message: `"${action.caster}" isn't a tracked character.` };
const spell = resolveSpellByName(c, action.spell);
if (!spell) return { ok: false, message: `${c.name} doesn't have "${action.spell}" prepared.` };
const r = castSpell(c, spell.id, action.atLevel);
if (!r.ok) return { ok: false, message: r.reason };
await charactersRepo.update(c.id, r.patch);
// Mirror new concentration onto the combatant so post-damage saves surface.
const conc = r.patch.concentration;
const actor = resolveActor(roster, action.caster);
if (encounterId && actor?.combatantId && conc) {
await encountersRepo.mutate(encounterId, (e) => updateCombatant(e, actor.combatantId!, { concentrating: conc.spellName }));
}
return { ok: true, message: r.log.join(' ') || `${c.name} cast ${spell.name}.` };
}
case 'spendResource': {
const c = pcFor(action.actor);
if (!c) return { ok: false, message: `"${action.actor}" isn't a tracked character.` };
const res = resolveResourceByName(c, action.resource);
if (!res) return { ok: false, message: `${c.name} has no "${action.resource}" resource.` };
const r = spendResource(c, res.id, action.amount);
if (!r.ok) return { ok: false, message: r.reason };
await charactersRepo.update(c.id, r.patch);
return { ok: true, message: r.log.join(' ') || `${c.name} spent ${action.amount} ${res.name}.` };
}
case 'addCombatant':
return { ok: false, message: 'Adding monsters from the director arrives in a later update.' };
}
},
[system, encounterId, roster, characters],
);
}
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { Campaign } from '@/lib/schemas';
import { aiSessionsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
@@ -9,6 +9,7 @@ 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';
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
const nowIso = (): string => new Date().toISOString();
@@ -44,6 +45,13 @@ export function useDirectorSession(campaign: Campaign) {
const [message, setMessage] = useState<string | null>(null);
const [lastTurn, setLastTurn] = useState<LatestTurn | null>(null);
// Roster for name-resolution when applying actions (rebuilt from live data).
const roster = useMemo(
() => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, transcriptWindow: [] }).roster,
[campaign, characters, activeEnc, persona],
);
const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters });
const run = useCallback(
async (input?: string) => {
setBusy(true);
@@ -103,6 +111,21 @@ export function useDirectorSession(campaign: Campaign) {
[session],
);
const applyAction = useCallback(
async (action: DirectorAction): Promise<ApplyResult> => {
const r = await apply(action);
if (r.ok && session) {
await aiSessionsRepo.appendEntries(session.id, [{ id: newId(), role: 'system', text: `Applied: ${r.message}`, ts: nowIso() }]);
}
if (r.rollRequests?.length) {
setLastTurn((prev) => (prev ? { ...prev, rollRequests: [...prev.rollRequests, ...r.rollRequests!] } : prev));
}
if (!r.ok) setMessage(r.message);
return r;
},
[apply, session],
);
const restart = useCallback(async () => {
if (!session) return;
await aiSessionsRepo.mutate(session.id, (s) => ({ ...s, transcript: [], summary: '', summarizedThrough: 0 }));
@@ -111,5 +134,5 @@ export function useDirectorSession(campaign: Campaign) {
setMessage(null);
}, [session]);
return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, restart } as const;
return { persona, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } as const;
}