UI/UX "Living Codex" 4/n: assistant features + emoji purge

- Replace all emoji icons (~26 instances) with Lucide icons across the app
  (DashboardPage, SessionSidebar, PlayerBoards, MyCharacterPanel,
  PlayerViewPage, EncounterTracker, CampaignsPage, HandoutControl,
  SessionControl, CharacterSheet, EncounterTipCard, ActionGuide)
- Expose real 5e race data in wizard (ASI/vision/traits instead of stub desc);
  add vision field to loadRaces5e return type
- Surface subclass descriptions after picking a subclass in the wizard
- Add per-class tips, race synergy notes, and ability/skill guidance in wizard
- New ActionGuide component: collapsible "What can I do on my turn?" in player view
- New SessionPrepCard: AI + fallback session hook generator on assistant page
- New NpcGenCard: AI + fallback quick NPC generator with "Add to campaign" action
- Inline NPC detail generator (wand button) on each NPC card in NpcsPage
- Quest hook generator button in QuestsPage header
- Condition tooltips in player party view (useConditionGlossary)
- Pass campaign.system through session snapshot so player view is system-aware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:29:06 +02:00
parent 235cdecb53
commit 740cf20b93
24 changed files with 1176 additions and 67 deletions
+8
View File
@@ -18,6 +18,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights'; import { CampaignInsights } from './CampaignInsights';
import { SessionPrepCard } from './SessionPrepCard';
import { NpcGenCard } from './NpcGenCard';
export function AssistantPage() { export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -128,6 +130,12 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2> <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." /> <SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session hooks</h2>
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Quick NPC</h2>
<NpcGenCard campaign={campaign} />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2> <h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} /> <CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section> </section>
+2 -1
View File
@@ -1,3 +1,4 @@
import { Brain } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas'; import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
data-testid="encounter-tip" data-testid="encounter-tip"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span> <Brain size={18} className="shrink-0 text-muted" aria-hidden />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{theme ? ( {theme ? (
<> <>
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { getSystem } from '@/lib/rules';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface Props {
campaign: Campaign;
}
export function NpcGenCard({ campaign }: Props) {
const navigate = useNavigate();
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const [hint, setHint] = useState('');
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [npc, setNpc] = useState<NpcQuick | null>(null);
const [message, setMessage] = useState<string | null>(null);
const ctx = {
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
systemLabel: getSystem(campaign.system).label,
questsSummary: 'No active quests.',
notesSummary: 'No notes yet.',
};
const generate = async () => {
setState('loading');
setMessage(null);
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const result = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (result.ok && 'data' in result) {
setNpc(result.data);
setState('ready');
return;
}
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template NPC instead.`);
}
setNpc(fallbackNpc());
setState('ready');
};
const addToCampaign = async () => {
if (!npc) return;
const created = await npcsRepo.create(campaign.id, npc.name);
await npcsRepo.update(created.id, {
role: npc.role,
description: `Motivation: ${npc.motivation}\n\nSecret: ${npc.secret}\n\nIntro hook: ${npc.hook}`,
});
void navigate({ to: '/npcs' });
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">
Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table.
</p>
<div className="flex gap-2">
<Input
value={hint}
onChange={(e) => setHint(e.target.value)}
placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`}
className="flex-1 text-sm"
aria-label="NPC hint"
/>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Generate (AI)' : 'Generate'}
</Button>
</div>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
{state === 'ready' && npc && (
<div className="mt-4 rounded-md border border-line bg-surface-2 p-3 text-sm">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<span className="font-display text-lg font-semibold text-ink">{npc.name}</span>
<span className="ml-2 text-xs text-muted">{npc.role}</span>
</div>
<Button size="sm" variant="primary" onClick={() => void addToCampaign()}>
Add to campaign
</Button>
</div>
<dl className="space-y-1.5 text-xs">
<NpcField label="Motivation" value={npc.motivation} />
<NpcField label="Secret" value={npc.secret} />
<NpcField label="Intro hook" value={npc.hook} />
</dl>
</div>
)}
</div>
);
}
function NpcField({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="inline font-semibold text-ink">{label}: </dt>
<dd className="inline text-muted">{value}</dd>
</div>
);
}
@@ -0,0 +1,88 @@
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>
);
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { type ReactNode, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react'; import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories'; import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample'; import { seedSampleCampaign } from '@/lib/sample';
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
</p> </p>
<hr className="gilt-rule mx-auto mt-5 max-w-xs" /> <hr className="gilt-rule mx-auto mt-5 max-w-xs" />
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3"> <div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." /> <FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." /> <FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." /> <FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
</div> </div>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2"> <div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button> <Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
@@ -83,7 +83,7 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
); );
} }
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) { function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
return ( return (
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong"> <div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
<div className="text-xl" aria-hidden>{icon}</div> <div className="text-xl" aria-hidden>{icon}</div>
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react'; import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas'; import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { getSystem, ABILITY_ABBR } from '@/lib/rules';
@@ -161,7 +161,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3"> <div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>} {c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> Link copied</> : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button> <Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button> <Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div> </div>
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Lightbulb } from 'lucide-react';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas'; import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { import {
@@ -12,8 +13,10 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format'; import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input'; import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -33,16 +36,16 @@ function playstyle(c: RulesetClass): string {
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = { const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
'5e': [ '5e': [
{ label: '🛡️ Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } }, { label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } }, { label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } }, { label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } }, { label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
], ],
pf2e: [ pf2e: [
{ label: '🛡️ Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } }, { label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } }, { label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } }, { label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } }, { label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
], ],
}; };
@@ -65,10 +68,37 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => { useEffect(() => {
let on = true; let on = true;
if (system === '5e') { if (system === '5e') {
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed })))); void loadRaces5e().then((rs) => {
if (!on) return;
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
setOrigins(rs.map((r) => {
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
return { name: r.name, desc, meta };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills })))); void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
} else { } else {
void loadPf2e('ancestries').then((rs) => on && setOrigins(rs.map((r) => ({ name: String(r.name), desc: String((r.description ?? r.text) ?? ''), ...(typeof r.hp === 'number' ? { hp: r.hp } : {}), ...((r.speed as { land?: number } | undefined)?.land ? { speed: (r.speed as { land: number }).land } : {}) })))); void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: String((r.summary ?? r.text) ?? ''),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
meta,
};
}));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') })))); void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
} }
return () => { on = false; }; return () => { on = false; };
@@ -285,21 +315,61 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div> </div>
</div> </div>
{selectedClass && selectedClass.subclasses.length > 0 && ( {selectedClass && (() => {
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}> const tip = getClassTip(system, selectedClass.slug);
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}> if (!tip) return null;
<option value=""> decide later </option> return (
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)} <div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
</Select> <div className="flex items-start gap-2">
</Field> <Lightbulb size={15} className="mt-0.5 shrink-0 text-accent" aria-hidden />
)} <div>
<div className="font-medium text-ink">{selectedClass.name}</div>
<p className="mt-0.5 text-muted">{tip.role}</p>
<div className="mt-1.5 text-xs text-muted">
<span className="font-medium text-ink">Key abilities:</span> {tip.primaryAbilities}
</div>
{tip.beginner && <Badge tone="verdigris" className="mt-1.5">Good for beginners</Badge>}
</div>
</div>
</div>
);
})()}
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
return (
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
{sc?.desc && (
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
{sc.desc}
</p>
)}
</Field>
);
})()}
</div> </div>
)} )}
{stepName === 'Origin' && ( {stepName === 'Origin' && (
<div className="grid gap-4 sm:grid-cols-2"> <div className="space-y-3">
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} /> <div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} /> <OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{selectedClass && selectedOrigin && (() => {
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
if (!synergy) return null;
return (
<p className="flex items-center gap-1.5 text-xs text-accent">
<Lightbulb size={12} className="shrink-0" aria-hidden />
{synergy}
</p>
);
})()}
</div> </div>
)} )}
@@ -312,7 +382,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div> </div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>} {method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>} {method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>} {selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
: null);
return text ? (
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
{text}
</p>
) : null;
})()}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>} {usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => { {ABILITIES.map((a, i) => {
@@ -339,7 +420,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Skills' && ( {stepName === 'Skills' && (
<div> <div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p> <p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-3 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p> <p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
<p className="mb-3 flex items-center gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="shrink-0 text-accent" aria-hidden />
{selectedClass.name}s often pick: <span className="ml-1 text-ink">{tip.skillSuggestions}</span>
</p>
) : null;
})()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => { {skillOptions.map((key) => {
const checked = skills.includes(key); const checked = skills.includes(key);
+2 -1
View File
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Dices, Dices,
@@ -548,7 +549,7 @@ function ConditionPicker({
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}> <option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
{cd.name} {cd.name}
{cd.valued ? ' (#)' : ''} {cd.valued ? ' (#)' : ''}
{taken.has(cd.name) ? ' ✓' : ''} {taken.has(cd.name) ? <Check size={12} aria-hidden /> : ''}
</option> </option>
))} ))}
<option value="__custom__">Custom</option> <option value="__custom__">Custom</option>
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Check, Megaphone } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize'; import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -21,7 +22,7 @@ export function HandoutControl() {
return ( return (
<> <>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players"> <Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
{active ? '📣 Handout ✓' : '📣 Handout'} <Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
</Button> </Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>} {active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{open && ( {open && (
+4 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { RadioTower, X } from 'lucide-react'; import { Check, Lock, RadioTower, X } from 'lucide-react';
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore'; import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync'; import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
@@ -28,7 +28,7 @@ export function SessionControl() {
if (!cloudUsername()) { if (!cloudUsername()) {
return ( return (
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}> <Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
🔒 Sign in to host <Lock size={14} aria-hidden /> Sign in to host
</Button> </Button>
); );
} }
@@ -38,7 +38,7 @@ export function SessionControl() {
const pw = window.prompt('Optional player password (leave blank for none):')?.trim(); const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
hostSession(campaign.id, pw || undefined); hostSession(campaign.id, pw || undefined);
}} }}
>📡 Host</Button> ><RadioTower size={14} aria-hidden /> Host</Button>
); );
} }
@@ -52,7 +52,7 @@ export function SessionControl() {
className="font-mono font-semibold tracking-wider text-accent-deep" className="font-mono font-semibold tracking-wider text-accent-deep"
title="Copy player link" title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }} onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button> >{joinCode}{copied ? <><Check size={11} aria-hidden /></> : ''}</button>
{seatRequests.length > 0 && ( {seatRequests.length > 0 && (
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white" <button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
+3 -2
View File
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
import type { SessionLogEntry } from '@/lib/schemas'; import type { SessionLogEntry } from '@/lib/schemas';
import type { RosterEntry } from '@/lib/sync/messages'; import type { RosterEntry } from '@/lib/sync/messages';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize'; import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Dice5, Mail, MessageSquare } from 'lucide-react';
import { Input, Textarea } from '@/components/ui/Input'; import { Input, Textarea } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
@@ -109,7 +110,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden /> <span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null} {p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
{isGm && p.playerId !== 'gm' && ( {isGm && p.playerId !== 'gm' && (
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button> <button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info"><Mail size={12} aria-hidden /></button>
)} )}
</li> </li>
))} ))}
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<ul className="space-y-0.5 text-xs"> <ul className="space-y-0.5 text-xs">
{(recap ?? []).map((e) => ( {(recap ?? []).map((e) => (
<li key={e.id} className="flex gap-2"> <li key={e.id} className="flex gap-2">
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span> <span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'} aria-hidden>{e.kind === 'roll' ? <Dice5 size={12} /> : <MessageSquare size={12} />}</span>
<span className="text-muted">{e.from}:</span> <span className="text-muted">{e.from}:</span>
<span className="min-w-0 flex-1 text-ink">{e.text}</span> <span className="min-w-0 flex-1 text-ink">{e.text}</span>
</li> </li>
+39
View File
@@ -0,0 +1,39 @@
import { Check } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
/** Collapsible "What can I do?" guide for the player in-session view. */
export function ActionGuide({ character }: { character: Character }) {
const guide = getTurnGuide(character.system, character.className?.toLowerCase() ?? '') ?? getGenericGuide(character.system);
return (
<details className="mt-3 rounded-lg border border-line bg-surface-2">
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-muted hover:text-ink">
What can I do on my turn?
</summary>
<div className="border-t border-line px-3 pb-3 pt-2">
<ul className="space-y-2">
{guide.actions.map((a) => (
<li key={a.label}>
<span className="text-xs font-semibold text-ink">{a.label}: </span>
<span className="text-xs text-muted">{a.desc}</span>
</li>
))}
</ul>
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
{guide.upgradeAt.note}
</p>
)}
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-success/30 bg-success/5 px-2 py-1 text-xs text-success">
<Check size={12} className="mr-1 inline" aria-hidden /> {guide.upgradeAt.note}
</p>
)}
{guide.tip && (
<p className="mt-2 border-t border-line pt-2 text-xs italic text-muted">{guide.tip}</p>
)}
</div>
</details>
);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Character, Defenses } from '@/lib/schemas'; import type { Character, Defenses } from '@/lib/schemas';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation'; import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore'; import { useRollStore } from '@/stores/rollStore';
@@ -61,7 +62,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} /> <Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} /> <Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} /> <Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
@@ -114,6 +115,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{/* Dice */} {/* Dice */}
<DiceBox characterId={c.id} /> <DiceBox characterId={c.id} />
</div> </div>
<ActionGuide character={c} />
</section> </section>
); );
} }
+10 -4
View File
@@ -1,6 +1,8 @@
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react'; import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react';
import type { SystemId } from '@/lib/rules';
import type { Snapshot } from '@/lib/sync/messages'; import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map'; import type { PlayerMap } from '@/lib/map';
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page'; import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex'; import { Meter, Badge } from '@/components/ui/Codex';
@@ -11,6 +13,7 @@ import { PlayerMapView } from './PlayerMapView';
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) { export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map, handout } = snapshot; const { party, encounter, map, handout } = snapshot;
const quests = snapshot.quests ?? []; const quests = snapshot.quests ?? [];
const condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined; const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
const privateHandout = usePlayerSessionStore((s) => s.privateHandout); const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
return ( return (
@@ -18,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{privateHandout && ( {privateHandout && (
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4"> <section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info"> <div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
<Mail size={13} aria-hidden /> 📨 Just for you <Mail size={13} aria-hidden /> Just for you
</div> </div>
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2> <h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>} {privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
@@ -74,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div> <div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && ( {c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => ( {c.conditions.map((cond, i) => {
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span> const tooltip = condGlossary.get(cond.name.toLowerCase());
))} return (
<span key={i} title={tooltip || undefined} className="cursor-help rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
);
})}
</div> </div>
)} )}
</div> </div>
+2 -1
View File
@@ -11,6 +11,7 @@ import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { useCharacters, useAllPcs } from '@/features/characters/hooks'; import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks'; import { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks'; import { useCalendar, useMaps } from '@/features/world/hooks';
import { Maximize2 } from 'lucide-react';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { PlayerBoards } from './PlayerBoards'; import { PlayerBoards } from './PlayerBoards';
@@ -47,7 +48,7 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1> <h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
<p className="mt-1 text-sm text-muted">{subtitle}</p> <p className="mt-1 text-sm text-muted">{subtitle}</p>
</div> </div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button> <Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
</div> </div>
<hr className="gilt-rule mt-3" /> <hr className="gilt-rule mt-3" />
</div> </div>
+20 -16
View File
@@ -1,6 +1,9 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react'; import {
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
ChevronRight, Shield, CheckCheck, Skull, Handshake,
} from 'lucide-react';
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas'; import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
@@ -15,20 +18,21 @@ export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
} }
const LINKS = [ type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
{ to: '/assistant', label: 'Assistant', icon: '🧠' }, const LINKS: { to: string; label: string; icon: NavIcon }[] = [
{ to: '/characters', label: 'Characters', icon: '🧝' }, { to: '/assistant', label: 'Assistant', icon: Brain },
{ to: '/combat', label: 'Combat', icon: '⚔' }, { to: '/characters', label: 'Characters', icon: Users },
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' }, { to: '/combat', label: 'Combat', icon: Swords },
{ to: '/npcs', label: 'NPCs', icon: '🎭' }, { to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
{ to: '/quests', label: 'Quests', icon: '🗺' }, { to: '/npcs', label: 'NPCs', icon: VenetianMask },
{ to: '/maps', label: 'Maps', icon: '🗺️' }, { to: '/quests', label: 'Quests', icon: Target },
{ to: '/calendar', label: 'Calendar', icon: '📅' }, { to: '/maps', label: 'Maps', icon: Map },
{ to: '/compendium', label: 'Compendium', icon: '📚' }, { to: '/calendar', label: 'Calendar', icon: CalendarDays },
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' }, { to: '/compendium', label: 'Compendium', icon: Library },
{ to: '/dice', label: 'Dice', icon: '🎲' }, { to: '/homebrew', label: 'Homebrew', icon: Hammer },
{ to: '/play', label: 'Player View', icon: '📺' }, { to: '/dice', label: 'Dice', icon: Dice5 },
] as const; { to: '/play', label: 'Player View', icon: Monitor },
];
function Dashboard({ campaign }: { campaign: Campaign }) { function Dashboard({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id); const characters = useCharacters(campaign.id);
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
to={l.to} to={l.to}
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow" className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
> >
<span className="text-2xl" aria-hidden>{l.icon}</span> <l.icon size={24} className="text-muted" aria-hidden />
<span className="text-sm text-ink">{l.label}</span> <span className="text-sm text-ink">{l.label}</span>
</Link> </Link>
))} ))}
+36 -2
View File
@@ -1,6 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Wand2 } from 'lucide-react';
import type { Campaign, Npc } from '@/lib/schemas'; import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories'; import { npcsRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks'; import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
@@ -33,7 +38,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
<> <>
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" /> <Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)} {filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
</div> </div>
</> </>
)} )}
@@ -41,13 +46,39 @@ function Npcs({ campaign }: { campaign: Campaign }) {
); );
} }
function NpcCard({ npc }: { npc: Npc }) { function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
const [n, setN] = useState(npc); const [n, setN] = useState(npc);
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, { const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description, name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
}), 350); }), 350);
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; }); const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
const generateDetails = async () => {
setGenState('loading');
const ctx = {
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
systemLabel: getSystem(campaign.system).label,
questsSummary: 'No active quests.',
notesSummary: 'No notes yet.',
};
const hint = [n.name, n.role].filter(Boolean).join(', ');
let result: NpcQuick | null = null;
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const res = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (res.ok && 'data' in res) result = res.data;
}
if (!result) result = fallbackNpc();
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
update({ description: desc, role: result.role || n.role });
setGenState('idle');
};
return ( return (
<div className="rounded-lg border border-line bg-panel p-4"> <div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -55,6 +86,9 @@ function NpcCard({ npc }: { npc: Npc }) {
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status"> <Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)} {STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select> </Select>
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
<Wand2 size={13} aria-hidden />
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button> <Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
</div> </div>
<div className="mt-2 grid grid-cols-3 gap-2"> <div className="mt-2 grid grid-cols-3 gap-2">
+44 -1
View File
@@ -2,8 +2,15 @@ import { useState } from 'react';
import type { Campaign, Quest, Objective } from '@/lib/schemas'; import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories'; import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids'; 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 { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useQuests } from './hooks'; 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 { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
@@ -20,15 +27,51 @@ export function QuestsPage() {
function Quests({ campaign }: { campaign: Campaign }) { function Quests({ campaign }: { campaign: Campaign }) {
const quests = useQuests(campaign.id); 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 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 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 ( return (
<Page> <Page>
<PageHeader <PageHeader
title="Quests" title="Quests"
subtitle={campaign.name} subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} 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 ? ( {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>} /> <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>} />
+251
View File
@@ -0,0 +1,251 @@
/** Deterministic, per-class action guide for the player "What can I do?" panel. */
export interface ActionStep {
label: string;
desc: string;
}
export interface TurnGuide {
actions: ActionStep[];
/** Optional level gate (shown when level < minLevel). */
upgradeAt?: { level: number; note: string };
/** Reminder shown at all levels. */
tip?: string;
}
// ---- 5e guides ----
const GUIDE_5E: Record<string, TurnGuide> = {
barbarian: {
actions: [
{ label: 'Action', desc: 'Attack — if you aren\'t raging yet, use a Bonus Action to Rage first (it\'s free this turn).' },
{ label: 'Bonus Action', desc: 'Rage (once per combat, free resources). At level 2: Reckless Attack costs no action — just declare it on your first attack roll.' },
{ label: 'Movement', desc: 'Your speed is 30 ft. While raging you can\'t cast spells or concentrate, but you\'re nigh-unstoppable.' },
{ label: 'Reaction', desc: 'Opportunity Attack — if an enemy leaves your reach, you can attack as a reaction.' },
],
upgradeAt: { level: 5, note: 'At level 5 you gain Extra Attack — make two attacks on your Attack action.' },
tip: 'Rage gives resistance to Bludgeoning, Piercing, and Slashing damage. Never fight without it if you expect more than one hit.',
},
bard: {
actions: [
{ label: 'Action', desc: 'Cast a spell (Vicious Mockery to impose disadvantage costs nothing but a cantrip slot), or use Help.' },
{ label: 'Bonus Action', desc: 'Bardic Inspiration — give a d6 to an ally; they can add it to any roll within the next 10 minutes. At level 5 it becomes a d8.' },
{ label: 'Movement', desc: '30 ft. Stay at range — you have light armour and low hit points.' },
{ label: 'Reaction', desc: 'Cutting Words (once per short rest, subtract your Inspiration die from an enemy\'s attack, check, or damage roll).' },
],
tip: 'Bardic Inspiration is your most powerful tool early on. Give it out before initiative is rolled when possible.',
},
cleric: {
actions: [
{ label: 'Action', desc: 'Cast a spell — Sacred Flame (cantrip, radiant damage) costs no slots. Use higher-level slots for Guiding Bolt or Spirit Guardians.' },
{ label: 'Bonus Action', desc: 'Healing Word is a bonus-action heal — save it to pick up a downed ally. Better than Cure Wounds in most combats.' },
{ label: 'Movement', desc: '30 ft. Channel Divinity is a free action, not a bonus action — use it on the same turn as a spell.' },
{ label: 'Reaction', desc: 'Shield of Faith (concentration) boosts an ally\'s AC as your Action; your Reaction is free for Opportunity Attacks.' },
],
tip: 'Healing Word as a Bonus Action to revive downed allies, then attack or cast on your main Action. Don\'t burn all your spell slots healing mid-combat.',
},
druid: {
actions: [
{ label: 'Action', desc: 'Cast a spell (Shillelagh makes your staff a Wisdom-based melee weapon) or use Wild Shape to become a beast.' },
{ label: 'Bonus Action', desc: 'Wild Shape is a Bonus Action — transform mid-round without losing your main Action. Your gear melds into your new form.' },
{ label: 'Movement', desc: '30 ft (or the Wild Shape form\'s speed — most animals are faster). Wild Shape HP is separate from yours; it\'s a free buffer.' },
{ label: 'Reaction', desc: 'No unique reaction early on. Use Opportunity Attacks in melee or cast Absorb Elements to halve elemental damage.' },
],
upgradeAt: { level: 2, note: 'At level 2 you unlock Wild Shape. Use the CR ¼ CR ½ beast forms as frontline tanks — when they run out of HP you revert, unharmed.' },
tip: 'Wild Shape HP is a free buffer on top of your own. Get hit in beast form, then revert and cast heals if needed.',
},
fighter: {
actions: [
{ label: 'Action', desc: 'Attack — one attack until level 5. Choose a Fighting Style at level 1 (Great Weapon Master, Archery, Defence, Dueling, etc.).' },
{ label: 'Bonus Action', desc: 'Second Wind (once per short rest) — recover 1d10 + Fighter level HP. At level 3 some subclasses add bonus action attacks (Battle Master, Champion).' },
{ label: 'Movement', desc: '30 ft (35 ft in medium armour with the Mobility feat). You can split movement before and after an attack.' },
{ label: 'Reaction', desc: 'Opportunity Attack — make one attack against any creature that moves out of your reach. Strongly consider the Sentinel feat later.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack — make two attacks per Attack action. Level 11: Three attacks. Level 20: Four attacks.' },
tip: 'You have the most Action Surge uses and the broadest weapon/armour access. Don\'t forget Second Wind between fights — it\'s free.',
},
monk: {
actions: [
{ label: 'Action', desc: 'Attack — use Dexterity with unarmed strikes and monk weapons (short sword, any simple weapon). Flurry of Blows can add 2 more hits per turn.' },
{ label: 'Bonus Action', desc: 'Flurry of Blows (1 ki — hit twice), Step of the Wind (1 ki — Dash or Disengage), or Patient Defense (1 ki — Dodge).' },
{ label: 'Movement', desc: '35 ft at level 1, increasing 5 ft every 4 levels. You don\'t need to engage by running in a straight line — use your speed creatively.' },
{ label: 'Reaction', desc: 'Deflect Missiles — catch or redirect incoming ranged weapon attacks. At level 3: Stunning Strike (2 ki on a hit) makes a target lose their next turn on a failed Con save.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + Stunning Strike. A Stunning Strike on a melee hit removes the target\'s next action and gives advantage to all attacks against them.' },
tip: 'Ki points replenish on a short rest. Use them every short rest — hoarding ki is a common new-player mistake.',
},
paladin: {
actions: [
{ label: 'Action', desc: 'Attack. After a hit you can expend a spell slot as a Bonus Action to add Divine Smite (2d8 radiant per slot level, doubled on crits — save slots for crits).' },
{ label: 'Bonus Action', desc: 'Divine Smite after a hit (your choice, after you see the roll). Lay on Hands is an Action. Healing using a spell slot is also an Action.' },
{ label: 'Movement', desc: '30 ft in heavy armour. Your Auras (level 6+) are passive — they don\'t cost actions, they just work.' },
{ label: 'Reaction', desc: 'Opportunity Attack. At level 6: Aura of Protection adds your Charisma modifier to all saving throws for nearby allies — no action needed.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + 2nd-level spell slots. Smiting with a 2nd-level slot adds 3d8 radiant — devastating on a crit.' },
tip: 'Divine Smite is declared after you know the attack hit and whether it\'s a crit (2× dice on a crit). Save your highest spell slots for confirmed crits.',
},
ranger: {
actions: [
{ label: 'Action', desc: 'Attack at range or melee. Mark a creature as your Favoured Enemy for bonus damage. Hunter\'s Mark (concentration, Bonus Action to recast) adds 1d6 per hit.' },
{ label: 'Bonus Action', desc: 'Cast Hunter\'s Mark (1st-level spell, concentration), or move it to a new target when the old one dies.' },
{ label: 'Movement', desc: '30 ft. Ranger subclasses often grant additional Bonus Actions at level 3 (Hunter: Horde Breaker; Beastmaster: command your companion).' },
{ label: 'Reaction', desc: 'Opportunity Attack. At level 7: Evasion — half damage (zero on a success) vs area attacks that target Dexterity saves.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack. Combine with Hunter\'s Mark for consistent damage every turn.' },
tip: 'Hunter\'s Mark is concentration — don\'t cast other concentration spells while it\'s active. It moves for free when a target drops, no spell slot needed.',
},
rogue: {
actions: [
{ label: 'Action', desc: 'Attack — one attack per turn (use finesse or ranged weapons for Dexterity-based Sneak Attack). Sneak Attack triggers once per turn when you have advantage or an ally is adjacent to your target.' },
{ label: 'Bonus Action', desc: 'Cunning Action (level 2): Dash, Disengage, or Hide as a Bonus Action. Disengage means you can walk away without triggering Opportunity Attacks.' },
{ label: 'Movement', desc: '30 ft. Use Disengage (Cunning Action) to safely retreat after attacking from range or melee.' },
{ label: 'Reaction', desc: 'Uncanny Dodge (level 5): use your Reaction to halve damage from one attack per round. Evasion (level 7): half/no damage from Dexterity saves.' },
],
upgradeAt: { level: 2, note: 'Level 2: Cunning Action unlocks the Rogue\'s unique mobility. Disengage every turn after attacking to avoid Opportunity Attacks.' },
tip: 'You only need Sneak Attack once per turn but it can trigger on Opportunity Attacks too. Set up flanking (adjacent ally) to guarantee advantage.',
},
sorcerer: {
actions: [
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Ray of Frost are damage cantrips that cost no slots. Chromatic Orb (1st slot) deals 3d8 of any element — strong early choice.' },
{ label: 'Bonus Action', desc: 'Quicken Spell (2 Sorcery Points): cast a spell as a Bonus Action instead of an Action — lets you attack twice in one turn with two spells.' },
{ label: 'Movement', desc: '30 ft. Stay far from enemies — use Misty Step (2nd-level slot) to teleport 30 ft if surrounded.' },
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot, level 6): cancel a spell being cast within 60 ft. No other unique reaction early on.' },
],
upgradeAt: { level: 3, note: 'Level 3: Metamagic unlocks. Twinned Spell (double target, 1 point per spell level) and Empowered Spell (1 point to reroll damage dice) are often picked first.' },
tip: 'Sorcery Points convert to extra spell slots (1 short rest). Don\'t spend all your Sorcery Points on Metamagic — keep some for emergency slots.',
},
warlock: {
actions: [
{ label: 'Action', desc: 'Cast a spell or use Eldritch Blast (cantrip, force damage, scales to 4 beams at level 17). Eldritch Blast + Agonizing Blast invocation is your go-to attack.' },
{ label: 'Bonus Action', desc: 'Hex (1st-level slot, concentration): choose an ability, target has disadvantage on checks with it, and you add 1d6 necrotic per hit. Move it when the target dies for free.' },
{ label: 'Movement', desc: '30 ft. Your spell slots recharge on a short rest — take short rests as often as the group allows to keep up output.' },
{ label: 'Reaction', desc: 'No unique early reaction. Devil\'s Sight invocation lets you see in magical darkness — combine with Darkness spell for advantage on all attacks.' },
],
upgradeAt: { level: 5, note: 'Level 5: 3rd-level spell slots (still only 2). Hypnotic Pattern and Hunger of Hadar are crowd-control standouts at this level.' },
tip: 'Your spell slots are few but powerful — always at your highest slot level. Short-rest classes (Monk, Fighter) decide short rest frequency; advocate for them if your DM forgets.',
},
wizard: {
actions: [
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Chill Touch are free cantrips. Hold your best slots (Fireball, Hypnotic Pattern) for fights that matter.' },
{ label: 'Bonus Action', desc: 'No universal bonus action. Misty Step (2nd-level) uses an Action, not a Bonus Action. Some subclass features add bonus action options.' },
{ label: 'Movement', desc: '30 ft. Stand behind your party and out of reach. Blur and Mirror Image are Concentration-free self-protections.' },
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot): cancel a spell being cast. Shield (1st-level slot): add +5 AC until your next turn (excellent use of a reaction slot).' },
],
upgradeAt: { level: 5, note: 'Level 5: Fireball and Lightning Bolt become available. These 3rd-level spells are game-changers — conserve 3rd-level slots for them early on.' },
tip: 'Copy spells into your spellbook whenever you find scrolls or can access another wizard\'s book. The more spells you know, the more prepared you can be.',
},
};
// ---- PF2e guides ----
const GUIDE_PF2E: Record<string, TurnGuide> = {
barbarian: {
actions: [
{ label: '1 action', desc: 'Strike — make one weapon attack. In PF2e, you can Strike up to 3 times in one turn, but each additional attack takes a -5 / -10 penalty to hit.' },
{ label: '2 actions', desc: 'Enter Rage (free the first time per encounter). Most of your power comes from raging — always start combat in Rage.' },
{ label: '3 actions', desc: 'Double-Strike, Sudden Charge (move + attack), or take the Intimidate action to Demoralize enemies.' },
{ label: 'Reaction', desc: 'Attack of Opportunity (from the Reactive Strike feat — Fighters get it free; Barbarians can buy it). You can also Step away from threats as a free action when not engaged.' },
],
tip: 'In PF2e you have 3 actions and 1 reaction per round. Move, Strike, Strike is a common sequence (with -5 on the second). Rage first to maximise your bonus damage.',
},
champion: {
actions: [
{ label: '1 action', desc: 'Strike — use your deity\'s favoured weapon for extra class synergy.' },
{ label: '2 actions', desc: 'Raise a Shield (1 action) + Strike (1 action) = Block reaction available AND make an attack. Shield\'s AC bonus only applies when raised.' },
{ label: '3 actions', desc: 'Move (1) + Raise Shield (1) + Strike (1) is your bread-and-butter turn.' },
{ label: 'Reaction', desc: 'Champion\'s Reaction — Retributive Strike (Paladin) or Rescuing Reach (Liberator): when an ally within 15 ft is hit, you can interpose and reduce the damage. This is why you exist.' },
],
tip: 'Your Champion\'s Reaction is the most powerful defensive tool in PF2e. Always stay within 15 ft of the party\'s squishiest member. Raise your Shield every single turn.',
},
cleric: {
actions: [
{ label: 'Heal / Harm', desc: 'Heal at 1 action targets only you; 2 actions targets a touched creature; 3 actions is an area burst that heals all allies and damages undead. Pick based on the situation.' },
{ label: 'Cast a Spell', desc: 'Most spells take 2 actions. Divine Spellcasters have access to Harm / Heal, Bless, and weapon spells through their deity.' },
{ label: 'Channel Smite', desc: '2 actions: Strike AND add a Harm/Heal charge to the hit (1 + spell rank damage). Extremely efficient if you want to be in melee.' },
{ label: 'Reaction', desc: 'No class reaction at level 1. Take a shield if you want a Block reaction — it matters against single big hits.' },
],
tip: 'The 3-action Heal burst is PF2e\'s most efficient healing: one 3-action spell heals every ally in range. Decide at the start of combat whether you\'re healing or offending that round.',
},
fighter: {
actions: [
{ label: '1 action', desc: 'Strike — Fighters have the highest weapon proficiency in the game (Legendary by level 17).' },
{ label: '2 actions', desc: 'Power Attack (level 1 feat) — Strike for extra damage with a -2 MAP. Double Slice (two weapon) or Knockdown (felling blow). Use one of these every turn early on.' },
{ label: '3 actions', desc: 'Sudden Charge (2 actions — move 2× speed + Strike), or Intimidating Strike (Strike + Demoralize in one action at level 10).' },
{ label: 'Reaction', desc: 'Reactive Strike — hit any creature that uses a manipulate action, Stands Up, or moves through your reach. Extremely valuable; use it every time it triggers.' },
],
tip: 'Your Reactive Strike is a free extra attack every single round. Build around triggering it: hold position, let enemies come to you, and punish movement.',
},
ranger: {
actions: [
{ label: 'Hunt Prey', desc: '1 action at the start of each combat. Gives +2 to Perception checks and +2 to damage against your Prey for the whole fight. Never skip this.' },
{ label: 'Strike', desc: '1 action. Ranger\'s Flurry (level 1 optional rule) reduces MAP on second Strike by 2 — great for two-weapon and bow builds.' },
{ label: 'Spells', desc: 'You get a small list of ranger spells from level 1 if you take the Spellcasting archetype (or the Druidic Ranger subclass). Many have 1-minute durations — prep before combat.' },
{ label: 'Reaction', desc: 'No class reaction at level 1. Consider the Attack of Opportunity feat chain for melee rangers. Evasive Arrow (ranged) can redirect enemy fire at level 12.' },
],
tip: 'Hunt Prey first, always. Your target has essentially -2 to -4 to their effective AC relative to you. If your Prey drops, use the free Hunt Prey reaction to pick a new one.',
},
rogue: {
actions: [
{ label: 'Strike', desc: 'Sneak Attack (+1d6 per 2 rogue levels) triggers on any flat-footed target. Flanking (you and an ally both adjacent to the target) makes the target flat-footed.' },
{ label: 'Feint', desc: '1 action — Deception check vs target\'s Perception DC. On success: flat-footed to you until end of your next turn. Enables Sneak Attack solo.' },
{ label: 'Skill Actions', desc: 'Tumble Through (Acrobatics), Steal, Disable Device, Recall Knowledge — Rogues have more skill actions than anyone. Use them every turn.' },
{ label: 'Reaction', desc: 'Nimble Dodge (level 1) — add +2 to AC once per round as a Reaction when attacked. Better than many class reactions.' },
],
tip: 'Sneak Attack requires flat-footed — arrange flanking at the start of every fight. Nimble Dodge protects you when flanking fails.',
},
sorcerer: {
actions: [
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. Haste is 2 actions on an ally — one of the best buffs in PF2e. Burning Hands, Fear, and Magic Missile are strong early options.' },
{ label: 'Cast a Spell (1 action)', desc: 'Cantrips and some focus spells can be cast for 1 action (with limitations). A cantrip each turn fills your 3rd action after moving and casting.' },
{ label: 'Blood Magic', desc: 'Your bloodline grants a Focus Pool and a Focus Spell. These replenish on 10-minute refocuses — use them freely.' },
{ label: 'Reaction', desc: 'No class reaction. Contingency (level 14 wizard spell) is the gold standard; until then, just move away from danger.' },
],
tip: 'PF2e Sorcerers cast Spontaneously — pick fewer spells but cast them at any level. Choose flexible options like Fear (scales well) over highly situational picks.',
},
wizard: {
actions: [
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. At level 1, Grease (difficult terrain in an area), Magic Missile (no roll), and Fear are excellent. Save your best slots for fights that demand them.' },
{ label: 'Recall Knowledge', desc: '1 action — identify a creature\'s weaknesses, immunities, and key abilities. In PF2e this is vital; knowing a troll regenerates fire damage prevents a wasted round.' },
{ label: 'Cantrip', desc: 'Electric Arc hits two adjacent targets for 1d4+INT — the best damage cantrip in PF2e. Use it when you don\'t have a better target for a spell slot.' },
{ label: 'Reaction', desc: 'Counterspell — expend a prepared copy of a spell to cancel the same spell cast against you. Prepare Counterspell-worthy copies of any spell the enemy might cast.' },
],
tip: 'Prepare Recall Knowledge on at least one party member (via the Identifying feature or ask your DM). In PF2e, knowing resistances and weaknesses is almost as valuable as a damage spell.',
},
monk: {
actions: [
{ label: 'Flurry of Blows', desc: '1 action — Strike twice in 1 action (first at -0, second at -4 MAP instead of the usual -5). Your signature move — use it every turn.' },
{ label: 'Ki Strike', desc: '1 action (focus spell) — Strike + add d6 force damage. Replenish with 10-minute refocus.' },
{ label: 'Stunning Fist', desc: '1 action (focus, 1 focus point) — Strike + Constitution save. On failure: Stunned 1 (lost 1 action next turn). An action-economy nightmare for enemies.' },
{ label: 'Reaction', desc: 'Deflect Arrow (level 2 feat) — reduce ranged weapon damage by 1d10+DEX with a Reaction. Consider the Crane Stance for +1 AC Reaction-free.' },
],
tip: 'Monks are an action-economy class — Flurry of Blows gives 2 Strikes for 1 action. Build your kit around actions that generate value: Flurry, a focus spell, and a positioning move.',
},
};
export function getTurnGuide(system: string, classSlug: string): TurnGuide | undefined {
const guides = system === 'pf2e' ? GUIDE_PF2E : GUIDE_5E;
return guides[classSlug.toLowerCase()];
}
/** Generic guide when no class-specific data is available. */
export function getGenericGuide(system: string): TurnGuide {
if (system === 'pf2e') {
return {
actions: [
{ label: '1 action', desc: 'Strike — make one weapon or unarmed attack.' },
{ label: '2 actions', desc: 'Most spells, Raise Shield, or a 2-action activity from your class.' },
{ label: '3 actions', desc: 'Move and still act: 1 Stride (1 action) + 2-action activity, or use a 3-action spell.' },
{ label: 'Reaction', desc: 'Attack of Opportunity (if you have it), Block (with a raised shield), or your class-specific reaction.' },
],
tip: 'PF2e gives you 3 actions and 1 reaction per round. Actions carry a Multiple Attack Penalty (MAP): -5 on the 2nd Strike, -10 on the 3rd.',
};
}
return {
actions: [
{ label: 'Action', desc: 'Attack, Cast a Spell, Dash, Dodge, Help, or Use an Object.' },
{ label: 'Bonus Action', desc: 'Class or spell abilities that cost a Bonus Action (e.g. second-wind, certain spells). You only have 1 per turn.' },
{ label: 'Movement', desc: 'Move up to your speed at any point during your turn. You can split it before and after your Action.' },
{ label: 'Reaction', desc: 'Opportunity Attack when an enemy leaves your reach, or a spell like Shield / Counterspell if you have one prepared.' },
],
tip: 'You get one Action, one possible Bonus Action, movement, and a Reaction each round. Reactions are used on other people\'s turns.',
};
}
+232
View File
@@ -0,0 +1,232 @@
/** Static per-class guidance shown during character creation. No AI, no async. */
export interface ClassTip {
role: string;
/** e.g. "Strength (attacks) · Charisma (spells, auras)" */
primaryAbilities: string;
beginner: boolean;
skillSuggestions: string;
abilityTip?: string;
}
const TIPS_5E: Record<string, ClassTip> = {
barbarian: {
role: 'A fearless, rage-fuelled warrior who charges into melee and soaks up punishment. Very simple to play — hit things, don\'t die.',
primaryAbilities: 'Strength (attacks, grapple) · Constitution (HP and Rage)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Perception, Survival',
abilityTip: 'Put your highest score in Strength, second in Constitution. Barbarians don\'t wear heavy armour — your AC = 10 + DEX + CON, so Constitution doubly matters.',
},
bard: {
role: 'A jack-of-all-trades who inspires allies, casts spells, and out-talks everyone. Works well in any role.',
primaryAbilities: 'Charisma (spells, Bardic Inspiration) · Dexterity (AC, initiative)',
beginner: false,
skillSuggestions: 'Persuasion, Deception, Performance, Insight',
abilityTip: 'Charisma is everything for a Bard. Second priority: Dexterity for AC, then Constitution for concentration spells.',
},
cleric: {
role: 'A divine spellcaster who heals allies and smites enemies. Flexible — some subclasses (War, Forge) fight in the front line.',
primaryAbilities: 'Wisdom (all spells, Channel Divinity) · Strength or Dexterity (depending on build)',
beginner: true,
skillSuggestions: 'Medicine, Religion, Insight, Persuasion',
abilityTip: 'Wisdom first — it powers every Cleric spell. Front-line Clerics want Strength second; support Clerics want Constitution for concentration.',
},
druid: {
role: 'A nature magic caster who can transform into animals (Wild Shape). Powerful but benefits from reading the options.',
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Nature, Perception, Survival, Insight',
abilityTip: 'Wisdom is your core stat. Constitution keeps concentration spells alive. Low Strength is fine — use Wild Shape for physical challenges.',
},
fighter: {
role: 'The most reliable front-line warrior. Gets more attacks than anyone, can wear any armour, and is very forgiving to play.',
primaryAbilities: 'Strength (melee attacks) or Dexterity (ranged / finesse builds) · Constitution (HP, Second Wind)',
beginner: true,
skillSuggestions: 'Athletics, Perception, Intimidation, History',
abilityTip: 'Pick a focus: heavy-armour melee (Strength + Constitution) or light-armour finesse (Dexterity + Constitution). Don\'t try to split them.',
},
monk: {
role: 'A fast unarmed martial artist who darts around the battlefield punching multiple enemies per turn.',
primaryAbilities: 'Dexterity (AC, attacks, finesse weapons) · Wisdom (Unarmored Defense, ki abilities)',
beginner: false,
skillSuggestions: 'Athletics, Acrobatics, Stealth, Insight',
abilityTip: 'Dexterity and Wisdom both feed your AC (= DEX + WIS) — invest in both. Ki points fuel everything; Constitution helps you take a hit while concentrating.',
},
paladin: {
role: 'A holy warrior who hits hard, heals allies, and radiates powerful protective auras. One of the strongest front-liners.',
primaryAbilities: 'Strength (melee attacks, armour) · Charisma (spells, auras, Lay on Hands, Divine Smite bonus)',
beginner: false,
skillSuggestions: 'Persuasion, Athletics, Religion, Insight',
abilityTip: 'You need two high stats: Strength for hitting things and Charisma for everything magical. Constitution can be your dump stat — heavy armour and d10 HP cover it.',
},
ranger: {
role: 'A skilled hunter and tracker who excels at a favoured terrain or enemy type. Best at skirmishing from range.',
primaryAbilities: 'Dexterity (ranged and finesse attacks, AC) or Strength (melee) · Wisdom (spells)',
beginner: false,
skillSuggestions: 'Perception, Stealth, Survival, Athletics',
abilityTip: 'Dexterity for bows and light armour is the most common build. Wisdom powers your spells — prioritise it over Charisma. Constitution for survivability.',
},
rogue: {
role: 'A sneaky striker who deals huge Sneak Attack damage on one hit per turn, plus the best skill coverage in the game.',
primaryAbilities: 'Dexterity (attacks, Sneak Attack with finesse/ranged, AC, Stealth) · Intelligence or Charisma (subclass)',
beginner: true,
skillSuggestions: 'Stealth, Perception, Sleight of Hand, Deception, Athletics',
abilityTip: 'Dexterity is your core stat. You only need one hit per turn for big damage — prioritise staying alive (and unseen) over maximising damage output.',
},
sorcerer: {
role: 'A blaster mage with innate magical power. Fewer spells than a Wizard but Metamagic bends the rules dramatically.',
primaryAbilities: 'Charisma (spell attacks and save DCs) · Constitution (concentration, survivability)',
beginner: true,
skillSuggestions: 'Persuasion, Arcana, Intimidation, Insight',
abilityTip: 'Charisma is everything. Constitution second — you are in a robe with 6 HP per level and will be targeted. Pick up the Mage Armour spell early.',
},
warlock: {
role: 'A pact-powered caster who recharges spell slots on a short rest — unique among casters. Eldritch Blast is your bread and butter.',
primaryAbilities: 'Charisma (spells, Eldritch Blast invocations) · Constitution (concentration)',
beginner: false,
skillSuggestions: 'Arcana, Intimidation, Deception, History',
abilityTip: 'Charisma first. Until high level you only have 12 spell slots — lean on Eldritch Blast with Invocations to stay useful every round.',
},
wizard: {
role: 'The most versatile spellcaster — prepare different spells each day for any situation. Deeply rewarding, needs spell knowledge.',
primaryAbilities: 'Intelligence (all spells, Arcane Recovery) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Arcana, History, Investigation, Medicine',
abilityTip: 'Intelligence powers everything. Constitution second — Concentration spells are your strongest tools and you need to maintain them after taking a hit.',
},
};
const TIPS_PF2E: Record<string, ClassTip> = {
alchemist: {
role: 'A tinkerer who brews bombs, mutagens, and elixirs. Highly customisable but needs careful resource management between encounters.',
primaryAbilities: 'Intelligence (formula DC, items per day) · Dexterity (AC in light armour)',
beginner: false,
skillSuggestions: 'Crafting, Arcana, Medicine, Thievery',
},
barbarian: {
role: 'A rage-fuelled melee brawler with huge hit points. Your Instinct (animal, giant, spirit, etc.) defines your flavour and bonus.',
primaryAbilities: 'Strength (attacks) · Constitution (HP — Rage adds CON to max HP)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Survival',
abilityTip: 'Strength first. Constitution second — Rage adds your Constitution modifier to HP on top of the base die. Dexterity for unarmoured builds.',
},
bard: {
role: 'A charismatic performer with occult spellcasting and composition cantrips that buff allies every round. Excellent support.',
primaryAbilities: 'Charisma (spells, composition cantrips) · Dexterity (AC, Reflex)',
beginner: false,
skillSuggestions: 'Performance, Deception, Diplomacy, Occultism',
abilityTip: 'Charisma is your foundation. Dexterity second for AC in light armour. Constitution for keeping concentration spells up.',
},
champion: {
role: 'A heavily armoured divine warrior whose class Reaction reduces damage taken by allies — uniquely powerful in a party.',
primaryAbilities: 'Strength (attacks) · Charisma (divine spells, Lay on Hands, Champion reaction) · Constitution (HP)',
beginner: true,
skillSuggestions: 'Religion, Athletics, Intimidation, Medicine',
abilityTip: 'Strength and Charisma both matter. Heavy armour covers weak Dexterity. Your Champion Reaction scales with Charisma.',
},
cleric: {
role: 'A divine spellcaster whose god determines your offensive or support role. The Divine Font gives extra heal/harm spells per day.',
primaryAbilities: 'Wisdom (spells, divine font) · Strength or Dexterity (deity weapon / unarmoured)',
beginner: true,
skillSuggestions: 'Religion, Medicine, Diplomacy, Insight',
abilityTip: 'Wisdom is your core stat. Warpriest Clerics add Strength; Cloistered Clerics need Constitution for sustained spellcasting.',
},
druid: {
role: 'A nature magic caster who can transform into animals or call on elemental forces. Very versatile with powerful spells.',
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (HP in Wild Shape forms)',
beginner: false,
skillSuggestions: 'Nature, Survival, Medicine, Athletics',
abilityTip: 'Wisdom is your primary stat. Constitution for HP when in Wild Shape. Dexterity for AC when not transformed.',
},
fighter: {
role: 'The best weapon combatant in the game — higher proficiency than any other class. Multiple attacks and powerful stances.',
primaryAbilities: 'Strength (melee / thrown attacks) or Dexterity (ranged / finesse attacks)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Acrobatics, Perception',
abilityTip: 'Pick Strength for melee or Dexterity for ranged/finesse. Constitution for hit points. Fighters don\'t need a third stat to shine.',
},
monk: {
role: 'An unarmed martial artist with extreme mobility. Ki abilities let you do things no weapon fighter can — flying kicks, stunning strikes.',
primaryAbilities: 'Strength or Dexterity (attacks and movement) · Wisdom (Unarmored Defense, ki)',
beginner: false,
skillSuggestions: 'Athletics, Acrobatics, Perception, Stealth',
abilityTip: 'Your AC = 10 + DEX + WIS, so both stats matter. Decide early: Strength-based martial arts or Dexterity-based finesse.',
},
oracle: {
role: 'A divine spontaneous caster with a Curse that grows as you cast higher-rank spells. Dramatic power with real tradeoffs.',
primaryAbilities: 'Charisma (all spells) · Constitution (HP, curse management)',
beginner: false,
skillSuggestions: 'Religion, Occultism, Diplomacy, Medicine',
abilityTip: 'Charisma first. Constitution second — your curse adds drawbacks that are harder to manage at low HP. Monitor your curse rank carefully.',
},
ranger: {
role: 'A skilled hunter and outlander. Hunt Prey gives a persistent bonus against your current target every combat — keep track of it.',
primaryAbilities: 'Dexterity (ranged attacks, AC) or Strength (melee) · Wisdom (spells)',
beginner: false,
skillSuggestions: 'Nature, Survival, Stealth, Athletics',
abilityTip: 'Choose a focus: ranged (Dexterity + bow) or melee (Strength + weapon). Wisdom for your ranger spells. Perception is trained free.',
},
rogue: {
role: 'A skill master and precision striker. Sneak Attack triggers when the target is Flat-Footed — positioning and flanking matter.',
primaryAbilities: 'Dexterity (attacks, AC, Stealth) · Intelligence or Charisma (Racket choice)',
beginner: true,
skillSuggestions: 'Stealth, Thievery, Deception, Perception, Acrobatics',
abilityTip: 'Dexterity is your core stat. Your second stat depends on your Racket: Scoundrel needs Charisma, Mastermind needs Intelligence, Thief needs Dexterity first.',
},
sorcerer: {
role: 'A spontaneous arcane or occult caster with innate power from a magical bloodline. More flexible casting than prepared classes.',
primaryAbilities: 'Charisma (all spells) · Constitution (concentration and survivability)',
beginner: true,
skillSuggestions: 'Arcana, Intimidation, Deception, Occultism',
abilityTip: 'Charisma is everything. Constitution second — you have light armour and will be targeted. Your bloodline gives you bonus spells automatically.',
},
swashbuckler: {
role: 'A dashing duelist who builds Panache through daring deeds, then spends it on devastating Finishers. Style and substance.',
primaryAbilities: 'Dexterity (attacks, AC) · Charisma (Panache, some class feats)',
beginner: false,
skillSuggestions: 'Acrobatics, Athletics, Deception, Performance',
abilityTip: 'Dexterity for everything combat-related. Charisma for Panache and social moments. Invest in whichever skill triggers your Style.',
},
witch: {
role: 'A patron-bonded spellcaster with powerful hexes and a familiar that delivers your touch spells. Protect your familiar.',
primaryAbilities: 'Intelligence, Wisdom, or Charisma (depends on patron) · Constitution for spellcasting',
beginner: false,
skillSuggestions: 'Arcana, Occultism, Nature, Diplomacy',
abilityTip: 'Your patron determines your casting stat — check your patron carefully. Constitution second to keep casting without disruption.',
},
wizard: {
role: 'A prepared arcane caster with more spells over a career than any other class. Thorough preparation wins battles.',
primaryAbilities: 'Intelligence (all spells, Arcane Thesis) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Arcana, Occultism, Society, History',
abilityTip: 'Intelligence first, always. Constitution second for concentration spells. Dexterity for AC matters early when you have no armour.',
},
};
export function getClassTip(system: string, classSlug: string): ClassTip | undefined {
const tips = system === 'pf2e' ? TIPS_PF2E : TIPS_5E;
return tips[classSlug.toLowerCase()];
}
/**
* Returns a synergy note if the race's meta string contains ability boosts that
* match the class's key abilities. Both systems' meta formats are handled:
* 5e: "+2 DEX, +1 WIS · 30 ft"
* PF2e: "+Con/+Wis · 10 HP"
*/
export function raceSynergyNote(raceMeta: string, keyAbilities: string[]): string | null {
if (!raceMeta || !keyAbilities.length) return null;
const boosted = new Set<string>();
// Match "+2 DEX" or "+Wis" or "+Con" patterns
for (const m of raceMeta.matchAll(/\+\d*([A-Za-z]{3})/g)) {
const ab = m[1];
if (ab) boosted.add(ab.toLowerCase());
}
const matches = keyAbilities.filter((k) => boosted.has(k.toLowerCase()));
if (!matches.length) return null;
const names: Record<string, string> = {
str: 'Strength', dex: 'Dexterity', con: 'Constitution',
int: 'Intelligence', wis: 'Wisdom', cha: 'Charisma',
};
const label = matches.map((k) => names[k] ?? k).join(' and ');
return `This ancestry boosts ${label} — a natural fit for this class's primary abilities.`;
}
+193
View File
@@ -0,0 +1,193 @@
import { z } from 'zod';
import type { CampaignContext } from './context';
// ---- Session hooks ----
export const sessionHookSchema = z.object({
hooks: z.array(z.object({
title: z.string(),
opening: z.string(),
tension: z.string(),
})).min(3).max(3),
});
export type SessionHook = z.infer<typeof sessionHookSchema>['hooks'][number];
export function buildSessionHookPrompt(ctx: CampaignContext): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a session-prep assistant for a ${ctx.systemLabel} game master. ` +
`Generate exactly 3 distinct session opening hooks. Each hook needs a short title (3-6 words), ` +
`an opening paragraph (2-3 sentences, suitable to read aloud or adapt at the table), and a one-sentence tension note ` +
`(what makes this session interesting from a dramatic standpoint). ` +
`Ground your hooks in the party's current situation — active quests, recent encounters, NPC threads. ` +
`Vary the tone: one action-forward, one character/social, one mystery or twist.`;
const partyLine = ctx.party.map((p) => `${p.name} (level ${p.level} ${p.className})`).join(', ') || 'the party';
const user =
`Campaign context:\n` +
`Party: ${partyLine}\n` +
`Active quests: ${ctx.questsSummary}\n` +
`Notes/lore: ${ctx.notesSummary}\n` +
(ctx.recentEncounters.length ? `Recent encounters: ${ctx.recentEncounters.slice(0, 3).map((e) => `${e.name} (${e.difficulty})`).join(', ')}\n` : '') +
`\nGenerate 3 opening hooks for the next session.`;
return { system, user };
}
const HOOK_TEMPLATES = [
{
title: 'Into the Unknown',
opening: (q: string) =>
`The road ahead leads deeper into uncertain territory. ${q ? `The party's current task — ${q} — draws them onward, ` : 'There\'s work to be done, '}but rumours along the way hint that things may be more complicated than expected.`,
tension: 'An unexpected complication threatens to derail the party\'s immediate plans.',
},
{
title: 'Old Debts',
opening: () =>
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`,
tension: 'What they need may directly conflict with the party\'s current goals.',
},
{
title: 'Crossed Paths',
opening: () =>
`Two separate threads tangle without warning: an urgent job lands at the worst possible moment, or unexpected help arrives from a quarter no one anticipated. The party has to decide quickly which thread to pull.`,
tension: 'The party must prioritise between two competing urgent situations.',
},
];
export function fallbackSessionHooks(ctx: CampaignContext): SessionHook[] {
const quests = ctx.questsSummary !== 'No active quests.'
? ctx.questsSummary.replace(/^\d+ active: /, '').split('; ')
: [];
const firstQuest = quests[0] ?? '';
return HOOK_TEMPLATES.map((t) => ({
title: t.title,
opening: t.opening(firstQuest),
tension: t.tension,
}));
}
// ---- NPC quick-gen ----
export const npcQuickSchema = z.object({
name: z.string(),
role: z.string(),
motivation: z.string(),
secret: z.string(),
hook: z.string(),
});
export type NpcQuick = z.infer<typeof npcQuickSchema>;
export function buildNpcPrompt(
ctx: Pick<CampaignContext, 'systemConstraint' | 'systemLabel' | 'questsSummary' | 'notesSummary'>,
hint?: string,
): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are an NPC generator for a ${ctx.systemLabel} game master. ` +
`Generate one vivid, playable NPC with a name, a role (short phrase: "innkeeper", "mercenary captain", "hedge mage", etc.), ` +
`a motivation (what they want right now — concrete, specific), a secret (one thing they would never willingly reveal), ` +
`and a hook (one sentence: how you introduce or use them at the table). Keep all fields concise — the GM fills in the rest.`;
const user =
`Campaign notes: ${ctx.notesSummary}. Active quests: ${ctx.questsSummary}.` +
(hint ? ` Requested type / context: ${hint}.` : '') +
` Generate one NPC.`;
return { system, user };
}
const NPC_TEMPLATES: NpcQuick[] = [
{
name: 'Aldric Stonehaven',
role: 'Inn owner',
motivation: 'Keep his inn profitable and his guests\' troubles from spilling into his common room',
secret: 'He owes a significant debt to a local thieves\' guild and occasionally passes along information about guests',
hook: 'He nervously wipes the same glass over and over, eyes darting to the door — and quietly asks the party if they\'re looking for work',
},
{
name: 'Sister Vera',
role: 'Temple healer',
motivation: 'Acquire a rare alchemical reagent to complete a cure for a sickness spreading in the lower city',
secret: 'She was once a skilled thief; the temple doesn\'t know, and she prefers it stays that way',
hook: 'She approaches the party after tending to their wounds, asking if they know anything about certain missing reagents',
},
{
name: 'Captain Brenn Ashvale',
role: 'Mercenary captain',
motivation: 'Collect on a long-overdue contract before her company falls apart from unpaid wages',
secret: 'The contract she\'s chasing was supposed to be straightforward — she\'s terrified of what she found in the ruins instead',
hook: 'She\'s drinking alone in the corner, fully armoured and clearly not planning to sleep tonight',
},
{
name: 'Mira of the Crossroads',
role: 'Travelling merchant',
motivation: 'Reach the next city before a rival merchant corners the market on her rare goods',
secret: 'The "rare goods" she\'s carrying were lifted from a noble\'s estate under ambiguous circumstances',
hook: 'She flagged down the party on the road, offering a discount on supplies in exchange for travelling together — she doesn\'t want to be alone',
},
];
export function fallbackNpc(): NpcQuick {
return NPC_TEMPLATES[Math.floor(Math.random() * NPC_TEMPLATES.length)]!;
}
// ---- Quest hook gen ----
export const questHookSchema = z.object({
title: z.string(),
description: z.string(),
reward: z.string(),
objectives: z.array(z.string()).min(1).max(4),
});
export type QuestHook = z.infer<typeof questHookSchema>;
export function buildQuestHookPrompt(ctx: CampaignContext): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a quest designer for a ${ctx.systemLabel} game master. ` +
`Generate one compelling quest: a title (5 words or fewer), a 23 sentence description for the GM (not a read-aloud — a situation summary), ` +
`a reward line (gold amount, item, or favour — be specific), and 24 concrete objectives as short imperative phrases.` +
`The quest should be completable in one session. Connect it to the existing campaign notes or active quests if possible.`;
const partyAvgLevel = ctx.partyLevels.length
? Math.round(ctx.partyLevels.reduce((a, b) => a + b, 0) / ctx.partyLevels.length)
: 1;
const user =
`Party average level: ${partyAvgLevel}. Active quests: ${ctx.questsSummary}. Notes: ${ctx.notesSummary}.\n` +
`Generate one new side quest appropriate for this party.`;
return { system, user };
}
const QUEST_TEMPLATES: QuestHook[] = [
{
title: 'The Missing Merchant',
description: 'A local merchant departed three days ago for a nearby town and never arrived. His family has received no word and fears the worst. The road is not normally dangerous, which makes the disappearance all the stranger.',
reward: '150 gp and the merchant\'s gratitude (discount on future trade goods)',
objectives: ['Investigate the merchant\'s last known stop', 'Search the road between settlements', 'Return the merchant safely — or discover what happened'],
},
{
title: 'Trouble at the Mill',
description: 'The grain mill that feeds the town has fallen silent. The miller and his apprentices vanished two nights ago. Locals report strange lights and sounds from the millpond after dark — something is in the water.',
reward: '100 gp from the town council and free lodging for a month',
objectives: ['Investigate the mill and surrounding area', 'Discover what is lurking in the millpond', 'Ensure the mill is safe to operate again'],
},
{
title: 'A Stolen Heirloom',
description: 'A noble family\'s ancestral signet ring was taken by a skilled thief during a dinner party. Every guest is a suspect. The family will pay handsomely to recover it quietly — and even more to know who took it.',
reward: '200 gp and a favour from a noble house',
objectives: ['Question the guests and staff', 'Identify the thief through investigation', 'Recover the signet ring'],
},
{
title: 'Old Ruins, New Danger',
description: 'Explorers opened a sealed chamber in nearby ruins and haven\'t returned. Their employer wants to know what happened and whether the contents of the chamber are still accessible. The ruins were sealed for a reason.',
reward: 'A share of whatever the explorers found, plus 100 gp per party member',
objectives: ['Enter the ruins through the explorers\' route', 'Locate the sealed chamber', 'Deal with whatever the explorers disturbed', 'Report back to the employer'],
},
];
export function fallbackQuestHook(): QuestHook {
return QUEST_TEMPLATES[Math.floor(Math.random() * QUEST_TEMPLATES.length)]!;
}
+2 -2
View File
@@ -95,12 +95,12 @@ export async function loadClasses(system: SystemId): Promise<RulesetClass[]> {
return cache.get(key) as RulesetClass[]; return cache.get(key) as RulesetClass[];
} }
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[]> { export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[]> {
if (!cache.has('races5e')) { if (!cache.has('races5e')) {
const mod = await import('@/data/srd/races.json'); const mod = await import('@/data/srd/races.json');
cache.set('races5e', mod.default as unknown[]); cache.set('races5e', mod.default as unknown[]);
} }
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[]; return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[];
} }
export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> { export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> {
+1
View File
@@ -71,6 +71,7 @@ export const playerQuestSchema = z.object({
export const snapshotSchema = z.object({ export const snapshotSchema = z.object({
campaignName: z.string(), campaignName: z.string(),
system: z.string().optional(),
calendarDay: int.nullable(), calendarDay: int.nullable(),
party: z.array(playerCharacterSchema), party: z.array(playerCharacterSchema),
encounter: playerEncounterSchema.nullable(), encounter: playerEncounterSchema.nullable(),
+1
View File
@@ -81,6 +81,7 @@ export function buildSnapshot(input: {
return { return {
snapshot: { snapshot: {
campaignName: campaign.name, campaignName: campaign.name,
system: campaign.system,
calendarDay: calendar ? calendar.currentDay : null, calendarDay: calendar ? calendar.currentDay : null,
party, party,
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null, encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,