dd694477b2
Crash fix: - restoreBackup (file + cloud pull) now validates each row through its Zod schema, backfilling new fields — an older backup no longer lands characters missing feats/concentration/etc and crashing the sheet. + test. pf2e parity (closing feature gaps vs 5e): - +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist, Magus, Psychic, Summoner, Thaumaturge) with data + class tips. - the wizard now enriches pf2e classes with their caster ability, so pf2e spellcasters get the Spells step + "Caster" tag like 5e. - editable Perception rank and spellcasting proficiency on the pf2e sheet (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots. - pf2e monster resistances/weaknesses/immunities now apply in combat (flat amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests. Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across Modal (every dialog), the sheet sections, dice, settings, player panels, world pages, map editor, roll tray, and session UI. Gate: 293 unit + e2e green; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
3.4 KiB
TypeScript
89 lines
3.4 KiB
TypeScript
import { useState } from 'react';
|
|
import { Copy, Check } from 'lucide-react';
|
|
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
|
|
import { complete } from '@/lib/llm/client';
|
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
|
import { buildCampaignContext } from '@/lib/assistant/context';
|
|
import { buildSessionHookPrompt, sessionHookSchema, fallbackSessionHooks, type SessionHook } from '@/lib/assistant/session';
|
|
import { Button } from '@/components/ui/Button';
|
|
|
|
interface Props {
|
|
campaign: Campaign;
|
|
characters: Character[];
|
|
notes: Note[];
|
|
quests: Quest[];
|
|
}
|
|
|
|
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
|
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
|
const canUseLlm = llmEnabled && hasKey;
|
|
|
|
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
|
|
const [hooks, setHooks] = useState<SessionHook[]>([]);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
|
|
const generate = async () => {
|
|
setState('loading');
|
|
setMessage(null);
|
|
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
|
|
|
|
if (canUseLlm) {
|
|
const { system, user } = buildSessionHookPrompt(ctx);
|
|
const result = await complete<{ hooks: SessionHook[] }>(getLlmConfig(), { system, user, schema: sessionHookSchema });
|
|
if (result.ok && 'data' in result) {
|
|
setHooks(result.data.hooks);
|
|
setState('ready');
|
|
return;
|
|
}
|
|
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template hooks instead.`);
|
|
}
|
|
|
|
setHooks(fallbackSessionHooks(ctx));
|
|
setState('ready');
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-lg border border-line bg-panel p-4">
|
|
<p className="mb-3 text-sm text-muted">
|
|
Generate three distinct session opening hooks grounded in your campaign's current threads.
|
|
</p>
|
|
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
|
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
|
|
</Button>
|
|
|
|
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
|
|
|
{state === 'ready' && hooks.length > 0 && (
|
|
<div className="mt-4 space-y-3">
|
|
{hooks.map((h, i) => (
|
|
<HookCard key={i} hook={h} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HookCard({ hook }: { hook: SessionHook }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const copy = () => {
|
|
void navigator.clipboard?.writeText(`${hook.title}\n\n${hook.opening}\n\n${hook.tension}`).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
};
|
|
return (
|
|
<div className="rounded-md border border-line bg-surface-2 p-3 text-sm">
|
|
<div className="mb-1 flex items-start justify-between gap-2">
|
|
<span className="font-display font-semibold text-ink">{hook.title}</span>
|
|
<button onClick={copy} title="Copy hook" className="shrink-0 text-muted hover:text-ink">
|
|
{copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
|
|
</button>
|
|
</div>
|
|
<p className="text-muted">{hook.opening}</p>
|
|
<p className="mt-1.5 text-xs italic text-faint">{hook.tension}</p>
|
|
</div>
|
|
);
|
|
}
|