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:
@@ -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 aren’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -151,6 +151,36 @@ export class TtrpgDatabase extends Dexie {
|
||||
if (c.feats === undefined) c.feats = [];
|
||||
});
|
||||
});
|
||||
|
||||
// v16 — multiclass: characters gain a `classes[]` array (per-class levels) and
|
||||
// promote `background` out of the notes string. The legacy className/level become
|
||||
// mirrors of classes[]. Existing single-class characters become a one-entry list.
|
||||
this.version(16).stores({}).upgrade(async (tx) => {
|
||||
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
|
||||
if (!Array.isArray(c.classes) || c.classes.length === 0) {
|
||||
const className = typeof c.className === 'string' && c.className ? c.className : '';
|
||||
const level = typeof c.level === 'number' && c.level > 0 ? c.level : 1;
|
||||
const notes = typeof c.notes === 'string' ? c.notes : '';
|
||||
// Pull "Subclass: X" / "Background: Y" out of the notes free-text (whole-line).
|
||||
const subMatch = /^Subclass:\s*(.+)$/m.exec(notes);
|
||||
const bgMatch = /^Background:\s*(.+)$/m.exec(notes);
|
||||
if (c.background === undefined || c.background === '') c.background = bgMatch ? bgMatch[1]!.trim() : (c.background ?? '');
|
||||
c.classes = className
|
||||
? [{ className, level, ...(subMatch ? { subclass: subMatch[1]!.trim() } : {}) }]
|
||||
: [];
|
||||
// Strip exactly the two parsed lines from notes, keep the rest of the user's text.
|
||||
c.notes = notes
|
||||
.split('\n')
|
||||
.filter((ln) => !/^Subclass:\s*/.test(ln) && !/^Background:\s*/.test(ln))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
if (c.background === undefined) c.background = '';
|
||||
if (c.alignment === undefined) c.alignment = '';
|
||||
if (c.appearance === undefined) c.appearance = '';
|
||||
if (c.personality === undefined) c.personality = '';
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,66 @@ export function dnd5ePact(level: number): { level: number; max: number; current:
|
||||
return p ? { level: p[1], max: p[0], current: p[0] } : undefined;
|
||||
}
|
||||
|
||||
// ---------------- Multiclass derivation ----------------
|
||||
|
||||
type SlotRow = { level: number; max: number; current: number };
|
||||
interface ClassPart { className: string; level: number }
|
||||
|
||||
function findDef(name: string): ClassDef | undefined {
|
||||
const want = name.trim().toLowerCase();
|
||||
return DND5E_CLASSES.find((c) => c.name.toLowerCase() === want);
|
||||
}
|
||||
|
||||
/**
|
||||
* Total HP for a (possibly multiclass) character: the very first character level uses
|
||||
* the max of its class's die; every later class level adds that class's average die +
|
||||
* CON. Equivalent to dnd5eHp for a single class.
|
||||
*/
|
||||
export function dnd5eMulticlassHp(classes: readonly ClassPart[], conMod: number): number {
|
||||
const dice: number[] = [];
|
||||
for (const e of classes) {
|
||||
const die = findDef(e.className)?.hitDie ?? 8;
|
||||
for (let i = 0; i < clamp(e.level); i++) dice.push(die);
|
||||
}
|
||||
if (dice.length === 0) return 1;
|
||||
let hp = dice[0]! + conMod; // first character level: max die + CON
|
||||
for (let i = 1; i < dice.length; i++) hp += Math.ceil(dice[i]! / 2) + 1 + conMod;
|
||||
return Math.max(1, hp);
|
||||
}
|
||||
|
||||
/** Bucket caster levels (raw) for the PHB multiclass slot formula. Warlock pact is excluded. */
|
||||
export function dnd5eMulticlassSpellLevel(classes: readonly ClassPart[]): { full: number; half: number; third: number } {
|
||||
let full = 0, half = 0;
|
||||
for (const e of classes) {
|
||||
const def = findDef(e.className);
|
||||
if (!def) continue;
|
||||
if (def.caster === 'full') full += clamp(e.level);
|
||||
else if (def.caster === 'half') half += clamp(e.level);
|
||||
}
|
||||
return { full, half, third: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Spell slots for a (possibly multiclass) 5e character, plus warlock pact slots.
|
||||
* One spellcasting class → that class's own table; two or more → the PHB multiclass
|
||||
* table; warlock pact magic is always separate. New `current` defaults to full.
|
||||
*/
|
||||
export function dnd5eClassesSlots(classes: readonly ClassPart[]): { slots: SlotRow[]; pact?: SlotRow } {
|
||||
const casters = classes
|
||||
.map((e) => ({ e, def: findDef(e.className) }))
|
||||
.filter((x): x is { e: ClassPart; def: ClassDef } => !!x.def);
|
||||
const spellClasses = casters.filter((x) => x.def.caster === 'full' || x.def.caster === 'half');
|
||||
const warlock = casters.find((x) => x.def.caster === 'pact');
|
||||
const pact = warlock ? dnd5ePact(warlock.e.level) : undefined;
|
||||
let slots: SlotRow[] = [];
|
||||
if (spellClasses.length === 1) {
|
||||
slots = dnd5eSlots(spellClasses[0]!.def.caster, spellClasses[0]!.e.level);
|
||||
} else if (spellClasses.length >= 2) {
|
||||
slots = multiclassSlots(dnd5eMulticlassSpellLevel(classes));
|
||||
}
|
||||
return { slots, ...(pact ? { pact } : {}) };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { multiclassCasterLevel, multiclassSlots } from './dnd5e/progression';
|
||||
import { multiclassCasterLevel, multiclassSlots, dnd5eMulticlassHp, dnd5eClassesSlots, dnd5eSlots, dnd5eHp } from './dnd5e/progression';
|
||||
|
||||
describe('multiclass spell slots (5e PHB)', () => {
|
||||
it('combines caster levels: full ×1, half ÷2, third ÷3 (floored)', () => {
|
||||
@@ -27,3 +27,40 @@ describe('multiclass spell slots (5e PHB)', () => {
|
||||
expect(s.length).toBe(9); // 9 spell levels at the cap
|
||||
});
|
||||
});
|
||||
|
||||
describe('dnd5eMulticlassHp', () => {
|
||||
it('matches single-class HP for one class', () => {
|
||||
// Fighter (d10) level 3, CON +2: 12 (L1) + 8 (L2) + 8 (L3) = 28
|
||||
expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 3 }], 2)).toBe(dnd5eHp(10, 3, 2));
|
||||
expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 3 }], 2)).toBe(28);
|
||||
});
|
||||
it('first overall level uses the primary class die at max', () => {
|
||||
// Wizard 1 (d6) / Fighter 1 (d10), CON +1: 6+1 (wizard L1 max) + (avg d10=6 +1) = 7 + 7 = 14
|
||||
expect(dnd5eMulticlassHp([{ className: 'Wizard', level: 1 }, { className: 'Fighter', level: 1 }], 1)).toBe(14);
|
||||
// Order matters — Fighter first gets the max d10
|
||||
expect(dnd5eMulticlassHp([{ className: 'Fighter', level: 1 }, { className: 'Wizard', level: 1 }], 1)).toBe(10 + 1 + (4 + 1)); // 16
|
||||
});
|
||||
});
|
||||
|
||||
describe('dnd5eClassesSlots', () => {
|
||||
it('single caster uses its own table (not the multiclass formula)', () => {
|
||||
// Paladin 5 alone → HALF table [4,2], NOT floor(5/2)=2 on the full table
|
||||
expect(dnd5eClassesSlots([{ className: 'Paladin', level: 5 }]).slots).toEqual(dnd5eSlots('half', 5));
|
||||
expect(dnd5eClassesSlots([{ className: 'Wizard', level: 5 }]).slots).toEqual(dnd5eSlots('full', 5));
|
||||
});
|
||||
it('two casters use the PHB multiclass table', () => {
|
||||
// Paladin 6 / Sorcerer 1 → combined level 3+1=4 → [4,3]
|
||||
expect(dnd5eClassesSlots([{ className: 'Paladin', level: 6 }, { className: 'Sorcerer', level: 1 }]).slots).toEqual([
|
||||
{ level: 1, max: 4, current: 4 },
|
||||
{ level: 2, max: 3, current: 3 },
|
||||
]);
|
||||
});
|
||||
it('warlock pact magic is separate from regular slots', () => {
|
||||
const r = dnd5eClassesSlots([{ className: 'Warlock', level: 5 }, { className: 'Wizard', level: 3 }]);
|
||||
expect(r.pact).toEqual({ level: 3, max: 2, current: 2 }); // Warlock 5 pact
|
||||
expect(r.slots).toEqual(dnd5eSlots('full', 3)); // only Wizard is a "spell-slot" caster here
|
||||
});
|
||||
it('a non-caster multiclass grants no slots', () => {
|
||||
expect(dnd5eClassesSlots([{ className: 'Fighter', level: 3 }, { className: 'Barbarian', level: 2 }]).slots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,14 @@ export type CharacterResource = z.infer<typeof resourceSchema>;
|
||||
|
||||
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
|
||||
|
||||
/** One class a character has levels in. `classes[0]` is the primary class. */
|
||||
export const classEntrySchema = z.object({
|
||||
className: z.string().min(1).max(80),
|
||||
level: int.min(1).max(20).default(1),
|
||||
subclass: z.string().max(80).optional(),
|
||||
});
|
||||
export type ClassEntry = z.infer<typeof classEntrySchema>;
|
||||
|
||||
/** Worn body armor, snapshotted from the compendium so AC is computed correctly. */
|
||||
export const equippedArmorSchema = z.object({
|
||||
name: z.string().max(120),
|
||||
@@ -144,9 +152,12 @@ export const characterSchema = z.object({
|
||||
portrait: z.string().optional(),
|
||||
/** ancestry (PF2e) / race (5e), free text for MVP */
|
||||
ancestry: z.string().max(80).default(''),
|
||||
/** class, free text for MVP */
|
||||
/** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */
|
||||
className: z.string().max(80).default(''),
|
||||
/** Total character level — a mirror of sum(classes[].level). */
|
||||
level: int.min(1).max(20).default(1),
|
||||
/** Per-class levels for multiclassing. Empty pre-migration → derive from className/level. */
|
||||
classes: z.array(classEntrySchema).default([]),
|
||||
/** background / heritage (promoted out of the notes string). */
|
||||
background: z.string().max(80).default(''),
|
||||
/** alignment, free text (e.g. "Chaotic Good", "Unaligned"). */
|
||||
@@ -201,6 +212,32 @@ export const characterSchema = z.object({
|
||||
|
||||
export type Character = z.infer<typeof characterSchema>;
|
||||
|
||||
/** Minimal shape the class helpers operate on (works on Character or a draft). */
|
||||
type WithClasses = { classes?: ClassEntry[]; className?: string; level?: number };
|
||||
|
||||
/** The primary class name: classes[0] if present, else the legacy className mirror. */
|
||||
export function primaryClass(c: WithClasses): string {
|
||||
return c.classes?.[0]?.className ?? c.className ?? '';
|
||||
}
|
||||
|
||||
/** Total character level: sum of class levels if present, else the legacy level mirror. */
|
||||
export function totalLevel(c: WithClasses): number {
|
||||
if (c.classes && c.classes.length > 0) {
|
||||
return Math.min(20, Math.max(1, c.classes.reduce((n, e) => n + (e.level || 0), 0)));
|
||||
}
|
||||
return c.level ?? 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the className/level mirrors from classes[] so consumers that read them
|
||||
* (and the ~30 sites reading c.level as total level) stay correct. No-op when classes
|
||||
* is empty. Returns only the changed mirror fields.
|
||||
*/
|
||||
export function normalizeClassMirror(c: WithClasses): { className: string; level: number } | Record<string, never> {
|
||||
if (!c.classes || c.classes.length === 0) return {};
|
||||
return { className: primaryClass(c), level: totalLevel(c) };
|
||||
}
|
||||
|
||||
export const characterDraftSchema = characterSchema.pick({
|
||||
name: true,
|
||||
kind: true,
|
||||
@@ -213,9 +250,10 @@ export type CharacterDraft = z.infer<typeof characterDraftSchema>;
|
||||
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||
export function characterDefaults(): Pick<
|
||||
Character,
|
||||
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality'
|
||||
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes'
|
||||
> {
|
||||
return {
|
||||
classes: [],
|
||||
background: '',
|
||||
alignment: '',
|
||||
appearance: '',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user