Deepen feature engine: full 5e subclass depth + PF2e parity

No-shortcuts pass on the class feature/choice engine, and PF2e brought level with 5e.

5e subclass depth (subclass.5e.ts):
- SUBCLASS_PROGRESSION_5E: per-level features for ~22 subclasses (SRD cores + popular),
  and subclass-specific CHOICES — Battle Master maneuvers (3/5/7/9 by level), Arcane Archer
  shots, Rune Knight runes, Way of Four Elements disciplines, Hunter's selectable features
  (Hunter's Prey/Defensive Tactics/Multiattack/Superior Defense), Totem Spirit, Dragon
  Ancestor. collectFeatures5e now expands the chosen subclass into its real features.

PF2e feature/choice engine (data.pf2e.ts) — full parity:
- CLASS_PROGRESSION_PF2E: signature features by level for all 24 classes.
- Universal choices generated for every class: Class feats (per-class levels incl. the
  1st-level martial feat), Skill feats (even levels), General feats (3/7/11/15/19),
  Ancestry feats (1/5/9/13/17), plus the 1st-level subclass (Instinct/Doctrine/Bloodline/
  Racket/…) from PF2E_SUBCLASSES with options.

Engine generalized: collectChoices(system)/collectFeatures(system) dispatch 5e vs pf2e;
ClassFeaturesSection now renders for BOTH systems (pf2e subclass resolves via choices[]).

Verified in-app: PF2e Barbarian 5 → Instinct (6 options) + Class(3)/Skill(2)/General(1)/
Ancestry(2) feats = '9 to choose' + Rage/Deny Advantage; selecting Dragon Instinct resolves
and shows as a feature. 5e Fighter+Battle Master → Maneuvers (choose 3) + Combat Superiority.
352 tests green (+ pf2e & subclass suites), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 02:11:26 +02:00
parent 82a2aef42f
commit 4ce7a90534
7 changed files with 634 additions and 47 deletions
@@ -1,19 +1,23 @@
import { collectChoices5e, collectFeatures5e } from '@/lib/rules';
import { collectChoices, collectFeatures } 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.
* Class features + every choice the character must make — 5e (Fighting Style, Pact Boon,
* Invocations, Metamagic, Expertise, subclass maneuvers…) and PF2e (class/skill/general/
* ancestry feats, instinct/doctrine/bloodline…) — so no unlock-able option is missed.
*/
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);
// 5e multiclasses via classes[]; pf2e is single-class with its subclass resolved in choices[].
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className
? [{ className: c.className, level: c.level, ...(c.system === 'pf2e' ? { subclass: c.choices.find((ch) => ch.key.endsWith(':subclass'))?.values[0] } : {}) }]
: [];
const features = collectFeatures(c.system, classes);
const choices = collectChoices(c.system, classes);
if (features.length === 0 && choices.length === 0) return null;
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
+88 -34
View File
@@ -1,13 +1,24 @@
import type { SystemId } from '../types';
import { CLASS_PROGRESSION_5E } from './data.5e';
import { SUBCLASS_PROGRESSION_5E } from './subclass.5e';
import {
CLASS_PROGRESSION_PF2E, PF2E_SUBCLASS_LABEL,
PF2E_SKILL_FEAT_LEVELS, PF2E_GENERAL_FEAT_LEVELS, PF2E_ANCESTRY_FEAT_LEVELS, pf2eClassFeatLevels,
} from './data.pf2e';
import { DND5E_SUBCLASSES } from '../dnd5e/progression';
import type { UnlockedChoice, UnlockedFeature } from './types';
import { PF2E_SUBCLASSES } from '../pf2e/progression';
import type { UnlockedChoice, UnlockedFeature, ClassChoiceDef, ClassFeatureDef } from './types';
interface ClassRef { className: string; level: number; subclass?: string | undefined }
function progFor(name: string) {
function progFor5e(name: string) {
const key = Object.keys(CLASS_PROGRESSION_5E).find((k) => k.toLowerCase() === name.trim().toLowerCase());
return key ? CLASS_PROGRESSION_5E[key] : undefined;
}
function canonicalPf2e(name: string) {
const want = name.trim().toLowerCase();
return Object.keys(CLASS_PROGRESSION_PF2E).find((k) => k.toLowerCase() === want) ?? name.trim();
}
/** 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 {
@@ -17,47 +28,90 @@ export function countAtLevel(countByLevel: Record<number, number> | undefined, l
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[] {
function addChoice(out: UnlockedChoice[], key: string, source: string, ch: ClassChoiceDef, level: number) {
if (level < ch.level) return;
const count = countAtLevel(ch.countByLevel, level, ch.pick ?? 1);
if (count <= 0) return;
out.push({ key, label: ch.label, source, count, ...(ch.options ? { options: ch.options } : {}), ...(ch.hint ? { hint: ch.hint } : {}) });
}
function addLevelChoice(out: UnlockedChoice[], cls: string, id: string, label: string, levels: number[], level: number, hint: string) {
const count = levels.filter((l) => l <= level).length;
if (count <= 0) return;
out.push({ key: `${cls}:${id}`, label, source: cls, count, hint });
}
const asFeature = (f: ClassFeatureDef, source: string): UnlockedFeature => ({ name: f.name, ...(f.text ? { text: f.text } : {}), source, level: f.level });
const byLevel = (a: UnlockedFeature, b: UnlockedFeature) => a.level - b.level || a.source.localeCompare(b.source);
// ---------------- 5e ----------------
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 } : {}),
});
const prog = progFor5e(e.className);
if (prog) for (const ch of prog.choices) if (ch.id !== 'asi') addChoice(out, `${e.className}:${ch.id}`, e.className, ch, e.level);
if (e.subclass) {
const sub = SUBCLASS_PROGRESSION_5E[e.subclass];
if (sub) for (const ch of sub.choices ?? []) addChoice(out, `${e.className}:${ch.id}`, e.subclass, ch, e.level);
}
}
return out;
}
/** Every class/subclass feature a 5e character has gained, for display, sorted by level. */
export function collectFeatures5e(classes: readonly ClassRef[]): UnlockedFeature[] {
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 });
}
const prog = progFor5e(e.className);
if (prog) for (const f of prog.features) if (e.level >= f.level) out.push(asFeature(f, e.className));
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 });
const sub = SUBCLASS_PROGRESSION_5E[e.subclass];
if (sub) {
for (const f of sub.features) if (e.level >= f.level) out.push(asFeature(f, `${e.className} · ${e.subclass}`));
} else {
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} · subclass`, level: prog?.subclass.level ?? 3 });
}
}
}
return out.sort((a, b) => a.level - b.level || a.source.localeCompare(b.source));
return out.sort(byLevel);
}
// ---------------- PF2e ----------------
function collectChoicesPf2e(classes: readonly ClassRef[]): UnlockedChoice[] {
const out: UnlockedChoice[] = [];
for (const e of classes) {
const name = canonicalPf2e(e.className);
const subs = PF2E_SUBCLASSES[name];
if (subs && e.level >= 1) {
out.push({ key: `${name}:subclass`, label: PF2E_SUBCLASS_LABEL[name] ?? 'Subclass', source: name, count: 1, options: subs.map((s) => s.name), hint: 'Your defining 1st-level class choice.' });
}
addLevelChoice(out, name, 'class-feat', 'Class Feat', pf2eClassFeatLevels(name), e.level, 'Record each class feat youve taken.');
addLevelChoice(out, name, 'skill-feat', 'Skill Feat', PF2E_SKILL_FEAT_LEVELS, e.level, 'A skill feat at every even level.');
addLevelChoice(out, name, 'general-feat', 'General Feat', PF2E_GENERAL_FEAT_LEVELS, e.level, 'General feats at levels 3/7/11/15/19.');
addLevelChoice(out, name, 'ancestry-feat', 'Ancestry Feat', PF2E_ANCESTRY_FEAT_LEVELS, e.level, 'Ancestry feats at levels 1/5/9/13/17.');
for (const ch of CLASS_PROGRESSION_PF2E[name]?.choices ?? []) addChoice(out, `${name}:${ch.id}`, name, ch, e.level);
}
return out;
}
function collectFeaturesPf2e(classes: readonly ClassRef[]): UnlockedFeature[] {
const out: UnlockedFeature[] = [];
for (const e of classes) {
const name = canonicalPf2e(e.className);
const prog = CLASS_PROGRESSION_PF2E[name];
if (prog) for (const f of prog.features) if (e.level >= f.level) out.push(asFeature(f, name));
if (e.subclass) {
const sc = (PF2E_SUBCLASSES[name] ?? []).find((s) => s.name === e.subclass);
out.push({ name: e.subclass, ...(sc?.desc ? { text: sc.desc } : {}), source: `${name} · ${PF2E_SUBCLASS_LABEL[name] ?? 'subclass'}`, level: 1 });
}
}
return out.sort(byLevel);
}
// ---------------- Dispatch ----------------
/** Every CHOICE a character must make at its current level(s), for the given system. */
export function collectChoices(system: SystemId, classes: readonly ClassRef[]): UnlockedChoice[] {
return system === 'pf2e' ? collectChoicesPf2e(classes) : collectChoices5e(classes);
}
/** Every class/subclass feature a character has gained, for display. */
export function collectFeatures(system: SystemId, classes: readonly ClassRef[]): UnlockedFeature[] {
return system === 'pf2e' ? collectFeaturesPf2e(classes) : collectFeatures5e(classes);
}
// Back-compat (5e-only) exports.
export { collectChoices5e, collectFeatures5e };
+266
View File
@@ -0,0 +1,266 @@
import type { ClassFeatureDef, ClassChoiceDef } from './types';
/**
* PF2e class progression. Unlike 5e, PF2e's *choices* are highly systematic — every
* character picks class / skill / general / ancestry feats at fixed levels, plus a
* 1st-level subclass (instinct / doctrine / bloodline / …). Those universal choices are
* generated in collect.ts; this file holds each class's signature FEATURES and any
* extra class-specific choices.
*/
export interface Pf2eClassProgression {
/** the 1st-level defining choice's label, if the class has one (Fighter/Monk don't). */
subclassLabel?: string;
features: ClassFeatureDef[];
/** class-specific choices beyond the universal feat slots. */
choices?: ClassChoiceDef[];
}
// ---- Universal PF2e choice schedules ----
export const PF2E_SKILL_FEAT_LEVELS = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
export const PF2E_GENERAL_FEAT_LEVELS = [3, 7, 11, 15, 19];
export const PF2E_ANCESTRY_FEAT_LEVELS = [1, 5, 9, 13, 17];
/** Classes that also grant a class feat at 1st level (CRB). All classes get them at even levels. */
const L1_CLASS_FEAT = new Set(['Barbarian', 'Fighter', 'Monk', 'Rogue']);
export function pf2eClassFeatLevels(className: string): number[] {
const even = [2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
return L1_CLASS_FEAT.has(className) ? [1, ...even] : even;
}
/** What each class calls its 1st-level subclass choice. */
export const PF2E_SUBCLASS_LABEL: Record<string, string> = {
Alchemist: 'Research Field', Animist: 'Apparition Practice', Barbarian: 'Instinct', Bard: 'Muse',
Champion: 'Cause', Cleric: 'Doctrine', Druid: 'Order', Exemplar: 'Ikon', Gunslinger: "Way",
Inventor: 'Innovation', Investigator: 'Methodology', Kineticist: 'Elemental Gate', Magus: 'Hybrid Study',
Oracle: 'Mystery', Psychic: 'Conscious Mind', Ranger: "Hunter's Edge", Rogue: 'Racket',
Sorcerer: 'Bloodline', Summoner: 'Eidolon', Swashbuckler: 'Style', Thaumaturge: 'Implement',
Witch: 'Patron', Wizard: 'Arcane Thesis',
};
const U: ClassFeatureDef[] = []; // (placeholder so each class lists its own features)
void U;
export const CLASS_PROGRESSION_PF2E: Record<string, Pf2eClassProgression> = {
Alchemist: {
subclassLabel: 'Research Field', features: [
{ level: 1, name: 'Alchemy & Advanced Alchemy', text: 'Free daily infused reagents; quick-craft alchemical items.' },
{ level: 1, name: 'Formula Book', text: 'Start with alchemical formulas you can craft.' },
{ level: 3, name: 'Field Discovery', text: 'A signature benefit from your research field.' },
{ level: 5, name: 'Field Benefit / Powerful Alchemy', text: 'Item DCs scale with your class DC.' },
{ level: 7, name: 'Iron Will / Perpetual Infusions', text: 'Craft certain items for free at will.' },
],
},
Animist: {
subclassLabel: 'Apparition Practice', features: [
{ level: 1, name: 'Apparitions & Spellcasting', text: 'Attune to spirits each day; cast divine spells from your apparition vessels.' },
{ level: 1, name: 'Apparition Attunement', text: 'Channel an apparition for a unique action.' },
{ level: 3, name: 'Third Apparition', text: 'Attune to an additional apparition.' },
],
},
Barbarian: {
subclassLabel: 'Instinct', features: [
{ level: 1, name: 'Rage', text: 'Enter a rage for bonus damage and temporary HP; lasts up to 1 minute.' },
{ level: 1, name: 'Instinct & Anathema', text: 'Your instinct shapes your rage and forbids certain acts.' },
{ level: 3, name: 'Deny Advantage', text: 'Lower-level foes cant make you Off-Guard by flanking.' },
{ level: 5, name: 'Brutality', text: 'Weapon specialization while raging; expert in weapons.' },
{ level: 7, name: 'Juggernaut', text: 'Master in Fortitude; success on Fort saves becomes a critical success.' },
{ level: 9, name: 'Lightning Reflexes', text: 'Expert in Reflex.' },
{ level: 11, name: 'Mighty Rage', text: 'Use a rage action as part of entering rage.' },
],
},
Bard: {
subclassLabel: 'Muse', features: [
{ level: 1, name: 'Occult Spellcasting & Composition', text: 'Cast occult spells; gain composition cantrips like Inspire Courage.' },
{ level: 1, name: 'Muse', text: 'Your muse grants a bonus feat and shapes your spells.' },
{ level: 3, name: 'Signature Spells', text: 'Cast a known spell at any rank you can manage.' },
{ level: 5, name: 'Lightning Reflexes', text: 'Expert in Reflex.' },
{ level: 7, name: 'Expert Spellcaster', text: 'Expert spell attack & DC.' },
{ level: 9, name: 'Great Fortitude / Resolve', text: 'Expert Fortitude; success on Will becomes a crit success.' },
],
},
Champion: {
subclassLabel: 'Cause', features: [
{ level: 1, name: 'Cause & Champions Reaction', text: 'Your holy/unholy cause grants a powerful reaction (e.g. Retributive Strike).' },
{ level: 1, name: "Deity & Devotion / Edicts & Anathema", text: 'Follow a deity; break your tenets and you lose your powers.' },
{ level: 3, name: 'Divine Ally / Healing Font', text: 'Bond a blade, steed, or shield; gain spell-like devotion abilities.' },
{ level: 5, name: 'Weapon Expertise / Ability Boosts', text: 'Expert with your weapons.' },
{ level: 7, name: 'Armor Expertise / Juggernaut', text: 'Expert in armor; master Fortitude.' },
{ level: 9, name: 'Champion Expertise / Divine Smite', text: 'Expert spell DC; your reaction deals extra damage.' },
],
choices: [{ id: 'deity', label: 'Deity', level: 1, pick: 1, hint: 'Your patron deity (sets your edicts/anathema).' }],
},
Cleric: {
subclassLabel: 'Doctrine', features: [
{ level: 1, name: 'Divine Spellcasting & Divine Font', text: 'Prepare divine spells; extra heal or harm spells each day.' },
{ level: 1, name: 'Deity & Doctrine', text: 'Your deity grants domains, spells, favored weapon; doctrine sets your progression.' },
{ level: 3, name: 'Second Doctrine', text: 'A milestone benefit from your doctrine (cloistered or warpriest).' },
{ level: 5, name: 'Ability Boosts / Alertness', text: 'Warpriest gains weapon training; cloistered sharpens spellcasting.' },
{ level: 7, name: 'Third Doctrine', text: 'Expert spellcasting (cloistered) or resolve (warpriest).' },
],
choices: [
{ id: 'deity', label: 'Deity', level: 1, pick: 1, hint: 'Sets your font, domains, and favored weapon.' },
{ id: 'domains', label: 'Domains', level: 1, countByLevel: { 1: 1 }, hint: 'Choose a domain from your deity (gains a focus spell).' },
],
},
Druid: {
subclassLabel: 'Order', features: [
{ level: 1, name: 'Primal Spellcasting & Order', text: 'Prepare primal spells; your order grants a focus spell and feat.' },
{ level: 1, name: 'Druidic Language / Anathema', text: 'Secret language; respect the wild or lose your magic.' },
{ level: 3, name: 'Order Spells / General Feat', text: 'A focus spell tied to your order.' },
{ level: 5, name: 'Wild Empathy / Ability Boosts', text: 'Speak crudely with animals.' },
{ level: 7, name: 'Expert Spellcaster', text: 'Expert spell attack & DC.' },
],
},
Exemplar: {
subclassLabel: 'Ikon', features: [
{ level: 1, name: 'Ikons & Spark Transcendence', text: 'Empower mythic ikons; shift your divine spark for surging effects.' },
{ level: 1, name: 'Immortal Spark', text: 'You are a living legend — special death/recovery rules.' },
{ level: 3, name: 'Second Ikon', text: 'Awaken another ikon.' },
],
},
Fighter: {
features: [
{ level: 1, name: 'Attack of Opportunity', text: 'React to Strike foes who move or act carelessly in reach.' },
{ level: 1, name: 'Fighter Weapon Mastery', text: 'Expert in your chosen weapon group — the best martial accuracy in the game.' },
{ level: 3, name: 'Bravery', text: 'Expert in Will; reduce frightened conditions.' },
{ level: 5, name: 'Fighter Weapon Mastery / Ability Boosts', text: 'Master with simple/martial weapons of your group.' },
{ level: 7, name: 'Battlefield Surveyor / Weapon Specialization', text: 'Expert Perception; extra weapon damage.' },
{ level: 9, name: 'Combat Flexibility', text: 'Swap in a fighter feat each day.' },
{ level: 11, name: 'Armor Expertise', text: 'Expert in armor; gain a defensive benefit.' },
],
},
Gunslinger: {
subclassLabel: 'Way', features: [
{ level: 1, name: 'Gunslingers Way', text: 'Your way grants a deed, a reload action, and a slingers reload trick.' },
{ level: 1, name: 'Singular Expertise', text: 'Bonus damage with firearms and crossbows.' },
{ level: 3, name: 'Stubborn / Vigilant Senses', text: 'Hard to keep off-balance.' },
{ level: 5, name: 'Gunslinger Weapon Mastery', text: 'Master with firearms/crossbows; fatal/critical specialization.' },
{ level: 7, name: 'Advanced Deed', text: 'A stronger version of your ways deed.' },
],
},
Inventor: {
subclassLabel: 'Innovation', features: [
{ level: 1, name: 'Innovation & Overdrive', text: 'Build a modified weapon, armor, or construct; rev up for bonus damage.' },
{ level: 1, name: 'Explode / Unstable', text: 'Push your innovation past safe limits for a powerful unstable action.' },
{ level: 3, name: 'Expert Overdrive / Offensive Boost', text: 'Stronger overdrive; an innovation upgrade.' },
],
},
Investigator: {
subclassLabel: 'Methodology', features: [
{ level: 1, name: 'On the Case & Devise a Stratagem', text: 'Pursue a lead; pre-roll a d20 to apply Int to a Strike.' },
{ level: 1, name: 'Methodology', text: 'Your investigative approach grants a signature skill feat/ability.' },
{ level: 3, name: 'Keen Recollection / Skillful Lessons', text: 'Add level to untrained Recall Knowledge.' },
{ level: 5, name: 'Weapon Expertise / Ability Boosts', text: 'Expert with simple weapons + your methodology.' },
],
},
Kineticist: {
subclassLabel: 'Elemental Gate', features: [
{ level: 1, name: 'Kinetic Gate & Aura', text: 'Open a gate to one or two elements; channel impulses through your kinetic aura.' },
{ level: 1, name: 'Elemental Blast', text: 'A scaling at-will elemental attack — no spell slots needed.' },
{ level: 3, name: 'Extract Element / Impulses', text: 'Strip a creature of its elemental defenses; learn new impulses.' },
{ level: 5, name: 'Gates Threshold', text: 'Expand your gate and impulse repertoire.' },
],
choices: [{ id: 'impulses', label: 'Impulse Feats', level: 1, countByLevel: { 1: 1, 3: 2, 5: 3, 7: 4, 9: 5, 11: 6, 13: 7, 15: 8, 17: 9, 19: 10 }, hint: 'Kineticists take impulse feats in their class-feat slots.' }],
},
Magus: {
subclassLabel: 'Hybrid Study', features: [
{ level: 1, name: 'Arcane Spellcasting & Spellstrike', text: 'Channel a spell into a weapon Strike; regain it by Recharging.' },
{ level: 1, name: 'Hybrid Study & Arcane Cascade', text: 'Your study shapes Spellstrike; enter a stance for bonus damage.' },
{ level: 3, name: 'Studious Spells / Lightning Reflexes', text: 'Bonus prepared utility spells.' },
{ level: 5, name: 'Weapon Expertise / Ability Boosts', text: 'Expert with simple/martial weapons.' },
],
},
Monk: {
features: [
{ level: 1, name: 'Powerful Fist & Flurry / Stances', text: 'Unarmed attacks deal 1d6 and dont give the unarmed penalty; martial monk feats.' },
{ level: 1, name: 'Monk Ki (optional)', text: 'Many monk feats grant ki/focus spells (Ki Strike, Ki Rush).' },
{ level: 3, name: 'Incredible Movement', text: '+speed while unarmored (grows with level).' },
{ level: 5, name: 'Expert Strikes / Ability Boosts', text: 'Expert with unarmed and monk weapons.' },
{ level: 7, name: 'Path to Perfection', text: 'Master one saving throw; crit-success on it.' },
{ level: 9, name: 'Metal Strikes / Monk Expertise', text: 'Unarmed strikes count as cold iron & silver; expert class DC.' },
],
},
Oracle: {
subclassLabel: 'Mystery', features: [
{ level: 1, name: 'Divine Spellcasting & Mystery', text: 'Cast divine spells; your mystery grants a curse, a focus spell, and bonus knowledge.' },
{ level: 1, name: 'Curse & Cursebound', text: 'Channel your mysterys curse for power at escalating cost.' },
{ level: 3, name: 'Advanced Revelation', text: 'A stronger revelation focus spell.' },
{ level: 5, name: 'Ability Boosts / Magical Fortitude', text: 'Expert Fortitude.' },
],
},
Psychic: {
subclassLabel: 'Conscious Mind', features: [
{ level: 1, name: 'Psychic Spellcasting & Psi', text: 'Cast occult spells; spend psi to Amp cantrips or Unleash your psyche.' },
{ level: 1, name: 'Conscious & Subconscious Mind', text: 'Your minds set your amps, key ability, and unleash effect.' },
{ level: 3, name: 'Psychic Resilience / Signature Spells', text: 'Resist mental damage; flexible casting.' },
],
},
Ranger: {
subclassLabel: "Hunter's Edge", features: [
{ level: 1, name: 'Hunt Prey & Hunters Edge', text: 'Mark a target for bonuses; your edge (flurry/precision/outwit) shapes the hunt.' },
{ level: 3, name: 'Iron Will / Trackless Step', text: 'Expert Will; leave no trail in the wild.' },
{ level: 5, name: 'Masterful Hunter (partial) / Ability Boosts', text: 'Edge benefits improve; weapon expertise.' },
{ level: 7, name: 'Vigilant Senses / Weapon Specialization', text: 'Expert Perception; extra weapon damage.' },
{ level: 9, name: 'Natures Edge / Swift Prey', text: 'Foes in natural terrain are off-guard; Hunt Prey as a free action.' },
],
},
Rogue: {
subclassLabel: 'Racket', features: [
{ level: 1, name: 'Sneak Attack', text: 'Extra precision damage (scales with level) vs off-guard foes.' },
{ level: 1, name: 'Rogues Racket', text: 'Your racket sets your key ability and a signature trick.' },
{ level: 1, name: 'Surprise Attack', text: 'Foes you beat in initiative are off-guard on the first round.' },
{ level: 3, name: 'Deny Advantage / Skill Feat', text: 'Lower-level foes cant make you off-guard.' },
{ level: 5, name: 'Weapon Tricks / Ability Boosts', text: 'Crit specialization with finesse/agile weapons vs off-guard.' },
{ level: 7, name: 'Evasion / Vigilant Senses', text: 'Expert Reflex; expert Perception.' },
],
},
Sorcerer: {
subclassLabel: 'Bloodline', features: [
{ level: 1, name: 'Spellcasting & Bloodline', text: 'Your bloodline sets your tradition, bonus spells, and a focus spell.' },
{ level: 1, name: 'Blood Magic', text: 'Casting bloodline/focus spells triggers a minor bonus effect.' },
{ level: 3, name: 'Signature Spells', text: 'Heighten a known spell to any rank you can cast.' },
{ level: 5, name: 'Magical Fortitude / Ability Boosts', text: 'Expert Fortitude.' },
{ level: 7, name: 'Expert Spellcaster', text: 'Expert spell attack & DC.' },
],
},
Summoner: {
subclassLabel: 'Eidolon', features: [
{ level: 1, name: 'Eidolon & Act Together', text: 'Summon a bonded otherworldly ally; act in tandem with it.' },
{ level: 1, name: 'Link Spells / Spellcasting', text: 'Share a small pool of spells through your eidolon.' },
{ level: 3, name: 'Evolution Feat / Lightning Reflexes', text: 'Upgrade your eidolon.' },
],
},
Swashbuckler: {
subclassLabel: 'Style', features: [
{ level: 1, name: 'Panache & Finishers', text: 'Gain panache via your style for speed/skill bonuses; spend it on big Finisher attacks.' },
{ level: 1, name: 'Confident Finisher', text: 'A basic finisher that deals precision damage even on a miss.' },
{ level: 3, name: 'Opportune Riposte / Skill Feat', text: 'React to crits against you with a Strike or Disarm.' },
{ level: 5, name: 'Weapon Expertise / Ability Boosts', text: 'Expert with simple/martial finesse weapons.' },
],
},
Thaumaturge: {
subclassLabel: 'Implement', features: [
{ level: 1, name: 'Implements & Exploit Vulnerability', text: 'Wield esoteric implements; find and exploit a foes weakness.' },
{ level: 1, name: "Thaumaturge's Investiture", text: 'Use everyday occult know-how (Recall Knowledge on anything).' },
{ level: 3, name: 'Second Implement / Implement Adept', text: 'Wield another implement and deepen its power.' },
],
choices: [{ id: 'implements', label: 'Implements', level: 1, countByLevel: { 1: 1, 3: 2, 5: 3, 9: 4 }, options: ['Amulet', 'Bell', 'Chalice', 'Lantern', 'Mirror', 'Regalia', 'Tome', 'Wand', 'Weapon'] }],
},
Witch: {
subclassLabel: 'Patron', features: [
{ level: 1, name: 'Patron & Familiar', text: 'A patron grants a familiar that delivers your hexes and stores your spells.' },
{ level: 1, name: 'Hexes & Spellcasting', text: 'Cast a tradition set by your patron; familiar teaches hex cantrips.' },
{ level: 3, name: 'Lesson / Basic Lesson', text: 'Learn a patron lesson (a hex + spell).' },
{ level: 5, name: 'Magical Fortitude / Ability Boosts', text: 'Expert Fortitude.' },
],
},
Wizard: {
subclassLabel: 'Arcane Thesis', features: [
{ level: 1, name: 'Arcane Spellcasting & Spellbook', text: 'Prepare arcane spells from your book; a curriculum/school shapes your magic.' },
{ level: 1, name: 'Arcane Thesis', text: 'A signature method (spell substitution, blending, familiar, …).' },
{ level: 1, name: 'Arcane School / Bond', text: 'A school grants a focus spell and extra slot; drain your bonded item to recast.' },
{ level: 3, name: 'Signature Spells / General Feat', text: 'Heighten a known spell to any rank you can cast.' },
{ level: 5, name: 'Lightning Reflexes / Ability Boosts', text: 'Expert Reflex.' },
],
},
};
+48 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { collectChoices5e, collectFeatures5e, countAtLevel } from './collect';
import { collectChoices5e, collectFeatures5e, collectChoices, collectFeatures, countAtLevel } from './collect';
const byKey = (cs: ReturnType<typeof collectChoices5e>, k: string) => cs.find((c) => c.key === k);
@@ -44,10 +44,55 @@ describe('collectChoices5e', () => {
});
describe('collectFeatures5e', () => {
it('lists class features gained by level + the chosen subclass', () => {
it('lists class features gained by level + the chosen subclass (expanded by level)', () => {
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
// The Fiend now expands into its per-level features, sourced to the subclass.
expect(fs.some((f) => f.name === "Dark One's Blessing" && /The Fiend/.test(f.source))).toBe(true);
expect(fs.some((f) => f.name === 'Mystic Arcanum')).toBe(false); // not until level 11
expect(fs.some((f) => f.name === 'Hurl Through Hell')).toBe(false); // Fiend L14, not at 5
});
});
describe('5e subclass depth', () => {
it('Battle Master surfaces Maneuvers (3 known at level 3) + Combat Superiority feature', () => {
const cls = [{ className: 'Fighter', level: 3, subclass: 'Battle Master' }];
expect(byKey(collectChoices('5e', cls), 'Fighter:maneuvers')!.count).toBe(3);
expect(collectFeatures('5e', cls).some((f) => f.name === 'Combat Superiority')).toBe(true);
});
it('Champion has its own features and no maneuvers', () => {
const cls = [{ className: 'Fighter', level: 7, subclass: 'Champion' }];
expect(byKey(collectChoices('5e', cls), 'Fighter:maneuvers')).toBeUndefined();
expect(collectFeatures('5e', cls).some((f) => f.name === 'Remarkable Athlete')).toBe(true);
});
it("Hunter's selectable features appear at their levels", () => {
const cls = [{ className: 'Ranger', level: 11, subclass: 'Hunter' }];
const ch = collectChoices('5e', cls);
expect(byKey(ch, 'Ranger:hunters-prey')).toBeTruthy();
expect(byKey(ch, 'Ranger:multiattack')).toBeTruthy();
});
});
describe('PF2e feature/choice engine (parity)', () => {
it('a Fighter gets class/skill/general/ancestry feat slots by level', () => {
const ch = collectChoices('pf2e', [{ className: 'Fighter', level: 6 }]);
expect(byKey(ch, 'Fighter:class-feat')!.count).toBe(4); // 1,2,4,6
expect(byKey(ch, 'Fighter:skill-feat')!.count).toBe(3); // 2,4,6
expect(byKey(ch, 'Fighter:general-feat')!.count).toBe(1); // 3
expect(byKey(ch, 'Fighter:ancestry-feat')!.count).toBe(2); // 1,5
expect(byKey(ch, 'Fighter:subclass')).toBeUndefined(); // Fighter has no 1st-level subclass
});
it('surfaces the 1st-level subclass (Instinct/Bloodline) with options', () => {
const barb = collectChoices('pf2e', [{ className: 'Barbarian', level: 1 }]);
expect(byKey(barb, 'Barbarian:subclass')).toMatchObject({ label: 'Instinct' });
expect(byKey(barb, 'Barbarian:subclass')!.options).toContain('Dragon Instinct');
const sorc = collectChoices('pf2e', [{ className: 'Sorcerer', level: 1 }]);
expect(byKey(sorc, 'Sorcerer:subclass')!.label).toBe('Bloodline');
expect(byKey(sorc, 'Sorcerer:class-feat')).toBeUndefined(); // caster: first class feat at level 2
});
it('lists signature class features + the chosen subclass', () => {
const fs = collectFeatures('pf2e', [{ className: 'Barbarian', level: 5, subclass: 'Dragon Instinct' }]);
expect(fs.some((f) => f.name === 'Rage')).toBe(true);
expect(fs.some((f) => f.name === 'Dragon Instinct')).toBe(true);
});
});
+218
View File
@@ -0,0 +1,218 @@
import type { ClassFeatureDef, ClassChoiceDef } from './types';
import { BATTLEMASTER_MANEUVERS } from './data.5e';
/**
* 5e subclass progression — per-level features and any subclass-specific CHOICES
* (Battle Master maneuvers, Arcane Archer shots, Hunter's selectable features, …).
* Keyed by the subclass *name* (matching classes.json / DND5E_SUBCLASSES), so it works
* whether the subclass is SRD or from the curated list. Subclasses not listed here fall
* back to their one-line description.
*/
export interface SubclassProgression {
className: string;
features: ClassFeatureDef[];
choices?: ClassChoiceDef[];
}
const ARCANE_SHOTS = [
'Banishing Arrow', 'Beguiling Arrow', 'Bursting Arrow', 'Enfeebling Arrow', 'Grasping Arrow',
'Piercing Arrow', 'Seeking Arrow', 'Shadow Arrow',
] as const;
const ELEMENTAL_DISCIPLINES = [
'Breath of Winter', 'Clench of the North Wind', 'Eternal Mountain Defense', 'Fangs of the Fire Snake',
'Fist of Four Thunders', 'Fist of Unbroken Air', 'Flames of the Phoenix', 'Gong of the Summit',
'Mist Stance', 'Ride the Wind', 'Rush of the Gale Spirits', 'Shape the Flowing River',
'Sweeping Cinder Strike', 'Water Whip', 'Wave of Rolling Earth',
] as const;
const RUNES = ['Cloud Rune', 'Fire Rune', 'Frost Rune', 'Stone Rune', 'Hill Rune', 'Storm Rune'] as const;
export const SUBCLASS_PROGRESSION_5E: Record<string, SubclassProgression> = {
// ---- Fighter ----
Champion: {
className: 'Fighter', features: [
{ level: 3, name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 1920.' },
{ level: 7, name: 'Remarkable Athlete', text: 'Add half proficiency to Str/Dex/Con checks; running long jump +Str-mod feet.' },
{ level: 10, name: 'Additional Fighting Style', text: 'Choose a second Fighting Style.' },
{ level: 15, name: 'Superior Critical', text: 'Critical hit on 1820.' },
{ level: 18, name: 'Survivor', text: 'Regain HP each turn if below half (5 + CON mod).' },
],
choices: [{ id: 'champion-style', label: 'Additional Fighting Style', level: 10, pick: 1, options: ['Archery', 'Defense', 'Dueling', 'Great Weapon Fighting', 'Protection', 'Two-Weapon Fighting'] }],
},
'Battle Master': {
className: 'Fighter', features: [
{ level: 3, name: 'Combat Superiority', text: 'Superiority dice (d8) fuel maneuvers; regain on a short/long rest. Save DC = 8 + prof + Str/Dex.' },
{ level: 3, name: 'Student of War', text: 'Proficiency with one artisans tool.' },
{ level: 7, name: 'Know Your Enemy', text: 'Study a creature for 1 min to learn how it compares to you.' },
{ level: 10, name: 'Improved Combat Superiority (d10)', text: 'Superiority dice become d10 (d12 at 18).' },
{ level: 15, name: 'Relentless', text: 'Regain one superiority die when you roll initiative with none left.' },
],
choices: [{ id: 'maneuvers', label: 'Maneuvers', level: 3, countByLevel: { 3: 3, 7: 5, 10: 7, 15: 9 }, options: BATTLEMASTER_MANEUVERS, hint: 'Known maneuvers; superiority dice: 4 (3), 5 (7), 6 (15).' }],
},
'Eldritch Knight': {
className: 'Fighter', features: [
{ level: 3, name: 'Spellcasting (Wizard, Int)', text: 'Learn wizard cantrips & spells (mostly abjuration/evocation). Add spells in the Spells section.' },
{ level: 3, name: 'Weapon Bond', text: 'Bond up to two weapons; summon them to your hand.' },
{ level: 7, name: 'War Magic', text: 'After casting a cantrip, make one weapon attack as a bonus action.' },
{ level: 10, name: 'Eldritch Strike', text: 'A weapon hit gives the target disadvantage on your next spell save.' },
{ level: 15, name: 'Arcane Charge', text: 'Action Surge lets you teleport 30 ft.' },
],
},
'Arcane Archer': {
className: 'Fighter', features: [
{ level: 3, name: 'Arcane Shot', text: 'Imbue arrows with magic; 2 uses per short rest. Save DC = 8 + prof + Int/Wis.' },
{ level: 7, name: 'Curving Shot', text: 'Reroll a missed magic-arrow attack at another target.' },
{ level: 15, name: 'Ever-Ready Shot', text: 'Regain an Arcane Shot use when you roll initiative with none left.' },
],
choices: [{ id: 'arcane-shots', label: 'Arcane Shot Options', level: 3, countByLevel: { 3: 2, 7: 3, 10: 4, 15: 5, 18: 6 }, options: ARCANE_SHOTS }],
},
'Rune Knight': {
className: 'Fighter', features: [
{ level: 3, name: 'Rune Carver & Giants Might', text: 'Inscribe magic runes on gear; become Large for bonus damage (1/rest).' },
{ level: 7, name: 'Runic Shield', text: 'Reaction to force a foe to reroll an attack against an ally.' },
{ level: 10, name: 'Great Stature', text: 'Grow taller; Giants Might bonus damage becomes 1d8.' },
],
choices: [{ id: 'runes', label: 'Runes', level: 3, countByLevel: { 3: 2, 7: 3, 10: 4, 15: 5 }, options: RUNES }],
},
// ---- Barbarian ----
'Path of the Berserker': {
className: 'Barbarian', features: [
{ level: 3, name: 'Frenzy', text: 'Rage with a melee bonus-action attack each turn; exhaustion when it ends.' },
{ level: 6, name: 'Mindless Rage', text: 'Cant be charmed or frightened while raging.' },
{ level: 10, name: 'Intimidating Presence', text: 'Frighten a creature as an action (Wis save).' },
{ level: 14, name: 'Retaliation', text: 'Reaction melee attack against a creature that damages you.' },
],
},
'Path of the Totem Warrior': {
className: 'Barbarian', features: [
{ level: 3, name: 'Spirit Seeker & Totem Spirit', text: 'Bear (resistance to all but psychic), Eagle (mobility), or Wolf (allies get advantage).' },
{ level: 6, name: 'Aspect of the Beast', text: 'A persistent totem benefit out of combat.' },
{ level: 14, name: 'Totemic Attunement', text: 'A powerful in-combat totem benefit.' },
],
choices: [{ id: 'totem', label: 'Totem Spirit', level: 3, pick: 1, options: ['Bear', 'Eagle', 'Wolf', 'Elk', 'Tiger'] }],
},
// ---- Cleric ----
'Life Domain': {
className: 'Cleric', features: [
{ level: 1, name: 'Bonus Proficiency & Disciple of Life', text: 'Heavy armor; healing spells restore extra HP (2 + spell level).' },
{ level: 2, name: 'Channel Divinity: Preserve Life', text: 'Heal total HP = 5 × cleric level, split among creatures.' },
{ level: 6, name: 'Blessed Healer', text: 'Healing others also heals you.' },
{ level: 8, name: 'Divine Strike', text: 'Once/turn, a weapon hit deals +1d8 radiant (2d8 at 14).' },
{ level: 17, name: 'Supreme Healing', text: 'Healing dice are maximized.' },
],
},
// ---- Druid ----
'Circle of the Moon': {
className: 'Druid', features: [
{ level: 2, name: 'Combat Wild Shape & Circle Forms', text: 'Wild Shape as a bonus action; shift into CR-1 beasts; spend slots to heal in form.' },
{ level: 6, name: 'Primal Strike', text: 'Beast-form attacks count as magical.' },
{ level: 10, name: 'Elemental Wild Shape', text: 'Become an elemental for 2 Wild Shape uses.' },
],
},
// ---- Bard ----
'College of Lore': {
className: 'Bard', features: [
{ level: 3, name: 'Bonus Proficiencies & Cutting Words', text: 'Three skills; reaction to subtract a Bardic Inspiration die from a foes roll.' },
{ level: 6, name: 'Additional Magical Secrets', text: 'Learn two spells from any class.' },
{ level: 14, name: 'Peerless Skill', text: 'Add a Bardic Inspiration die to your own ability check.' },
],
},
// ---- Monk ----
'Way of the Open Hand': {
className: 'Monk', features: [
{ level: 3, name: 'Open Hand Technique', text: 'Flurry hits can knock prone, push 15 ft, or deny reactions.' },
{ level: 6, name: 'Wholeness of Body', text: 'Heal yourself 3 × monk level once per long rest.' },
{ level: 11, name: 'Tranquility', text: 'Begin each day under a sanctuary effect.' },
{ level: 17, name: 'Quivering Palm', text: 'Set up lethal vibrations you can trigger (CON save).' },
],
},
'Way of the Four Elements': {
className: 'Monk', features: [
{ level: 3, name: 'Disciple of the Elements', text: 'Spend ki to cast elemental disciplines. Save DC = 8 + prof + Wis.' },
],
choices: [{ id: 'disciplines', label: 'Elemental Disciplines', level: 3, countByLevel: { 3: 2, 6: 3, 11: 4, 17: 5 }, options: ELEMENTAL_DISCIPLINES }],
},
// ---- Paladin ----
'Oath of Devotion': {
className: 'Paladin', features: [
{ level: 3, name: 'Channel Divinity: Sacred Weapon & Turn the Unholy', text: 'Add CHA to weapon attacks and make it magical; turn fiends/undead.' },
{ level: 7, name: 'Aura of Devotion', text: 'You and nearby allies cant be charmed.' },
{ level: 15, name: 'Purity of Spirit', text: 'Always under a Protection from Evil and Good effect.' },
{ level: 20, name: 'Holy Nimbus', text: 'Emanate sunlight that damages fiends/undead; advantage on saves vs their spells.' },
],
},
// ---- Ranger ----
Hunter: {
className: 'Ranger', features: [
{ level: 3, name: "Hunter's Prey", text: 'A signature offense vs your chosen prey.' },
{ level: 7, name: 'Defensive Tactics', text: 'A survival edge against being swarmed or hindered.' },
{ level: 11, name: 'Multiattack', text: 'Hit multiple foes at once.' },
{ level: 15, name: "Superior Hunter's Defense", text: 'A potent defensive reaction.' },
],
choices: [
{ id: 'hunters-prey', label: "Hunter's Prey", level: 3, pick: 1, options: ["Colossus Slayer", 'Giant Killer', 'Horde Breaker'] },
{ id: 'defensive-tactics', label: 'Defensive Tactics', level: 7, pick: 1, options: ['Escape the Horde', 'Multiattack Defense', 'Steel Will'] },
{ id: 'multiattack', label: 'Multiattack', level: 11, pick: 1, options: ['Volley', 'Whirlwind Attack'] },
{ id: 'superior-defense', label: "Superior Hunter's Defense", level: 15, pick: 1, options: ['Evasion', 'Stand Against the Tide', 'Uncanny Dodge'] },
],
},
// ---- Rogue ----
Thief: {
className: 'Rogue', features: [
{ level: 3, name: 'Fast Hands & Second-Story Work', text: 'Use Cunning Action to Use an Object/Sleight of Hand/Disarm; climb at full speed.' },
{ level: 9, name: 'Supreme Sneak', text: 'Advantage on Stealth if you move ≤ half speed.' },
{ level: 13, name: 'Use Magic Device', text: 'Ignore class/race/level requirements on magic items.' },
{ level: 17, name: 'Thiefs Reflexes', text: 'Two turns in the first round of combat.' },
],
},
Assassin: {
className: 'Rogue', features: [
{ level: 3, name: 'Assassinate & Bonus Proficiencies', text: 'Advantage vs foes who havent acted; hits vs surprised foes auto-crit. Disguise/poisoner tools.' },
{ level: 9, name: 'Infiltration Expertise', text: 'Establish a false identity.' },
{ level: 13, name: 'Impostor', text: 'Mimic a persons speech, writing, and behavior.' },
{ level: 17, name: 'Death Strike', text: 'Double damage vs a surprised target that fails a CON save.' },
],
},
'Arcane Trickster': {
className: 'Rogue', features: [
{ level: 3, name: 'Spellcasting (Wizard, Int) & Mage Hand Legerdemain', text: 'Learn wizard spells (mostly enchantment/illusion); an invisible, sneaky Mage Hand.' },
{ level: 9, name: 'Magical Ambush', text: 'Foes have disadvantage to save vs your spells if youre hidden from them.' },
{ level: 13, name: 'Versatile Trickster', text: 'Use Mage Hand to make a foe off-guard for Sneak Attack.' },
],
},
// ---- Sorcerer ----
'Draconic Bloodline': {
className: 'Sorcerer', features: [
{ level: 1, name: 'Dragon Ancestor & Draconic Resilience', text: 'Pick a dragon; +1 HP/level and a natural-armor AC of 13 + DEX.' },
{ level: 6, name: 'Elemental Affinity', text: 'Add CHA to one damage roll of your ancestrys element; spend a point for resistance.' },
{ level: 14, name: 'Dragon Wings', text: 'Sprout wings for a flying speed.' },
{ level: 18, name: 'Draconic Presence', text: 'Aura of awe or fear (CHA save).' },
],
choices: [{ id: 'dragon', label: 'Dragon Ancestor', level: 1, pick: 1, options: ['Black (acid)', 'Blue (lightning)', 'Brass (fire)', 'Bronze (lightning)', 'Copper (acid)', 'Gold (fire)', 'Green (poison)', 'Red (fire)', 'Silver (cold)', 'White (cold)'] }],
},
// ---- Warlock ----
'The Fiend': {
className: 'Warlock', features: [
{ level: 1, name: "Dark One's Blessing", text: 'Gain temp HP (CHA mod + warlock level) when you drop a foe.' },
{ level: 6, name: "Dark One's Own Luck", text: 'Add a d10 to an ability check or save (1/rest).' },
{ level: 10, name: 'Fiendish Resilience', text: 'Choose a damage type to resist after a rest.' },
{ level: 14, name: 'Hurl Through Hell', text: 'Banish a hit creature through the lower planes for 10d10 psychic.' },
],
},
'The Hexblade': {
className: 'Warlock', features: [
{ level: 1, name: "Hexblade's Curse & Hex Warrior", text: 'Curse a foe for bonus damage/crits; use CHA for one weapon and gain martial/medium-armor proficiency.' },
{ level: 6, name: 'Accursed Specter', text: 'Raise a slain humanoid as a one-time specter ally.' },
{ level: 10, name: 'Armor of Hexes', text: 'Cursed targets may miss you on a roll.' },
{ level: 14, name: 'Master of Hexes', text: 'Spread your curse to a new foe when the cursed one dies.' },
],
},
// ---- Wizard ----
'School of Evocation': {
className: 'Wizard', features: [
{ level: 2, name: 'Evocation Savant & Sculpt Spells', text: 'Copy evocation spells cheaply; shield allies from your area spells.' },
{ level: 6, name: 'Potent Cantrip', text: 'Targets take half damage from your cantrips even on a save.' },
{ level: 10, name: 'Empowered Evocation', text: 'Add INT to one damage roll of an evocation spell.' },
{ level: 14, name: 'Overchannel', text: 'Maximize a spells damage (with escalating self-damage).' },
],
},
};
+1 -1
View File
@@ -22,5 +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 { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e } from './features/collect';
export type { UnlockedChoice, UnlockedFeature } from './features/types';