From 740cf20b93d1097e6a43dfbac1807f448037a9ec Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 23:29:06 +0200 Subject: [PATCH] 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 --- src/features/assistant/AssistantPage.tsx | 8 + src/features/assistant/EncounterTipCard.tsx | 3 +- src/features/assistant/NpcGenCard.tsx | 112 ++++++++ src/features/assistant/SessionPrepCard.tsx | 88 ++++++ src/features/campaigns/CampaignsPage.tsx | 12 +- src/features/characters/CharacterSheet.tsx | 4 +- .../characters/builder/CreationWizard.tsx | 136 ++++++++-- src/features/combat/EncounterTracker.tsx | 3 +- src/features/play/HandoutControl.tsx | 3 +- src/features/play/SessionControl.tsx | 8 +- src/features/play/SessionSidebar.tsx | 5 +- src/features/player/ActionGuide.tsx | 39 +++ src/features/player/MyCharacterPanel.tsx | 4 +- src/features/player/PlayerBoards.tsx | 14 +- src/features/player/PlayerViewPage.tsx | 3 +- src/features/world/DashboardPage.tsx | 36 +-- src/features/world/NpcsPage.tsx | 38 ++- src/features/world/QuestsPage.tsx | 45 +++- src/lib/assistant/actions.ts | 251 ++++++++++++++++++ src/lib/assistant/builder.ts | 232 ++++++++++++++++ src/lib/assistant/session.ts | 193 ++++++++++++++ src/lib/compendium/index.ts | 4 +- src/lib/sync/messages.ts | 1 + src/lib/sync/snapshot.ts | 1 + 24 files changed, 1176 insertions(+), 67 deletions(-) create mode 100644 src/features/assistant/NpcGenCard.tsx create mode 100644 src/features/assistant/SessionPrepCard.tsx create mode 100644 src/features/player/ActionGuide.tsx create mode 100644 src/lib/assistant/actions.ts create mode 100644 src/lib/assistant/builder.ts create mode 100644 src/lib/assistant/session.ts diff --git a/src/features/assistant/AssistantPage.tsx b/src/features/assistant/AssistantPage.tsx index d6714b1..99aa7e4 100644 --- a/src/features/assistant/AssistantPage.tsx +++ b/src/features/assistant/AssistantPage.tsx @@ -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 {(c) => }; @@ -128,6 +130,12 @@ function Assistant({ campaign }: { campaign: Campaign }) {

Session prep

+

Session hooks

+ + +

Quick NPC

+ +

Campaign insights

diff --git a/src/features/assistant/EncounterTipCard.tsx b/src/features/assistant/EncounterTipCard.tsx index c5105cc..88c32fc 100644 --- a/src/features/assistant/EncounterTipCard.tsx +++ b/src/features/assistant/EncounterTipCard.tsx @@ -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" >
- 🧠 +
{theme ? ( <> diff --git a/src/features/assistant/NpcGenCard.tsx b/src/features/assistant/NpcGenCard.tsx new file mode 100644 index 0000000..c50d770 --- /dev/null +++ b/src/features/assistant/NpcGenCard.tsx @@ -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(null); + const [message, setMessage] = useState(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(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 ( +
+

+ Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table. +

+
+ setHint(e.target.value)} + placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`} + className="flex-1 text-sm" + aria-label="NPC hint" + /> + +
+ + {message &&

{message}

} + + {state === 'ready' && npc && ( +
+
+
+ {npc.name} + {npc.role} +
+ +
+
+ + + +
+
+ )} +
+ ); +} + +function NpcField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}:
+
{value}
+
+ ); +} diff --git a/src/features/assistant/SessionPrepCard.tsx b/src/features/assistant/SessionPrepCard.tsx new file mode 100644 index 0000000..7256b0b --- /dev/null +++ b/src/features/assistant/SessionPrepCard.tsx @@ -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([]); + const [message, setMessage] = useState(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 ( +
+

+ Generate three distinct session opening hooks grounded in your campaign's current threads. +

+ + + {message &&

{message}

} + + {state === 'ready' && hooks.length > 0 && ( +
+ {hooks.map((h, i) => ( + + ))} +
+ )} +
+ ); +} + +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 ( +
+
+ {hook.title} + +
+

{hook.opening}

+

{hook.tension}

+
+ ); +} diff --git a/src/features/campaigns/CampaignsPage.tsx b/src/features/campaigns/CampaignsPage.tsx index 299227d..5aaee1d 100644 --- a/src/features/campaigns/CampaignsPage.tsx +++ b/src/features/campaigns/CampaignsPage.tsx @@ -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 }) {


- - - + } title="Build characters" desc="A friendly, data-driven builder for new players." /> + } title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." /> + } title="Play live" desc="Host a room; players manage their own character." />
@@ -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 (
{icon}
diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 839d661..e7cbbe5 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -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 }) {
- {c.kind === 'pc' && } + {c.kind === 'pc' && }
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 29a4727..d5200f6 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -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 = { '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
- {selectedClass && selectedClass.subclasses.length > 0 && ( - - - - )} + {selectedClass && (() => { + const tip = getClassTip(system, selectedClass.slug); + if (!tip) return null; + return ( +
+
+ +
+
{selectedClass.name}
+

{tip.role}

+
+ Key abilities: {tip.primaryAbilities} +
+ {tip.beginner && Good for beginners} +
+
+
+ ); + })()} + + {selectedClass && selectedClass.subclasses.length > 0 && (() => { + const sc = selectedClass.subclasses.find((s) => s.name === subclass); + return ( + + + {sc?.desc && ( +

+ {sc.desc} +

+ )} +
+ ); + })()} )} {stepName === 'Origin' && ( -
- - +
+
+ + +
+ {selectedClass && selectedOrigin && (() => { + const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities); + if (!synergy) return null; + return ( +

+ + {synergy} +

+ ); + })()}
)} @@ -312,7 +382,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{method === 'roll' && } {method === 'pointbuy' &&

Points remaining: {pointBuyRemaining(pb)} / 27

} - {selectedClass && selectedClass.keyAbilities.length > 0 &&

Tip: prioritise {selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a {selectedClass.name}.

} + {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 ? ( +

+ + {text} +

+ ) : null; + })()} {usesPool && !poolValid &&

Assign each value to a different ability.

}
{ABILITIES.map((a, i) => { @@ -339,7 +420,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl {stepName === 'Skills' && (

Choose {Math.min(skillCount, skillOptions.length)} {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).

-

{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.'}

+

{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.'}

+ {selectedClass && (() => { + const tip = getClassTip(system, selectedClass.slug); + return tip?.skillSuggestions ? ( +

+ + {selectedClass.name}s often pick: {tip.skillSuggestions} +

+ ) : null; + })()}
{skillOptions.map((key) => { const checked = skills.includes(key); diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index fa4853c..8ff2713 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { ArrowDown, ArrowUp, + Check, ChevronLeft, ChevronRight, Dices, @@ -548,7 +549,7 @@ function ConditionPicker({ ))} diff --git a/src/features/play/HandoutControl.tsx b/src/features/play/HandoutControl.tsx index 66817b5..96401a0 100644 --- a/src/features/play/HandoutControl.tsx +++ b/src/features/play/HandoutControl.tsx @@ -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 ( <> {active && } {open && ( diff --git a/src/features/play/SessionControl.tsx b/src/features/play/SessionControl.tsx index ebb5d3f..f9f0368 100644 --- a/src/features/play/SessionControl.tsx +++ b/src/features/play/SessionControl.tsx @@ -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 ( ); } @@ -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 + > Host ); } @@ -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 ? ' βœ“' : ''} + >{joinCode}{copied ? <> : ''} {seatRequests.length > 0 && ( + )} ))} @@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
    {(recap ?? []).map((e) => (
  • - {e.kind === 'roll' ? '🎲' : 'πŸ’¬'} + {e.kind === 'roll' ? : } {e.from}: {e.text}
  • diff --git a/src/features/player/ActionGuide.tsx b/src/features/player/ActionGuide.tsx new file mode 100644 index 0000000..be2dd4c --- /dev/null +++ b/src/features/player/ActionGuide.tsx @@ -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 ( +
    + + What can I do on my turn? β–Έ + +
    +
      + {guide.actions.map((a) => ( +
    • + {a.label}: + {a.desc} +
    • + ))} +
    + {guide.upgradeAt && character.level < guide.upgradeAt.level && ( +

    + ↑ {guide.upgradeAt.note} +

    + )} + {guide.upgradeAt && character.level >= guide.upgradeAt.level && ( +

    + {guide.upgradeAt.note} +

    + )} + {guide.tip && ( +

    {guide.tip}

    + )} +
    +
    + ); +} diff --git a/src/features/player/MyCharacterPanel.tsx b/src/features/player/MyCharacterPanel.tsx index ef7234f..75aeffc 100644 --- a/src/features/player/MyCharacterPanel.tsx +++ b/src/features/player/MyCharacterPanel.tsx @@ -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
) : (
- setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} /> + setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} /> setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
setDef({ exhaustion })} /> @@ -114,6 +115,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact {/* Dice */}
+ ); } diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx index fc86841..c9d3390 100644 --- a/src/features/player/PlayerBoards.tsx +++ b/src/features/player/PlayerBoards.tsx @@ -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 }) { 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 && (
- πŸ“¨ Just for you + Just for you

{privateHandout.title || 'A private note'}

{privateHandout.body &&

{privateHandout.body}

} @@ -74,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}
{c.conditions.length > 0 && (
- {c.conditions.map((cond, i) => ( - {cond.name}{cond.value ? ` ${cond.value}` : ''} - ))} + {c.conditions.map((cond, i) => { + const tooltip = condGlossary.get(cond.name.toLowerCase()); + return ( + {cond.name}{cond.value ? ` ${cond.value}` : ''} + ); + })}
)}
diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 4b44eff..34149d4 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -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;

{title}

{subtitle}

- +

diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index b957842..4562238 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -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 {(c) => }; } -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" > - {l.icon} + {l.label} ))} diff --git a/src/features/world/NpcsPage.tsx b/src/features/world/NpcsPage.tsx index 1c03a7d..81ca962 100644 --- a/src/features/world/NpcsPage.tsx +++ b/src/features/world/NpcsPage.tsx @@ -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 }) { <> setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
- {filtered.map((n) => )} + {filtered.map((n) => )}
)} @@ -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) => 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(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 (
@@ -55,6 +86,9 @@ function NpcCard({ npc }: { npc: Npc }) { +
diff --git a/src/features/world/QuestsPage.tsx b/src/features/world/QuestsPage.tsx index dec9499..79cc50c 100644 --- a/src/features/world/QuestsPage.tsx +++ b/src/features/world/QuestsPage.tsx @@ -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(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 ( questsRepo.create(campaign.id, 'New quest')}>+ New quest} + actions={ +
+ + +
+ } /> {quests.length === 0 ? ( questsRepo.create(campaign.id, 'New quest')}>+ New quest} /> diff --git a/src/lib/assistant/actions.ts b/src/lib/assistant/actions.ts new file mode 100644 index 0000000..9c7ea2b --- /dev/null +++ b/src/lib/assistant/actions.ts @@ -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 = { + 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 = { + 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.', + }; +} diff --git a/src/lib/assistant/builder.ts b/src/lib/assistant/builder.ts new file mode 100644 index 0000000..22ba3f3 --- /dev/null +++ b/src/lib/assistant/builder.ts @@ -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 = { + 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 = { + 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(); + // 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 = { + 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.`; +} diff --git a/src/lib/assistant/session.ts b/src/lib/assistant/session.ts new file mode 100644 index 0000000..5755d8b --- /dev/null +++ b/src/lib/assistant/session.ts @@ -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['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; + +export function buildNpcPrompt( + ctx: Pick, + 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; + +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)]!; +} diff --git a/src/lib/compendium/index.ts b/src/lib/compendium/index.ts index 3792d9e..58900d3 100644 --- a/src/lib/compendium/index.ts +++ b/src/lib/compendium/index.ts @@ -95,12 +95,12 @@ export async function loadClasses(system: SystemId): Promise { 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 }[]> { diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 787f00e..55a1beb 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -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(), diff --git a/src/lib/sync/snapshot.ts b/src/lib/sync/snapshot.ts index dabba85..5e4e70f 100644 --- a/src/lib/sync/snapshot.ts +++ b/src/lib/sync/snapshot.ts @@ -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,