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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user