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:
2026-06-08 09:55:48 +02:00
parent 7271978d7d
commit 04fa90e580
18 changed files with 946 additions and 147 deletions
+84
View File
@@ -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;
}
+1
View File
@@ -17,6 +17,7 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
];
export * from './types';
export * from './progression';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
+71
View File
@@ -0,0 +1,71 @@
import type { ClassDef } from '../progression';
/**
* Curated PF2e class data for the guided builder. There is no class data file on
* disk, so the core classes' HP, key ability, initial proficiencies, trained-skill
* count, and caster type are authored here. `hitDie` holds HP-per-level (PF2e style).
* Save ranks are keyed by the ability the save uses (con/dex/wis) to match the sheet.
*/
export const PF2E_CLASSES: ClassDef[] = [
cls('Alchemist', 8, ['int'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'trained', 3, 'none'),
cls('Barbarian', 12, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'expert', 3, 'none'),
cls('Bard', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'expert', 4, 'full', 'cha'),
cls('Champion', 10, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 2, 'none'),
cls('Cleric', 8, ['wis'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
cls('Druid', 8, ['wis'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
cls('Fighter', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 3, 'none'),
cls('Investigator', 8, ['int'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
cls('Monk', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'expert' }, 'trained', 4, 'none'),
cls('Oracle', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'),
cls('Ranger', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 4, 'none'),
cls('Rogue', 8, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 7, 'none'),
cls('Sorcerer', 6, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'cha'),
cls('Swashbuckler', 10, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
cls('Witch', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'int'),
cls('Wizard', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'int'),
];
function cls(
name: string, hp: number, keyAbilities: ClassDef['keyAbilities'],
saves: NonNullable<ClassDef['saveRanks']>, perception: ClassDef['perception'],
skillCount: number, caster: ClassDef['caster'], spellAbility?: ClassDef['spellAbility'],
): ClassDef {
return {
name, hitDie: hp, keyAbilities, saveProficient: [], saveRanks: saves,
perception: perception ?? 'trained', skillKeys: [], skillCount, caster,
...(spellAbility ? { spellAbility, spellRank: 'trained' as const } : {}),
};
}
/** PF2e HP = ancestry HP + (class HP + CON mod) × level. */
export function pf2eHp(classHp: number, level: number, conMod: number, ancestryHp: number): number {
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
return Math.max(1, ancestryHp + (classHp + conMod) * lvl);
}
/**
* Spell slots per rank for a full caster. A new rank unlocks at each odd level;
* each available rank gets 3 slots (the standard for prepared/spontaneous casters).
* Approximate and editable on the sheet.
*/
export function pf2eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] {
if (caster !== 'full') return [];
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
const topRank = Math.min(10, Math.ceil(lvl / 2));
const slots: { level: number; max: number; current: number }[] = [];
for (let r = 1; r <= topRank; r++) {
const max = r === 10 ? 1 : 3;
slots.push({ level: r, max, current: max });
}
return slots;
}
/** Levels granting 4 ability boosts. */
export function pf2eBoostLevels(): number[] {
return [5, 10, 15, 20];
}
/** Levels granting a skill increase. */
export function pf2eSkillIncreaseLevels(): number[] {
return [3, 5, 7, 9, 11, 13, 15, 17, 19];
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
getClassDef, getClassNames, classSkillCount, classSkillChoices,
buildCharacter, planLevelUp, applyIncreases, bumpRank,
} from './progression';
import type { AbilityScores } from './types';
const abilities: AbilityScores = { str: 16, dex: 12, con: 14, int: 10, wis: 13, cha: 8 };
describe('class tables', () => {
it('exposes core classes for both systems', () => {
expect(getClassNames('5e')).toContain('Wizard');
expect(getClassNames('pf2e')).toContain('Champion');
expect(getClassDef('5e', 'fighter')?.hitDie).toBe(10);
});
it('pf2e skill count adds INT modifier; 5e does not', () => {
expect(classSkillCount('5e', 'Rogue', 3)).toBe(4); // fixed
expect(classSkillCount('pf2e', 'Rogue', 3)).toBe(10); // 7 + INT 3
});
it('restricts skill choices for a class with a list, full list otherwise', () => {
expect(classSkillChoices('5e', ['a', 'b'], 'Cleric')).toContain('religion');
expect(classSkillChoices('5e', ['acrobatics', 'arcana'], 'Bard')).toEqual(['acrobatics', 'arcana']);
});
});
describe('buildCharacter — 5e', () => {
it('computes HP from hit die + CON over levels and sets save proficiencies', () => {
const built = buildCharacter('5e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'] });
// d10 + CON 2 at level 1
expect(built.hp.max).toBe(12);
expect(built.saveRanks).toEqual({ str: 'trained', con: 'trained' });
expect(built.skillRanks).toEqual({ athletics: 'trained' });
expect(built.spellcasting.slots).toEqual([]);
});
it('gives a full caster spell slots and a spell ability', () => {
const built = buildCharacter('5e', { className: 'Wizard', level: 3, abilities, skillChoices: [] });
expect(built.spellcastingAbility).toBe('int');
expect(built.spellcasting.slots).toEqual([
{ level: 1, max: 4, current: 4 }, { level: 2, max: 2, current: 2 },
]);
});
it('gives a warlock pact magic', () => {
const built = buildCharacter('5e', { className: 'Warlock', level: 3, abilities, skillChoices: [] });
expect(built.spellcasting.pact).toEqual({ level: 2, max: 2, current: 2 });
});
});
describe('buildCharacter — pf2e', () => {
it('computes HP = ancestry + (class + CON) × level and sets save ranks/perception', () => {
const built = buildCharacter('pf2e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'], ancestryHp: 8 });
expect(built.hp.max).toBe(8 + (10 + 2) * 1); // 20
expect(built.saveRanks).toMatchObject({ con: 'expert', dex: 'expert', wis: 'trained' });
expect(built.perceptionRank).toBe('expert');
});
});
describe('planLevelUp', () => {
it('flags a 5e ASI at level 4 and updates caster slots', () => {
const plan = planLevelUp('5e', 'Wizard', 3, 2);
expect(plan.nextLevel).toBe(4);
expect(plan.choices.some((c) => c.kind === 'asi')).toBe(true);
expect(plan.slots?.length).toBeGreaterThan(0);
});
it('flags pf2e ability boosts at level 5 and a skill increase', () => {
const plan = planLevelUp('pf2e', 'Rogue', 4, 1);
expect(plan.nextLevel).toBe(5);
expect(plan.choices.some((c) => c.kind === 'boosts')).toBe(true);
expect(plan.choices.some((c) => c.kind === 'skill-increase')).toBe(true);
});
});
describe('apply helpers', () => {
it('5e increases add +1 per pick (same twice = +2)', () => {
expect(applyIncreases(abilities, ['str', 'str'], '5e').str).toBe(18);
expect(applyIncreases(abilities, ['dex', 'con'], '5e')).toMatchObject({ dex: 13, con: 15 });
});
it('pf2e boosts add +2 under 18, +1 at/over 18', () => {
expect(applyIncreases({ ...abilities, str: 16 }, ['str'], 'pf2e').str).toBe(18);
expect(applyIncreases({ ...abilities, str: 18 }, ['str'], 'pf2e').str).toBe(19);
});
it('bumpRank walks the ladder and caps at legendary', () => {
expect(bumpRank('untrained')).toBe('trained');
expect(bumpRank('expert')).toBe('master');
expect(bumpRank('legendary')).toBe('legendary');
});
});
+188
View File
@@ -0,0 +1,188 @@
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
import { abilityModifier } from './abilities';
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
export type CasterKind = 'none' | 'full' | 'half' | 'pact';
/** Spellcasting abilities allowed by the character schema. */
export type SpellAbility = 'int' | 'wis' | 'cha';
export interface ClassDef {
name: string;
/** 5e: hit die size (6/8/10/12). PF2e: HP gained per level. */
hitDie: number;
keyAbilities: AbilityKey[];
/** 5e: abilities whose saves become trained. */
saveProficient: AbilityKey[];
/** PF2e: initial save ranks keyed by the save's ability (con/dex/wis). */
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
/** PF2e: initial perception rank. */
perception?: ProficiencyRank;
/** Skills to choose from (5e). Empty = choose from all (Bard / all PF2e). */
skillKeys: string[];
/** Number of trained skills to choose (PF2e adds INT mod on top at build time). */
skillCount: number;
caster: CasterKind;
spellAbility?: SpellAbility;
spellRank?: ProficiencyRank;
}
export interface SpellSlot { level: number; max: number; current: number }
export interface BuildChoices {
className: string;
level: number;
abilities: AbilityScores;
/** chosen skill keys → trained */
skillChoices: string[];
/** PF2e ancestry HP (from the ancestries data); ignored for 5e. */
ancestryHp?: number;
/** Used only when the class is unknown (custom): 5e hit die size. */
hitDieOverride?: number;
}
/** Derived character fields produced by the builder — a Partial<Character> patch. */
export interface BuiltCharacter {
abilities: AbilityScores;
hp: { current: number; max: number; temp: number };
skillRanks: Record<string, ProficiencyRank>;
saveRanks: Record<string, ProficiencyRank>;
perceptionRank: ProficiencyRank;
spellcastingAbility?: SpellAbility;
spellcastingRank?: ProficiencyRank;
spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] };
}
const TABLES: Record<SystemId, ClassDef[]> = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES };
export function getClassDefs(system: SystemId): ClassDef[] {
return TABLES[system];
}
export function getClassNames(system: SystemId): string[] {
return TABLES[system].map((c) => c.name);
}
export function getClassDef(system: SystemId, className: string): ClassDef | undefined {
const want = className.trim().toLowerCase();
return TABLES[system].find((c) => c.name.toLowerCase() === want);
}
/** Skills the class may choose from (full list when unrestricted). */
export function classSkillChoices(system: SystemId, allSkillKeys: string[], className: string): string[] {
const def = getClassDef(system, className);
if (def && def.skillKeys.length > 0) return def.skillKeys;
return allSkillKeys;
}
/** How many skills to pick (PF2e adds INT modifier, min 0). */
export function classSkillCount(system: SystemId, className: string, intMod: number): number {
const def = getClassDef(system, className);
const base = def?.skillCount ?? (system === 'pf2e' ? 3 : 2);
return system === 'pf2e' ? base + Math.max(0, intMod) : base;
}
/** Build the derived fields for a brand-new character from the wizard's choices. */
export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCharacter {
const def = getClassDef(system, choices.className);
const conMod = abilityModifier(choices.abilities.con);
const die = def?.hitDie ?? choices.hitDieOverride ?? (system === 'pf2e' ? 8 : 8);
const hpMax = system === 'pf2e'
? pf2eHp(die, choices.level, conMod, choices.ancestryHp ?? 8)
: dnd5eHp(die, choices.level, conMod);
const skillRanks: Record<string, ProficiencyRank> = {};
for (const key of choices.skillChoices) skillRanks[key] = 'trained';
const saveRanks: Record<string, ProficiencyRank> = {};
if (system === 'pf2e') {
const ranks: Partial<Record<AbilityKey, ProficiencyRank>> = def?.saveRanks ?? { con: 'trained', dex: 'trained', wis: 'trained' };
for (const [ability, rank] of Object.entries(ranks)) {
if (rank) saveRanks[ability] = rank;
}
} else {
for (const ability of def?.saveProficient ?? []) saveRanks[ability] = 'trained';
}
const caster = def?.caster ?? 'none';
const slots = system === 'pf2e' ? pf2eSlots(caster, choices.level) : dnd5eSlots(caster, choices.level);
const pact = system === '5e' && caster === 'pact' ? dnd5ePact(choices.level) : undefined;
return {
abilities: choices.abilities,
hp: { current: hpMax, max: hpMax, temp: 0 },
skillRanks,
saveRanks,
perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained',
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] },
};
}
// ---------------- Level-up planning ----------------
export type LevelChoice =
| { kind: 'asi'; label: string }
| { kind: 'boosts'; count: number; label: string }
| { kind: 'skill-increase'; label: string }
| { kind: 'feat'; label: string };
export interface LevelUpPlan {
nextLevel: number;
hpGainAverage: number;
hitDie: number;
slots?: SpellSlot[];
pact?: SpellSlot;
notes: string[];
choices: LevelChoice[];
}
export function planLevelUp(system: SystemId, className: string, currentLevel: number, conMod: number): LevelUpPlan {
const nextLevel = Math.min(20, Math.floor(currentLevel) + 1);
const def = getClassDef(system, className);
const die = def?.hitDie ?? 8;
const caster = def?.caster ?? 'none';
const notes: string[] = [];
const choices: LevelChoice[] = [];
if (system === '5e') {
const hpGainAverage = Math.max(1, Math.ceil(die / 2) + 1 + conMod);
const slots = dnd5eSlots(caster, nextLevel);
const pact = caster === 'pact' ? dnd5ePact(nextLevel) : undefined;
if (dnd5eAsiLevels(className).includes(nextLevel)) {
choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' });
}
if (slots.length) notes.push('Spell slots updated for the new level.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices };
}
// PF2e
const hpGainAverage = Math.max(1, die + conMod);
const slots = pf2eSlots(caster, nextLevel);
if (pf2eBoostLevels().includes(nextLevel)) {
choices.push({ kind: 'boosts', count: 4, label: 'Ability boosts — raise four different abilities (+2 if under 18, otherwise +1).' });
}
if (pf2eSkillIncreaseLevels().includes(nextLevel)) {
choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' });
}
if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' });
if (slots.length) notes.push('Spell slots updated for the new level.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices };
}
// ---------------- Apply helpers (pure) ----------------
const RANK_ORDER: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
export function bumpRank(rank: ProficiencyRank): ProficiencyRank {
const i = RANK_ORDER.indexOf(rank);
return RANK_ORDER[Math.min(RANK_ORDER.length - 1, i + 1)]!;
}
/** Apply ability increases. 5e: +1 per pick (pick the same twice for +2). PF2e: +2 if <18 else +1. */
export function applyIncreases(abilities: AbilityScores, picks: AbilityKey[], system: SystemId): AbilityScores {
const next = { ...abilities };
for (const k of picks) {
next[k] = system === 'pf2e' ? (next[k] >= 18 ? next[k] + 1 : next[k] + 2) : next[k] + 1;
}
return next;
}