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:
@@ -0,0 +1,84 @@
|
||||
import type { ClassDef } from '../progression';
|
||||
|
||||
/**
|
||||
* Curated 5e SRD class data for the guided builder. The on-disk MPMB files carry
|
||||
* only names, so hit dice / proficiencies / caster type are authored here for the
|
||||
* core classes. Unknown classes fall back to manual entry in the wizard.
|
||||
*/
|
||||
export const DND5E_CLASSES: ClassDef[] = [
|
||||
{ name: 'Barbarian', hitDie: 12, keyAbilities: ['str'], saveProficient: ['str', 'con'], skillCount: 2,
|
||||
skillKeys: ['animal-handling', 'athletics', 'intimidation', 'nature', 'perception', 'survival'], caster: 'none' },
|
||||
{ name: 'Bard', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['dex', 'cha'], skillCount: 3,
|
||||
skillKeys: [], caster: 'full', spellAbility: 'cha' },
|
||||
{ name: 'Cleric', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['wis', 'cha'], skillCount: 2,
|
||||
skillKeys: ['history', 'insight', 'medicine', 'persuasion', 'religion'], caster: 'full', spellAbility: 'wis' },
|
||||
{ name: 'Druid', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['int', 'wis'], skillCount: 2,
|
||||
skillKeys: ['arcana', 'animal-handling', 'insight', 'medicine', 'nature', 'perception', 'religion', 'survival'], caster: 'full', spellAbility: 'wis' },
|
||||
{ name: 'Fighter', hitDie: 10, keyAbilities: ['str', 'dex'], saveProficient: ['str', 'con'], skillCount: 2,
|
||||
skillKeys: ['acrobatics', 'animal-handling', 'athletics', 'history', 'insight', 'intimidation', 'perception', 'survival'], caster: 'none' },
|
||||
{ name: 'Monk', hitDie: 8, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 2,
|
||||
skillKeys: ['acrobatics', 'athletics', 'history', 'insight', 'religion', 'stealth'], caster: 'none' },
|
||||
{ name: 'Paladin', hitDie: 10, keyAbilities: ['str', 'cha'], saveProficient: ['wis', 'cha'], skillCount: 2,
|
||||
skillKeys: ['athletics', 'insight', 'intimidation', 'medicine', 'persuasion', 'religion'], caster: 'half', spellAbility: 'cha' },
|
||||
{ name: 'Ranger', hitDie: 10, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 3,
|
||||
skillKeys: ['animal-handling', 'athletics', 'insight', 'investigation', 'nature', 'perception', 'stealth', 'survival'], caster: 'half', spellAbility: 'wis' },
|
||||
{ name: 'Rogue', hitDie: 8, keyAbilities: ['dex'], saveProficient: ['dex', 'int'], skillCount: 4,
|
||||
skillKeys: ['acrobatics', 'athletics', 'deception', 'insight', 'intimidation', 'investigation', 'perception', 'performance', 'persuasion', 'sleight-of-hand', 'stealth'], caster: 'none' },
|
||||
{ name: 'Sorcerer', hitDie: 6, keyAbilities: ['cha'], saveProficient: ['con', 'cha'], skillCount: 2,
|
||||
skillKeys: ['arcana', 'deception', 'insight', 'intimidation', 'persuasion', 'religion'], caster: 'full', spellAbility: 'cha' },
|
||||
{ name: 'Warlock', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['wis', 'cha'], skillCount: 2,
|
||||
skillKeys: ['arcana', 'deception', 'history', 'intimidation', 'investigation', 'nature', 'religion'], caster: 'pact', spellAbility: 'cha' },
|
||||
{ name: 'Wizard', hitDie: 6, keyAbilities: ['int'], saveProficient: ['int', 'wis'], skillCount: 2,
|
||||
skillKeys: ['arcana', 'history', 'insight', 'investigation', 'medicine', 'religion'], caster: 'full', spellAbility: 'int' },
|
||||
];
|
||||
|
||||
// Spell slots [1st..9th] by character level for a full caster (PHB).
|
||||
const FULL: Record<number, number[]> = {
|
||||
1: [2], 2: [3], 3: [4, 2], 4: [4, 3], 5: [4, 3, 2], 6: [4, 3, 3], 7: [4, 3, 3, 1], 8: [4, 3, 3, 2],
|
||||
9: [4, 3, 3, 3, 1], 10: [4, 3, 3, 3, 2], 11: [4, 3, 3, 3, 2, 1], 12: [4, 3, 3, 3, 2, 1],
|
||||
13: [4, 3, 3, 3, 2, 1, 1], 14: [4, 3, 3, 3, 2, 1, 1], 15: [4, 3, 3, 3, 2, 1, 1, 1], 16: [4, 3, 3, 3, 2, 1, 1, 1],
|
||||
17: [4, 3, 3, 3, 2, 1, 1, 1, 1], 18: [4, 3, 3, 3, 3, 1, 1, 1, 1], 19: [4, 3, 3, 3, 3, 2, 1, 1, 1], 20: [4, 3, 3, 3, 3, 2, 2, 1, 1],
|
||||
};
|
||||
// Half caster (Paladin/Ranger), spells from level 2.
|
||||
const HALF: Record<number, number[]> = {
|
||||
2: [2], 3: [3], 4: [3], 5: [4, 2], 6: [4, 2], 7: [4, 3], 8: [4, 3], 9: [4, 3, 2], 10: [4, 3, 2],
|
||||
11: [4, 3, 3], 12: [4, 3, 3], 13: [4, 3, 3, 1], 14: [4, 3, 3, 1], 15: [4, 3, 3, 2], 16: [4, 3, 3, 2],
|
||||
17: [4, 3, 3, 3, 1], 18: [4, 3, 3, 3, 1], 19: [4, 3, 3, 3, 2], 20: [4, 3, 3, 3, 2],
|
||||
};
|
||||
// Warlock pact magic: [slotCount, slotLevel].
|
||||
const PACT: Record<number, [number, number]> = {
|
||||
1: [1, 1], 2: [2, 1], 3: [2, 2], 4: [2, 2], 5: [2, 3], 6: [2, 3], 7: [2, 4], 8: [2, 4], 9: [2, 5], 10: [2, 5],
|
||||
11: [3, 5], 12: [3, 5], 13: [3, 5], 14: [3, 5], 15: [3, 5], 16: [3, 5], 17: [4, 5], 18: [4, 5], 19: [4, 5], 20: [4, 5],
|
||||
};
|
||||
|
||||
function clamp(level: number): number {
|
||||
return Math.max(1, Math.min(20, Math.floor(level) || 1));
|
||||
}
|
||||
|
||||
export function dnd5eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] {
|
||||
const lvl = clamp(level);
|
||||
const table = caster === 'full' ? FULL[lvl] : caster === 'half' ? HALF[lvl] : undefined;
|
||||
if (!table) return [];
|
||||
return table.map((max, i) => ({ level: i + 1, max, current: max }));
|
||||
}
|
||||
|
||||
export function dnd5ePact(level: number): { level: number; max: number; current: number } | undefined {
|
||||
const p = PACT[clamp(level)];
|
||||
return p ? { level: p[1], max: p[0], current: p[0] } : undefined;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
const avg = Math.ceil(hitDie / 2) + 1;
|
||||
return Math.max(1, hitDie + conMod + (lvl - 1) * (avg + conMod));
|
||||
}
|
||||
|
||||
/** Levels that grant an Ability Score Improvement (or feat). Includes class extras. */
|
||||
export function dnd5eAsiLevels(className: string): number[] {
|
||||
const base = [4, 8, 12, 16, 19];
|
||||
const c = className.toLowerCase();
|
||||
if (c === 'fighter') return [...base, 6, 14].sort((a, b) => a - b);
|
||||
if (c === 'rogue') return [...base, 10].sort((a, b) => a - b);
|
||||
return base;
|
||||
}
|
||||
Reference in New Issue
Block a user