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>
153 lines
7.2 KiB
TypeScript
153 lines
7.2 KiB
TypeScript
|
|
import { useState } from 'react';
|
|
import { X } from 'lucide-react';
|
|
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
|
import { questsRepo } from '@/lib/db/repositories';
|
|
import { newId } from '@/lib/ids';
|
|
import { complete } from '@/lib/llm/client';
|
|
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
|
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
|
|
import { buildCampaignContext } from '@/lib/assistant/context';
|
|
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
|
import { useQuests } from './hooks';
|
|
import { useCharacters } from '@/features/characters/hooks';
|
|
import { useNotes } from '@/features/world/hooks';
|
|
import { useEncounters } from '@/features/combat/hooks';
|
|
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Input, Select } from '@/components/ui/Input';
|
|
import { cn } from '@/lib/cn';
|
|
|
|
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
|
|
const STATUS_COLOR: Record<Quest['status'], string> = {
|
|
active: 'text-info', 'on-hold': 'text-muted', completed: 'text-success', failed: 'text-danger',
|
|
};
|
|
|
|
export function QuestsPage() {
|
|
return <RequireCampaign>{(c) => <Quests campaign={c} />}</RequireCampaign>;
|
|
}
|
|
|
|
function Quests({ campaign }: { campaign: Campaign }) {
|
|
const quests = useQuests(campaign.id);
|
|
const characters = useCharacters(campaign.id);
|
|
const notes = useNotes(campaign.id);
|
|
const encounters = useEncounters(campaign.id);
|
|
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
|
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
|
const [genBusy, setGenBusy] = useState(false);
|
|
const llmEnabled = useAssistantStore((s) => s.enabled);
|
|
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
|
const canUseLlm = llmEnabled && hasKey;
|
|
|
|
const generateQuest = async () => {
|
|
setGenBusy(true);
|
|
try {
|
|
let hook: QuestHook | null = null;
|
|
if (canUseLlm) {
|
|
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
|
|
const { system, user } = buildQuestHookPrompt(ctx);
|
|
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
|
|
if (res.ok && 'data' in res) hook = res.data;
|
|
}
|
|
if (!hook) hook = fallbackQuestHook();
|
|
const created = await questsRepo.create(campaign.id, hook.title);
|
|
await questsRepo.update(created.id, {
|
|
description: hook.description,
|
|
reward: hook.reward,
|
|
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
|
|
});
|
|
} finally {
|
|
setGenBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader
|
|
title="Quests"
|
|
subtitle={campaign.name}
|
|
actions={
|
|
<div className="flex gap-2">
|
|
<Button variant="secondary" disabled={genBusy} onClick={() => void generateQuest()}>
|
|
{genBusy ? 'Generating…' : 'Generate quest hook'}
|
|
</Button>
|
|
<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
{quests.length === 0 ? (
|
|
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
|
|
) : (
|
|
<div className="space-y-3">
|
|
{sorted.map((q) => <QuestCard key={q.id} quest={q} />)}
|
|
</div>
|
|
)}
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function QuestCard({ quest }: { quest: Quest }) {
|
|
const [q, setQ] = useState(quest);
|
|
const [newObj, setNewObj] = useState('');
|
|
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
|
|
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
|
|
}), 350);
|
|
const update = (patch: Partial<Quest>) => setQ((p) => { const next = { ...p, ...patch }; save(next); return next; });
|
|
|
|
const setObjective = (id: string, patch: Partial<Objective>) =>
|
|
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
|
|
const addObjective = () => {
|
|
if (newObj.trim() === '') return;
|
|
update({ objectives: [...q.objectives, { id: newId(), text: newObj.trim(), done: false }] });
|
|
setNewObj('');
|
|
};
|
|
const done = q.objectives.filter((o) => o.done).length;
|
|
|
|
return (
|
|
<div className="rounded-lg border border-line bg-panel p-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Input className="flex-1 font-display font-semibold" value={q.title} onChange={(e) => update({ title: e.target.value })} aria-label="Quest title" />
|
|
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</Select>
|
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
|
</div>
|
|
|
|
<textarea
|
|
value={q.description}
|
|
onChange={(e) => update({ description: e.target.value })}
|
|
placeholder="What's this quest about?"
|
|
className="mt-2 min-h-16 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
|
/>
|
|
|
|
<label className="mt-2 block text-xs text-muted">
|
|
Reward
|
|
<Input value={q.reward} onChange={(e) => update({ reward: e.target.value })} placeholder="500 gp, a favour, a magic sword…" />
|
|
</label>
|
|
|
|
<div className="mt-3">
|
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
|
|
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
|
|
</div>
|
|
<ul className="space-y-1">
|
|
{q.objectives.map((o) => (
|
|
<li key={o.id} className="flex items-center gap-2 text-sm">
|
|
<input type="checkbox" checked={o.done} onChange={(e) => setObjective(o.id, { done: e.target.checked })} aria-label={o.text} />
|
|
<input
|
|
className={cn('flex-1 bg-transparent text-sm focus:outline-none', o.done ? 'text-muted line-through' : 'text-ink')}
|
|
value={o.text}
|
|
onChange={(e) => setObjective(o.id, { text: e.target.value })}
|
|
/>
|
|
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"><X size={13} aria-hidden /></button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<div className="mt-1 flex gap-2">
|
|
<Input className="h-8 text-sm" value={newObj} onChange={(e) => setNewObj(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addObjective()} placeholder="Add objective…" />
|
|
<Button size="sm" onClick={addObjective}>Add</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|