Files
ttrpg_manager/src/features/world/QuestsPage.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

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