Proper guided character builder + level-up wizard
The bare name/class/level form left new characters as shells (1 HP, all-10
abilities, no proficiencies). Now creation and level-up are guided and auto-populate.
- Hand-authored progression tables (no SRD class/slot data exists on disk):
src/lib/rules/{dnd5e,pf2e}/progression.ts — core classes' hit die/HP, key
abilities, save & perception proficiencies, trained-skill counts, caster type;
5e full/half/pact spell-slot tables, pf2e slot approximation; ASI/boost/skill
schedules.
- src/lib/rules/progression.ts: getClassDefs/getClassDef, classSkillChoices/Count,
buildCharacter() (HP from hit die + CON over levels, trained saves/skills, spell
slots + ability), planLevelUp() (HP gain, new slots, ASI/boost/skill choices),
applyIncreases/bumpRank. Pure + 12 unit tests.
- Multi-step CreationWizard (Basics → Abilities → Skills → Review) replaces the
old form; pulls PF2e ancestry HP/speed from the bestiary; review previews HP/AC/
saves/skills/slots; lands on a ready-to-play sheet.
- LevelUpModal reworked into a guided wizard: auto HP + spell slots, presents the
level's actual choices (5e ASI/feat, pf2e ability boosts + skill increase) and
applies them; keeps the strategic build-route advisor.
e2e helper drives the wizard; updated 7 specs to the new flow; new character-wizard
spec asserts a complete level-3 Wizard (HP 17, slots 4xL1 2xL2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
|
||||
@@ -74,7 +74,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && <CharacterFormModal campaign={campaign} onClose={() => setCreating(false)} />}
|
||||
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -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<Character['kind']>('pc');
|
||||
const [ancestry, setAncestry] = useState('');
|
||||
const [className, setClassName] = useState('');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New character"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={save}>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Name">
|
||||
<Input data-autofocus value={name} onChange={(e) => { setName(e.target.value); setError(null); }} placeholder="Aria Stormwind" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Type">
|
||||
<Select value={kind} onChange={(e) => setKind(e.target.value as Character['kind'])}>
|
||||
<option value="pc">Player Character</option>
|
||||
<option value="npc">NPC</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Level">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={level}
|
||||
onChange={(e) => setLevel(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||
<Input value={ancestry} onChange={(e) => setAncestry(e.target.value)} placeholder={campaign.system === 'pf2e' ? 'Dwarf' : 'Half-Elf'} />
|
||||
</Field>
|
||||
<Field label="Class">
|
||||
<Input value={className} onChange={(e) => setClassName(e.target.value)} placeholder="Fighter" />
|
||||
</Field>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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 [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 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<AbilityMethod>('standard');
|
||||
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
|
||||
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
|
||||
const [pb, setPb] = useState<number[]>([8, 8, 8, 8, 8, 8]);
|
||||
const usesPool = method === 'standard' || method === 'roll';
|
||||
const chooseMethod = (m: AbilityMethod) => {
|
||||
setMethod(m);
|
||||
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
|
||||
};
|
||||
const abilities = useMemo<AbilityScores>(() => {
|
||||
const out = {} as AbilityScores;
|
||||
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
|
||||
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<string[]>([]);
|
||||
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 (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`New character — ${STEPS[step]}`}
|
||||
className="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
{step > 0 && <Button variant="secondary" onClick={() => setStep((s) => s - 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>}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Step indicator */}
|
||||
<ol className="mb-4 flex 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>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-3">
|
||||
<Field label="Name"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Character name" /></Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Type">
|
||||
<Select value={kind} onChange={(e) => setKind(e.target.value as Character['kind'])}>
|
||||
<option value="pc">Player character</option>
|
||||
<option value="npc">NPC</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
|
||||
</div>
|
||||
<div 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>
|
||||
</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 && (
|
||||
<div>
|
||||
<div className="mb-3 flex gap-1">
|
||||
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
|
||||
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}>↻ 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>}
|
||||
{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);
|
||||
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>
|
||||
{usesPool ? (
|
||||
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
|
||||
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
|
||||
</Select>
|
||||
) : (
|
||||
<NumberField className="mt-1" value={pb[i]!} min={POINT_BUY_MIN} max={POINT_BUY_MAX} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} />
|
||||
)}
|
||||
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<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">
|
||||
{skillOptions.map((key) => {
|
||||
const checked = skills.includes(key);
|
||||
const disabled = !checked && skills.length >= skillCount;
|
||||
return (
|
||||
<label key={key} className={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', checked ? 'border-accent/60 bg-accent/5 text-ink' : disabled ? 'border-line text-muted opacity-50' : 'border-line text-ink')}>
|
||||
<input type="checkbox" checked={checked} disabled={disabled} onChange={() => toggleSkill(key)} />
|
||||
{skillLabel(key)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<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="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>
|
||||
</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 Stat({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel p-2">
|
||||
<div className="font-display text-lg font-semibold text-accent">{value}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Review({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span>
|
||||
<span className="text-ink">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Character>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [die, setDie] = useState<number>(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<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
|
||||
// Boosts (pf2e): four distinct picks.
|
||||
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
|
||||
// Skill increase (pf2e): one skill to bump.
|
||||
const [skillKey, setSkillKey] = useState<string>(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<Character> = {
|
||||
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 (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Level up to ${Math.min(20, character.level + 1)}`}
|
||||
title={`Level up to ${plan.nextLevel}`}
|
||||
className="max-w-xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={character.level >= 20} onClick={apply}>Level up</Button>
|
||||
<Button variant="primary" disabled={atMax} onClick={apply}>Apply level {plan.nextLevel}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-xs text-muted">
|
||||
Hit die
|
||||
<Select value={die} onChange={(e) => setDie(Number(e.target.value))}>
|
||||
{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block text-xs text-muted">
|
||||
HP method
|
||||
<Select value={method} onChange={(e) => setMethod(e.target.value as 'average' | 'roll')}>
|
||||
<option value="average">Average (+{average})</option>
|
||||
<option value="roll">Roll the hit die</option>
|
||||
</Select>
|
||||
</label>
|
||||
<p className="text-sm text-muted">
|
||||
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
|
||||
</p>
|
||||
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>}
|
||||
{atMax ? (
|
||||
<p className="text-sm text-warning">Already at level 20.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* HP */}
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Hit points</h3>
|
||||
{character.system === '5e' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
|
||||
<option value="average">Average (+{plan.hpGainAverage})</option>
|
||||
<option value="roll">Roll d{plan.hitDie}</option>
|
||||
</Select>
|
||||
<span className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (class {plan.hitDie} + CON {conMod >= 0 ? '+' : ''}{conMod})</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{character.level < 20 && <LevelUpAdvisor campaign={campaign} character={character} />}
|
||||
</div>
|
||||
{plan.notes.map((n) => <p key={n} className="text-sm text-muted">• {n}</p>)}
|
||||
|
||||
{/* ASI (5e) */}
|
||||
{asi && (
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability Score Improvement</h3>
|
||||
<div className="mb-2 flex gap-1">
|
||||
{(['asi', 'feat'] as const).map((m) => (
|
||||
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
|
||||
{m === 'asi' ? '+2 abilities' : 'Take a feat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{asiMode === 'asi' ? (
|
||||
<div className="flex gap-2">
|
||||
{[0, 1].map((i) => (
|
||||
<Select key={i} aria-label={`ASI ability ${i + 1}`} value={asiPicks[i]} onChange={(e) => setAsiPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
||||
{ABILITIES.map((a) => <option key={a} value={a}>{ABILITY_ABBR[a]} (+1)</option>)}
|
||||
</Select>
|
||||
))}
|
||||
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted">Add your chosen feat on the sheet after leveling.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Boosts (pf2e) */}
|
||||
{boosts && (
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability boosts (choose 4)</h3>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
|
||||
{ABILITIES.map((a) => <option key={a} value={a} disabled={boostPicks.includes(a) && boostPicks[i] !== a}>{ABILITY_ABBR[a]}</option>)}
|
||||
</Select>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Skill increase (pf2e) */}
|
||||
{skillInc && (
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Skill increase</h3>
|
||||
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
|
||||
{sys.skills.map((s) => {
|
||||
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
|
||||
return <option key={s.key} value={s.key}>{s.label}: {cur} → {bumpRank(cur)}</option>;
|
||||
})}
|
||||
</Select>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{plan.choices.filter((c) => c.kind === 'feat').map((c) => (
|
||||
<p key={c.label} className="text-sm text-muted">• {c.label}</p>
|
||||
))}
|
||||
|
||||
{/* Strategic build-route advice */}
|
||||
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user