diff --git a/e2e/character-build.spec.ts b/e2e/character-build.spec.ts index 748ef5f..b2d07f6 100644 --- a/e2e/character-build.spec.ts +++ b/e2e/character-build.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -14,18 +15,12 @@ test('generate ability scores and level up', async ({ page }) => { await page.locator('input[data-autofocus]').fill('Build'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Builder'); - await page.getByRole('button', { name: 'Create' }).click(); - await page.getByRole('link', { name: /Builder/ }).click(); - - // Generate scores via standard array (default assignment 15,14,13,12,10,8) - await page.getByRole('button', { name: 'Generate' }).click(); - await page.getByRole('button', { name: 'Apply' }).click(); + // Guided wizard builds a complete level-1 character (standard array → STR 15). + await createCharacter(page, 'Builder'); await expect(page.getByLabel('STR score')).toHaveValue('15'); - // Level up from 1 -> 2 + // Guided level-up from 1 -> 2 (HP + any choices applied automatically) await page.getByRole('button', { name: 'Level up' }).click(); - await page.getByRole('button', { name: 'Level up' }).last().click(); + await page.getByRole('button', { name: /Apply level 2/ }).click(); await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2'); }); diff --git a/e2e/character-depth.spec.ts b/e2e/character-depth.spec.ts index 964fc57..694810d 100644 --- a/e2e/character-depth.spec.ts +++ b/e2e/character-depth.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -15,10 +16,7 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn await page.locator('input[data-autofocus]').fill('Phandelver'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Gandalf'); - await page.getByRole('button', { name: 'Create' }).click(); - await page.getByRole('link', { name: /Gandalf/ }).click(); + await createCharacter(page, 'Gandalf'); // Set INT high so spell DC is computable await page.getByLabel('INT score').fill('18'); diff --git a/e2e/character-wizard.spec.ts b/e2e/character-wizard.spec.ts new file mode 100644 index 0000000..d366c89 --- /dev/null +++ b/e2e/character-wizard.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('creation wizard builds a complete character (HP, skills, spell slots)', 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 + 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(); + + // 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(); + + // 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 + await expect(page.getByText('Hit Points')).toBeVisible(); + await expect(page.getByText(/\/\s*17/)).toBeVisible(); +}); diff --git a/e2e/compendium.spec.ts b/e2e/compendium.spec.ts index fc3bfe2..805a7f7 100644 --- a/e2e/compendium.spec.ts +++ b/e2e/compendium.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -15,9 +16,7 @@ test('compendium: browse 5e categories, switch to PF2e, cross-link a spell', asy await page.locator('input[data-autofocus]').fill('Saltmarsh'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Wizard Wendy'); - await page.getByRole('button', { name: 'Create' }).click(); + await createCharacter(page, 'Wizard Wendy'); // Compendium → 5e categories exist (Feats is a new one) await page.getByRole('link', { name: 'Compendium' }).click(); diff --git a/e2e/fixes.spec.ts b/e2e/fixes.spec.ts index f37987f..10f0514 100644 --- a/e2e/fixes.spec.ts +++ b/e2e/fixes.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -66,9 +67,8 @@ test('combat condition picker adds preset and valued conditions as tags', async test('character can be deleted', async ({ page }) => { await makeCampaign(page, 'Delete Test'); await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Doomed'); - await page.getByRole('button', { name: 'Create' }).click(); + await createCharacter(page, 'Doomed'); // lands on the sheet + await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click(); await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible(); await page.getByRole('button', { name: 'Delete' }).click(); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..d0fac6a --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,22 @@ +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. + */ +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) + await page.getByRole('button', { name: 'Next' }).click(); // → Skills + + const next = page.getByRole('button', { name: 'Next' }); + const boxes = page.locator('input[type="checkbox"]'); + const count = await boxes.count(); + for (let i = 0; i < count; i++) { + if (!(await next.isDisabled())) break; + await boxes.nth(i).check(); + } + await next.click(); // → Review + await page.getByRole('button', { name: 'Create character' }).click(); +} diff --git a/e2e/interactive-dice.spec.ts b/e2e/interactive-dice.spec.ts index c5de66d..815cfe7 100644 --- a/e2e/interactive-dice.spec.ts +++ b/e2e/interactive-dice.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -14,10 +15,7 @@ test('rolling a skill from the sheet shows the roll tray', async ({ page }) => { await page.locator('input[data-autofocus]').fill('Dice Camp'); await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Roller'); - await page.getByRole('button', { name: 'Create' }).click(); - await page.getByRole('link', { name: /Roller/ }).click(); + await createCharacter(page, 'Roller'); // Click the Acrobatics modifier to roll it await page.getByTitle('Roll Acrobatics (DEX)').click(); diff --git a/e2e/player-view.spec.ts b/e2e/player-view.spec.ts index 78a4498..87a2f90 100644 --- a/e2e/player-view.spec.ts +++ b/e2e/player-view.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -16,9 +17,7 @@ test('player view shows the party and hides enemy HP numbers', async ({ page }) // A player character await page.getByRole('link', { name: 'Characters' }).click(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Hero'); - await page.getByRole('button', { name: 'Create' }).click(); + await createCharacter(page, 'Hero'); // An active encounter with the hero + a monster await page.getByRole('link', { name: 'Combat' }).click(); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 17a5527..35ca556 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; // Each test starts from a clean IndexedDB so runs are deterministic. test.beforeEach(async ({ page }) => { @@ -24,10 +25,7 @@ test('full core flow: campaign → character → dice → combat → compendium' // --- Character --- await page.getByRole('link', { name: 'Characters' }).click(); await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible(); - await page.getByRole('button', { name: '+ New character' }).first().click(); - await page.locator('input[data-autofocus]').fill('Ireena'); - await page.getByRole('button', { name: 'Create' }).click(); - await page.getByRole('link', { name: /Ireena/ }).click(); + await createCharacter(page, 'Ireena'); // On the sheet: set STR to 16 and expect +3 modifier await page.getByLabel('STR score').fill('16'); diff --git a/src/features/characters/CharactersPage.tsx b/src/features/characters/CharactersPage.tsx index 5627403..315f8b8 100644 --- a/src/features/characters/CharactersPage.tsx +++ b/src/features/characters/CharactersPage.tsx @@ -9,7 +9,7 @@ import { useCharacters } from './hooks'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; -import { Field, Input, Select } from '@/components/ui/Input'; +import { CreationWizard } from './builder/CreationWizard'; export function CharactersPage() { return {(campaign) => }; @@ -74,7 +74,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) { )} - {creating && setCreating(false)} />} + {creating && setCreating(false)} />} ); } @@ -164,77 +164,3 @@ function CharacterCard({ c }: { c: Character }) { ); } -function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) { - const [name, setName] = useState(''); - const [kind, setKind] = useState('pc'); - const [ancestry, setAncestry] = useState(''); - const [className, setClassName] = useState(''); - const [level, setLevel] = useState(1); - const [error, setError] = useState(null); - - const save = async () => { - if (name.trim() === '') { - setError('Name is required'); - return; - } - await charactersRepo.create(campaign.id, { - system: campaign.system, - name: name.trim(), - kind, - ancestry: ancestry.trim(), - className: className.trim(), - level, - }); - onClose(); - }; - - return ( - - - - - } - > -
- - { setName(e.target.value); setError(null); }} placeholder="Aria Stormwind" /> - -
- - - - - setLevel(Math.max(1, Math.min(20, Number(e.target.value) || 1)))} - /> - -
-
- - setAncestry(e.target.value)} placeholder={campaign.system === 'pf2e' ? 'Dwarf' : 'Half-Elf'} /> - - - setClassName(e.target.value)} placeholder="Fighter" /> - -
- {error &&

{error}

} -
-
- ); -} diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx new file mode 100644 index 0000000..d714935 --- /dev/null +++ b/src/features/characters/builder/CreationWizard.tsx @@ -0,0 +1,296 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import type { Campaign, Character } from '@/lib/schemas'; +import { charactersRepo } from '@/lib/db/repositories'; +import { + getSystem, ABILITY_ABBR, abilityModifier, type AbilityKey, type AbilityScores, + getClassNames, getClassDef, classSkillChoices, classSkillCount, buildCharacter, +} from '@/lib/rules'; +import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; +import { loadPf2e } from '@/lib/compendium'; +import { createRng } from '@/lib/rng'; +import { formatModifier } from '@/lib/format'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +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']; +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; + +export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) { + const navigate = useNavigate(); + const sys = getSystem(campaign.system); + 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); + + // Basics + 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 [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 className = classChoice === 'Custom' ? customClass.trim() : classChoice; + const def = getClassDef(campaign.system, className); + const selectedAncestry = ancestries.find((a) => a.name === ancestry); + + // Abilities + const [method, setMethod] = useState('standard'); + const [pool, setPool] = useState([...STANDARD_ARRAY]); + const [assignment, setAssignment] = useState(ABILITIES.map((_, i) => i)); + const [pb, setPb] = useState([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(() => { + const out = {} as AbilityScores; + ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; }); + return out; + }, [usesPool, pool, assignment, pb]); + const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; + const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; + + // 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 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], + ); + + const stepValid = [ + name.trim().length > 0 && className.length > 0, + poolValid && pbValid, + skills.length === Math.min(skillCount, skillOptions.length), + true, + ]; + + const finish = async () => { + const created = await charactersRepo.create(campaign.id, { + system: campaign.system, name: name.trim(), kind, + ancestry: ancestry.trim(), className, level, + }); + await charactersRepo.update(created.id, { + ...built, + notes: background.trim() ? `Background: ${background.trim()}` : '', + ...(selectedAncestry?.speed ? { speed: selectedAncestry.speed } : {}), + }); + onClose(); + void navigate({ to: '/characters/$characterId', params: { characterId: created.id } }); + }; + + const isLast = step === STEPS.length - 1; + + return ( + + + {step > 0 && } + {isLast + ? + : } + + } + > + {/* Step indicator */} +
    + {STEPS.map((s, i) => ( +
  1. + {i + 1}. {s} +
  2. + ))} +
+ + {step === 0 && ( +
+ setName(e.target.value)} placeholder="Character name" /> +
+ + + + +
+
+ + + + {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 && ( +
+
+ {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => ( + + ))} +
+ {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(' / ')}

} + {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); + return ( +
+
{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}
+ {usesPool ? ( + + ) : ( + setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} /> + )} +
{formatModifier(abilityModifier(value))}
+
+ ); + })} +
+
+ )} + + {step === 2 && ( +
+

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

+
+ {skillOptions.map((key) => { + const checked = skills.includes(key); + const disabled = !checked && skills.length >= skillCount; + return ( + + ); + })} +
+
+ )} + + {step === 3 && ( +
+
+
{name || 'Unnamed'} — level {level} {[ancestry, className].filter(Boolean).join(' ')}
+
+ + +
+
+ `${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. +

+
+ )} +
+ ); +} + +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 Stat({ label, value }: { label: string; value: number | string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Review({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx index c4f6920..d6b2167 100644 --- a/src/features/characters/sheet/LevelUpModal.tsx +++ b/src/features/characters/sheet/LevelUpModal.tsx @@ -1,6 +1,9 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import type { Campaign, Character } from '@/lib/schemas'; -import { abilityModifier } from '@/lib/rules'; +import { + abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, + planLevelUp, applyIncreases, bumpRank, getClassDef, +} from '@/lib/rules'; import { rollDice } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { Modal } from '@/components/ui/Modal'; @@ -8,68 +11,161 @@ import { Button } from '@/components/ui/Button'; import { Select } from '@/components/ui/Input'; import { LevelUpAdvisor } from './LevelUpAdvisor'; -const HIT_DICE = [6, 8, 10, 12] as const; +const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; export function LevelUpModal({ character, onApply, onClose }: { character: Character; onApply: (patch: Partial) => void; onClose: () => void; }) { - const [die, setDie] = useState(8); - const [method, setMethod] = useState<'average' | 'roll'>('average'); const conMod = abilityModifier(character.abilities.con); + const plan = useMemo( + () => planLevelUp(character.system, character.className, character.level, conMod), + [character.system, character.className, character.level, conMod], + ); + const sys = getSystem(character.system); + const def = getClassDef(character.system, character.className); - const average = Math.ceil(die / 2) + 1; // 5e fixed value per level + const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average'); + const asi = plan.choices.find((c) => c.kind === 'asi'); + const boosts = plan.choices.find((c) => c.kind === 'boosts'); + const skillInc = plan.choices.find((c) => c.kind === 'skill-increase'); + + // ASI (5e): two +1 picks (same twice = +2) or take a feat instead. + const keyDefaults = def?.keyAbilities ?? ['str', 'dex']; + const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi'); + const [asiPicks, setAsiPicks] = useState([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']); + // Boosts (pf2e): four distinct picks. + const [boostPicks, setBoostPicks] = useState(['str', 'dex', 'con', 'wis']); + // Skill increase (pf2e): one skill to bump. + const [skillKey, setSkillKey] = useState(sys.skills[0]?.key ?? ''); + + const atMax = character.level >= 20; const apply = () => { - const base = method === 'average' ? average : rollDice(`1d${die}`, createRng()).total; - const gain = Math.max(1, base + conMod); - onApply({ - level: Math.min(20, character.level + 1), + const gain = character.system === 'pf2e' + ? plan.hpGainAverage + : hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod); + + let abilities = character.abilities; + if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); + if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); + + const patch: Partial = { + level: plan.nextLevel, hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, - }); + abilities, + }; + if (plan.slots) { + patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; + } + if (skillInc && skillKey) { + const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained'; + patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) }; + } + onApply(patch); onClose(); }; - const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : ''); - - // The advisor only needs the system + id; synthesize a campaign from the character. - const campaign: Campaign = { - id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '', - }; + const hpPreview = character.system === 'pf2e' + ? `+${plan.hpGainAverage}` + : hpMethod === 'average' ? `+${plan.hpGainAverage}` : `1d${plan.hitDie} ${conMod >= 0 ? '+' : ''}${conMod}`; return ( - + } > -
- - -

- HP gained: {preview} (CON {conMod >= 0 ? '+' : ''}{conMod}) -

- {character.level >= 20 &&

Already at level 20.

} + {atMax ? ( +

Already at level 20.

+ ) : ( +
+ {/* HP */} +
+

Hit points

+ {character.system === '5e' ? ( +
+ + Gain: {hpPreview} (CON {conMod >= 0 ? '+' : ''}{conMod}) +
+ ) : ( +

Gain: {hpPreview} (class {plan.hitDie} + CON {conMod >= 0 ? '+' : ''}{conMod})

+ )} +
- {character.level < 20 && } -
+ {plan.notes.map((n) =>

• {n}

)} + + {/* ASI (5e) */} + {asi && ( +
+

Ability Score Improvement

+
+ {(['asi', 'feat'] as const).map((m) => ( + + ))} +
+ {asiMode === 'asi' ? ( +
+ {[0, 1].map((i) => ( + + ))} + pick the same twice for +2 +
+ ) : ( +

Add your chosen feat on the sheet after leveling.

+ )} +
+ )} + + {/* Boosts (pf2e) */} + {boosts && ( +
+

Ability boosts (choose 4)

+
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ )} + + {/* Skill increase (pf2e) */} + {skillInc && ( +
+

Skill increase

+ +
+ )} + + {plan.choices.filter((c) => c.kind === 'feat').map((c) => ( +

• {c.label}

+ ))} + + {/* Strategic build-route advice */} + +
+ )}
); } diff --git a/src/lib/rules/dnd5e/progression.ts b/src/lib/rules/dnd5e/progression.ts new file mode 100644 index 0000000..764e9a7 --- /dev/null +++ b/src/lib/rules/dnd5e/progression.ts @@ -0,0 +1,84 @@ +import type { ClassDef } from '../progression'; + +/** + * Curated 5e SRD class data for the guided builder. The on-disk MPMB files carry + * only names, so hit dice / proficiencies / caster type are authored here for the + * core classes. Unknown classes fall back to manual entry in the wizard. + */ +export const DND5E_CLASSES: ClassDef[] = [ + { name: 'Barbarian', hitDie: 12, keyAbilities: ['str'], saveProficient: ['str', 'con'], skillCount: 2, + skillKeys: ['animal-handling', 'athletics', 'intimidation', 'nature', 'perception', 'survival'], caster: 'none' }, + { name: 'Bard', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['dex', 'cha'], skillCount: 3, + skillKeys: [], caster: 'full', spellAbility: 'cha' }, + { name: 'Cleric', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['wis', 'cha'], skillCount: 2, + skillKeys: ['history', 'insight', 'medicine', 'persuasion', 'religion'], caster: 'full', spellAbility: 'wis' }, + { name: 'Druid', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['int', 'wis'], skillCount: 2, + skillKeys: ['arcana', 'animal-handling', 'insight', 'medicine', 'nature', 'perception', 'religion', 'survival'], caster: 'full', spellAbility: 'wis' }, + { name: 'Fighter', hitDie: 10, keyAbilities: ['str', 'dex'], saveProficient: ['str', 'con'], skillCount: 2, + skillKeys: ['acrobatics', 'animal-handling', 'athletics', 'history', 'insight', 'intimidation', 'perception', 'survival'], caster: 'none' }, + { name: 'Monk', hitDie: 8, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 2, + skillKeys: ['acrobatics', 'athletics', 'history', 'insight', 'religion', 'stealth'], caster: 'none' }, + { name: 'Paladin', hitDie: 10, keyAbilities: ['str', 'cha'], saveProficient: ['wis', 'cha'], skillCount: 2, + skillKeys: ['athletics', 'insight', 'intimidation', 'medicine', 'persuasion', 'religion'], caster: 'half', spellAbility: 'cha' }, + { name: 'Ranger', hitDie: 10, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 3, + skillKeys: ['animal-handling', 'athletics', 'insight', 'investigation', 'nature', 'perception', 'stealth', 'survival'], caster: 'half', spellAbility: 'wis' }, + { name: 'Rogue', hitDie: 8, keyAbilities: ['dex'], saveProficient: ['dex', 'int'], skillCount: 4, + skillKeys: ['acrobatics', 'athletics', 'deception', 'insight', 'intimidation', 'investigation', 'perception', 'performance', 'persuasion', 'sleight-of-hand', 'stealth'], caster: 'none' }, + { name: 'Sorcerer', hitDie: 6, keyAbilities: ['cha'], saveProficient: ['con', 'cha'], skillCount: 2, + skillKeys: ['arcana', 'deception', 'insight', 'intimidation', 'persuasion', 'religion'], caster: 'full', spellAbility: 'cha' }, + { name: 'Warlock', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['wis', 'cha'], skillCount: 2, + skillKeys: ['arcana', 'deception', 'history', 'intimidation', 'investigation', 'nature', 'religion'], caster: 'pact', spellAbility: 'cha' }, + { name: 'Wizard', hitDie: 6, keyAbilities: ['int'], saveProficient: ['int', 'wis'], skillCount: 2, + skillKeys: ['arcana', 'history', 'insight', 'investigation', 'medicine', 'religion'], caster: 'full', spellAbility: 'int' }, +]; + +// Spell slots [1st..9th] by character level for a full caster (PHB). +const FULL: Record = { + 1: [2], 2: [3], 3: [4, 2], 4: [4, 3], 5: [4, 3, 2], 6: [4, 3, 3], 7: [4, 3, 3, 1], 8: [4, 3, 3, 2], + 9: [4, 3, 3, 3, 1], 10: [4, 3, 3, 3, 2], 11: [4, 3, 3, 3, 2, 1], 12: [4, 3, 3, 3, 2, 1], + 13: [4, 3, 3, 3, 2, 1, 1], 14: [4, 3, 3, 3, 2, 1, 1], 15: [4, 3, 3, 3, 2, 1, 1, 1], 16: [4, 3, 3, 3, 2, 1, 1, 1], + 17: [4, 3, 3, 3, 2, 1, 1, 1, 1], 18: [4, 3, 3, 3, 3, 1, 1, 1, 1], 19: [4, 3, 3, 3, 3, 2, 1, 1, 1], 20: [4, 3, 3, 3, 3, 2, 2, 1, 1], +}; +// Half caster (Paladin/Ranger), spells from level 2. +const HALF: Record = { + 2: [2], 3: [3], 4: [3], 5: [4, 2], 6: [4, 2], 7: [4, 3], 8: [4, 3], 9: [4, 3, 2], 10: [4, 3, 2], + 11: [4, 3, 3], 12: [4, 3, 3], 13: [4, 3, 3, 1], 14: [4, 3, 3, 1], 15: [4, 3, 3, 2], 16: [4, 3, 3, 2], + 17: [4, 3, 3, 3, 1], 18: [4, 3, 3, 3, 1], 19: [4, 3, 3, 3, 2], 20: [4, 3, 3, 3, 2], +}; +// Warlock pact magic: [slotCount, slotLevel]. +const PACT: Record = { + 1: [1, 1], 2: [2, 1], 3: [2, 2], 4: [2, 2], 5: [2, 3], 6: [2, 3], 7: [2, 4], 8: [2, 4], 9: [2, 5], 10: [2, 5], + 11: [3, 5], 12: [3, 5], 13: [3, 5], 14: [3, 5], 15: [3, 5], 16: [3, 5], 17: [4, 5], 18: [4, 5], 19: [4, 5], 20: [4, 5], +}; + +function clamp(level: number): number { + return Math.max(1, Math.min(20, Math.floor(level) || 1)); +} + +export function dnd5eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] { + const lvl = clamp(level); + const table = caster === 'full' ? FULL[lvl] : caster === 'half' ? HALF[lvl] : undefined; + if (!table) return []; + return table.map((max, i) => ({ level: i + 1, max, current: max })); +} + +export function dnd5ePact(level: number): { level: number; max: number; current: number } | undefined { + const p = PACT[clamp(level)]; + return p ? { level: p[1], max: p[0], current: p[0] } : undefined; +} + +/** Average HP at a level: max die at L1, then per-level average + CON each level. */ +export function dnd5eHp(hitDie: number, level: number, conMod: number): number { + const lvl = clamp(level); + const avg = Math.ceil(hitDie / 2) + 1; + return Math.max(1, hitDie + conMod + (lvl - 1) * (avg + conMod)); +} + +/** Levels that grant an Ability Score Improvement (or feat). Includes class extras. */ +export function dnd5eAsiLevels(className: string): number[] { + const base = [4, 8, 12, 16, 19]; + const c = className.toLowerCase(); + if (c === 'fighter') return [...base, 6, 14].sort((a, b) => a - b); + if (c === 'rogue') return [...base, 10].sort((a, b) => a - b); + return base; +} diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index 7a285c9..ab82de6 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -17,6 +17,7 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [ ]; export * from './types'; +export * from './progression'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; export { applyRest } from './rest'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; diff --git a/src/lib/rules/pf2e/progression.ts b/src/lib/rules/pf2e/progression.ts new file mode 100644 index 0000000..e64fc55 --- /dev/null +++ b/src/lib/rules/pf2e/progression.ts @@ -0,0 +1,71 @@ +import type { ClassDef } from '../progression'; + +/** + * Curated PF2e class data for the guided builder. There is no class data file on + * disk, so the core classes' HP, key ability, initial proficiencies, trained-skill + * count, and caster type are authored here. `hitDie` holds HP-per-level (PF2e style). + * Save ranks are keyed by the ability the save uses (con/dex/wis) to match the sheet. + */ +export const PF2E_CLASSES: ClassDef[] = [ + cls('Alchemist', 8, ['int'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'trained', 3, 'none'), + cls('Barbarian', 12, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'expert', 3, 'none'), + cls('Bard', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'expert', 4, 'full', 'cha'), + cls('Champion', 10, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 2, 'none'), + cls('Cleric', 8, ['wis'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'wis'), + cls('Druid', 8, ['wis'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'trained', 2, 'full', 'wis'), + cls('Fighter', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 3, 'none'), + cls('Investigator', 8, ['int'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'), + cls('Monk', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'expert' }, 'trained', 4, 'none'), + cls('Oracle', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'), + cls('Ranger', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 4, 'none'), + cls('Rogue', 8, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 7, 'none'), + cls('Sorcerer', 6, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'cha'), + cls('Swashbuckler', 10, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'), + cls('Witch', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'int'), + cls('Wizard', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'int'), +]; + +function cls( + name: string, hp: number, keyAbilities: ClassDef['keyAbilities'], + saves: NonNullable, perception: ClassDef['perception'], + skillCount: number, caster: ClassDef['caster'], spellAbility?: ClassDef['spellAbility'], +): ClassDef { + return { + name, hitDie: hp, keyAbilities, saveProficient: [], saveRanks: saves, + perception: perception ?? 'trained', skillKeys: [], skillCount, caster, + ...(spellAbility ? { spellAbility, spellRank: 'trained' as const } : {}), + }; +} + +/** PF2e HP = ancestry HP + (class HP + CON mod) × level. */ +export function pf2eHp(classHp: number, level: number, conMod: number, ancestryHp: number): number { + const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1)); + return Math.max(1, ancestryHp + (classHp + conMod) * lvl); +} + +/** + * Spell slots per rank for a full caster. A new rank unlocks at each odd level; + * each available rank gets 3 slots (the standard for prepared/spontaneous casters). + * Approximate and editable on the sheet. + */ +export function pf2eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] { + if (caster !== 'full') return []; + const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1)); + const topRank = Math.min(10, Math.ceil(lvl / 2)); + const slots: { level: number; max: number; current: number }[] = []; + for (let r = 1; r <= topRank; r++) { + const max = r === 10 ? 1 : 3; + slots.push({ level: r, max, current: max }); + } + return slots; +} + +/** Levels granting 4 ability boosts. */ +export function pf2eBoostLevels(): number[] { + return [5, 10, 15, 20]; +} + +/** Levels granting a skill increase. */ +export function pf2eSkillIncreaseLevels(): number[] { + return [3, 5, 7, 9, 11, 13, 15, 17, 19]; +} diff --git a/src/lib/rules/progression.test.ts b/src/lib/rules/progression.test.ts new file mode 100644 index 0000000..7859b43 --- /dev/null +++ b/src/lib/rules/progression.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { + getClassDef, getClassNames, classSkillCount, classSkillChoices, + buildCharacter, planLevelUp, applyIncreases, bumpRank, +} from './progression'; +import type { AbilityScores } from './types'; + +const abilities: AbilityScores = { str: 16, dex: 12, con: 14, int: 10, wis: 13, cha: 8 }; + +describe('class tables', () => { + it('exposes core classes for both systems', () => { + expect(getClassNames('5e')).toContain('Wizard'); + expect(getClassNames('pf2e')).toContain('Champion'); + expect(getClassDef('5e', 'fighter')?.hitDie).toBe(10); + }); + it('pf2e skill count adds INT modifier; 5e does not', () => { + expect(classSkillCount('5e', 'Rogue', 3)).toBe(4); // fixed + expect(classSkillCount('pf2e', 'Rogue', 3)).toBe(10); // 7 + INT 3 + }); + it('restricts skill choices for a class with a list, full list otherwise', () => { + expect(classSkillChoices('5e', ['a', 'b'], 'Cleric')).toContain('religion'); + expect(classSkillChoices('5e', ['acrobatics', 'arcana'], 'Bard')).toEqual(['acrobatics', 'arcana']); + }); +}); + +describe('buildCharacter — 5e', () => { + it('computes HP from hit die + CON over levels and sets save proficiencies', () => { + const built = buildCharacter('5e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'] }); + // d10 + CON 2 at level 1 + expect(built.hp.max).toBe(12); + expect(built.saveRanks).toEqual({ str: 'trained', con: 'trained' }); + expect(built.skillRanks).toEqual({ athletics: 'trained' }); + expect(built.spellcasting.slots).toEqual([]); + }); + it('gives a full caster spell slots and a spell ability', () => { + const built = buildCharacter('5e', { className: 'Wizard', level: 3, abilities, skillChoices: [] }); + expect(built.spellcastingAbility).toBe('int'); + expect(built.spellcasting.slots).toEqual([ + { level: 1, max: 4, current: 4 }, { level: 2, max: 2, current: 2 }, + ]); + }); + it('gives a warlock pact magic', () => { + const built = buildCharacter('5e', { className: 'Warlock', level: 3, abilities, skillChoices: [] }); + expect(built.spellcasting.pact).toEqual({ level: 2, max: 2, current: 2 }); + }); +}); + +describe('buildCharacter — pf2e', () => { + it('computes HP = ancestry + (class + CON) × level and sets save ranks/perception', () => { + const built = buildCharacter('pf2e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'], ancestryHp: 8 }); + expect(built.hp.max).toBe(8 + (10 + 2) * 1); // 20 + expect(built.saveRanks).toMatchObject({ con: 'expert', dex: 'expert', wis: 'trained' }); + expect(built.perceptionRank).toBe('expert'); + }); +}); + +describe('planLevelUp', () => { + it('flags a 5e ASI at level 4 and updates caster slots', () => { + const plan = planLevelUp('5e', 'Wizard', 3, 2); + expect(plan.nextLevel).toBe(4); + expect(plan.choices.some((c) => c.kind === 'asi')).toBe(true); + expect(plan.slots?.length).toBeGreaterThan(0); + }); + it('flags pf2e ability boosts at level 5 and a skill increase', () => { + const plan = planLevelUp('pf2e', 'Rogue', 4, 1); + expect(plan.nextLevel).toBe(5); + expect(plan.choices.some((c) => c.kind === 'boosts')).toBe(true); + expect(plan.choices.some((c) => c.kind === 'skill-increase')).toBe(true); + }); +}); + +describe('apply helpers', () => { + it('5e increases add +1 per pick (same twice = +2)', () => { + expect(applyIncreases(abilities, ['str', 'str'], '5e').str).toBe(18); + expect(applyIncreases(abilities, ['dex', 'con'], '5e')).toMatchObject({ dex: 13, con: 15 }); + }); + it('pf2e boosts add +2 under 18, +1 at/over 18', () => { + expect(applyIncreases({ ...abilities, str: 16 }, ['str'], 'pf2e').str).toBe(18); + expect(applyIncreases({ ...abilities, str: 18 }, ['str'], 'pf2e').str).toBe(19); + }); + it('bumpRank walks the ladder and caps at legendary', () => { + expect(bumpRank('untrained')).toBe('trained'); + expect(bumpRank('expert')).toBe('master'); + expect(bumpRank('legendary')).toBe('legendary'); + }); +}); diff --git a/src/lib/rules/progression.ts b/src/lib/rules/progression.ts new file mode 100644 index 0000000..d1c05e7 --- /dev/null +++ b/src/lib/rules/progression.ts @@ -0,0 +1,188 @@ +import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types'; +import { abilityModifier } from './abilities'; +import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression'; +import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression'; + +export type CasterKind = 'none' | 'full' | 'half' | 'pact'; +/** Spellcasting abilities allowed by the character schema. */ +export type SpellAbility = 'int' | 'wis' | 'cha'; + +export interface ClassDef { + name: string; + /** 5e: hit die size (6/8/10/12). PF2e: HP gained per level. */ + hitDie: number; + keyAbilities: AbilityKey[]; + /** 5e: abilities whose saves become trained. */ + saveProficient: AbilityKey[]; + /** PF2e: initial save ranks keyed by the save's ability (con/dex/wis). */ + saveRanks?: Partial>; + /** PF2e: initial perception rank. */ + perception?: ProficiencyRank; + /** Skills to choose from (5e). Empty = choose from all (Bard / all PF2e). */ + skillKeys: string[]; + /** Number of trained skills to choose (PF2e adds INT mod on top at build time). */ + skillCount: number; + caster: CasterKind; + spellAbility?: SpellAbility; + spellRank?: ProficiencyRank; +} + +export interface SpellSlot { level: number; max: number; current: number } + +export interface BuildChoices { + className: string; + level: number; + abilities: AbilityScores; + /** chosen skill keys → trained */ + skillChoices: string[]; + /** PF2e ancestry HP (from the ancestries data); ignored for 5e. */ + ancestryHp?: number; + /** Used only when the class is unknown (custom): 5e hit die size. */ + hitDieOverride?: number; +} + +/** Derived character fields produced by the builder — a Partial patch. */ +export interface BuiltCharacter { + abilities: AbilityScores; + hp: { current: number; max: number; temp: number }; + skillRanks: Record; + saveRanks: Record; + perceptionRank: ProficiencyRank; + spellcastingAbility?: SpellAbility; + spellcastingRank?: ProficiencyRank; + spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] }; +} + +const TABLES: Record = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES }; + +export function getClassDefs(system: SystemId): ClassDef[] { + return TABLES[system]; +} +export function getClassNames(system: SystemId): string[] { + return TABLES[system].map((c) => c.name); +} +export function getClassDef(system: SystemId, className: string): ClassDef | undefined { + const want = className.trim().toLowerCase(); + return TABLES[system].find((c) => c.name.toLowerCase() === want); +} + +/** Skills the class may choose from (full list when unrestricted). */ +export function classSkillChoices(system: SystemId, allSkillKeys: string[], className: string): string[] { + const def = getClassDef(system, className); + if (def && def.skillKeys.length > 0) return def.skillKeys; + return allSkillKeys; +} + +/** How many skills to pick (PF2e adds INT modifier, min 0). */ +export function classSkillCount(system: SystemId, className: string, intMod: number): number { + const def = getClassDef(system, className); + const base = def?.skillCount ?? (system === 'pf2e' ? 3 : 2); + return system === 'pf2e' ? base + Math.max(0, intMod) : base; +} + +/** Build the derived fields for a brand-new character from the wizard's choices. */ +export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCharacter { + const def = getClassDef(system, choices.className); + const conMod = abilityModifier(choices.abilities.con); + const die = def?.hitDie ?? choices.hitDieOverride ?? (system === 'pf2e' ? 8 : 8); + + const hpMax = system === 'pf2e' + ? pf2eHp(die, choices.level, conMod, choices.ancestryHp ?? 8) + : dnd5eHp(die, choices.level, conMod); + + const skillRanks: Record = {}; + for (const key of choices.skillChoices) skillRanks[key] = 'trained'; + + const saveRanks: Record = {}; + if (system === 'pf2e') { + const ranks: Partial> = def?.saveRanks ?? { con: 'trained', dex: 'trained', wis: 'trained' }; + for (const [ability, rank] of Object.entries(ranks)) { + if (rank) saveRanks[ability] = rank; + } + } else { + for (const ability of def?.saveProficient ?? []) saveRanks[ability] = 'trained'; + } + + const caster = def?.caster ?? 'none'; + const slots = system === 'pf2e' ? pf2eSlots(caster, choices.level) : dnd5eSlots(caster, choices.level); + const pact = system === '5e' && caster === 'pact' ? dnd5ePact(choices.level) : undefined; + + return { + abilities: choices.abilities, + hp: { current: hpMax, max: hpMax, temp: 0 }, + skillRanks, + saveRanks, + perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained', + ...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}), + spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] }, + }; +} + +// ---------------- Level-up planning ---------------- + +export type LevelChoice = + | { kind: 'asi'; label: string } + | { kind: 'boosts'; count: number; label: string } + | { kind: 'skill-increase'; label: string } + | { kind: 'feat'; label: string }; + +export interface LevelUpPlan { + nextLevel: number; + hpGainAverage: number; + hitDie: number; + slots?: SpellSlot[]; + pact?: SpellSlot; + notes: string[]; + choices: LevelChoice[]; +} + +export function planLevelUp(system: SystemId, className: string, currentLevel: number, conMod: number): LevelUpPlan { + const nextLevel = Math.min(20, Math.floor(currentLevel) + 1); + const def = getClassDef(system, className); + const die = def?.hitDie ?? 8; + const caster = def?.caster ?? 'none'; + const notes: string[] = []; + const choices: LevelChoice[] = []; + + if (system === '5e') { + const hpGainAverage = Math.max(1, Math.ceil(die / 2) + 1 + conMod); + const slots = dnd5eSlots(caster, nextLevel); + const pact = caster === 'pact' ? dnd5ePact(nextLevel) : undefined; + if (dnd5eAsiLevels(className).includes(nextLevel)) { + choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' }); + } + if (slots.length) notes.push('Spell slots updated for the new level.'); + return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices }; + } + + // PF2e + const hpGainAverage = Math.max(1, die + conMod); + const slots = pf2eSlots(caster, nextLevel); + if (pf2eBoostLevels().includes(nextLevel)) { + choices.push({ kind: 'boosts', count: 4, label: 'Ability boosts — raise four different abilities (+2 if under 18, otherwise +1).' }); + } + if (pf2eSkillIncreaseLevels().includes(nextLevel)) { + choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' }); + } + if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' }); + if (slots.length) notes.push('Spell slots updated for the new level.'); + return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices }; +} + +// ---------------- Apply helpers (pure) ---------------- + +const RANK_ORDER: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary']; + +export function bumpRank(rank: ProficiencyRank): ProficiencyRank { + const i = RANK_ORDER.indexOf(rank); + return RANK_ORDER[Math.min(RANK_ORDER.length - 1, i + 1)]!; +} + +/** Apply ability increases. 5e: +1 per pick (pick the same twice for +2). PF2e: +2 if <18 else +1. */ +export function applyIncreases(abilities: AbilityScores, picks: AbilityKey[], system: SystemId): AbilityScores { + const next = { ...abilities }; + for (const k of picks) { + next[k] = system === 'pf2e' ? (next[k] >= 18 ? next[k] + 1 : next[k] + 2) : next[k] + 1; + } + return next; +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 0095428..0f203a8 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file