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
+30
View File
@@ -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 = '';
});
});
}
}
+60
View File
@@ -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);
+38 -1
View File
@@ -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([]);
});
});
+40 -2
View File
@@ -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: '',