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:
2026-06-08 15:35:15 +02:00
parent e1ba3fe1b9
commit 06615020f6
3 changed files with 251 additions and 153 deletions
+11 -12
View File
@@ -9,34 +9,33 @@ test.beforeEach(async ({ page }) => {
await page.reload();
});
test('creation wizard builds a complete character (HP, skills, spell slots)', async ({ page }) => {
test('creation wizard builds a complete character (HP, skills, spells)', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Wizard Camp');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
// Basics: a level-3 Wizard
// Class step: a level-3 Wizard (a caster, so a Spells step appears).
await page.getByLabel('Name').fill('Mira');
await page.getByLabel('Class').selectOption('Wizard');
await page.getByLabel('Level', { exact: true }).fill('3');
await page.getByRole('button', { name: 'Next' }).click();
await page.getByTestId('class-card').filter({ hasText: 'Wizard' }).click();
await page.getByRole('button', { name: 'Next' }).click(); // → Origin
await page.getByRole('button', { name: 'Next' }).click(); // → Abilities (standard array valid)
await page.getByRole('button', { name: 'Next' }).click(); // → Skills
// Abilities: standard array assignment is valid by default (CON 13 → +1)
await page.getByRole('button', { name: 'Next' }).click();
// Skills: Wizard chooses 2
const boxes = page.locator('input[type="checkbox"]');
await boxes.nth(0).check();
await boxes.nth(1).check();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('button', { name: 'Next' }).click(); // → Spells (Wizard is a caster)
await expect(page.getByPlaceholder('Search spells…')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click(); // → Review
// Review shows derived HP + spell slots before finishing
await expect(page.getByText('Max HP')).toBeVisible();
await expect(page.getByText('Spell slots')).toBeVisible();
await page.getByRole('button', { name: 'Create character' }).click();
// Lands on the sheet with real HP (d6 + CON over 3 levels = 17), not the old 1/1
// Lands on the sheet with real derived HP, not the old 1/1.
await expect(page.getByText('Hit Points')).toBeVisible();
await expect(page.getByText(/\/\s*17/)).toBeVisible();
});
+6 -2
View File
@@ -2,12 +2,16 @@ import { type Page } from '@playwright/test';
/**
* Walk the guided creation wizard and land on the new character's sheet.
* Uses the default class; selects the minimum required trained skills.
* Picks the Barbarian class (a non-caster, so there's no Spells step) and the
* default standard array; selects the minimum required trained skills.
*/
export async function createCharacter(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.getByLabel('Name').fill(name);
await page.getByRole('button', { name: 'Next' }).click(); // → Abilities (standard array is valid by default)
// Class step: pick a class card (waits for the data-driven list to load).
await page.getByTestId('class-card').filter({ hasText: 'Barbarian' }).click();
await page.getByRole('button', { name: 'Next' }).click(); // → Origin
await page.getByRole('button', { name: 'Next' }).click(); // → Abilities (standard array valid)
await page.getByRole('button', { name: 'Next' }).click(); // → Skills
const next = page.getByRole('button', { name: 'Next' });
+234 -139
View File
@@ -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>;
}