ce9bbc8220
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>
50 lines
1.6 KiB
TypeScript
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>
|
|
);
|
|
}
|