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:
@@ -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 = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 attack’s 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],
|
||||
},
|
||||
};
|
||||
@@ -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 Warlock’s 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
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
Reference in New Issue
Block a user