Phase 5: data-driven, new-player-friendly character builder
Rebuilt CreationWizard to consume the structured ruleset data (Phase 7): - Class step: rich class cards (description, hit die, key ability, caster, playstyle tag) + subclass picker, plus "ready-made hero" quick-start templates that prefill class + abilities for instant play. - Origin step: searchable ancestry/race + background pickers with descriptions (auto speed/ancestry-HP where known). - Abilities: key-ability hints from the chosen class. Skills: choices from class data. - Spells step (casters only): searchable starting-spell picker → spellcasting.spells. - Review applies derived build + data-driven saves (esp. PF2e classes). - Updated the e2e helper + wizard spec to the new flow (class-card data-testid). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { Campaign, Character } from '@/lib/schemas';
|
||||
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
getSystem, ABILITY_ABBR, abilityModifier, type AbilityKey, type AbilityScores,
|
||||
getClassNames, getClassDef, classSkillChoices, classSkillCount, buildCharacter,
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter,
|
||||
type AbilityKey, type AbilityScores, type ProficiencyRank,
|
||||
} from '@/lib/rules';
|
||||
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||
import { loadPf2e } from '@/lib/compendium';
|
||||
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 { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -17,11 +19,32 @@ import { NumberField } from '@/components/ui/NumberField';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
const HIT_DICE = [6, 8, 10, 12];
|
||||
type AbilityMethod = 'standard' | 'pointbuy' | 'roll';
|
||||
type Ancestry = { name: string; hp?: number; speed?: number };
|
||||
|
||||
const STEPS = ['Basics', 'Abilities', 'Skills', 'Review'] as const;
|
||||
interface Origin { name: string; desc: string; meta?: string; hp?: number; speed?: number }
|
||||
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();
|
||||
@@ -29,42 +52,40 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const allSkillKeys = sys.skills.map((s) => s.key);
|
||||
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
// ---- loaded data ----
|
||||
const [classes, setClasses] = useState<RulesetClass[]>([]);
|
||||
const [origins, setOrigins] = useState<Origin[]>([]);
|
||||
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
||||
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
||||
|
||||
// Basics
|
||||
useEffect(() => { let on = true; void loadClasses(campaign.system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [campaign.system]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
if (campaign.system === '5e') {
|
||||
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
|
||||
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('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
|
||||
}
|
||||
return () => { on = false; };
|
||||
}, [campaign.system]);
|
||||
|
||||
// ---- selections ----
|
||||
const [step, setStep] = useState(0);
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<Character['kind']>('pc');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [classChoice, setClassChoice] = useState(getClassNames(campaign.system)[0] ?? 'Custom');
|
||||
const [customClass, setCustomClass] = useState('');
|
||||
const [hitDie, setHitDie] = useState(8);
|
||||
const [classSlug, setClassSlug] = useState('');
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [ancestry, setAncestry] = useState('');
|
||||
const [background, setBackground] = useState('');
|
||||
|
||||
// PF2e ancestries (for HP/speed)
|
||||
const [ancestries, setAncestries] = useState<Ancestry[]>([]);
|
||||
useEffect(() => {
|
||||
if (campaign.system !== 'pf2e') return;
|
||||
let live = true;
|
||||
void loadPf2e('ancestries').then((rows) => {
|
||||
if (!live) return;
|
||||
setAncestries(rows.map((r) => {
|
||||
const speed = r.speed as { land?: number } | undefined;
|
||||
return {
|
||||
name: String(r.name),
|
||||
...(typeof r.hp === 'number' ? { hp: r.hp } : {}),
|
||||
...(speed?.land ? { speed: speed.land } : {}),
|
||||
};
|
||||
}));
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [campaign.system]);
|
||||
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
|
||||
const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null;
|
||||
const isCaster = !!selectedClass && selectedClass.caster !== 'none';
|
||||
|
||||
const className = classChoice === 'Custom' ? customClass.trim() : classChoice;
|
||||
const def = getClassDef(campaign.system, className);
|
||||
const selectedAncestry = ancestries.find((a) => a.name === ancestry);
|
||||
|
||||
// Abilities
|
||||
// ---- abilities ----
|
||||
const [method, setMethod] = useState<AbilityMethod>('standard');
|
||||
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
|
||||
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
|
||||
@@ -83,118 +104,170 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
|
||||
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
|
||||
|
||||
// Skills
|
||||
// ---- skills ----
|
||||
const [skills, setSkills] = useState<string[]>([]);
|
||||
const intMod = abilityModifier(abilities.int);
|
||||
const skillCount = classSkillCount(campaign.system, className, intMod);
|
||||
const skillOptions = classSkillChoices(campaign.system, allSkillKeys, className);
|
||||
const skillCount = selectedClass
|
||||
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0)
|
||||
: 2;
|
||||
const skillOptions = useMemo(() => {
|
||||
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
|
||||
// map class skill labels → our skill keys
|
||||
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase()));
|
||||
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, allSkillKeys, sys]);
|
||||
const toggleSkill = (key: string) =>
|
||||
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
|
||||
|
||||
// Derived preview
|
||||
const built = useMemo(
|
||||
() => buildCharacter(campaign.system, {
|
||||
className, level, abilities, skillChoices: skills,
|
||||
...(selectedAncestry?.hp ? { ancestryHp: selectedAncestry.hp } : {}),
|
||||
hitDieOverride: hitDie,
|
||||
}),
|
||||
[campaign.system, className, level, abilities, skills, selectedAncestry, hitDie],
|
||||
);
|
||||
// ---- spells ----
|
||||
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
||||
const [spellQuery, setSpellQuery] = useState('');
|
||||
useEffect(() => {
|
||||
if (!isCaster || allSpells.length) return;
|
||||
let on = true;
|
||||
if (campaign.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, campaign.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]));
|
||||
|
||||
const stepValid = [
|
||||
name.trim().length > 0 && className.length > 0,
|
||||
poolValid && pbValid,
|
||||
skills.length === Math.min(skillCount, skillOptions.length),
|
||||
true,
|
||||
];
|
||||
// ---- derived build ----
|
||||
const built = useMemo(() => buildCharacter(campaign.system, {
|
||||
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
|
||||
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
|
||||
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
|
||||
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]);
|
||||
|
||||
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
|
||||
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
||||
|
||||
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([]); }
|
||||
setMethod('pointbuy');
|
||||
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: campaign.system, name: name.trim(), kind,
|
||||
ancestry: ancestry.trim(), className, level,
|
||||
system: campaign.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) => ({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)), prepared: false, notes: '' }));
|
||||
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
|
||||
await charactersRepo.update(created.id, {
|
||||
...built,
|
||||
notes: background.trim() ? `Background: ${background.trim()}` : '',
|
||||
...(selectedAncestry?.speed ? { speed: selectedAncestry.speed } : {}),
|
||||
...(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 = step === STEPS.length - 1;
|
||||
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 — ${STEPS[step]}`}
|
||||
className="max-w-2xl"
|
||||
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={() => setStep((s) => s - 1)}>Back</Button>}
|
||||
{step > 0 && <Button variant="secondary" onClick={() => go(-1)}>Back</Button>}
|
||||
{isLast
|
||||
? <Button variant="primary" onClick={finish}>Create character</Button>
|
||||
: <Button variant="primary" disabled={!stepValid[step]} onClick={() => setStep((s) => s + 1)}>Next</Button>}
|
||||
? <Button variant="primary" disabled={!selectedClass} onClick={finish}>Create character</Button>
|
||||
: <Button variant="primary" disabled={!stepValid[stepName]} onClick={() => go(1)}>Next</Button>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Step indicator */}
|
||||
<ol className="mb-4 flex gap-1 text-xs">
|
||||
<ol className="mb-4 flex flex-wrap gap-1 text-xs">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s} className={cn('flex-1 rounded-md px-2 py-1 text-center', i === step ? 'bg-accent text-accent-ink' : i < step ? 'bg-elevated text-ink' : 'bg-surface text-muted')}>
|
||||
{i + 1}. {s}
|
||||
</li>
|
||||
<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>
|
||||
|
||||
{step === 0 && (
|
||||
{stepName === 'Class' && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Name"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Character name" /></Field>
|
||||
<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="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 className="grid grid-cols-2 gap-3">
|
||||
<Field label="Class">
|
||||
<Select value={classChoice} onChange={(e) => setClassChoice(e.target.value)} aria-label="Class">
|
||||
{getClassNames(campaign.system).map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="Custom">Custom…</option>
|
||||
|
||||
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
|
||||
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(TEMPLATES[campaign.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 text-xs font-semibold uppercase tracking-wide text-muted">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">{campaign.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">{c.description}</p>}
|
||||
</button>
|
||||
))}
|
||||
{classes.length === 0 && <p className="text-sm text-muted">Loading classes…</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedClass && selectedClass.subclasses.length > 0 && (
|
||||
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
|
||||
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
|
||||
<option value="">— decide later —</option>
|
||||
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
{classChoice === 'Custom'
|
||||
? <Field label="Custom class"><Input value={customClass} onChange={(e) => setCustomClass(e.target.value)} placeholder="Class name" /></Field>
|
||||
: <Field label="Hit die / HP"><Input value={def ? (campaign.system === 'pf2e' ? `${def.hitDie} HP/level` : `d${def.hitDie}`) : '—'} disabled /></Field>}
|
||||
</div>
|
||||
{classChoice === 'Custom' && campaign.system === '5e' && (
|
||||
<Field label="Hit die">
|
||||
<Select value={hitDie} onChange={(e) => setHitDie(Number(e.target.value))}>{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)}</Select>
|
||||
</Field>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Ancestry / race">
|
||||
{campaign.system === 'pf2e' && ancestries.length > 0 ? (
|
||||
<Select value={ancestry} onChange={(e) => setAncestry(e.target.value)} aria-label="Ancestry">
|
||||
<option value="">Choose…</option>
|
||||
{ancestries.map((a) => <option key={a.name} value={a.name}>{a.name}</option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={ancestry} onChange={(e) => setAncestry(e.target.value)} placeholder="e.g. Human" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Background (optional)"><Input value={background} onChange={(e) => setBackground(e.target.value)} placeholder="e.g. Acolyte" /></Field>
|
||||
</div>
|
||||
{def && <p className="text-xs text-muted">{classSummary(campaign.system, className)}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
{stepName === 'Origin' && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<OriginPicker title={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepName === 'Abilities' && (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-1">
|
||||
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
|
||||
@@ -203,12 +276,12 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}>↻ Reroll</Button>}
|
||||
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
|
||||
{def && <p className="mb-3 text-xs text-muted">Suggested key {def.keyAbilities.length > 1 ? 'abilities' : 'ability'}: <span className="text-ink">{def.keyAbilities.map((k) => ABILITY_ABBR[k]).join(' / ')}</span></p>}
|
||||
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>}
|
||||
{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 value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
|
||||
const isKey = def?.keyAbilities.includes(a);
|
||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||
return (
|
||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
|
||||
@@ -227,7 +300,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
{stepName === 'Skills' && (
|
||||
<div>
|
||||
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
@@ -245,52 +318,74 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
{stepName === 'Spells' && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-muted">Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.</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 || 'Unnamed'} — level {level} {[ancestry, className].filter(Boolean).join(' ')}</div>
|
||||
<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="Ability scores" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
|
||||
<Review label="Trained saves" value={Object.keys(built.saveRanks).map((k) => k.toUpperCase()).join(', ') || '—'} />
|
||||
<Review label="Trained skills" value={Object.keys(built.skillRanks).map(skillLabel).join(', ') || '—'} />
|
||||
{built.spellcasting.slots.length > 0 && (
|
||||
<Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />
|
||||
)}
|
||||
{built.spellcasting.pact && <Review label="Pact slots" value={`${built.spellcasting.pact.max}× L${built.spellcasting.pact.level}`} />}
|
||||
<p className="text-xs text-muted">
|
||||
You can add equipment, spells known, class features, and resources on the sheet next.
|
||||
</p>
|
||||
<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 classSummary(system: Campaign['system'], className: string): string {
|
||||
const def = getClassDef(system, className);
|
||||
if (!def) return '';
|
||||
const caster = def.caster === 'none' ? 'non-caster' : def.caster === 'pact' ? 'pact caster' : `${def.caster} caster`;
|
||||
return `${className}: ${system === 'pf2e' ? `${def.hitDie} HP/level` : `d${def.hitDie} hit die`}, ${caster}.`;
|
||||
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="text-xs font-semibold uppercase tracking-wide text-muted">{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 line-clamp-3 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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user