4534865e74
- 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>
91 lines
5.2 KiB
TypeScript
91 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|