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:
@@ -18,6 +18,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { CampaignInsights } from './CampaignInsights';
|
||||
import { SessionPrepCard } from './SessionPrepCard';
|
||||
import { NpcGenCard } from './NpcGenCard';
|
||||
|
||||
export function AssistantPage() {
|
||||
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>
|
||||
<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>
|
||||
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
|
||||
</section>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Brain } from 'lucide-react';
|
||||
import type { Campaign, Encounter } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
|
||||
data-testid="encounter-tip"
|
||||
>
|
||||
<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">
|
||||
{theme ? (
|
||||
<>
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
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 { campaignsRepo } from '@/lib/db/repositories';
|
||||
import { seedSampleCampaign } from '@/lib/sample';
|
||||
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
|
||||
</p>
|
||||
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
|
||||
<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="🗺️" 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={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
|
||||
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
|
||||
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
|
||||
<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 (
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
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 { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
@@ -161,7 +161,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</div>
|
||||
</div>
|
||||
<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="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
@@ -12,8 +13,10 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Codex';
|
||||
import { Field, Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
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 }[]> = {
|
||||
'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: '🔥 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: '✨ Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
|
||||
{ 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: '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 } },
|
||||
],
|
||||
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: '🔥 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: '✨ Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
|
||||
{ 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: '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 } },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -65,10 +68,37 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
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 }))));
|
||||
} 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) ?? '') }))));
|
||||
}
|
||||
return () => { on = false; };
|
||||
@@ -285,21 +315,61 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClass && selectedClass.subclasses.length > 0 && (
|
||||
<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>
|
||||
</Field>
|
||||
)}
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
if (!tip) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{stepName === 'Origin' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -312,7 +382,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
{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>}
|
||||
{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>}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{ABILITIES.map((a, i) => {
|
||||
@@ -339,7 +420,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
{stepName === 'Skills' && (
|
||||
<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-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">
|
||||
{skillOptions.map((key) => {
|
||||
const checked = skills.includes(key);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Dices,
|
||||
@@ -548,7 +549,7 @@ function ConditionPicker({
|
||||
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
|
||||
{cd.name}
|
||||
{cd.valued ? ' (#)' : ''}
|
||||
{taken.has(cd.name) ? ' ✓' : ''}
|
||||
{taken.has(cd.name) ? <Check size={12} aria-hidden /> : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value="__custom__">Custom…</option>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Megaphone } from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -21,7 +22,7 @@ export function HandoutControl() {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout">✕</Button>}
|
||||
{open && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
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 { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
@@ -28,7 +28,7 @@ export function SessionControl() {
|
||||
if (!cloudUsername()) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function SessionControl() {
|
||||
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
|
||||
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"
|
||||
title="Copy player link"
|
||||
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 && (
|
||||
<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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
|
||||
import type { SessionLogEntry } from '@/lib/schemas';
|
||||
import type { RosterEntry } from '@/lib/sync/messages';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Dice5, Mail, MessageSquare } from 'lucide-react';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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 />
|
||||
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
|
||||
{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>
|
||||
))}
|
||||
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{(recap ?? []).map((e) => (
|
||||
<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="min-w-0 flex-1 text-ink">{e.text}</span>
|
||||
</li>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import type { Character, Defenses } from '@/lib/schemas';
|
||||
import { ActionGuide } from './ActionGuide';
|
||||
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { useRollStore } from '@/stores/rollStore';
|
||||
@@ -61,7 +62,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
</div>
|
||||
) : (
|
||||
<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 } })} />
|
||||
<div className="flex items-center justify-between">
|
||||
<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 */}
|
||||
<DiceBox characterId={c.id} />
|
||||
</div>
|
||||
<ActionGuide character={c} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 { PlayerMap } from '@/lib/map';
|
||||
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
|
||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { EmptyState } from '@/components/ui/Page';
|
||||
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> }) {
|
||||
const { party, encounter, map, handout } = snapshot;
|
||||
const quests = snapshot.quests ?? [];
|
||||
const condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
|
||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||
return (
|
||||
@@ -18,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
||||
{privateHandout && (
|
||||
<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">
|
||||
<Mail size={13} aria-hidden /> 📨 Just for you
|
||||
<Mail size={13} aria-hidden /> Just for you
|
||||
</div>
|
||||
<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>}
|
||||
@@ -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>
|
||||
{c.conditions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{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>
|
||||
))}
|
||||
{c.conditions.map((cond, i) => {
|
||||
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>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { useCalendar, useMaps } from '@/features/world/hooks';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
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>
|
||||
<p className="mt-1 text-sm text-muted">{subtitle}</p>
|
||||
</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>
|
||||
<hr className="gilt-rule mt-3" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
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 { diceRepo } from '@/lib/db/repositories';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
@@ -15,20 +18,21 @@ export function DashboardPage() {
|
||||
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/assistant', label: 'Assistant', icon: '🧠' },
|
||||
{ to: '/characters', label: 'Characters', icon: '🧝' },
|
||||
{ to: '/combat', label: 'Combat', icon: '⚔' },
|
||||
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
|
||||
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
|
||||
{ to: '/quests', label: 'Quests', icon: '🗺' },
|
||||
{ to: '/maps', label: 'Maps', icon: '🗺️' },
|
||||
{ to: '/calendar', label: 'Calendar', icon: '📅' },
|
||||
{ to: '/compendium', label: 'Compendium', icon: '📚' },
|
||||
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
|
||||
{ to: '/dice', label: 'Dice', icon: '🎲' },
|
||||
{ to: '/play', label: 'Player View', icon: '📺' },
|
||||
] as const;
|
||||
type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
|
||||
const LINKS: { to: string; label: string; icon: NavIcon }[] = [
|
||||
{ to: '/assistant', label: 'Assistant', icon: Brain },
|
||||
{ to: '/characters', label: 'Characters', icon: Users },
|
||||
{ to: '/combat', label: 'Combat', icon: Swords },
|
||||
{ to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
|
||||
{ to: '/npcs', label: 'NPCs', icon: VenetianMask },
|
||||
{ to: '/quests', label: 'Quests', icon: Target },
|
||||
{ to: '/maps', label: 'Maps', icon: Map },
|
||||
{ to: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ to: '/compendium', label: 'Compendium', icon: Library },
|
||||
{ to: '/homebrew', label: 'Homebrew', icon: Hammer },
|
||||
{ to: '/dice', label: 'Dice', icon: Dice5 },
|
||||
{ to: '/play', label: 'Player View', icon: Monitor },
|
||||
];
|
||||
|
||||
function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import type { Campaign, Npc } from '@/lib/schemas';
|
||||
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 { useNpcs } from './hooks';
|
||||
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" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
@@ -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 [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, {
|
||||
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
|
||||
}), 350);
|
||||
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 (
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<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">
|
||||
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</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>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
|
||||
@@ -2,8 +2,15 @@ import { useState } from 'react';
|
||||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||||
import { questsRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { complete } from '@/lib/llm/client';
|
||||
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
|
||||
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
|
||||
import { buildCampaignContext } from '@/lib/assistant/context';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { useQuests } from './hooks';
|
||||
import { useCharacters } from '@/features/characters/hooks';
|
||||
import { useNotes } from '@/features/world/hooks';
|
||||
import { useEncounters } from '@/features/combat/hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -20,15 +27,51 @@ export function QuestsPage() {
|
||||
|
||||
function Quests({ campaign }: { campaign: Campaign }) {
|
||||
const quests = useQuests(campaign.id);
|
||||
const characters = useCharacters(campaign.id);
|
||||
const notes = useNotes(campaign.id);
|
||||
const encounters = useEncounters(campaign.id);
|
||||
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
|
||||
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
|
||||
const [genBusy, setGenBusy] = useState(false);
|
||||
const llmEnabled = useAssistantStore((s) => s.enabled);
|
||||
const hasKey = useAssistantStore((s) => !!s.apiKey);
|
||||
const canUseLlm = llmEnabled && hasKey;
|
||||
|
||||
const generateQuest = async () => {
|
||||
setGenBusy(true);
|
||||
try {
|
||||
let hook: QuestHook | null = null;
|
||||
if (canUseLlm) {
|
||||
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
|
||||
const { system, user } = buildQuestHookPrompt(ctx);
|
||||
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
|
||||
if (res.ok && 'data' in res) hook = res.data;
|
||||
}
|
||||
if (!hook) hook = fallbackQuestHook();
|
||||
const created = await questsRepo.create(campaign.id, hook.title);
|
||||
await questsRepo.update(created.id, {
|
||||
description: hook.description,
|
||||
reward: hook.reward,
|
||||
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
|
||||
});
|
||||
} finally {
|
||||
setGenBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Quests"
|
||||
subtitle={campaign.name}
|
||||
actions={<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 ? (
|
||||
<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>} />
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
@@ -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 1–2 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.`;
|
||||
}
|
||||
@@ -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 2–3 sentence description for the GM (not a read-aloud — a situation summary), ` +
|
||||
`a reward line (gold amount, item, or favour — be specific), and 2–4 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)]!;
|
||||
}
|
||||
@@ -95,12 +95,12 @@ export async function loadClasses(system: SystemId): Promise<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')) {
|
||||
const mod = await import('@/data/srd/races.json');
|
||||
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 }[]> {
|
||||
|
||||
@@ -71,6 +71,7 @@ export const playerQuestSchema = z.object({
|
||||
|
||||
export const snapshotSchema = z.object({
|
||||
campaignName: z.string(),
|
||||
system: z.string().optional(),
|
||||
calendarDay: int.nullable(),
|
||||
party: z.array(playerCharacterSchema),
|
||||
encounter: playerEncounterSchema.nullable(),
|
||||
|
||||
@@ -81,6 +81,7 @@ export function buildSnapshot(input: {
|
||||
return {
|
||||
snapshot: {
|
||||
campaignName: campaign.name,
|
||||
system: campaign.system,
|
||||
calendarDay: calendar ? calendar.currentDay : null,
|
||||
party,
|
||||
encounter: activeEnc ? toPlayerEncounter(activeEnc) : null,
|
||||
|
||||
Reference in New Issue
Block a user