f7e1c43612
- 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>
600 lines
34 KiB
TypeScript
600 lines
34 KiB
TypeScript
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 (1–30).</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 you’ve 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 ? ' — that’s 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>;
|
||
}
|