Files
ttrpg_manager/src/features/assistant/SessionPrepCard.tsx
T
NilsBriggen dd694477b2 Polish sprint 1/2: restore crash fix, pf2e parity, full glyph purge
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>
2026-06-09 11:29:36 +02:00

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&apos;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>
);
}