Files
ttrpg_manager/src/features/characters/builder/CreationWizard.tsx
T
NilsBriggen f7e1c43612 Clear lint warnings from the audit branch (deps + extract NotFound)
- CreationWizard: add sys.skills to the loader effect's deps (stable singleton).
- Extract NotFound to src/app/NotFound.tsx so router.tsx no longer trips
  react-refresh/only-export-components.

Branch is now warning-clean (the 3 remaining eslint errors are pre-existing on master,
in files this branch does not touch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:50:27 +02:00

600 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
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 { briefOverview, dedupeByName } from './overview';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
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';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
type AbilityMethod = 'standard' | 'pointbuy' | 'roll' | 'manual';
interface Origin {
name: string; desc: string; meta?: string; hp?: number; speed?: number;
/** 5e racial ability score increases, applied to the final scores. */
asiBonuses?: Partial<AbilityScores>;
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
skillGrants?: string[];
}
interface SpellOpt { name: string; level: number; meta: string }
/** A class's "what it plays like" tag, to help newcomers pick. */
function playstyle(c: RulesetClass): string {
if (c.caster !== 'none' && c.caster.length <= 3) return c.hitDie >= 8 && c.system === 'pf2e' ? 'Caster' : 'Spellcaster';
if (c.hitDie >= 10 || c.hitDie >= 8) return 'Martial';
return 'Skirmisher';
}
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 } },
],
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 } },
],
};
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const navigate = useNavigate();
// Characters are campaign-agnostic: the wizard asks which system to build for,
// defaulting to the campaign's. Changing it resets all dependent selections.
const [system, setSystem] = useState<SystemId>(campaign.system);
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
// ---- loaded data ----
const [classes, setClasses] = useState<RulesetClass[]>([]);
const [origins, setOrigins] = useState<Origin[]>([]);
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
useEffect(() => {
let on = true;
void loadClasses(system).then((c) => {
if (!on) return;
// The pf2e class data loads with caster:'none' and no subclasses; merge the
// curated caster ability (so pf2e spellcasters get the Spells step + "Caster"
// tag) and the defining 1st-level choice list (instinct/doctrine/bloodline…).
const enriched = system === 'pf2e'
? c.map((rc) => {
const def = getClassDef('pf2e', rc.name);
const subclasses = PF2E_SUBCLASSES[rc.name] ?? rc.subclasses;
const caster = def && def.caster !== 'none' && def.spellAbility ? def.spellAbility : rc.caster;
return { ...rc, caster, subclasses };
})
: c;
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
});
return () => { on = false; };
}, [system]);
useEffect(() => {
let on = true;
if (system === '5e') {
const resolve = makeSkillResolver(sys.skills);
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 asiBonuses = parseAsiBonuses(r.asi ?? '');
const asiSummary = ABILITIES.filter((a) => asiBonuses[a]).map((a) => `+${asiBonuses[a]} ${ABILITY_ABBR[a]}`).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
const skillGrants = parseTraitSkills(r.traits ?? '', resolve);
return { name: r.name, desc, meta, asiBonuses, ...(skillGrants.length ? { skillGrants } : {}) };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => {
const skillGrants = parseBackgroundSkills(b.skills ?? '', resolve);
return { name: b.name, desc: b.desc, meta: b.skills, ...(skillGrants.length ? { skillGrants } : {}) };
})));
} else {
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(dedupeByName(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: briefOverview(String((r.summary ?? r.text) ?? '')),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
meta,
};
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
}
return () => { on = false; };
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
}, [system, sys.skills]);
// ---- selections ----
const [step, setStep] = useState(0);
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');
const [level, setLevel] = useState(1);
const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = useState('');
const [ancestry, setAncestry] = useState('');
const [background, setBackground] = useState('');
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null;
const selectedBackground = backgrounds.find((b) => b.name === background) ?? null;
const classDef = selectedClass ? getClassDef(system, selectedClass.name) : undefined;
const isCaster = !!selectedClass && selectedClass.caster !== 'none';
// ---- abilities ----
const [method, setMethod] = useState<AbilityMethod>('standard');
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
const [pb, setPb] = useState<number[]>([8, 8, 8, 8, 8, 8]);
const usesPool = method === 'standard' || method === 'roll';
const chooseMethod = (m: AbilityMethod) => {
setMethod(m);
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
};
const abilities = useMemo<AbilityScores>(() => {
const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
// 5e: fold the chosen race's ability score increases into the final scores.
if (system === '5e' && selectedOrigin?.asiBonuses) {
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
}
return out;
}, [usesPool, pool, assignment, pb, system, selectedOrigin]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// ---- skills ----
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
// Prefer the curated class table (canonical free-choice count) over the parsed
// data file, which over-counts pf2e classes and mangles some 5e skill names.
const baseSkillCount = classDef?.skillCount ?? selectedClass?.skillCount ?? 2;
const skillCount = selectedClass
? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass) return allSkillKeys;
// Curated class skill list (canonical for 5e); empty curated list = choose from all.
if (classDef && classDef.skillKeys.length > 0) return classDef.skillKeys;
if (selectedClass.skillChoices.length === 0) return allSkillKeys;
// Fallback to the parsed data, tolerating Oxford "and X" remnants.
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase().replace(/^and\s+/, '')));
const labelOf = (k: string) => sys.skills.find((s) => s.key === k)?.label.toLowerCase() ?? k.toLowerCase();
const matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase()));
return matched.length ? matched : allSkillKeys;
}, [selectedClass, classDef, allSkillKeys, sys]);
const toggleSkill = (key: string) =>
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
// 5e skill proficiencies granted by background + race (applied as trained, on top of class picks).
const grantedSkills = useMemo(
() => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []),
[system, selectedOrigin, selectedBackground],
);
// ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
const [spellQuery, setSpellQuery] = useState('');
useEffect(() => {
if (!isCaster || allSpells.length) return;
let on = true;
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
return () => { on = false; };
}, [isCaster, system, allSpells.length]);
const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase();
const maxLevel = Math.min(9, Math.ceil(level / 2));
return allSpells.filter((s) => s.level <= maxLevel && (!q || s.name.toLowerCase().includes(q))).slice(0, 60);
}, [allSpells, spellQuery, level]);
const toggleSpell = (s: SpellOpt) =>
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
// ---- derived build ----
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(grantedSkills.length ? { grantedSkills } : {}),
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [system, selectedClass, level, abilities, skills, grantedSkills, selectedOrigin]);
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
if (next === system) return;
setSystem(next);
// Reset everything keyed on the system so stale picks don't leak across systems.
// The class/origin/background/spell lists reload from the loaders above.
setClassSlug('');
setSubclass('');
setSkills([]);
setSpellPicks([]);
setSpellQuery('');
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
setAncestry('');
setBackground('');
setStep(0);
};
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
// Templates can use scores above the point-buy cap (PF2e starts a key stat at 18),
// which would softlock the point-buy step ("-Infinity / 27"). Use manual entry then.
const fitsPointBuy = ABILITIES.every((a) => t.ability[a] >= POINT_BUY_MIN && t.ability[a] <= POINT_BUY_MAX);
setMethod(fitsPointBuy ? 'pointbuy' : 'manual');
setPb(ABILITIES.map((a) => t.ability[a]));
if (!name) setName(t.label.replace(/^[^A-Za-z]+/, ''));
};
const stepValid: Record<string, boolean> = {
Class: !!selectedClass,
Origin: true,
Abilities: poolValid && pbValid,
Skills: skills.length === Math.min(skillCount, skillOptions.length),
Spells: true,
Review: true,
};
const finish = async () => {
if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, {
system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level,
});
// For classes outside the curated tables (esp. PF2e), fill saves from data.
const saveRanks: Record<string, ProficiencyRank> = { ...built.saveRanks };
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
await charactersRepo.update(created.id, {
...built,
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
spellcasting: { ...built.spellcasting, spells },
...(notes ? { notes } : {}),
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
});
onClose();
void navigate({ to: '/characters/$characterId', params: { characterId: created.id } });
};
const isLast = stepName === 'Review';
const go = (d: number) => setStep((s) => Math.max(0, Math.min(STEPS.length - 1, s + d)));
return (
<Modal
open onClose={onClose}
title={`New character — ${stepName}`}
className="max-w-3xl"
footer={
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
{step > 0 && <Button variant="secondary" onClick={() => go(-1)}>Back</Button>}
{isLast
? <Button variant="primary" disabled={!selectedClass} onClick={finish}>Create character</Button>
: <Button variant="primary" disabled={!stepValid[stepName]} onClick={() => go(1)}>Next</Button>}
</>
}
>
<ol className="mb-4 flex flex-wrap gap-1 text-xs">
{STEPS.map((s, i) => (
<li key={s} className={cn('rounded-md px-2 py-1', i === step ? 'bg-accent text-accent-ink' : i < step ? 'bg-elevated text-ink' : 'bg-surface text-muted')}>{i + 1}. {s}</li>
))}
</ol>
{stepName === 'Class' && (
<div className="space-y-3">
<Field label="Name"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your hero's name" /></Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Type"><Select value={kind} onChange={(e) => setKind(e.target.value as Character['kind'])}><option value="pc">Player character</option><option value="npc">NPC</option></Select></Field>
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
</div>
<div>
<div className="mb-1 smallcaps">Game system</div>
<div className="flex gap-1" role="group" aria-label="Game system">
{SYSTEM_OPTIONS.map((opt) => (
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
className={cn('rounded-md px-3 py-1.5 text-sm', system === opt.id ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>
{opt.label}
</button>
))}
</div>
</div>
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
<div className="mb-1 smallcaps">New here? Start from a ready-made hero</div>
<div className="flex flex-wrap gap-1">
{(TEMPLATES[system] ?? []).map((t) => (
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
))}
</div>
</div>
<div>
<div className="mb-1 smallcaps">Choose a class</div>
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{classes.map((c) => (
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
className={cn('rounded-lg border p-2 text-left', classSlug === c.slug ? 'border-accent bg-accent/5' : 'border-line bg-surface hover:border-accent/60')}>
<div className="flex items-center justify-between">
<span className="font-display font-semibold text-ink">{c.name}</span>
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</div>
<div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
</button>
))}
{classes.length === 0 && <p className="text-sm text-muted">Loading classes</p>}
</div>
</div>
{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="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>
)}
{stepName === 'Abilities' && (
<div>
<div className="mb-3 flex flex-wrap gap-1">
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{method === 'manual' && <p className="mb-3 text-sm text-muted">Enter each score directly (130).</p>}
{system === '5e' && selectedOrigin && Object.values(selectedOrigin.asiBonuses ?? {}).some(Boolean) && (
<p className="mb-2 text-xs text-accent">
{selectedOrigin.name} racial bonus ({ABILITIES.filter((a) => selectedOrigin.asiBonuses?.[a]).map((a) => `+${selectedOrigin.asiBonuses![a]} ${ABILITY_ABBR[a]}`).join(', ')}) is added to your final scores.
</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) => {
const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0;
const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const value = base + racial;
const isKey = selectedClass?.keyAbilities.includes(a);
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30;
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => {
const v = Number(e.target.value);
// Swap on collision so a value can be moved between abilities directly.
setAssignment((prev) => {
const next = [...prev];
const j = next.findIndex((x, k) => x === v && k !== i);
if (j !== -1) next[j] = next[i]!;
next[i] = v;
return next;
});
}}>
{pool.map((p, idx) => <option key={idx} value={idx}>{p}</option>)}
</Select>
) : (
<NumberField className="mt-1" value={pb[i]!} min={fMin} max={fMax} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} />
)}
<div className="mt-1 text-xs text-accent">
{racial ? <span className="text-muted">{base}+{racial} · </span> : null}{formatModifier(abilityModifier(value))}
</div>
</div>
);
})}
</div>
</div>
)}
{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-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);
const disabled = !checked && skills.length >= skillCount;
return (
<label key={key} className={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', checked ? 'border-accent/60 bg-accent/5 text-ink' : disabled ? 'border-line text-muted opacity-50' : 'border-line text-ink')}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={() => toggleSkill(key)} />
{skillLabel(key)}
</label>
);
})}
</div>
</div>
)}
{stepName === 'Spells' && (
<div>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — thats quite a few; you can trim some later if you like.' : ''}
</p>
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => {
const checked = spellPicks.some((x) => x.name === s.name);
return (
<label key={s.name} className={cn('flex items-center gap-2 rounded-md border px-2 py-1 text-sm', checked ? 'border-accent/60 bg-accent/5' : 'border-line')}>
<input type="checkbox" checked={checked} onChange={() => toggleSpell(s)} />
<span className="flex-1 truncate text-ink">{s.name}</span>
<span className="text-[10px] text-muted">{s.level === 0 ? 'cantrip' : `lv ${s.level}`}</span>
</label>
);
})}
{allSpells.length === 0 && <p className="text-sm text-muted">Loading spells</p>}
</div>
</div>
)}
{stepName === 'Review' && selectedClass && (
<div className="space-y-3 text-sm">
<div className="rounded-lg border border-line bg-surface p-3">
<div className="font-medium text-ink">{name || selectedClass.name} level {level} {[ancestry, selectedClass.name].filter(Boolean).join(' ')}{subclass ? ` (${subclass})` : ''}</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-center">
<Stat label="Max HP" value={built.hp.max} />
<Stat label="AC" value={sys.baseArmorClass({ level, abilities, armorBonus: 0 })} />
</div>
</div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{background && <Review label="Background" value={background} />}
<Review label="Trained skills" value={skills.map(skillLabel).join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
<p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p>
</div>
)}
</Modal>
);
}
function OriginPicker({ title, options, value, onPick }: { title: string; options: Origin[]; value: string; onPick: (v: string) => void }) {
const [q, setQ] = useState('');
const filtered = options.filter((o) => !q || o.name.toLowerCase().includes(q.toLowerCase())).slice(0, 60);
const sel = options.find((o) => o.name === value);
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">{title}</span>
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
</div>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} />
<div className="max-h-44 space-y-1 overflow-y-auto pr-1">
{filtered.map((o) => (
<button key={o.name} onClick={() => onPick(o.name)} className={cn('block w-full rounded-md border px-2 py-1 text-left text-sm', value === o.name ? 'border-accent bg-accent/5 text-ink' : 'border-line text-ink hover:border-accent/60')}>
{o.name}{o.meta ? <span className="ml-1 text-[10px] text-muted">{String(o.meta).slice(0, 28)}</span> : ''}
</button>
))}
{options.length === 0 && <p className="text-xs text-muted">Loading</p>}
</div>
{sel?.desc && <p className="mt-1 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">{sel.desc}</p>}
</div>
);
}
function Stat({ label, value }: { label: string; value: number | string }) {
return <div className="rounded-md border border-line bg-panel p-2"><div className="font-display text-lg font-semibold text-accent">{value}</div><div className="text-xs uppercase tracking-wide text-muted">{label}</div></div>;
}
function Review({ label, value }: { label: string; value: string }) {
return <div className="flex gap-2"><span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span><span className="text-ink">{value}</span></div>;
}