Phase 3: full multiclass model + automatic spell slots (5e)

- Character gains a classes[] array (per-class levels + subclass); className/level become
  derived mirrors (primaryClass/totalLevel/normalizeClassMirror helpers). Dexie v16
  migration backfills classes[] from className/level and pulls Subclass:/Background: out
  of the notes free-text (whole-line, keeping the rest).
- Correct 5e multiclass derivation (unit-tested): dnd5eMulticlassHp (first character level
  = max die, rest avg+CON), dnd5eMulticlassSpellLevel, dnd5eClassesSlots (one caster uses
  its own table; two+ use the PHB multiclass table; warlock pact stays separate).
- Sheet ClassesEditor (5e): add/remove classes, set subclass/level; editing auto-recomputes
  the level/class mirrors and reconciles spell-slot maxes (preserving spent slots) — so
  spell slots are now calculated automatically. A 'Recalc HP' button (never clobbers
  hand-rolled HP silently).
- LevelUpModal is class-aware: pick which class to advance or multiclass into a new one;
  applies to classes[], re-derives mirrors + combined slots. pf2e path unchanged.
- Wizard writes classes[] on creation; subclass no longer stored in notes.

Verified in-app: existing char migrated to classes[]; Fighter 3 -> +Wizard 5 = total 8,
spell slots auto-appear at 4/3/2, reconcile preserves spent, removal restores. 339 tests
green (10 multiclass), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:24:02 +02:00
parent 7775851bbe
commit 4534865e74
9 changed files with 335 additions and 25 deletions
+13 -7
View File
@@ -21,6 +21,7 @@ import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format';
import { useCampaigns } from '@/features/campaigns/hooks';
import { rankLabel } from './sheet/labels';
import { ClassesEditor } from './sheet/ClassesEditor';
import { InventorySection } from './sheet/InventorySection';
import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
@@ -170,13 +171,18 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
<p className="mt-1 text-[10px] leading-tight text-muted">Direct edits update proficiency-based stats only HP and spell slots arent recalculated. Use <span className="text-ink">Level Up</span> to advance a level.</p>
</Labeled>
{c.system === '5e' ? (
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
) : (
<>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled>
</>
)}
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled>
@@ -358,17 +358,15 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
// Subclass still rides in notes for now (Phase 3 moves it onto classes[]); background is a real field.
const notes = subclass ? `Subclass: ${subclass}` : '';
await charactersRepo.update(created.id, {
...built,
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
spellcasting: { ...built.spellcasting, spells },
...(background ? { background } : {}),
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
...(personality.trim() ? { personality: personality.trim() } : {}),
...(notes ? { notes } : {}),
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
});
onClose();
@@ -0,0 +1,90 @@
import { Plus, X, RefreshCw } from 'lucide-react';
import type { Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror } from '@/lib/schemas';
import { getClassNames, abilityModifier } from '@/lib/rules';
import { dnd5eClassesSlots, dnd5eMulticlassHp, DND5E_SUBCLASSES } from '@/lib/rules/dnd5e/progression';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
type SlotRow = { level: number; max: number; current: number };
/** Update slot maxes from the derived set while preserving how many are currently spent. */
function reconcileSlots(prev: Spellcasting, derived: { slots: SlotRow[]; pact?: SlotRow }): { slots: SlotRow[]; pact?: SlotRow } {
const slots = derived.slots.map((r) => {
const ex = prev.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
});
const pact = derived.pact
? { ...derived.pact, current: Math.min(derived.pact.max, prev.pact?.current ?? derived.pact.max) }
: undefined;
return pact ? { slots, pact } : { slots };
}
/**
* 5e multiclass editor: one row per class. Editing classes recomputes the level/class
* mirrors and auto-reconciles spell-slot maxes (preserving spent slots). HP is recomputed
* only on the explicit button so hand-rolled HP isn't clobbered.
*/
export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
// Fall back to the legacy className/level for any character not yet on classes[].
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className ? [{ className: c.className, level: c.level }] : [];
const names = getClassNames('5e');
const total = classes.reduce((n, e) => n + (e.level || 0), 0);
const commit = (next: ClassEntry[]) => {
const mirror = normalizeClassMirror({ classes: next });
const { slots, pact } = reconcileSlots(c.spellcasting, dnd5eClassesSlots(next));
const spellcasting: Spellcasting = { ...c.spellcasting, slots };
if (pact) spellcasting.pact = pact; else delete spellcasting.pact;
update({ classes: next, ...mirror, spellcasting });
};
const patchEntry = (i: number, p: Partial<ClassEntry>) => commit(classes.map((e, j) => (j === i ? { ...e, ...p } : e)));
const addClass = () => commit([...classes, { className: names.find((n) => !classes.some((e) => e.className === n)) ?? 'Fighter', level: 1 }]);
const removeClass = (i: number) => commit(classes.filter((_, j) => j !== i));
const recalcHp = () => {
const max = dnd5eMulticlassHp(classes, abilityModifier(c.abilities.con));
update({ hp: { ...c.hp, max, current: Math.min(c.hp.current, max) } });
};
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">Classes · level {Math.min(20, total)}</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={recalcHp} title="Recompute max HP from these classes"><RefreshCw size={12} aria-hidden /> HP</Button>
<Button size="sm" variant="ghost" onClick={addClass}><Plus size={12} aria-hidden /> class</Button>
</div>
</div>
<div className="space-y-1.5">
{classes.map((e, i) => {
const subs = DND5E_SUBCLASSES[e.className] ?? [];
return (
<div key={i} className="flex flex-wrap items-center gap-1.5 rounded-md border border-line bg-panel px-2 py-1.5">
<Input className="h-8 min-w-28 flex-1" list="dnd5e-class-names" value={e.className} onChange={(ev) => patchEntry(i, { className: ev.target.value })} aria-label={`Class ${i + 1}`} placeholder="Class" />
{subs.length > 0 ? (
<Select className="h-8 w-auto py-0 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`}>
<option value=""> subclass </option>
{subs.some((s) => s.name === e.subclass) ? null : e.subclass ? <option value={e.subclass}>{e.subclass}</option> : null}
{subs.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
) : (
<Input className="h-8 w-28 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`} placeholder="Subclass" />
)}
<label className="text-[10px] text-muted">lvl<NumberField className="ml-1 w-12" value={e.level} min={1} max={20} onChange={(v) => patchEntry(i, { level: v })} aria-label={`${e.className} level`} /></label>
{classes.length > 1 && (
<Button size="icon" variant="ghost" className="h-7 w-7 text-danger" onClick={() => removeClass(i)} aria-label={`Remove ${e.className}`}><X size={13} aria-hidden /></Button>
)}
</div>
);
})}
{classes.length === 0 && <p className="text-xs text-muted">No class set.</p>}
</div>
<datalist id="dnd5e-class-names">{names.map((n) => <option key={n} value={n} />)}</datalist>
</div>
);
}
+62 -11
View File
@@ -1,9 +1,11 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import {
abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef,
} from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
@@ -19,12 +21,25 @@ export function LevelUpModal({ character, onApply, onClose }: {
onClose: () => void;
}) {
const conMod = abilityModifier(character.abilities.con);
const is5e = character.system === '5e';
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
const classList: ClassEntry[] = character.classes.length
? character.classes
: character.className ? [{ className: character.className, level: character.level }] : [];
const NEW = '__new__';
const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className);
const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter');
const levelingName = is5e ? (levelClass === NEW ? newClassName : levelClass) : character.className;
const classCurrentLevel = is5e ? (classList.find((e) => e.className === levelingName)?.level ?? 0) : character.level;
const plan = useMemo(
() => planLevelUp(character.system, character.className, character.level, conMod),
[character.system, character.className, character.level, conMod],
() => planLevelUp(character.system, levelingName, classCurrentLevel, conMod),
[character.system, levelingName, classCurrentLevel, conMod],
);
const sys = getSystem(character.system);
const def = getClassDef(character.system, character.className);
const def = getClassDef(character.system, levelingName);
const total = totalLevel(character);
const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average');
const asi = plan.choices.find((c) => c.kind === 'asi');
@@ -40,7 +55,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
// Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
const atMax = character.level >= 20;
const atMax = total >= 20;
const apply = () => {
const gain = character.system === 'pf2e'
? plan.hpGainAverage
@@ -51,13 +66,31 @@ export function LevelUpModal({ character, onApply, onClose }: {
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 (is5e) {
// Bump the chosen class (or add a new one), re-derive the mirrors + combined slots.
const nextClasses: ClassEntry[] = levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
Object.assign(patch, normalizeClassMirror({ classes: nextClasses }));
patch.classes = nextClasses;
const derived = dnd5eClassesSlots(nextClasses);
const slots = derived.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
});
const spellcasting: Spellcasting = { ...character.spellcasting, slots };
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
else delete spellcasting.pact;
patch.spellcasting = spellcasting;
} else {
patch.level = plan.nextLevel;
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) };
@@ -74,12 +107,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
<Modal
open
onClose={onClose}
title={`Level up to ${plan.nextLevel}`}
title={is5e ? `Level up — total level ${Math.min(20, total + 1)}` : `Level up to ${plan.nextLevel}`}
className="max-w-xl"
footer={
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={atMax} onClick={apply}>Apply level {plan.nextLevel}</Button>
<Button variant="primary" disabled={atMax} onClick={apply}>Apply</Button>
</>
}
>
@@ -87,6 +120,24 @@ export function LevelUpModal({ character, onApply, onClose }: {
<p className="text-sm text-warning">Already at level 20.</p>
) : (
<div className="space-y-4">
{/* Which class (5e multiclass) */}
{is5e && classList.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Class to advance</h3>
<div className="flex flex-wrap items-center gap-2">
<Select value={levelClass} onChange={(e) => setLevelClass(e.target.value)} aria-label="Class to level up">
{classList.map((e) => <option key={e.className} value={e.className}>{e.className} {e.level} {Math.min(20, e.level + 1)}</option>)}
<option value={NEW}>+ Multiclass into</option>
</Select>
{levelClass === NEW && (
<Select value={newClassName} onChange={(e) => setNewClassName(e.target.value)} aria-label="New class">
{getClassNames('5e').filter((n) => !classList.some((e) => e.className === n)).map((n) => <option key={n} value={n}>{n}</option>)}
</Select>
)}
</div>
</section>
)}
{/* HP */}
<section>
<h3 className="mb-1 smallcaps">Hit points</h3>