Phase 7: class feature & choice engine — every unlockable option is surfaced

A character's class choices were never prompted (the user's example: a Warlock must
choose a Pact Boon + Eldritch Invocations). Now:
- CLASS_PROGRESSION_5E authors all 12 classes' features by level AND every decision point:
  Fighting Style, Pact Boon, Eldritch Invocations (count grows by level), Metamagic,
  Expertise, Favored Enemy/Terrain, with full option lists.
- collectChoices5e / collectFeatures5e compute, across a (multiclass) character's classes,
  exactly which choices are unlocked (and how many to pick at the current level) and which
  features are gained. Subclass selection (classes editor) and ASI/feat (builder/level-up)
  are handled elsewhere and excluded. Unit-tested incl. the Warlock pact example.
- New 'Class Features & Choices' sheet section lists features by level and presents a picker
  per unlocked choice, with a 'N to choose' badge so nothing is missed. Resolved choices
  persist (character.choices, v17 migration).

Verified in-app: Fighter 3 / Warlock 5 surfaces Fighting Style (1), Pact Boon (1), Eldritch
Invocations (3) + Pact Magic feature; selecting one drops the pending count. 346 tests green
(7 new), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:51:23 +02:00
parent ce6e2f430f
commit 82a2aef42f
10 changed files with 485 additions and 2 deletions
@@ -28,6 +28,7 @@ import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection';
import { ClassFeaturesSection } from './sheet/ClassFeaturesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal';
import { LevelUpModal } from './sheet/LevelUpModal';
@@ -316,6 +317,8 @@ export function CharacterSheet({ character }: { character: Character }) {
<ResourcesSection c={c} update={update} />
{/* Feats & features */}
<ClassFeaturesSection c={c} update={update} />
<FeatsSection c={c} update={update} />
{/* Inventory, currency, encumbrance */}
@@ -0,0 +1,88 @@
import { collectChoices5e, collectFeatures5e } from '@/lib/rules';
import type { ClassEntry } from '@/lib/schemas';
import { Input, Select } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex';
import { SheetSection, type SectionProps } from './common';
/**
* Class features + every choice the character must make (Fighting Style, Pact Boon,
* Eldritch Invocations, Metamagic, Expertise, …) so no unlock-able option is missed.
* 5e only — driven by CLASS_PROGRESSION_5E.
*/
export function ClassFeaturesSection({ c, update }: SectionProps) {
if (c.system !== '5e') return null;
const classes: ClassEntry[] = c.classes.length ? c.classes : c.className ? [{ className: c.className, level: c.level }] : [];
const features = collectFeatures5e(classes);
const choices = collectChoices5e(classes);
if (features.length === 0 && choices.length === 0) return null;
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
const setVal = (key: string, idx: number, value: string) => {
const cur = [...getVals(key)];
while (cur.length <= idx) cur.push('');
cur[idx] = value;
update({ choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] });
};
const pendingTotal = choices.reduce((n, ch) => n + Math.max(0, ch.count - getVals(ch.key).filter(Boolean).length), 0);
return (
<SheetSection title="Class Features & Choices">
{choices.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="smallcaps">Choices</span>
{pendingTotal > 0 && <Badge tone="ember">{pendingTotal} to choose</Badge>}
</div>
<div className="space-y-2">
{choices.map((ch) => {
const vals = getVals(ch.key);
const filled = vals.filter(Boolean).length;
return (
<div key={ch.key} className="rounded-md border border-line bg-panel p-2">
<div className="mb-1 flex items-center gap-2 text-sm">
<span className="font-medium text-ink">{ch.label}</span>
<span className="text-[10px] uppercase text-muted">{ch.source}</span>
{filled < ch.count && <Badge tone="ember">choose {ch.count - filled}</Badge>}
</div>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{Array.from({ length: ch.count }).map((_, i) => {
const val = vals[i] ?? '';
return ch.options ? (
<Select key={i} className="text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`}>
<option value=""> choose </option>
{val && !ch.options.includes(val) && <option value={val}>{val}</option>}
{ch.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<Input key={i} className="h-8 text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`} placeholder={`${ch.label} ${i + 1}`} />
);
})}
</div>
{ch.hint && <p className="mt-1 text-[10px] text-muted">{ch.hint}</p>}
</div>
);
})}
</div>
</div>
)}
{features.length > 0 && (
<div>
<div className="mb-2 smallcaps">Features</div>
<ul className="space-y-1.5">
{features.map((f, i) => (
<li key={`${f.source}-${f.name}-${i}`} className="rounded-md border border-line bg-panel px-2 py-1.5 text-sm">
<div className="flex items-baseline gap-2">
<span className="font-medium text-ink">{f.name}</span>
<span className="text-[10px] uppercase text-muted">{f.source} · L{f.level}</span>
</div>
{f.text && <p className="mt-0.5 text-xs text-muted">{f.text}</p>}
</li>
))}
</ul>
</div>
)}
</SheetSection>
);
}
+7
View File
@@ -181,6 +181,13 @@ export class TtrpgDatabase extends Dexie {
if (c.personality === undefined) c.personality = '';
});
});
// v17 — class feature choices (Fighting Style, Pact Boon, Invocations…). Defaulted.
this.version(17).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.choices === undefined) c.choices = [];
});
});
}
}
+63
View File
@@ -0,0 +1,63 @@
import { CLASS_PROGRESSION_5E } from './data.5e';
import { DND5E_SUBCLASSES } from '../dnd5e/progression';
import type { UnlockedChoice, UnlockedFeature } from './types';
interface ClassRef { className: string; level: number; subclass?: string | undefined }
function progFor(name: string) {
const key = Object.keys(CLASS_PROGRESSION_5E).find((k) => k.toLowerCase() === name.trim().toLowerCase());
return key ? CLASS_PROGRESSION_5E[key] : undefined;
}
/** Total to pick at a class level: highest countByLevel entry ≤ level, else the fixed `pick`. */
export function countAtLevel(countByLevel: Record<number, number> | undefined, level: number, pick = 1): number {
if (!countByLevel) return pick;
let n = 0;
for (const [lvl, c] of Object.entries(countByLevel)) if (Number(lvl) <= level) n = Math.max(n, c);
return n;
}
/**
* Every CHOICE a 5e character must make at its current level(s), across all classes —
* Fighting Style, Pact Boon, Eldritch Invocations, Metamagic, Expertise, Favored Enemy,
* etc. Subclass selection is handled by the classes editor and ASI/feat by the builder /
* level-up, so both are excluded here.
*/
export function collectChoices5e(classes: readonly ClassRef[]): UnlockedChoice[] {
const out: UnlockedChoice[] = [];
for (const e of classes) {
const prog = progFor(e.className);
if (!prog) continue;
for (const ch of prog.choices) {
if (ch.id === 'asi' || e.level < ch.level) continue;
const count = countAtLevel(ch.countByLevel, e.level, ch.pick ?? 1);
if (count <= 0) continue;
out.push({
key: `${e.className}:${ch.id}`,
label: ch.label,
source: e.className,
count,
...(ch.options ? { options: ch.options } : {}),
...(ch.hint ? { hint: ch.hint } : {}),
});
}
}
return out;
}
/** Every class/subclass feature a 5e character has gained, for display, sorted by level. */
export function collectFeatures5e(classes: readonly ClassRef[]): UnlockedFeature[] {
const out: UnlockedFeature[] = [];
for (const e of classes) {
const prog = progFor(e.className);
if (!prog) continue;
for (const f of prog.features) {
if (e.level >= f.level) out.push({ name: f.name, ...(f.text ? { text: f.text } : {}), source: e.className, level: f.level });
}
if (e.subclass) {
const sc = (DND5E_SUBCLASSES[e.className] ?? []).find((s) => s.name === e.subclass);
out.push({ name: e.subclass, ...(sc?.desc ? { text: sc.desc } : {}), source: `${e.className} · ${prog.subclass.label}`, level: prog.subclass.level });
}
}
return out.sort((a, b) => a.level - b.level || a.source.localeCompare(b.source));
}
+206
View File
@@ -0,0 +1,206 @@
import type { ClassProgression } from './types';
// ---- Shared option lists ----
export const FIGHTING_STYLES = [
'Archery', 'Defense', 'Dueling', 'Great Weapon Fighting', 'Protection', 'Two-Weapon Fighting',
'Blind Fighting', 'Interception', 'Superior Technique', 'Thrown Weapon Fighting', 'Unarmed Fighting',
] as const;
export const PACT_BOONS = ['Pact of the Chain', 'Pact of the Blade', 'Pact of the Tome', 'Pact of the Talisman'] as const;
export const METAMAGIC = [
'Careful Spell', 'Distant Spell', 'Empowered Spell', 'Extended Spell', 'Heightened Spell',
'Quickened Spell', 'Seeking Spell', 'Subtle Spell', 'Transmuted Spell', 'Twinned Spell',
] as const;
export const ELDRITCH_INVOCATIONS = [
'Agonizing Blast', 'Armor of Shadows', 'Ascendant Step', "Devil's Sight", 'Eldritch Mind', 'Eldritch Sight',
'Eldritch Spear', 'Eyes of the Rune Keeper', 'Fiendish Vigor', 'Gaze of Two Minds', 'Investment of the Chain Master',
'Mask of Many Faces', 'Misty Visions', 'One with Shadows', 'Repelling Blast', 'Thirsting Blade', "Beast Speech",
'Book of Ancient Secrets', 'Chains of Carceri', 'Lifedrinker', 'Master of Myriad Forms', 'Voice of the Chain Master',
'Whispers of the Grave', 'Witch Sight', 'Grasp of Hadar', 'Lance of Lethargy', 'Maddening Hex', 'Tomb of Levistus',
] as const;
export const BATTLEMASTER_MANEUVERS = [
"Ambush", "Bait and Switch", "Brace", "Commander's Strike", "Commanding Presence", "Disarming Attack",
"Distracting Strike", "Evasive Footwork", "Feinting Attack", "Goading Attack", "Grappling Strike", "Lunging Attack",
"Maneuvering Attack", "Menacing Attack", "Parry", "Precision Attack", "Pushing Attack", "Quick Toss", "Rally",
"Riposte", "Sweeping Attack", "Tactical Assessment", "Trip Attack",
] as const;
export const FAVORED_ENEMIES = [
'Aberrations', 'Beasts', 'Celestials', 'Constructs', 'Dragons', 'Elementals', 'Fey', 'Fiends',
'Giants', 'Monstrosities', 'Oozes', 'Plants', 'Undead', 'Two humanoid races',
] as const;
export const FAVORED_TERRAIN = [
'Arctic', 'Coast', 'Desert', 'Forest', 'Grassland', 'Mountain', 'Swamp', 'Underdark',
] as const;
export const SKILL_NAMES = [
'Acrobatics', 'Animal Handling', 'Arcana', 'Athletics', 'Deception', 'History', 'Insight', 'Intimidation',
'Investigation', 'Medicine', 'Nature', 'Perception', 'Performance', 'Persuasion', 'Religion', 'Sleight of Hand',
'Stealth', 'Survival',
] as const;
const ASI_FEAT = { id: 'asi', label: 'Ability Score Improvement / Feat', level: 4, pick: 1, hint: 'Assigned in the builder / on level-up.' };
/** Per-class features + choices. Keyed by the curated class name. */
export const CLASS_PROGRESSION_5E: Record<string, ClassProgression> = {
Barbarian: {
subclass: { level: 3, label: 'Primal Path' },
features: [
{ level: 1, name: 'Rage', text: 'Bonus action: advantage on STR checks/saves, +rage damage on STR melee, resistance to bludgeoning/piercing/slashing.' },
{ level: 1, name: 'Unarmored Defense', text: 'AC = 10 + DEX + CON when not wearing armor.' },
{ level: 2, name: 'Reckless Attack', text: 'Advantage on STR melee attacks this turn; attacks against you have advantage.' },
{ level: 2, name: 'Danger Sense', text: 'Advantage on DEX saves vs effects you can see.' },
{ level: 5, name: 'Extra Attack & Fast Movement', text: 'Two attacks per Attack action; +10 ft speed unarmored.' },
{ level: 7, name: 'Feral Instinct', text: 'Advantage on initiative; can act while surprised if you rage.' },
{ level: 9, name: 'Brutal Critical', text: 'Extra weapon die on melee crits (more at 13/17).' },
{ level: 11, name: 'Relentless Rage', text: 'Drop to 1 HP instead of 0 (DC 10 CON save, +5 each use).' },
],
choices: [ASI_FEAT],
},
Bard: {
subclass: { level: 3, label: 'Bard College' },
features: [
{ level: 1, name: 'Bardic Inspiration', text: 'Bonus action: give an ally a d6 (grows to d12) to add to a check/attack/save.' },
{ level: 2, name: 'Jack of All Trades', text: 'Add half proficiency to non-proficient ability checks.' },
{ level: 2, name: 'Song of Rest', text: 'Allies regain extra HP on a short rest.' },
{ level: 5, name: 'Font of Inspiration', text: 'Regain Bardic Inspiration on a short rest.' },
{ level: 6, name: 'Countercharm', text: 'Allies get advantage vs frightened/charmed while you perform.' },
{ level: 10, name: 'Magical Secrets', text: 'Learn 2 spells from any class (more at 14/18).' },
],
choices: [
{ id: 'expertise', label: 'Expertise', level: 3, countByLevel: { 3: 2, 10: 4 }, options: SKILL_NAMES, hint: 'Double proficiency in chosen skills.' },
ASI_FEAT,
],
},
Cleric: {
subclass: { level: 1, label: 'Divine Domain' },
features: [
{ level: 1, name: 'Spellcasting & Divine Domain', text: 'Prepare cleric spells; your domain grants bonus spells + features.' },
{ level: 2, name: 'Channel Divinity', text: 'Turn Undead + a domain option; 1/rest (2 at 6, 3 at 18).' },
{ level: 5, name: 'Destroy Undead', text: 'Channel Divinity destroys low-CR undead.' },
{ level: 10, name: 'Divine Intervention', text: 'Call on your deity for aid (% = level).' },
],
choices: [ASI_FEAT],
},
Druid: {
subclass: { level: 2, label: 'Druid Circle' },
features: [
{ level: 1, name: 'Druidic & Spellcasting', text: 'Secret druid language; prepare druid spells.' },
{ level: 2, name: 'Wild Shape', text: 'Transform into beasts you have seen (CR/limits by level); 2/rest.' },
{ level: 18, name: 'Timeless Body & Beast Spells', text: 'Age slowly; cast many spells while wild-shaped.' },
],
choices: [
{ id: 'wild-companion', label: 'Wild Companion (optional)', level: 2, pick: 1, options: ['Use Wild Shape to cast Find Familiar'], hint: 'Optional class feature.' },
ASI_FEAT,
],
},
Fighter: {
subclass: { level: 3, label: 'Martial Archetype' },
features: [
{ level: 1, name: 'Second Wind', text: 'Bonus action: regain 1d10 + fighter level HP; 1/short rest.' },
{ level: 2, name: 'Action Surge', text: 'One extra action; 1/short rest (2 at 17).' },
{ level: 5, name: 'Extra Attack', text: 'Two attacks per Attack action (3 at 11, 4 at 20).' },
{ level: 9, name: 'Indomitable', text: 'Reroll a failed save; 1/long rest (more at 13/17).' },
],
choices: [
{ id: 'fighting-style', label: 'Fighting Style', level: 1, pick: 1, options: FIGHTING_STYLES },
{ ...ASI_FEAT, hint: 'Fighter also gains bonus ASIs at 6 and 14.' },
],
},
Monk: {
subclass: { level: 3, label: 'Monastic Tradition' },
features: [
{ level: 1, name: 'Martial Arts & Unarmored Defense', text: 'DEX for unarmed/monk weapons, martial arts die; AC = 10 + DEX + WIS.' },
{ level: 2, name: 'Ki & Unarmored Movement', text: 'Ki points fuel Flurry of Blows, Patient Defense, Step of the Wind; +speed.' },
{ level: 3, name: 'Deflect Missiles', text: 'Reaction to reduce ranged weapon damage.' },
{ level: 4, name: 'Slow Fall', text: 'Reaction to reduce falling damage.' },
{ level: 5, name: 'Extra Attack & Stunning Strike', text: 'Two attacks; spend ki to stun on a hit (CON save).' },
{ level: 7, name: 'Evasion & Stillness of Mind', text: 'Take no damage on successful DEX saves; end charm/fear.' },
],
choices: [ASI_FEAT],
},
Paladin: {
subclass: { level: 3, label: 'Sacred Oath' },
features: [
{ level: 1, name: 'Divine Sense & Lay on Hands', text: 'Detect celestials/fiends/undead; heal pool = 5 × level.' },
{ level: 2, name: 'Divine Smite', text: 'Expend a spell slot on a melee hit for 2d8 radiant (+1d8/slot level, +1d8 vs undead/fiend).' },
{ level: 3, name: 'Divine Health & Channel Divinity', text: 'Immune to disease; oath Channel Divinity options.' },
{ level: 6, name: 'Aura of Protection', text: 'You and nearby allies add your CHA mod to saves.' },
{ level: 10, name: 'Aura of Courage', text: 'You and nearby allies can\'t be frightened.' },
{ level: 11, name: 'Improved Divine Smite', text: 'All melee hits deal +1d8 radiant.' },
],
choices: [
{ id: 'fighting-style', label: 'Fighting Style', level: 2, pick: 1, options: FIGHTING_STYLES },
ASI_FEAT,
],
},
Ranger: {
subclass: { level: 3, label: 'Ranger Conclave' },
features: [
{ level: 1, name: 'Favored Enemy & Natural Explorer', text: 'Bonus to track/recall a creature type; expertise in a favored terrain.' },
{ level: 2, name: 'Spellcasting', text: 'Learn ranger spells (half-caster).' },
{ level: 5, name: 'Extra Attack', text: 'Two attacks per Attack action.' },
{ level: 8, name: "Land's Stride", text: 'Move through nonmagical difficult terrain unhindered.' },
{ level: 10, name: 'Hide in Plain Sight', text: 'Camouflage for a big Stealth bonus.' },
],
choices: [
{ id: 'fighting-style', label: 'Fighting Style', level: 2, pick: 1, options: FIGHTING_STYLES },
{ id: 'favored-enemy', label: 'Favored Enemy', level: 1, countByLevel: { 1: 1, 6: 2, 14: 3 }, options: FAVORED_ENEMIES },
{ id: 'favored-terrain', label: 'Favored Terrain', level: 1, countByLevel: { 1: 1, 6: 2, 10: 3 }, options: FAVORED_TERRAIN },
ASI_FEAT,
],
},
Rogue: {
subclass: { level: 3, label: 'Roguish Archetype' },
features: [
{ level: 1, name: 'Sneak Attack & Thieves Cant', text: 'Extra damage (1d6, +1d6 every odd level) once per turn with advantage or a flanking ally; secret cant.' },
{ level: 2, name: 'Cunning Action', text: 'Bonus action to Dash, Disengage, or Hide.' },
{ level: 5, name: 'Uncanny Dodge', text: 'Reaction to halve one attacks damage.' },
{ level: 7, name: 'Evasion', text: 'No damage on successful DEX saves, half on failure.' },
{ level: 11, name: 'Reliable Talent', text: 'Treat d20 rolls of 9 or lower as 10 on proficient checks.' },
],
choices: [
{ id: 'expertise', label: 'Expertise', level: 1, countByLevel: { 1: 2, 6: 4 }, options: SKILL_NAMES, hint: 'Double proficiency (thieves tools may be one).' },
{ ...ASI_FEAT, hint: 'Rogue also gains a bonus ASI at 10.' },
],
},
Sorcerer: {
subclass: { level: 1, label: 'Sorcerous Origin' },
features: [
{ level: 1, name: 'Spellcasting & Sorcerous Origin', text: 'Innate spellcasting; your origin grants features.' },
{ level: 2, name: 'Font of Magic', text: 'Sorcery Points; convert to/from spell slots.' },
{ level: 20, name: 'Sorcerous Restoration', text: 'Regain 4 Sorcery Points on a short rest.' },
],
choices: [
{ id: 'metamagic', label: 'Metamagic', level: 3, countByLevel: { 3: 2, 10: 3, 17: 4 }, options: METAMAGIC },
ASI_FEAT,
],
},
Warlock: {
subclass: { level: 1, label: 'Otherworldly Patron' },
features: [
{ level: 1, name: 'Pact Magic', text: 'Short-rest spell slots, always at the highest level you can cast.' },
{ level: 11, name: 'Mystic Arcanum', text: 'Cast one 6th-level spell 1/long rest (7th at 13, 8th at 15, 9th at 17).' },
{ level: 20, name: 'Eldritch Master', text: 'Regain all Pact Magic slots on a 1-minute rest, 1/long rest.' },
],
choices: [
{ id: 'pact-boon', label: 'Pact Boon', level: 3, pick: 1, options: PACT_BOONS },
{ id: 'invocations', label: 'Eldritch Invocations', level: 2, countByLevel: { 2: 2, 5: 3, 7: 4, 9: 5, 12: 6, 15: 7, 18: 8 }, options: ELDRITCH_INVOCATIONS, hint: 'Pick from those whose prerequisites you meet.' },
ASI_FEAT,
],
},
Wizard: {
subclass: { level: 2, label: 'Arcane Tradition' },
features: [
{ level: 1, name: 'Spellcasting & Spellbook', text: 'Prepare wizard spells from your book; copy new spells into it.' },
{ level: 1, name: 'Arcane Recovery', text: 'Recover some spell slots on a short rest (1/day).' },
{ level: 18, name: 'Spell Mastery', text: 'Cast a chosen 1st- and 2nd-level spell at will.' },
{ level: 20, name: 'Signature Spells', text: 'Two 3rd-level spells always prepared, free 1/rest each.' },
],
choices: [ASI_FEAT],
},
};
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { collectChoices5e, collectFeatures5e, countAtLevel } from './collect';
const byKey = (cs: ReturnType<typeof collectChoices5e>, k: string) => cs.find((c) => c.key === k);
describe('countAtLevel', () => {
it('takes the highest threshold ≤ level', () => {
const c = { 2: 2, 5: 3, 7: 4 };
expect(countAtLevel(c, 1)).toBe(0);
expect(countAtLevel(c, 2)).toBe(2);
expect(countAtLevel(c, 6)).toBe(3);
expect(countAtLevel(c, 9)).toBe(4);
});
it('uses the fixed pick when there is no countByLevel', () => {
expect(countAtLevel(undefined, 5, 1)).toBe(1);
});
});
describe('collectChoices5e', () => {
it('surfaces a Warlocks Pact Boon and Invocations (the patron pact example)', () => {
const cs = collectChoices5e([{ className: 'Warlock', level: 5 }]);
const pact = byKey(cs, 'Warlock:pact-boon');
const inv = byKey(cs, 'Warlock:invocations');
expect(pact).toMatchObject({ label: 'Pact Boon', count: 1 });
expect(pact!.options).toContain('Pact of the Blade');
expect(inv).toMatchObject({ label: 'Eldritch Invocations', count: 3 }); // 3 known at level 5
});
it('Pact Boon is not offered before level 3', () => {
expect(byKey(collectChoices5e([{ className: 'Warlock', level: 2 }]), 'Warlock:pact-boon')).toBeUndefined();
expect(byKey(collectChoices5e([{ className: 'Warlock', level: 2 }]), 'Warlock:invocations')!.count).toBe(2);
});
it('Fighter fighting style, Rogue expertise, Sorcerer metamagic by level', () => {
expect(byKey(collectChoices5e([{ className: 'Fighter', level: 1 }]), 'Fighter:fighting-style')!.count).toBe(1);
expect(byKey(collectChoices5e([{ className: 'Rogue', level: 6 }]), 'Rogue:expertise')!.count).toBe(4);
expect(byKey(collectChoices5e([{ className: 'Sorcerer', level: 3 }]), 'Sorcerer:metamagic')!.count).toBe(2);
});
it('combines choices across a multiclass and excludes ASI/subclass', () => {
const cs = collectChoices5e([{ className: 'Fighter', level: 3 }, { className: 'Warlock', level: 3 }]);
expect(byKey(cs, 'Fighter:fighting-style')).toBeTruthy();
expect(byKey(cs, 'Warlock:pact-boon')).toBeTruthy();
expect(cs.some((c) => c.key.endsWith(':asi'))).toBe(false);
expect(cs.some((c) => c.key.endsWith(':subclass'))).toBe(false);
});
});
describe('collectFeatures5e', () => {
it('lists class features gained by level + the chosen subclass', () => {
const fs = collectFeatures5e([{ className: 'Warlock', level: 5, subclass: 'The Fiend' }]);
expect(fs.some((f) => f.name === 'Pact Magic')).toBe(true);
expect(fs.some((f) => f.name === 'The Fiend')).toBe(true); // subclass surfaced as a feature
expect(fs.some((f) => f.name === 'Mystic Arcanum')).toBe(false); // not until level 11
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* 5e class progression: the features a class gains and — critically — the CHOICES a
* player must make (Fighting Style, Pact Boon, Eldritch Invocations, Metamagic,
* Expertise, subclass, …). The builder/sheet surfaces every unlocked choice so none
* is silently missed. Feature *text* is descriptive; mechanical grants come via origin
* skills (already applied) and feats.
*/
export interface ClassFeatureDef {
level: number;
name: string;
text?: string;
}
export interface ClassChoiceDef {
/** stable id, unique within the class (combined with class name at runtime). */
id: string;
label: string;
/** first class level the choice unlocks. */
level: number;
/**
* Total number to choose by a given class level — for choices that grow with level
* (Invocations, Metamagic, Maneuvers, Expertise). Keys are class levels; the count is
* the highest entry ≤ the character's level in that class.
*/
countByLevel?: Record<number, number>;
/** fixed number to pick (non-growing). Default 1. */
pick?: number;
/** known options; omitted = free-text entry. */
options?: readonly string[];
/** short helper shown under the picker. */
hint?: string;
}
export interface ClassProgression {
/** when the subclass is chosen, and what the class calls it. */
subclass: { level: number; label: string };
features: ClassFeatureDef[];
choices: ClassChoiceDef[];
}
/** A choice unlocked for a specific character (resolved count + source). */
export interface UnlockedChoice {
key: string; // `${className}:${choiceId}` — unique per character
label: string;
source: string; // class name
count: number; // how many to pick at the character's current class level
options?: readonly string[];
hint?: string;
}
/** A feature a character has gained, for display. */
export interface UnlockedFeature {
name: string;
text?: string;
source: string; // e.g. "Fighter 2", "Champion 3"
level: number; // character level it was gained (class level)
}
+2
View File
@@ -22,3 +22,5 @@ export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './a
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
export { collectChoices5e, collectFeatures5e } from './features/collect';
export type { UnlockedChoice, UnlockedFeature } from './features/types';
+4 -1
View File
@@ -158,6 +158,8 @@ export const characterSchema = z.object({
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([]),
/** Resolved class choices (Fighting Style, Pact Boon, Invocations…), keyed by choice key. */
choices: z.array(z.object({ key: z.string(), values: z.array(z.string().max(120)).default([]) })).default([]),
/** background / heritage (promoted out of the notes string). */
background: z.string().max(80).default(''),
/** alignment, free text (e.g. "Chaotic Good", "Unaligned"). */
@@ -250,10 +252,11 @@ 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' | 'classes'
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices'
> {
return {
classes: [],
choices: [],
background: '',
alignment: '',
appearance: '',
File diff suppressed because one or more lines are too long