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>
);
}