diff --git a/e2e/character-wizard.spec.ts b/e2e/character-wizard.spec.ts index d366c89..39018b3 100644 --- a/e2e/character-wizard.spec.ts +++ b/e2e/character-wizard.spec.ts @@ -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(); }); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index d0fac6a..73dba4e 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -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 { 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' }); diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index d714935..5d2c91c 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -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 = { + '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([]); + const [origins, setOrigins] = useState([]); + const [backgrounds, setBackgrounds] = useState([]); + const [allSpells, setAllSpells] = useState([]); - // 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('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([]); - 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('standard'); const [pool, setPool] = useState([...STANDARD_ARRAY]); const [assignment, setAssignment] = useState(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([]); 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([]); + 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 = { + 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 = { ...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 ( - {step > 0 && } + {step > 0 && } {isLast - ? - : } + ? + : } } > - {/* Step indicator */} -
    +
      {STEPS.map((s, i) => ( -
    1. - {i + 1}. {s} -
    2. +
    3. {i + 1}. {s}
    4. ))}
    - {step === 0 && ( + {stepName === 'Class' && (
    - setName(e.target.value)} placeholder="Character name" /> + setName(e.target.value)} placeholder="Your hero's name" />
    - - - +
    -
    - - setSubclass(e.target.value)}> + + {selectedClass.subclasses.map((s) => )} - {classChoice === 'Custom' - ? setCustomClass(e.target.value)} placeholder="Class name" /> - : } -
    - {classChoice === 'Custom' && campaign.system === '5e' && ( - - - )} -
    - - {campaign.system === 'pf2e' && ancestries.length > 0 ? ( - - ) : ( - setAncestry(e.target.value)} placeholder="e.g. Human" /> - )} - - setBackground(e.target.value)} placeholder="e.g. Acolyte" /> -
    - {def &&

    {classSummary(campaign.system, className)}

    }
    )} - {step === 1 && ( + {stepName === 'Origin' && ( +
    + + +
    + )} + + {stepName === 'Abilities' && (
    {([['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
    {method === 'roll' && } {method === 'pointbuy' &&

    Points remaining: {pointBuyRemaining(pb)} / 27

    } - {def &&

    Suggested key {def.keyAbilities.length > 1 ? 'abilities' : 'ability'}: {def.keyAbilities.map((k) => ABILITY_ABBR[k]).join(' / ')}

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

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

    } {usesPool && !poolValid &&

    Assign each value to a different ability.

    }
    {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 (
    {ABILITY_ABBR[a]}{isKey ? ' ★' : ''}
    @@ -227,7 +300,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
    )} - {step === 2 && ( + {stepName === 'Skills' && (

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

    @@ -245,52 +318,74 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
    )} - {step === 3 && ( + {stepName === 'Spells' && ( +
    +

    Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.

    + setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" /> +
    + {spellResults.map((s) => { + const checked = spellPicks.some((x) => x.name === s.name); + return ( + + ); + })} + {allSpells.length === 0 &&

    Loading spells…

    } +
    +
    + )} + + {stepName === 'Review' && selectedClass && (
    -
    {name || 'Unnamed'} — level {level} {[ancestry, className].filter(Boolean).join(' ')}
    +
    {name || selectedClass.name} — level {level} {[ancestry, selectedClass.name].filter(Boolean).join(' ')}{subclass ? ` (${subclass})` : ''}
    - `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} /> - k.toUpperCase()).join(', ') || '—'} /> - - {built.spellcasting.slots.length > 0 && ( - `${s.max}×L${s.level}`).join(' ')} /> - )} - {built.spellcasting.pact && } -

    - You can add equipment, spells known, class features, and resources on the sheet next. -

    + `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} /> + {background && } + + {built.spellcasting.slots.length > 0 && `${s.max}×L${s.level}`).join(' ')} />} + {spellPicks.length > 0 && s.name).join(', ')} />} +

    You can fine-tune equipment, feats, and more on the sheet next.

    )} ); } -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 ( +
    +
    + {title} + {value && } +
    + setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}…`} className="mb-1" aria-label={`Search ${title}`} /> +
    + {filtered.map((o) => ( + + ))} + {options.length === 0 &&

    Loading…

    } +
    + {sel?.desc &&

    {sel.desc}

    } +
    + ); } function Stat({ label, value }: { label: string; value: number | string }) { - return ( -
    -
    {value}
    -
    {label}
    -
    - ); + return
    {value}
    {label}
    ; } - function Review({ label, value }: { label: string; value: string }) { - return ( -
    - {label} - {value} -
    - ); + return
    {label}{value}
    ; }