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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user