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:
@@ -1,19 +1,23 @@
|
|||||||
import { collectChoices5e, collectFeatures5e } from '@/lib/rules';
|
import { collectChoices, collectFeatures } from '@/lib/rules';
|
||||||
import type { ClassEntry } from '@/lib/schemas';
|
import type { ClassEntry } from '@/lib/schemas';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { Badge } from '@/components/ui/Codex';
|
import { Badge } from '@/components/ui/Codex';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class features + every choice the character must make (Fighting Style, Pact Boon,
|
* Class features + every choice the character must make — 5e (Fighting Style, Pact Boon,
|
||||||
* Eldritch Invocations, Metamagic, Expertise, …) so no unlock-able option is missed.
|
* Invocations, Metamagic, Expertise, subclass maneuvers…) and PF2e (class/skill/general/
|
||||||
* 5e only — driven by CLASS_PROGRESSION_5E.
|
* ancestry feats, instinct/doctrine/bloodline…) — so no unlock-able option is missed.
|
||||||
*/
|
*/
|
||||||
export function ClassFeaturesSection({ c, update }: SectionProps) {
|
export function ClassFeaturesSection({ c, update }: SectionProps) {
|
||||||
if (c.system !== '5e') return null;
|
// 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 }] : [];
|
const classes: ClassEntry[] = c.classes.length
|
||||||
const features = collectFeatures5e(classes);
|
? c.classes
|
||||||
const choices = collectChoices5e(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;
|
if (features.length === 0 && choices.length === 0) return null;
|
||||||
|
|
||||||
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
|
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
|
import type { SystemId } from '../types';
|
||||||
import { CLASS_PROGRESSION_5E } from './data.5e';
|
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 { 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 }
|
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());
|
const key = Object.keys(CLASS_PROGRESSION_5E).find((k) => k.toLowerCase() === name.trim().toLowerCase());
|
||||||
return key ? CLASS_PROGRESSION_5E[key] : undefined;
|
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`. */
|
/** 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 {
|
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;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function addChoice(out: UnlockedChoice[], key: string, source: string, ch: ClassChoiceDef, level: number) {
|
||||||
* Every CHOICE a 5e character must make at its current level(s), across all classes —
|
if (level < ch.level) return;
|
||||||
* Fighting Style, Pact Boon, Eldritch Invocations, Metamagic, Expertise, Favored Enemy,
|
const count = countAtLevel(ch.countByLevel, level, ch.pick ?? 1);
|
||||||
* etc. Subclass selection is handled by the classes editor and ASI/feat by the builder /
|
if (count <= 0) return;
|
||||||
* level-up, so both are excluded here.
|
out.push({ key, label: ch.label, source, count, ...(ch.options ? { options: ch.options } : {}), ...(ch.hint ? { hint: ch.hint } : {}) });
|
||||||
*/
|
}
|
||||||
export function collectChoices5e(classes: readonly ClassRef[]): UnlockedChoice[] {
|
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[] = [];
|
const out: UnlockedChoice[] = [];
|
||||||
for (const e of classes) {
|
for (const e of classes) {
|
||||||
const prog = progFor(e.className);
|
const prog = progFor5e(e.className);
|
||||||
if (!prog) continue;
|
if (prog) for (const ch of prog.choices) if (ch.id !== 'asi') addChoice(out, `${e.className}:${ch.id}`, e.className, ch, e.level);
|
||||||
for (const ch of prog.choices) {
|
if (e.subclass) {
|
||||||
if (ch.id === 'asi' || e.level < ch.level) continue;
|
const sub = SUBCLASS_PROGRESSION_5E[e.subclass];
|
||||||
const count = countAtLevel(ch.countByLevel, e.level, ch.pick ?? 1);
|
if (sub) for (const ch of sub.choices ?? []) addChoice(out, `${e.className}:${ch.id}`, e.subclass, ch, e.level);
|
||||||
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
function collectFeatures5e(classes: readonly ClassRef[]): UnlockedFeature[] {
|
||||||
/** Every class/subclass feature a 5e character has gained, for display, sorted by level. */
|
|
||||||
export function collectFeatures5e(classes: readonly ClassRef[]): UnlockedFeature[] {
|
|
||||||
const out: UnlockedFeature[] = [];
|
const out: UnlockedFeature[] = [];
|
||||||
for (const e of classes) {
|
for (const e of classes) {
|
||||||
const prog = progFor(e.className);
|
const prog = progFor5e(e.className);
|
||||||
if (!prog) continue;
|
if (prog) for (const f of prog.features) if (e.level >= f.level) out.push(asFeature(f, e.className));
|
||||||
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) {
|
if (e.subclass) {
|
||||||
|
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);
|
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 });
|
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 you’ve 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 };
|
||||||
|
|||||||
@@ -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 can’t 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 & Champion’s 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: 'Gunslinger’s Way', text: 'Your way grants a deed, a reload action, and a slinger’s 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 way’s 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: 'Gate’s 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 don’t 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 mystery’s 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 & Hunter’s 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: 'Nature’s 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: 'Rogue’s 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 can’t 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 foe’s 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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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);
|
const byKey = (cs: ReturnType<typeof collectChoices5e>, k: string) => cs.find((c) => c.key === k);
|
||||||
|
|
||||||
@@ -44,10 +44,55 @@ describe('collectChoices5e', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('collectFeatures5e', () => {
|
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' }]);
|
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 === '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 === '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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 19–20.' },
|
||||||
|
{ 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 18–20.' },
|
||||||
|
{ 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 artisan’s 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 & Giant’s 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; Giant’s 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: 'Can’t 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 foe’s 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 can’t 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: 'Thief’s 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 haven’t 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 person’s 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 you’re 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 ancestry’s 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 spell’s damage (with escalating self-damage).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -22,5 +22,5 @@ export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './a
|
|||||||
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
|
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
|
||||||
export { applyRest } from './rest';
|
export { applyRest } from './rest';
|
||||||
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
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';
|
export type { UnlockedChoice, UnlockedFeature } from './features/types';
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user