Phase 11: data-driven Assistant
- Deterministic advisors (src/lib/assistant): party resources (downed/bloodied/ spent slots/rest nudges), session prep (dangling [[wiki links]], active quests, empty journal), live combat hints (turn, downed combatants) — pure + tested - Data-driven encounter builder: greedily assembles level/CR-appropriate monsters to a target difficulty via the Phase-3 budget; one click builds + opens combat - Assistant page: suggestion cards with one-click actions (goto, long rest, create note); wired into routes, dashboard, command palette - 8 advisor/builder unit tests + assistant e2e Conversational LLM layer intentionally deferred (no provider/key in this env); the deterministic advisor is the offline default per the plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Campaign } from '@/lib/schemas';
|
||||
import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories';
|
||||
import { getSystem, applyRest } from '@/lib/rules';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { rollDice } from '@/lib/dice/notation';
|
||||
import { addCombatant } from '@/lib/combat/engine';
|
||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||
import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
|
||||
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useNotes, useQuests } from '@/features/world/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function AssistantPage() {
|
||||
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
const SEV_CLS = { info: 'border-line', warn: 'border-warning/50', danger: 'border-danger/50' } as const;
|
||||
const DIFFICULTY: Record<string, string[]> = {
|
||||
'5e': ['Easy', 'Medium', 'Hard', 'Deadly'],
|
||||
pf2e: ['Low', 'Moderate', 'Severe', 'Extreme'],
|
||||
};
|
||||
|
||||
function Assistant({ campaign }: { campaign: Campaign }) {
|
||||
const navigate = useNavigate();
|
||||
const setActiveEncounter = useUiStore((s) => s.setActiveEncounter);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const notes = useNotes(campaign.id);
|
||||
const quests = useQuests(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const activeEnc = encounters.find((e) => e.status === 'active');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
const suggestions = useMemo<Suggestion[]>(
|
||||
() => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)],
|
||||
[activeEnc, characters, notes, quests],
|
||||
);
|
||||
|
||||
const runAction = async (a: SuggestAction) => {
|
||||
if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; }
|
||||
if (a.type === 'longRest') {
|
||||
const c = await charactersRepo.get(a.characterId);
|
||||
if (!c) return;
|
||||
const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp);
|
||||
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
|
||||
setMsg('Applied a long rest.');
|
||||
return;
|
||||
}
|
||||
if (a.type === 'createNote') {
|
||||
await notesRepo.create(campaign.id, a.title);
|
||||
void navigate({ to: '/notes' });
|
||||
}
|
||||
};
|
||||
|
||||
const buildEncounter = async (difficulty: string) => {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
|
||||
if (partyLevels.length === 0) { setMsg('Add player characters first.'); return; }
|
||||
const raw = campaign.system === '5e' ? await loadMonsters() : await loadPf2e('creatures');
|
||||
const pool = raw as unknown as (Record<string, unknown> & { cr?: number; level?: number })[];
|
||||
const chosen = buildSuggestedEncounter(campaign.system, partyLevels, pool, difficulty, createRng());
|
||||
if (chosen.length === 0) { setMsg('No suitable monsters found for that difficulty.'); return; }
|
||||
|
||||
let enc = await encountersRepo.create(campaign.id, `${difficulty} encounter`);
|
||||
for (const m of chosen) {
|
||||
const mm = m as Record<string, unknown>;
|
||||
const is5e = campaign.system === '5e';
|
||||
const name = String(mm.name);
|
||||
const ac = Number(is5e ? mm.armor_class : mm.ac) || 10;
|
||||
const hp = Number(is5e ? mm.hit_points : mm.hp) || 1;
|
||||
const initBonus = is5e ? Math.floor(((Number(mm.dexterity) || 10) - 10) / 2) : Number(mm.perception) || 0;
|
||||
const cr = Number(mm.cr);
|
||||
const level = Number(mm.level);
|
||||
enc = addCombatant(enc, {
|
||||
id: newId(), name, kind: 'monster',
|
||||
initiative: rollDice('1d20', createRng()).total + initBonus, initBonus,
|
||||
ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '',
|
||||
...(is5e && Number.isFinite(cr) ? { cr } : {}),
|
||||
...(!is5e && Number.isFinite(level) ? { level } : {}),
|
||||
});
|
||||
}
|
||||
await encountersRepo.save(enc);
|
||||
setActiveEncounter(enc.id);
|
||||
void navigate({ to: '/combat' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const byCat = (cat: Suggestion['category']) => suggestions.filter((s) => s.category === cat);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title="Assistant" subtitle={`${campaign.name} · suggestions from your campaign data`} />
|
||||
{msg && <p className="mb-3 text-sm text-success" aria-live="polite">{msg}</p>}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Encounter builder</h2>
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(DIFFICULTY[campaign.system] ?? []).map((d) => (
|
||||
<Button key={d} variant="secondary" disabled={busy} onClick={() => buildEncounter(d)}>{d}</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Combat</h2>
|
||||
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Party resources</h2>
|
||||
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
|
||||
|
||||
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
|
||||
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
|
||||
</section>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionList({ items, onAction, empty }: { items: Suggestion[]; onAction: (a: SuggestAction) => void; empty: string }) {
|
||||
if (items.length === 0) return <p className="text-sm text-muted">{empty}</p>;
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((s) => (
|
||||
<li key={s.id} className={cn('flex items-center gap-3 rounded-lg border bg-panel p-3', SEV_CLS[s.severity])}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ink">{s.title}</div>
|
||||
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
|
||||
</div>
|
||||
{s.action && (
|
||||
<Button size="sm" variant="secondary" onClick={() => onAction(s.action!)}>{s.action.label}</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user