From 4ce7a905342b5449face36109ca55904374552ad Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 02:11:26 +0200 Subject: [PATCH] Deepen feature engine: full 5e subclass depth + PF2e parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../characters/sheet/ClassFeaturesSection.tsx | 20 +- src/lib/rules/features/collect.ts | 122 +++++--- src/lib/rules/features/data.pf2e.ts | 266 ++++++++++++++++++ src/lib/rules/features/features.test.ts | 51 +++- src/lib/rules/features/subclass.5e.ts | 218 ++++++++++++++ src/lib/rules/index.ts | 2 +- tsconfig.app.tsbuildinfo | 2 +- 7 files changed, 634 insertions(+), 47 deletions(-) create mode 100644 src/lib/rules/features/data.pf2e.ts create mode 100644 src/lib/rules/features/subclass.5e.ts diff --git a/src/features/characters/sheet/ClassFeaturesSection.tsx b/src/features/characters/sheet/ClassFeaturesSection.tsx index a7e4b2c..88891e6 100644 --- a/src/features/characters/sheet/ClassFeaturesSection.tsx +++ b/src/features/characters/sheet/ClassFeaturesSection.tsx @@ -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 ?? []; diff --git a/src/lib/rules/features/collect.ts b/src/lib/rules/features/collect.ts index 525f7ce..f1268bb 100644 --- a/src/lib/rules/features/collect.ts +++ b/src/lib/rules/features/collect.ts @@ -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 | undefined, level: number, pick = 1): number { @@ -17,47 +28,90 @@ export function countAtLevel(countByLevel: Record | 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 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 }; diff --git a/src/lib/rules/features/data.pf2e.ts b/src/lib/rules/features/data.pf2e.ts new file mode 100644 index 0000000..4d14dc2 --- /dev/null +++ b/src/lib/rules/features/data.pf2e.ts @@ -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 = { + 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 = { + 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.' }, + ], + }, +}; diff --git a/src/lib/rules/features/features.test.ts b/src/lib/rules/features/features.test.ts index 83b7cb6..cf880f5 100644 --- a/src/lib/rules/features/features.test.ts +++ b/src/lib/rules/features/features.test.ts @@ -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, 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); }); }); diff --git a/src/lib/rules/features/subclass.5e.ts b/src/lib/rules/features/subclass.5e.ts new file mode 100644 index 0000000..8a92309 --- /dev/null +++ b/src/lib/rules/features/subclass.5e.ts @@ -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 = { + // ---- 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).' }, + ], + }, +}; diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index 4329282..5feb3c8 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -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'; diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index f3c5ee5..fefcb18 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file