Files
ttrpg_manager/src/features/assistant/director/ActionCard.tsx
T
NilsBriggen ce9bbc8220 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>
2026-06-10 03:07:16 +02:00

50 lines
1.6 KiB
TypeScript

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';
/**
* 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,
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="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>
);
}