From 82a2aef42f8f4c1fc5f1b3311a74bca6e6552d2c Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 01:51:23 +0200 Subject: [PATCH] =?UTF-8?q?Phase=207:=20class=20feature=20&=20choice=20eng?= =?UTF-8?q?ine=20=E2=80=94=20every=20unlockable=20option=20is=20surfaced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character's class choices were never prompted (the user's example: a Warlock must choose a Pact Boon + Eldritch Invocations). Now: - CLASS_PROGRESSION_5E authors all 12 classes' features by level AND every decision point: Fighting Style, Pact Boon, Eldritch Invocations (count grows by level), Metamagic, Expertise, Favored Enemy/Terrain, with full option lists. - collectChoices5e / collectFeatures5e compute, across a (multiclass) character's classes, exactly which choices are unlocked (and how many to pick at the current level) and which features are gained. Subclass selection (classes editor) and ASI/feat (builder/level-up) are handled elsewhere and excluded. Unit-tested incl. the Warlock pact example. - New 'Class Features & Choices' sheet section lists features by level and presents a picker per unlocked choice, with a 'N to choose' badge so nothing is missed. Resolved choices persist (character.choices, v17 migration). Verified in-app: Fighter 3 / Warlock 5 surfaces Fighting Style (1), Pact Boon (1), Eldritch Invocations (3) + Pact Magic feature; selecting one drops the pending count. 346 tests green (7 new), build OK. Co-Authored-By: Claude Opus 4.8 --- src/features/characters/CharacterSheet.tsx | 3 + .../characters/sheet/ClassFeaturesSection.tsx | 88 ++++++++ src/lib/db/db.ts | 7 + src/lib/rules/features/collect.ts | 63 ++++++ src/lib/rules/features/data.5e.ts | 206 ++++++++++++++++++ src/lib/rules/features/features.test.ts | 53 +++++ src/lib/rules/features/types.ts | 58 +++++ src/lib/rules/index.ts | 2 + src/lib/schemas/character.ts | 5 +- tsconfig.app.tsbuildinfo | 2 +- 10 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 src/features/characters/sheet/ClassFeaturesSection.tsx create mode 100644 src/lib/rules/features/collect.ts create mode 100644 src/lib/rules/features/data.5e.ts create mode 100644 src/lib/rules/features/features.test.ts create mode 100644 src/lib/rules/features/types.ts diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 1691354..2b3de19 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -28,6 +28,7 @@ import { ResourcesSection } from './sheet/ResourcesSection'; import { SpellcastingSection } from './sheet/SpellcastingSection'; import { DefensesSection } from './sheet/DefensesSection'; import { FeatsSection } from './sheet/FeatsSection'; +import { ClassFeaturesSection } from './sheet/ClassFeaturesSection'; import { AbilityGenModal } from './sheet/AbilityGenModal'; import { LevelUpModal } from './sheet/LevelUpModal'; @@ -316,6 +317,8 @@ export function CharacterSheet({ character }: { character: Character }) { {/* Feats & features */} + + {/* Inventory, currency, encumbrance */} diff --git a/src/features/characters/sheet/ClassFeaturesSection.tsx b/src/features/characters/sheet/ClassFeaturesSection.tsx new file mode 100644 index 0000000..a7e4b2c --- /dev/null +++ b/src/features/characters/sheet/ClassFeaturesSection.tsx @@ -0,0 +1,88 @@ +import { collectChoices5e, collectFeatures5e } from '@/lib/rules'; +import type { ClassEntry } from '@/lib/schemas'; +import { Input, Select } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Codex'; +import { SheetSection, type SectionProps } from './common'; + +/** + * Class features + every choice the character must make (Fighting Style, Pact Boon, + * Eldritch Invocations, Metamagic, Expertise, …) so no unlock-able option is missed. + * 5e only — driven by CLASS_PROGRESSION_5E. + */ +export function ClassFeaturesSection({ c, update }: SectionProps) { + if (c.system !== '5e') return null; + const classes: ClassEntry[] = c.classes.length ? c.classes : c.className ? [{ className: c.className, level: c.level }] : []; + const features = collectFeatures5e(classes); + const choices = collectChoices5e(classes); + if (features.length === 0 && choices.length === 0) return null; + + const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? []; + const setVal = (key: string, idx: number, value: string) => { + const cur = [...getVals(key)]; + while (cur.length <= idx) cur.push(''); + cur[idx] = value; + update({ choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] }); + }; + + const pendingTotal = choices.reduce((n, ch) => n + Math.max(0, ch.count - getVals(ch.key).filter(Boolean).length), 0); + + return ( + + {choices.length > 0 && ( +
+
+ Choices + {pendingTotal > 0 && {pendingTotal} to choose} +
+
+ {choices.map((ch) => { + const vals = getVals(ch.key); + const filled = vals.filter(Boolean).length; + return ( +
+
+ {ch.label} + {ch.source} + {filled < ch.count && choose {ch.count - filled}} +
+
+ {Array.from({ length: ch.count }).map((_, i) => { + const val = vals[i] ?? ''; + return ch.options ? ( + + ) : ( + setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`} placeholder={`${ch.label} ${i + 1}`} /> + ); + })} +
+ {ch.hint &&

{ch.hint}

} +
+ ); + })} +
+
+ )} + + {features.length > 0 && ( +
+
Features
+
    + {features.map((f, i) => ( +
  • +
    + {f.name} + {f.source} · L{f.level} +
    + {f.text &&

    {f.text}

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