From b3914b1dda95152b6079154b3655e1cf3a3e6a1a Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 14:14:01 +0200 Subject: [PATCH] Bug hunt + 5e/PF2e parity + character-build UX overhaul Audited every calculation (26-agent adversarial workflow + hand-verification). Core math was sound; fixed real bugs, closed parity gaps, and overhauled the character-build UX. 396 -> 424 tests; lint clean; build green. Correctness fixes: - Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default) - 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge - PF2e dazzled no longer makes a creature easier to hit - PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity) - Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened - Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any) PF2e survival subsystem (parity with 5e death saves): - mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay) - DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest; drained HP reduction Character-build UX: - Persisted abilityBuild (base + per-source contributions); sheet & wizard show every ability source; higher-level PF2e gains L5/10/15/20 boosts - Creation surfaces granted skills (incl. new PF2e background skill parsing) - LevelUpModal enumerates features gained + prompts every owed choice (subclass, feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt Co-Authored-By: Claude Opus 4.8 --- src/features/characters/CharacterSheet.tsx | 47 ++++- .../characters/builder/CreationWizard.tsx | 98 ++++++++-- .../characters/sheet/DefensesSection.tsx | 156 +++++++++++----- .../characters/sheet/LevelUpModal.tsx | 168 ++++++++++++++++-- src/features/cloud/SyncStatusIndicator.tsx | 4 +- src/features/combat/EncounterTracker.tsx | 2 +- src/lib/assistant/director/schema.ts | 14 +- src/lib/assistant/session.ts | 2 +- src/lib/combat/engine.test.ts | 6 + src/lib/combat/engine.ts | 3 +- src/lib/llm/types.ts | 1 + src/lib/mechanics/conditionEffects.ts | 5 +- src/lib/mechanics/creatureState.test.ts | 46 ++++- src/lib/mechanics/creatureState.ts | 60 ++++++- src/lib/mechanics/dying.test.ts | 63 +++++++ src/lib/mechanics/dying.ts | 70 ++++++++ src/lib/mechanics/index.ts | 3 +- src/lib/mechanics/normalize/armor.ts | 8 +- .../mechanics/normalize/monsterDefenses.ts | 3 + src/lib/mechanics/normalize/normalize.test.ts | 15 +- src/lib/mechanics/normalize/spell.ts | 11 +- src/lib/mechanics/types.ts | 3 +- src/lib/rules/abilityBuild.test.ts | 68 +++++++ src/lib/rules/abilityBuild.ts | 71 ++++++++ src/lib/rules/conditions.ts | 2 +- src/lib/rules/dnd5e/index.ts | 3 +- src/lib/rules/features/collect.ts | 21 +++ src/lib/rules/features/features.test.ts | 19 +- src/lib/rules/index.ts | 3 +- src/lib/rules/pf2e/index.ts | 3 +- src/lib/rules/rest.ts | 12 ++ src/lib/rules/rules.test.ts | 10 ++ src/lib/schemas/character.ts | 25 +++ tsconfig.app.tsbuildinfo | 2 +- 34 files changed, 911 insertions(+), 116 deletions(-) create mode 100644 src/lib/mechanics/dying.test.ts create mode 100644 src/lib/mechanics/dying.ts create mode 100644 src/lib/rules/abilityBuild.test.ts create mode 100644 src/lib/rules/abilityBuild.ts diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 2b3de19..25bda40 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from 'react'; import { Link } from '@tanstack/react-router'; import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react'; import type { Character } from '@/lib/schemas'; -import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; -import { getSystem, ABILITY_ABBR } from '@/lib/rules'; -import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics'; +import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; +import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules'; +import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics'; import { charactersRepo, campaignsRepo } from '@/lib/db/repositories'; import { encodeClaim } from '@/lib/sync/playerLink'; import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize'; @@ -104,6 +104,21 @@ export function CharacterSheet({ character }: { character: Character }) { ...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}), }; + // Ability-score source breakdown: use the persisted build, or synthesize a manual + // one from the finals for legacy characters so the sheet still shows totals. + const abilityBuild = c.abilityBuild ?? synthManualBuild(c.abilities); + const abilityBuildView = abilityBreakdown(abilityBuild); + /** Edit an ability total on the sheet → records a Manual adjustment, keeps build+finals in sync. */ + const setAbilityTotal = (a: AbilityKey, total: number) => { + const nb = setManualTotal(abilityBuild, a, total); + update({ abilityBuild: nb, abilities: computeAbilities(nb) }); + }; + /** Re-generated base scores (AbilityGenModal) replace the base, keeping all sources. */ + const applyGeneratedScores = (scores: AbilityScores) => { + const nb = setBuildBase(abilityBuild, scores); + update({ abilityBuild: nb, abilities: computeAbilities(nb) }); + }; + const ac = sys.baseArmorClass(rulesInput); const initiative = sys.initiativeModifier(rulesInput); const skills = sys.skillModifiers(rulesInput); @@ -221,6 +236,7 @@ export function CharacterSheet({ character }: { character: Character }) {
{ABILITIES.map((a) => { const mod = sys.abilityModifier(c.abilities[a]); + const breakdown = abilityBuildView[a]; return (
{ABILITY_ABBR[a]}
@@ -231,12 +247,21 @@ export function CharacterSheet({ character }: { character: Character }) { min={1} max={30} aria-label={`${ABILITY_ABBR[a]} score`} - onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })} + onChange={(v) => setAbilityTotal(a, v)} /> + {breakdown.parts.length > 0 && ( +
` · ${p.delta >= 0 ? '+' : ''}${p.delta} ${p.label}`).join('')}`}> + {breakdown.base} + {breakdown.parts.map((p, i) => ( + {p.delta >= 0 ? '+' : ''}{p.delta} + ))} +
+ )}
); })}
+

Hover a score to see its sources. Editing a score records a “Manual” adjustment.

{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */} @@ -359,7 +384,7 @@ export function CharacterSheet({ character }: { character: Character }) { {genScores && ( - setGenScores(false)} onApply={(abilities) => update({ abilities })} /> + setGenScores(false)} onApply={applyGeneratedScores} /> )} {levelUp && ( setLevelUp(false)} onApply={(patch) => update(patch)} /> @@ -398,17 +423,23 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial) = } setDelta(0); }; - const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; + const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions }); + const effMax = eff.max; + const ratio = effMax > 0 ? c.hp.current / effMax : 0; return (
Hit Points
{c.hp.current} - / {c.hp.max} + / {effMax} + {eff.reduction > 0 && {c.hp.max}} {c.hp.temp > 0 && (+{c.hp.temp})}
+ {eff.reduction > 0 && ( +
{eff.reasons.join(' · ')}
+ )}
- +
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 4add558..8a500c6 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { Lightbulb, RotateCcw, Star } from 'lucide-react'; -import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas'; +import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas'; import { charactersRepo } from '@/lib/db/repositories'; import { getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS, @@ -150,11 +150,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un }; }))); }); - void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ - name: String(b.name), - desc: briefOverview(String((b.description ?? b.text) ?? '')), - backgroundBoosts: parseBackgroundBoosts(b.attribute), - }))))); + const resolvePf2e = makeSkillResolver(sys.skills); + void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => { + // PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the + // 16 core skills and are dropped here — they're tracked as custom Lore separately). + const rawSkills = Array.isArray((b as Record).skill) ? ((b as Record).skill as string[]) : []; + const skillGrants = rawSkills.map((s) => resolvePf2e(String(s))).filter((k): k is string => !!k); + return { + name: String(b.name), + desc: briefOverview(String((b.description ?? b.text) ?? '')), + backgroundBoosts: parseBackgroundBoosts(b.attribute), + ...(skillGrants.length ? { skillGrants } : {}), + }; + })))); } return () => { on = false; }; // sys.skills is a stable singleton keyed by `system`, so `system` covers it. @@ -203,22 +211,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES }); if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts }); for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES }); + // Higher-level characters also gained 4 ability boosts at levels 5/10/15/20. + for (const L of [5, 10, 15, 20]) if (L <= level) for (let i = 0; i < 4; i++) slots.push({ id: `lvl${L}-${i}`, label: `Level ${L} boost`, options: ABILITIES }); return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots }; - }, [system, selectedOrigin, selectedBackground, classDef, selectedClass]); + }, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]); const [boostPicks, setBoostPicks] = useState>({}); // A boost slot's "source" — boosts within one source must target different abilities. - const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : 'free'); + const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : id.startsWith('lvl') ? id.split('-')[0]! : 'free'); // Seed a legal default assignment (distinct within each source) when slots change. useEffect(() => { if (!boostSlots) return; const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[]; const favored = keyOpts[0] ?? 'str'; const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)]; - const used: Record> = { ancestry: new Set(boostSlots.fixed), background: new Set(), class: new Set(), free: new Set() }; + const used: Record> = { ancestry: new Set(boostSlots.fixed) }; const next: Record = {}; for (const s of boostSlots.slots) { - const u = used[boostSource(s.id)]!; + const u = (used[boostSource(s.id)] ??= new Set()); const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o)) ?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored; next[s.id] = pick; @@ -254,6 +264,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; + // Capture the ability scores as a source breakdown (base + every boost/ASI/flaw) so + // the sheet can show where each point came from — final = computeAbilities(build). + const buildAbilityBuild = (): AbilityBuild => { + if (system === 'pf2e' && boostSlots) { + const adjustments: AbilityAdjustment[] = []; + if (boostSlots.flaw) adjustments.push({ label: 'Ancestry flaw', ability: boostSlots.flaw, kind: 'flat', amount: -2 }); + for (const ab of boostSlots.fixed) adjustments.push({ label: 'Ancestry', ability: ab, kind: 'boost', amount: 0 }); + for (const s of boostSlots.slots) { + const ab = boostPicks[s.id]; + if (ab) adjustments.push({ label: s.label, ability: ab, kind: 'boost', amount: 0 }); + } + return { base: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, adjustments }; + } + const base = {} as AbilityScores; + ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; }); + const adjustments: AbilityAdjustment[] = []; + if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); } + for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); } + return { base, adjustments }; + }; + // ---- skills ---- const [skills, setSkills] = useState([]); const intMod = abilityModifier(abilities.int); @@ -277,11 +308,22 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const toggleSkill = (key: string) => setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev)); - // 5e skill proficiencies granted by background + race (applied as trained, on top of class picks). - const grantedSkills = useMemo( - () => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []), - [system, selectedOrigin, selectedBackground], - ); + // Skill proficiencies granted (trained) by ancestry/race + background — shown to the + // user so they don't waste free picks, and merged on top of class picks at build time. + const grantedSkillSources = useMemo(() => { + const out: { key: string; source: string }[] = []; + const add = (keys: string[] | undefined, source: string) => { + for (const k of keys ?? []) if (!out.some((o) => o.key === k)) out.push({ key: k, source }); + }; + add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race')); + add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background'); + return out; + }, [system, selectedOrigin, selectedBackground]); + const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]); + // Free picks exclude already-granted skills, so the user can't waste a choice on one. + const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]); + // Drop any free pick that becomes granted (e.g. after switching background). + useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]); // ---- spells ---- const [spellPicks, setSpellPicks] = useState([]); @@ -348,7 +390,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un Class: !!selectedClass, Origin: true, Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0, - Skills: skills.length === Math.min(skillCount, skillOptions.length), + Skills: skills.length === Math.min(skillCount, freeSkillOptions.length), Spells: true, Details: true, Review: true, @@ -368,6 +410,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) })); await charactersRepo.update(created.id, { ...built, + abilityBuild: buildAbilityBuild(), ...(Object.keys(saveRanks).length ? { saveRanks } : {}), classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }], spellcasting: { ...built.spellcasting, spells }, @@ -651,8 +694,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un {stepName === 'Skills' && (
-

Choose {Math.min(skillCount, skillOptions.length)} {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).

+

Choose {Math.min(skillCount, freeSkillOptions.length)} {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).

{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}

+ {grantedSkillSources.length > 0 && ( +
+
Already Trained (from your origin)
+
+ {grantedSkillSources.map((g) => ( + + ✓ {skillLabel(g.key)} · {g.source} + + ))} +
+
+ )} + {level > 1 && ( +

+ {system === 'pf2e' + ? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheet’s level-up flow.' + : 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheet’s level-up flow.'} +

+ )} {selectedClass && (() => { const tip = getClassTip(system, selectedClass.slug); return tip?.skillSuggestions ? ( @@ -663,7 +725,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un ) : null; })()}
- {skillOptions.map((key) => { + {freeSkillOptions.map((key) => { const checked = skills.includes(key); const disabled = !checked && skills.length >= skillCount; return ( diff --git a/src/features/characters/sheet/DefensesSection.tsx b/src/features/characters/sheet/DefensesSection.tsx index f73480b..587501d 100644 --- a/src/features/characters/sheet/DefensesSection.tsx +++ b/src/features/characters/sheet/DefensesSection.tsx @@ -1,7 +1,11 @@ -import { Minus, Plus, Star } from 'lucide-react'; +import { useState } from 'react'; +import { Minus, Plus, Skull, Star } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { NumberField } from '@/components/ui/NumberField'; +import { maxDying, isDead, recoveryDc, knockOut, applyRecovery } from '@/lib/mechanics'; +import type { Degree } from '@/lib/dice/check'; +import type { Condition } from '@/lib/schemas/common'; import { SheetSection, type SectionProps } from './common'; function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) { @@ -23,57 +27,117 @@ function Pips({ count, filled, onToggle, label }: { count: number; filled: numbe ); } +/** PF2e dying/wounded/doomed with knock-out + human-rolled recovery checks. */ +function Pf2eDefenses({ c, update }: SectionProps) { + const d = c.defenses; + const [fromCrit, setFromCrit] = useState(false); + const dead = isDead(d.dying, d.doomed); + + const withUnconscious = (conds: Condition[]): Condition[] => + conds.some((x) => x.name.trim().toLowerCase() === 'unconscious') ? conds : [...conds, { name: 'Unconscious' }]; + const withoutUnconscious = (conds: Condition[]): Condition[] => + conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious'); + + const setDefenses = (p: Partial) => update({ defenses: { ...d, ...p } }); + /** Set dying and keep the Unconscious condition in sync (dying>0 ⇒ unconscious). */ + const setDying = (dying: number) => + update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + + const doKnockOut = () => { + const { dying } = knockOut(d, fromCrit); + update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) }); + }; + const doRecover = (degree: Degree) => { + const res = applyRecovery(d, degree); + update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + }; + + return ( +
+ +
+ + {dead && Dead} +
+
+ + setDefenses({ wounded: v })} aria-label="Wounded value" /> + + + setDefenses({ doomed: v })} aria-label="Doomed value" /> + + +
+ + {d.heroPoints} + +
+
+ +
+ {d.dying === 0 ? ( +
+ Knocked to 0 HP? Enter dying (adds your Wounded value{d.wounded > 0 ? ` of ${d.wounded}` : ''}). +
+ + +
+
+ ) : ( +
+
+ Recovery check + DC {recoveryDc(d.dying)} +
+

Roll a flat d20 vs the DC, then record the result (success lowers Dying; reaching 0 makes you Wounded):

+
+ + + + +
+
+ )} +
+
+ ); +} + export function DefensesSection({ c, update }: SectionProps) { const d = c.defenses; const setD = (p: Partial) => update({ defenses: { ...d, ...p } }); return ( -
- {c.system === '5e' ? ( - <> - -
- - Successes - setD({ deathSaves: { ...d.deathSaves, successes: n } })} /> - - - Failures - setD({ deathSaves: { ...d.deathSaves, failures: n } })} /> - -
-
- - - - - setD({ exhaustion: v })} aria-label="Exhaustion level" /> - - - ) : ( - <> - - setD({ dying: v })} aria-label="Dying value" /> - - - setD({ wounded: v })} aria-label="Wounded value" /> - - - setD({ doomed: v })} aria-label="Doomed value" /> - - -
- - {d.heroPoints} - -
-
- - )} -
+ {c.system === '5e' ? ( +
+ +
+ + Successes + setD({ deathSaves: { ...d.deathSaves, successes: n } })} /> + + + Failures + setD({ deathSaves: { ...d.deathSaves, failures: n } })} /> + +
+
+ + + + + setD({ exhaustion: v })} aria-label="Exhaustion level" /> + {d.exhaustion >= 4 && Max HP halved} + +
+ ) : ( + + )}
); } diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx index 0ba47d0..43eb0d7 100644 --- a/src/features/characters/sheet/LevelUpModal.tsx +++ b/src/features/characters/sheet/LevelUpModal.tsx @@ -1,16 +1,17 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas'; import { normalizeClassMirror, totalLevel } from '@/lib/schemas'; import { abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, planLevelUp, applyIncreases, bumpRank, getClassDef, + collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature, } from '@/lib/rules'; import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression'; import { rollDice } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; -import { Select } from '@/components/ui/Input'; +import { Input, Select } from '@/components/ui/Input'; import { LevelUpAdvisor } from './LevelUpAdvisor'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; @@ -24,9 +25,12 @@ export function LevelUpModal({ character, onApply, onClose }: { const is5e = character.system === '5e'; // 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class. - const classList: ClassEntry[] = character.classes.length - ? character.classes - : character.className ? [{ className: character.className, level: character.level }] : []; + const classList: ClassEntry[] = useMemo( + () => (character.classes.length + ? character.classes + : character.className ? [{ className: character.className, level: character.level }] : []), + [character.classes, character.className, character.level], + ); const NEW = '__new__'; const [levelClass, setLevelClass] = useState(classList[0]?.className ?? character.className); const [newClassName, setNewClassName] = useState(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter'); @@ -55,6 +59,72 @@ export function LevelUpModal({ character, onApply, onClose }: { // Skill increase (pf2e): one skill to bump. const [skillKey, setSkillKey] = useState(sys.skills[0]?.key ?? ''); + // Classes after this level-up — drives feature/choice enumeration. + const nextClasses: ClassEntry[] = useMemo(() => { + if (is5e) { + return levelClass === NEW + ? [...classList, { className: newClassName, level: 1 }] + : classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e)); + } + return classList.length + ? classList.map((e, i) => (i === 0 ? { ...e, level: plan.nextLevel } : e)) + : [{ className: character.className, level: plan.nextLevel }]; + }, [is5e, levelClass, newClassName, classList, levelingName, plan.nextLevel, character.className]); + + // Features GAINED at this level (read-only "what you get"). + const featuresGained = useMemo(() => { + const k = (f: UnlockedFeature) => `${f.source}|${f.name}|${f.level}`; + const before = new Set(collectFeatures(character.system, classList).map(k)); + return collectFeatures(character.system, nextClasses).filter((f) => !before.has(k(f))); + }, [character.system, classList, nextClasses]); + + // How many of a choice the character has already recorded (subclass also counts the + // class entry's own subclass field, set before the choices array existed). + const resolvedCount = (key: string) => { + const fromChoices = character.choices.find((c) => c.key === key)?.values.length ?? 0; + if (key.endsWith(':subclass')) { + const cn = key.slice(0, -':subclass'.length).toLowerCase(); + return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0); + } + return fromChoices; + }; + // CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations, feats, + // pf2e subclass…) — the count not yet recorded. + const pendingChoices = useMemo( + () => collectChoices(character.system, nextClasses) + .map((choice) => ({ choice, pick: Math.max(0, choice.count - resolvedCount(choice.key)) })) + .filter((x) => x.pick > 0), + // eslint-disable-next-line react-hooks/exhaustive-deps + [character.system, nextClasses, character.choices], + ); + + // 5e subclass selection (a class field, not a generic choice) when it first unlocks. + const sub5e = useMemo(() => { + if (!is5e) return undefined; + const p = subclassPrompt('5e', levelingName); + const entry = nextClasses.find((e) => e.className === levelingName); + if (!p || !entry || entry.level < p.level || entry.subclass) return undefined; + return p; + }, [is5e, levelingName, nextClasses]); + + const [choicePicks, setChoicePicks] = useState>({}); + const [subclassPick, setSubclassPick] = useState(''); + const setChoicePick = (key: string, i: number, val: string) => + setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; }); + // Seed defaults for option-based choices so an untouched picker still records its pick. + useEffect(() => { + setChoicePicks((prev) => { + const next = { ...prev }; + for (const { choice, pick } of pendingChoices) { + if (!choice.options) continue; + const arr = [...(next[choice.key] ?? [])]; + for (let i = 0; i < pick; i++) if (arr[i] == null) arr[i] = choice.options[0]!; + next[choice.key] = arr; + } + return next; + }); + }, [pendingChoices]); + const atMax = total >= 20; const apply = () => { const gain = character.system === 'pf2e' @@ -65,19 +135,35 @@ export function LevelUpModal({ character, onApply, onClose }: { if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); + // Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry. + const subFromChoice = Object.entries(choicePicks) + .find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim(); + const subToApply = (sub5e ? (subclassPick || sub5e.options[0]) : undefined) ?? subFromChoice; + const nextClassesFinal = subToApply + ? nextClasses.map((e) => (e.className === levelingName && !e.subclass ? { ...e, subclass: subToApply } : e)) + : nextClasses; + + // Merge newly-picked choices into the character's resolved-choices array. + const merged = character.choices.map((c) => ({ key: c.key, values: [...c.values] })); + for (const [key, vals] of Object.entries(choicePicks)) { + const clean = vals.map((v) => v.trim()).filter(Boolean); + if (!clean.length) continue; + const existing = merged.find((c) => c.key === key); + if (existing) existing.values.push(...clean); + else merged.push({ key, values: clean }); + } + const patch: Partial = { hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, abilities, + classes: nextClassesFinal, + ...normalizeClassMirror({ classes: nextClassesFinal }), + choices: merged, }; if (is5e) { - // Bump the chosen class (or add a new one), re-derive the mirrors + combined slots. - const nextClasses: ClassEntry[] = levelClass === NEW - ? [...classList, { className: newClassName, level: 1 }] - : classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e)); - Object.assign(patch, normalizeClassMirror({ classes: nextClasses })); - patch.classes = nextClasses; - const derived = dnd5eClassesSlots(nextClasses); + // Re-derive combined spell slots, preserving current values. + const derived = dnd5eClassesSlots(nextClassesFinal); const slots = derived.slots.map((r) => { const ex = character.spellcasting.slots.find((s) => s.level === r.level); return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max }; @@ -86,9 +172,8 @@ export function LevelUpModal({ character, onApply, onClose }: { if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) }; else delete spellcasting.pact; patch.spellcasting = spellcasting; - } else { - patch.level = plan.nextLevel; - if (plan.slots) patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; + } else if (plan.slots) { + patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; } if (skillInc && skillKey) { @@ -209,9 +294,56 @@ export function LevelUpModal({ character, onApply, onClose }: { )} - {plan.choices.filter((c) => c.kind === 'feat').map((c) => ( -

• {c.label}

- ))} + {/* What you gain at this level (read-only) */} + {featuresGained.length > 0 && ( +
+

What you gain

+
    + {featuresGained.map((f, i) => ( +
  • + {f.name} + {f.text ? — {f.text} : null} +
  • + ))} +
+
+ )} + + {/* 5e subclass selection (at its archetype level) */} + {sub5e && ( +
+

{sub5e.label}

+ +
+ )} + + {/* Choices still owed at this level (feats, fighting style, pact boon, …) */} + {pendingChoices.length > 0 && ( +
+

Choices to make

+
+ {pendingChoices.map(({ choice, pick }) => ( +
+
{choice.label}{pick > 1 ? ` — choose ${pick}` : ''}
+ {choice.hint &&
{choice.hint}
} +
+ {Array.from({ length: pick }, (_, i) => ( + choice.options ? ( + + ) : ( + setChoicePick(choice.key, i, e.target.value)} /> + ) + ))} +
+
+ ))} +
+
+ )} {/* Strategic build-route advice */} diff --git a/src/features/cloud/SyncStatusIndicator.tsx b/src/features/cloud/SyncStatusIndicator.tsx index f15dd8d..f2eee7a 100644 --- a/src/features/cloud/SyncStatusIndicator.tsx +++ b/src/features/cloud/SyncStatusIndicator.tsx @@ -66,7 +66,7 @@ function ConflictResolver() { const [busy, setBusy] = useState(false); const setCloudSync = useConnectivityStore((s) => s.setCloudSync); - const useCloud = async () => { + const acceptCloud = async () => { setBusy(true); try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } } finally { setBusy(false); setOpen(false); } @@ -86,7 +86,7 @@ function ConflictResolver() {

The cloud copy was changed on another device since this one last synced.

- +
diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index ecff130..f289c33 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -529,7 +529,7 @@ function CombatantRow({ )} {/* Mechanical consequences the conditions impose (enforced read-model). */} {(() => { - const st = deriveState(system, 30, c.conditions); + const st = deriveState(system, 30, c.conditions, { hpMax: c.hp.max, ...(c.level !== undefined ? { level: c.level } : {}) }); return st.badges.length > 0 ? (
{st.badges.map((b) => ( diff --git a/src/lib/assistant/director/schema.ts b/src/lib/assistant/director/schema.ts index c06a90c..497a236 100644 --- a/src/lib/assistant/director/schema.ts +++ b/src/lib/assistant/director/schema.ts @@ -10,8 +10,12 @@ import { z } from 'zod'; */ const str = z.coerce.string(); -const coerceInt = z.coerce.number().int(); const coerceAmount = z.coerce.number().int().min(0).max(9999); +// Bounded coercers reject the nonsensical values the model sometimes invents +// (negative DCs, 12th-rank spells, exhaustion: -3) at the schema boundary. +const coerceDc = z.coerce.number().int().min(0).max(35); +const coerceSpellLevel = z.coerce.number().int().min(0).max(9); +const coercePositive = z.coerce.number().int().min(1); function asRecord(v: unknown): Record | undefined { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; @@ -119,7 +123,7 @@ export const rollRequestSchema = z.object({ label: str.default('Roll'), kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'), expression: str.default('1d20'), - dc: coerceInt.optional(), + dc: coerceDc.optional(), against: str.optional(), }); export type RollRequest = z.infer; @@ -129,9 +133,9 @@ export const directorActionSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }), z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }), z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }), - z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coerceInt.optional() }), - z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceInt.optional() }), - z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceInt.default(1) }), + z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coercePositive.optional() }), + z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceSpellLevel.optional() }), + z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceAmount.default(1) }), z.object({ kind: z.literal('advanceTurn') }), z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }), z.object({ kind: z.literal('log'), text: str }), diff --git a/src/lib/assistant/session.ts b/src/lib/assistant/session.ts index 5755d8b..f565412 100644 --- a/src/lib/assistant/session.ts +++ b/src/lib/assistant/session.ts @@ -44,7 +44,7 @@ const HOOK_TEMPLATES = [ { title: 'Old Debts', opening: () => - `A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`, + `A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They're not hostile, but they want something only the party can provide.`, tension: 'What they need may directly conflict with the party\'s current goals.', }, { diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index 0bd5c66..8e266d2 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -183,6 +183,12 @@ describe('HP transitions', () => { expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27 expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change }); + + it('pf2e immunity "all" zeroes every damage type', () => { + const im = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: ['all'], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } }; + expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change + expect(applyDamage(im, 10, 'cold').hp.current).toBe(30); + }); }); describe('endEncounter', () => { diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index 9855c64..4b6884e 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -237,7 +237,8 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat let dmg = amount; const def = c.damageDefenses; if (type && def) { - if (def.immune.includes(type)) return c; // immune — no change + // 'all' (pf2e) immunity applies to every damage type. + if (def.immune.includes(type) || (def.immune as string[]).includes('all')) return c; // immune — no change // 5e: halve / double if (def.vulnerable.includes(type)) dmg = amount * 2; else if (def.resist.includes(type)) dmg = Math.floor(amount / 2); diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts index dc34f45..201dd7b 100644 --- a/src/lib/llm/types.ts +++ b/src/lib/llm/types.ts @@ -42,6 +42,7 @@ export interface CompleteOptions { /** When provided, structured JSON output is requested and validated against it. * Input is left open (`any`) so schemas using coerce/preprocess/transform — whose * parsed input type differs from T — are accepted; T is pinned to the output. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema input type varies (coerce/preprocess); only output T matters here schema?: ZodType; /** A short name describing the JSON shape (used by providers that require it). */ schemaName?: string; diff --git a/src/lib/mechanics/conditionEffects.ts b/src/lib/mechanics/conditionEffects.ts index 0802887..c1171a5 100644 --- a/src/lib/mechanics/conditionEffects.ts +++ b/src/lib/mechanics/conditionEffects.ts @@ -65,7 +65,10 @@ export const CONDITION_EFFECTS_PF2E: Record = { stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn sickened: { statusPenalty: 'all' }, // every check and DC - dazzled: { attacksAgainst: 'advantage' }, + // Dazzled imposes concealment on the dazzled creature's OWN targets (a flat check on + // its attacks) — it does NOT lower its AC. The app can't model the flat-check, so we + // record no advantage to attackers here rather than wrongly making it easier to hit. + dazzled: {}, // fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState. fatigued: {}, slowed: {}, diff --git a/src/lib/mechanics/creatureState.test.ts b/src/lib/mechanics/creatureState.test.ts index fbf08ac..37013e5 100644 --- a/src/lib/mechanics/creatureState.test.ts +++ b/src/lib/mechanics/creatureState.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { deriveState, tickConditionsEndOfTurn } from './creatureState'; +import { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn } from './creatureState'; import { concentrationDC } from './cast'; import type { Condition } from '@/lib/schemas/common'; @@ -58,6 +58,16 @@ describe('deriveState (5e)', () => { expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0); }); + it('exhaustion 4 halves max HP when context is provided', () => { + const s = deriveState('5e', 30, [cond('Exhaustion', 4)], { hpMax: 45 }); + expect(s.hpMaxReduction).toBe(22); // floor(45/2) + expect(s.badges).toContain('Max HP −22'); + }); + + it('exhaustion below 4 leaves max HP untouched', () => { + expect(deriveState('5e', 30, [cond('Exhaustion', 3)], { hpMax: 45 }).hpMaxReduction).toBe(0); + }); + it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => { const s = deriveState('5e', 30, [cond('Prone')]); expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged'); @@ -92,6 +102,40 @@ describe('deriveState (pf2e)', () => { expect(s.attacksAgainstModifier).toBe('advantage'); }); + it('dazzled does NOT make the creature easier to hit (concealment is on its own attacks)', () => { + const s = deriveState('pf2e', 25, [cond('Dazzled')]); + expect(s.attacksAgainstModifier).toBe('normal'); + }); + + it('quickened badges the extra action', () => { + const s = deriveState('pf2e', 25, [cond('Quickened')]); + expect(s.badges).toContain('Quickened (+1 action)'); + }); + + it('drained reduces max HP by level × value', () => { + const s = deriveState('pf2e', 25, [cond('Drained', 2)], { level: 5 }); + expect(s.hpMaxReduction).toBe(10); // 5 × 2 + expect(s.statusPenalties.con).toBe(2); // also a Fortitude penalty + }); +}); + +describe('deriveEffectiveMaxHp', () => { + it('halves 5e max HP at exhaustion 4 (from defenses)', () => { + const r = deriveEffectiveMaxHp({ system: '5e', baseMaxHp: 40, level: 8, exhaustion: 4, conditions: [] }); + expect(r.max).toBe(20); + expect(r.reduction).toBe(20); + }); + + it('subtracts level × drained for pf2e', () => { + const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 50, level: 6, conditions: [cond('Drained', 3)] }); + expect(r.max).toBe(32); // 50 - 18 + }); + + it('never drops below 1', () => { + const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 10, level: 20, conditions: [cond('Drained', 4)] }); + expect(r.max).toBe(1); + }); + it('frightened decays by 1 at end of turn, removed at 0', () => { expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]); expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]); diff --git a/src/lib/mechanics/creatureState.ts b/src/lib/mechanics/creatureState.ts index 987e5a6..5210817 100644 --- a/src/lib/mechanics/creatureState.ts +++ b/src/lib/mechanics/creatureState.ts @@ -22,10 +22,24 @@ export interface MechanicalState { * apply to different rolls, so they are tracked separately — never summed. */ statusPenalties: Partial>; + /** + * Reduction to maximum HP from conditions: 5e exhaustion 4 halves the max; pf2e + * drained N subtracts level × N. 0 when no reduction applies (or when the caller + * passes no hpMax/level context). + */ + hpMaxReduction: number; /** short human-readable badges for the UI, e.g. "Speed 0", "−2 Str" */ badges: string[]; } +/** Optional numeric context so deriveState can compute HP-max reductions. */ +export interface DeriveContext { + /** the creature's *base* maximum HP (before reduction), for 5e exhaustion 4. */ + hpMax?: number; + /** the creature's level, for pf2e drained (level × value). */ + level?: number; +} + /** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */ function combineAdv(a: AdvState, b: AdvState): AdvState { if (a === b) return a; @@ -51,7 +65,7 @@ const PENALTY_LABEL: Record = { * Unknown / homebrew condition names contribute nothing (they remain visible * labels), exactly as before. */ -export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState { +export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[], ctx?: DeriveContext): MechanicalState { const table = conditionEffects(system); let speed = baseSpeed; let speedZero = false; @@ -60,6 +74,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con let incapacitated = false; let abilityCheckDisadvantage = false; let allSavesDisadvantage = false; + let hpMaxReduction = 0; let prone5e = false; const statusPenalties: Partial> = {}; const saveModifiers: Partial> = {}; @@ -74,6 +89,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con if (lvl >= 1) abilityCheckDisadvantage = true; if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; } + if (lvl >= 4 && ctx?.hpMax) hpMaxReduction = Math.floor(ctx.hpMax / 2); // HP maximum halved if (lvl >= 5) speedZero = true; // speed 0 extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`); continue; @@ -88,6 +104,11 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con extraBadges.push('Fatigued (−1 AC & saves)'); continue; } + // --- pf2e Quickened: grants one extra action; no roll penalty. --- + if (system === 'pf2e' && name === 'quickened') { + extraBadges.push('Quickened (+1 action)'); + continue; + } const eff = table[name]; if (!eff) continue; @@ -108,6 +129,10 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con const fam = eff.statusPenalty; statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value); } + // pf2e Drained N also reduces maximum HP by level × N. + if (system === 'pf2e' && name === 'drained' && cond.value && ctx?.level) { + hpMaxReduction += ctx.level * cond.value; + } } // 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail). @@ -128,6 +153,7 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) { if (val > 0) badges.push(`−${val} ${PENALTY_LABEL[fam]}`); } + if (hpMaxReduction > 0) badges.push(`Max HP −${hpMaxReduction}`); badges.push(...extraBadges); return { @@ -138,10 +164,42 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con saveModifiers, abilityCheckDisadvantage, statusPenalties, + hpMaxReduction, badges, }; } +/** + * Effective maximum HP after condition-based reductions: 5e exhaustion 4 halves the + * maximum; pf2e Drained N subtracts level × N. Pure — the sheet and tracker show this + * reduced max but never mutate the stored `hp.max`, matching the surface-don't-apply + * rule. Returns the reduced max plus human-readable reasons for the UI to explain it. + */ +export function deriveEffectiveMaxHp(input: { + system: SystemId; + baseMaxHp: number; + level: number; + /** 5e exhaustion level (from defenses), if tracked separately from conditions. */ + exhaustion?: number; + conditions: Condition[]; +}): { max: number; reduction: number; reasons: string[] } { + let reduction = 0; + const reasons: string[] = []; + if (input.system === '5e' && (input.exhaustion ?? 0) >= 4) { + reduction += Math.floor(input.baseMaxHp / 2); + reasons.push('Exhaustion 4 — maximum HP halved'); + } + if (input.system === 'pf2e') { + const drained = input.conditions.find((c) => c.name.trim().toLowerCase() === 'drained')?.value ?? 0; + if (drained > 0) { + const drop = input.level * drained; + reduction += drop; + reasons.push(`Drained ${drained} — −${drop} maximum HP`); + } + } + return { max: Math.max(1, input.baseMaxHp - reduction), reduction, reasons }; +} + /** * End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1 * at the end of each of the affected creature's turns (removed at 0). Returns a new diff --git a/src/lib/mechanics/dying.test.ts b/src/lib/mechanics/dying.test.ts new file mode 100644 index 0000000..3dfbff8 --- /dev/null +++ b/src/lib/mechanics/dying.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying'; +import type { Condition } from '@/lib/schemas/common'; + +const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name }); + +describe('maxDying / isDead', () => { + it('doomed lowers the death threshold (floor 1)', () => { + expect(maxDying(0)).toBe(4); + expect(maxDying(1)).toBe(3); + expect(maxDying(3)).toBe(1); + expect(maxDying(9)).toBe(1); + }); + it('dead once dying reaches the doomed-reduced maximum', () => { + expect(isDead(4, 0)).toBe(true); + expect(isDead(3, 0)).toBe(false); + expect(isDead(3, 1)).toBe(true); // max 3 + expect(isDead(1, 3)).toBe(true); // max 1 + }); +}); + +describe('recoveryDc', () => { + it('is 10 + dying value', () => { + expect(recoveryDc(1)).toBe(11); + expect(recoveryDc(3)).toBe(13); + }); +}); + +describe('knockOut', () => { + it('enters dying 1 + wounded (2 + wounded on a crit)', () => { + expect(knockOut({ wounded: 0 })).toEqual({ dying: 1 }); + expect(knockOut({ wounded: 2 })).toEqual({ dying: 3 }); + expect(knockOut({ wounded: 1 }, true)).toEqual({ dying: 3 }); + }); +}); + +describe('applyRecovery', () => { + it('adjusts dying by degree of success', () => { + expect(applyRecovery({ dying: 3, wounded: 0 }, 'critical-success')).toEqual({ dying: 1, wounded: 0 }); + expect(applyRecovery({ dying: 2, wounded: 0 }, 'success')).toEqual({ dying: 1, wounded: 0 }); + expect(applyRecovery({ dying: 2, wounded: 0 }, 'failure')).toEqual({ dying: 3, wounded: 0 }); + expect(applyRecovery({ dying: 1, wounded: 0 }, 'critical-failure')).toEqual({ dying: 3, wounded: 0 }); + }); + it('recovering out of dying increases wounded', () => { + expect(applyRecovery({ dying: 1, wounded: 0 }, 'success')).toEqual({ dying: 0, wounded: 1 }); + expect(applyRecovery({ dying: 2, wounded: 1 }, 'critical-success')).toEqual({ dying: 0, wounded: 2 }); + }); + it('does not increase wounded if not already dying', () => { + expect(applyRecovery({ dying: 0, wounded: 2 }, 'success')).toEqual({ dying: 0, wounded: 2 }); + }); +}); + +describe('pf2eRestDecay', () => { + it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => { + const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]); + expect(r.defenses).toEqual({ doomed: 1, wounded: 0 }); + expect(r.conditions).toEqual([cond('Drained', 1), cond('Frightened', 1)]); + }); + it('removes drained when it would drop to 0', () => { + const r = pf2eRestDecay({ doomed: 0, wounded: 0 }, [cond('Drained', 1)]); + expect(r.conditions).toEqual([]); + }); +}); diff --git a/src/lib/mechanics/dying.ts b/src/lib/mechanics/dying.ts new file mode 100644 index 0000000..35b9586 --- /dev/null +++ b/src/lib/mechanics/dying.ts @@ -0,0 +1,70 @@ +import type { Defenses } from '@/lib/schemas/character'; +import type { Condition } from '@/lib/schemas/common'; +import type { Degree } from '@/lib/dice/check'; + +/** + * Pathfinder 2e dying / wounded / doomed mechanics — the PF2e analog of 5e death + * saves (`deathSaves.ts`). Pure helpers; the sheet drives them with human-rolled + * recovery checks (the app never rolls). Dying/wounded/doomed live on + * `character.defenses`; Drained/Fatigued live in the conditions array. + */ + +/** Maximum dying value before death — normally 4, reduced by doomed (floor 1). */ +export function maxDying(doomed: number): number { + return Math.max(1, 4 - Math.max(0, doomed)); +} + +/** Dead once the dying value reaches the (doomed-reduced) maximum. */ +export function isDead(dying: number, doomed: number): boolean { + return dying >= maxDying(doomed); +} + +/** The flat recovery-check DC while dying: 10 + current dying value. */ +export function recoveryDc(dying: number): number { + return 10 + Math.max(0, dying); +} + +/** + * Knocked out (reduced to 0 HP): gain dying 1, plus your wounded value; 2 instead of + * 1 if the blow was a critical hit or you critically failed the triggering save. + * Wounded itself is unchanged here — it only grows when you RECOVER from dying. + */ +export function knockOut(d: Pick, fromCrit = false): { dying: number } { + return { dying: (fromCrit ? 2 : 1) + Math.max(0, d.wounded) }; +} + +/** + * Apply a recovery-check degree to the dying value: + * critical success −2 · success −1 · failure +1 · critical failure +2. + * Reaching dying 0 ends the dying condition and leaves you wounded (or more wounded). + */ +export function applyRecovery(d: Pick, degree: Degree): { dying: number; wounded: number } { + const delta = degree === 'critical-success' ? -2 : degree === 'success' ? -1 : degree === 'failure' ? 1 : 2; + const dying = Math.max(0, d.dying + delta); + const wounded = dying === 0 && d.dying > 0 ? d.wounded + 1 : d.wounded; + return { dying, wounded }; +} + +/** + * Condition decay from a full night's rest (Rest for the Night): doomed −1, wounded + * cleared (you wake at full HP), Drained −1, and Fatigued removed. Dying is assumed + * already 0. Returns both the defenses patch and the updated conditions array. + */ +export function pf2eRestDecay( + d: Pick, + conditions: Condition[], +): { defenses: { doomed: number; wounded: number }; conditions: Condition[] } { + const defenses = { doomed: Math.max(0, d.doomed - 1), wounded: 0 }; + const next: Condition[] = []; + for (const c of conditions) { + const name = c.name.trim().toLowerCase(); + if (name === 'fatigued') continue; // fatigued ends on a full rest + if (name === 'drained' && c.value !== undefined) { + const value = c.value - 1; + if (value > 0) next.push({ ...c, value }); // else drops + continue; + } + next.push(c); + } + return { defenses, conditions: next }; +} diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts index bf0d28b..973079d 100644 --- a/src/lib/mechanics/index.ts +++ b/src/lib/mechanics/index.ts @@ -18,8 +18,9 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast'; export { spendResource, regainResource } from './resources'; export { rollDeathSave, type DeathSaveOutcome } from './deathSaves'; +export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying'; export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects'; -export { deriveState, tickConditionsEndOfTurn, type MechanicalState } from './creatureState'; +export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState'; let spellMechCache: Map | null = null; let armorMechCache: Map | null = null; diff --git a/src/lib/mechanics/normalize/armor.ts b/src/lib/mechanics/normalize/armor.ts index 08ab9e5..688e079 100644 --- a/src/lib/mechanics/normalize/armor.ts +++ b/src/lib/mechanics/normalize/armor.ts @@ -24,7 +24,13 @@ export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null { : 'unarmored'; // Shields don't cap Dex (they're additive); honor the source dexCap otherwise. - const dexCap = isShield ? null : (raw.dexCap ?? null); + // When the source omits dexCap, fall back by category: heavy ignores Dex (0), + // medium caps at +2, light/unarmored is uncapped (null). + const dexCap = isShield + ? null + : raw.dexCap !== undefined && raw.dexCap !== null + ? raw.dexCap + : category === 'heavy' ? 0 : category === 'medium' ? 2 : null; return { name: raw.name, diff --git a/src/lib/mechanics/normalize/monsterDefenses.ts b/src/lib/mechanics/normalize/monsterDefenses.ts index 40f54a5..8c1c272 100644 --- a/src/lib/mechanics/normalize/monsterDefenses.ts +++ b/src/lib/mechanics/normalize/monsterDefenses.ts @@ -65,6 +65,9 @@ export function normalizeMonsterDefensesPf2e(raw: { const immune: DamageType[] = []; const conditionImmune: string[] = []; for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) { + // "all" is a damage immunity (applies to every type), like resistance/weakness — + // not a condition. Special-case it before the damage-type lookup. + if (String(i).trim().toLowerCase() === 'all') { immune.push('all' as DamageType); continue; } const t = pf2eType(String(i)); if (t) immune.push(t); else conditionImmune.push(String(i).trim().toLowerCase()); diff --git a/src/lib/mechanics/normalize/normalize.test.ts b/src/lib/mechanics/normalize/normalize.test.ts index 4c27e9e..5dcba85 100644 --- a/src/lib/mechanics/normalize/normalize.test.ts +++ b/src/lib/mechanics/normalize/normalize.test.ts @@ -69,7 +69,20 @@ describe('normalizeSpell5e', () => { expect(m!.level).toBe(1); expect(m!.concentration).toBe(true); expect(m!.school).toBe('enchantment'); - expect(m!.save).toEqual({ ability: 'wis', basis: 'half' }); + // Fear is a non-basic Will save (degrees inflict Frightened) — it negates, not halves. + expect(m!.save).toEqual({ ability: 'wis', basis: 'negates' }); + }); + + it('reads a basic save as half from the pf2e basic flag', () => { + const m = normalizeSpellPf2e({ + name: 'Fireball', slug: 'fireball', + system: { + level: { value: 3 }, + traits: { value: ['fire'] }, + defense: { save: { statistic: 'reflex', basic: true } }, + }, + } as never); + expect(m!.save).toEqual({ ability: 'dex', basis: 'half' }); }); }); diff --git a/src/lib/mechanics/normalize/spell.ts b/src/lib/mechanics/normalize/spell.ts index e614b9a..2cd58ab 100644 --- a/src/lib/mechanics/normalize/spell.ts +++ b/src/lib/mechanics/normalize/spell.ts @@ -35,7 +35,9 @@ function parseSave(desc: string): SpellSave | null { if (/(?:succeed on|must (?:make|succeed)|makes?)\s*(?:a|an)?\s*$/.test(before)) score += 2; if (best === null || score > best.score) best = { ability, score, idx: m.index }; } - if (!best) return null; + // A negative best means the only matches were "(dis)advantage on X saves" mentions — + // those reference an external save, not a save THIS spell forces. Don't invent one. + if (!best || best.score < 0) return null; const half = /half as much|half the damage|half damage|halve the damage/i.test(desc); return { ability: best.ability, basis: half ? 'half' : 'negates' }; } @@ -93,8 +95,11 @@ export function normalizeSpellPf2e(raw: CompendiumEntry): SpellMechanics | null const level = Number((sys.level as { value?: number } | undefined)?.value ?? 0) || 0; const traits = ((sys.traits as { value?: string[] } | undefined)?.value ?? []) as string[]; const concentration = traits.includes('concentrate') || /sustained/i.test(String((sys.duration as { value?: string } | undefined)?.value ?? '')); - const saveAbilityWord = String((sys.defense as { save?: { statistic?: string } } | undefined)?.save?.statistic ?? ''); + const saveDef = (sys.defense as { save?: { statistic?: string; basic?: boolean } } | undefined)?.save; + const saveAbilityWord = String(saveDef?.statistic ?? ''); const saveAbil = ABILITY_WORD[saveAbilityWord.toLowerCase()] ?? pf2eSaveToAbility(saveAbilityWord); + // Only "basic" saves halve damage; non-basic saves negate/avert the effect. The + // Foundry data flags this explicitly (`defense.save.basic`); default to negates. return { slug, name, @@ -102,7 +107,7 @@ export function normalizeSpellPf2e(raw: CompendiumEntry): SpellMechanics | null school: traits.find((t) => SCHOOLS.has(t)) ?? '', concentration, ritual: traits.includes('ritual'), - save: saveAbil ? { ability: saveAbil, basis: 'half' } : null, + save: saveAbil ? { ability: saveAbil, basis: saveDef?.basic ? 'half' : 'negates' } : null, damage: [], }; } diff --git a/src/lib/mechanics/types.ts b/src/lib/mechanics/types.ts index bd2fc69..e86b558 100644 --- a/src/lib/mechanics/types.ts +++ b/src/lib/mechanics/types.ts @@ -68,7 +68,8 @@ export function effectiveArmorClass( shieldBonus = 0, miscBonus = 0, ): number { - const dex = armor.dexCap === null ? dexMod : Math.min(dexMod, armor.dexCap); + // Heavy armor (dexCap 0) contributes exactly 0 from Dex — never a negative penalty. + const dex = armor.dexCap === null ? dexMod : armor.dexCap === 0 ? 0 : Math.min(dexMod, armor.dexCap); return armor.baseAc + dex + shieldBonus + miscBonus; } diff --git a/src/lib/rules/abilityBuild.test.ts b/src/lib/rules/abilityBuild.test.ts new file mode 100644 index 0000000..f8ec87a --- /dev/null +++ b/src/lib/rules/abilityBuild.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal } from './abilityBuild'; +import type { AbilityBuild } from '@/lib/schemas/character'; + +const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; + +describe('computeAbilities', () => { + it('applies pf2e boosts with the <18 (+2) / ≥18 (+1) rule and a flaw', () => { + const build: AbilityBuild = { + base: base10, + adjustments: [ + { label: 'Ancestry flaw', ability: 'cha', kind: 'flat', amount: -2 }, + { label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Class', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, // 10→12→14→16→18 + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, // 18→19 (+1) + ], + }; + expect(computeAbilities(build).str).toBe(19); + expect(computeAbilities(build).cha).toBe(8); + }); + + it('adds flat 5e racial + ASI bonuses', () => { + const build: AbilityBuild = { + base: { ...base10, str: 15, con: 14 }, + adjustments: [ + { label: 'Race', ability: 'str', kind: 'flat', amount: 2 }, + { label: 'ASI (L4)', ability: 'str', kind: 'flat', amount: 1 }, + { label: 'Race', ability: 'con', kind: 'flat', amount: 1 }, + ], + }; + expect(computeAbilities(build).str).toBe(18); + expect(computeAbilities(build).con).toBe(15); + }); +}); + +describe('abilityBreakdown', () => { + it('reports realized deltas per source', () => { + const build: AbilityBuild = { + base: base10, + adjustments: [ + { label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, + ], + }; + const b = abilityBreakdown(build).str; + expect(b.base).toBe(10); + expect(b.parts).toEqual([{ label: 'Ancestry', delta: 2 }, { label: 'Free', delta: 2 }]); + expect(b.total).toBe(14); + }); +}); + +describe('setManualTotal', () => { + it('records a Manual flat delta so the total matches the typed value', () => { + const build = setManualTotal(synthManualBuild(base10), 'str', 16); + expect(computeAbilities(build).str).toBe(16); + expect(build.adjustments).toContainEqual({ label: 'Manual', ability: 'str', kind: 'flat', amount: 6 }); + }); + + it('removes the Manual delta when the target matches the computed total', () => { + const withBoost: AbilityBuild = { base: base10, adjustments: [{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 }] }; + const manual = setManualTotal(withBoost, 'str', 16); // 12 base+boost → manual +4 + const reset = setManualTotal(manual, 'str', 12); // back to computed + expect(reset.adjustments.some((a) => a.label === 'Manual')).toBe(false); + expect(computeAbilities(reset).str).toBe(12); + }); +}); diff --git a/src/lib/rules/abilityBuild.ts b/src/lib/rules/abilityBuild.ts new file mode 100644 index 0000000..48e4cda --- /dev/null +++ b/src/lib/rules/abilityBuild.ts @@ -0,0 +1,71 @@ +import type { AbilityKey, AbilityScores } from './types'; +import { ABILITY_KEYS } from './types'; +import type { AbilityBuild } from '@/lib/schemas/character'; + +/** + * Replay an ability build into final scores. PF2e boosts add +2 while the score is + * below 18, otherwise +1 (so order matters); flat adjustments add their exact amount + * (5e racial/ASI bonuses, a PF2e flaw of −2, manual tweaks). This is the single place + * the final scores are derived from the persisted build. + */ +export function computeAbilities(build: AbilityBuild): AbilityScores { + const s: AbilityScores = { ...build.base }; + for (const adj of build.adjustments) { + s[adj.ability] += adj.kind === 'boost' ? (s[adj.ability] < 18 ? 2 : 1) : adj.amount; + } + return s; +} + +export interface AbilityBreakdown { + base: number; + parts: { label: string; delta: number }[]; + total: number; +} + +/** + * Per-ability breakdown for display: the base value plus each contribution's realized + * delta (a PF2e boost shows as +2 or +1 depending on the running total). Drives the + * "10 base · +2 Ancestry · +1 Free" line on the sheet and wizard. + */ +export function abilityBreakdown(build: AbilityBuild): Record { + const running: AbilityScores = { ...build.base }; + const out = {} as Record; + for (const a of ABILITY_KEYS) out[a] = { base: build.base[a], parts: [], total: build.base[a] }; + for (const adj of build.adjustments) { + const before = running[adj.ability]; + const delta = adj.kind === 'boost' ? (before < 18 ? 2 : 1) : adj.amount; + running[adj.ability] = before + delta; + out[adj.ability].parts.push({ label: adj.label, delta }); + out[adj.ability].total = running[adj.ability]; + } + return out; +} + +/** A build with no recorded sources — the migration fallback for legacy characters. */ +export function synthManualBuild(abilities: AbilityScores): AbilityBuild { + return { base: { ...abilities }, adjustments: [] }; +} + +const MANUAL_LABEL = 'Manual'; + +/** + * Set/replace the single "Manual" flat adjustment for one ability so the final total + * equals `target`. Lets the sheet keep `abilities = computeAbilities(build)` while the + * user types an override, recording the override as a visible "Manual ±N" source. + */ +export function setManualTotal(build: AbilityBuild, ability: AbilityKey, target: number): AbilityBuild { + const others = build.adjustments.filter( + (adj) => !(adj.kind === 'flat' && adj.label === MANUAL_LABEL && adj.ability === ability), + ); + const totalWithout = computeAbilities({ base: build.base, adjustments: others })[ability]; + const delta = target - totalWithout; + const adjustments = delta !== 0 + ? [...others, { label: MANUAL_LABEL, ability, kind: 'flat' as const, amount: delta }] + : others; + return { base: { ...build.base }, adjustments }; +} + +/** Replace the base scores (e.g. re-rolling the 5e array) while keeping all sources. */ +export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild { + return { base: { ...base }, adjustments: build.adjustments }; +} diff --git a/src/lib/rules/conditions.ts b/src/lib/rules/conditions.ts index 452bb63..8ad7f4c 100644 --- a/src/lib/rules/conditions.ts +++ b/src/lib/rules/conditions.ts @@ -10,7 +10,6 @@ export interface ConditionDef { export const CONDITIONS_5E: readonly ConditionDef[] = [ { name: 'Blinded', valued: false }, { name: 'Charmed', valued: false }, - { name: 'Concentrating', valued: false }, { name: 'Deafened', valued: false }, { name: 'Exhaustion', valued: true }, { name: 'Frightened', valued: false }, @@ -29,6 +28,7 @@ export const CONDITIONS_5E: readonly ConditionDef[] = [ /** Pathfinder 2e conditions. Many carry a value. */ export const CONDITIONS_PF2E: readonly ConditionDef[] = [ { name: 'Blinded', valued: false }, + { name: 'Broken', valued: true }, { name: 'Clumsy', valued: true }, { name: 'Concealed', valued: false }, { name: 'Confused', valued: false }, diff --git a/src/lib/rules/dnd5e/index.ts b/src/lib/rules/dnd5e/index.ts index 724f75b..3622390 100644 --- a/src/lib/rules/dnd5e/index.ts +++ b/src/lib/rules/dnd5e/index.ts @@ -83,7 +83,8 @@ export const dnd5e: RulesSystem = { const misc = input.armorBonus ?? 0; if (input.equippedArmor) { const { baseAc, dexCap } = input.equippedArmor; - return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + misc; + // Heavy armor (dexCap 0) ignores Dex entirely — a negative Dex must not lower AC. + return baseAc + (dexCap === null ? dex : dexCap === 0 ? 0 : Math.min(dex, dexCap)) + misc; } // Unarmored AC. Shield/misc bonuses are represented as armorBonus on top. return 10 + dex + misc; diff --git a/src/lib/rules/features/collect.ts b/src/lib/rules/features/collect.ts index f1268bb..13279f9 100644 --- a/src/lib/rules/features/collect.ts +++ b/src/lib/rules/features/collect.ts @@ -113,5 +113,26 @@ export function collectChoices(system: SystemId, classes: readonly ClassRef[]): export function collectFeatures(system: SystemId, classes: readonly ClassRef[]): UnlockedFeature[] { return system === 'pf2e' ? collectFeaturesPf2e(classes) : collectFeatures5e(classes); } +/** + * The subclass selection a class makes (5e: at its archetype level; pf2e: at level 1), + * with the options to choose from. Used by the level-up flow to prompt the pick when the + * character reaches that level without a subclass set. Returns undefined if the class has + * no modeled subclasses. + */ +export function subclassPrompt(system: SystemId, className: string): { level: number; label: string; options: string[] } | undefined { + if (system === 'pf2e') { + const name = canonicalPf2e(className); + const subs = PF2E_SUBCLASSES[name]; + if (!subs || subs.length === 0) return undefined; + return { level: 1, label: PF2E_SUBCLASS_LABEL[name] ?? 'Subclass', options: subs.map((s) => s.name) }; + } + const prog = progFor5e(className); + if (!prog) return undefined; + const key = Object.keys(CLASS_PROGRESSION_5E).find((k) => k.toLowerCase() === className.trim().toLowerCase()) ?? className; + const options = (DND5E_SUBCLASSES[key] ?? []).map((s) => s.name); + if (options.length === 0) return undefined; + return { level: prog.subclass.level, label: prog.subclass.label, options }; +} + // Back-compat (5e-only) exports. export { collectChoices5e, collectFeatures5e }; diff --git a/src/lib/rules/features/features.test.ts b/src/lib/rules/features/features.test.ts index cf880f5..e82c182 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, collectChoices, collectFeatures, countAtLevel } from './collect'; +import { collectChoices5e, collectFeatures5e, collectChoices, collectFeatures, countAtLevel, subclassPrompt } from './collect'; const byKey = (cs: ReturnType, k: string) => cs.find((c) => c.key === k); @@ -96,3 +96,20 @@ describe('PF2e feature/choice engine (parity)', () => { expect(fs.some((f) => f.name === 'Dragon Instinct')).toBe(true); }); }); + +describe('subclassPrompt', () => { + it('5e Fighter chooses its archetype at level 3 with PHB options', () => { + const p = subclassPrompt('5e', 'Fighter'); + expect(p?.level).toBe(3); + expect(p?.options).toContain('Battle Master'); + }); + it('pf2e classes choose their defining feature at level 1', () => { + const p = subclassPrompt('pf2e', 'Barbarian'); + expect(p?.level).toBe(1); + expect(p?.label).toBe('Instinct'); + expect(p?.options).toContain('Dragon Instinct'); + }); + it('returns undefined for a class with no modeled subclasses', () => { + expect(subclassPrompt('5e', 'Nonexistent')).toBeUndefined(); + }); +}); diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index 5feb3c8..e8a0961 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -20,7 +20,8 @@ export * from './types'; export * from './progression'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities'; +export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, type AbilityBreakdown } from './abilityBuild'; export { applyRest } from './rest'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; -export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e } from './features/collect'; +export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect'; export type { UnlockedChoice, UnlockedFeature } from './features/types'; diff --git a/src/lib/rules/pf2e/index.ts b/src/lib/rules/pf2e/index.ts index 2d4233b..fcfe72f 100644 --- a/src/lib/rules/pf2e/index.ts +++ b/src/lib/rules/pf2e/index.ts @@ -95,7 +95,8 @@ export const pf2e: RulesSystem = { if (input.equippedArmor) { const { baseAc, dexCap } = input.equippedArmor; // baseAc embeds the armor's flat AC (10 + armor item bonus); add Dex + prof + misc. - return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + prof + misc; + // dexCap 0 ignores Dex entirely — a negative Dex must not lower AC. + return baseAc + (dexCap === null ? dex : dexCap === 0 ? 0 : Math.min(dex, dexCap)) + prof + misc; } return 10 + dex + prof + misc; }, diff --git a/src/lib/rules/rest.ts b/src/lib/rules/rest.ts index 7f75183..c4cb314 100644 --- a/src/lib/rules/rest.ts +++ b/src/lib/rules/rest.ts @@ -1,5 +1,6 @@ import type { Character } from '@/lib/schemas/character'; import type { RestOption } from './types'; +import { pf2eRestDecay } from '@/lib/mechanics/dying'; /** * Compute the character changes a rest produces. Pure — returns a partial patch @@ -43,6 +44,17 @@ export function applyRest(c: Character, opt: RestOption): Partial { }; } + // pf2e: a full night's rest decays doomed (−1) and drained (−1), clears wounded + // (you wake at full HP) and ends fatigued. Dying is assumed already 0. + if (c.system === 'pf2e') { + if (opt.restoresHp) { + const decay = pf2eRestDecay(c.defenses, c.conditions); + patch.defenses = { ...c.defenses, ...decay.defenses }; + patch.conditions = decay.conditions; + } + return patch; + } + const exhaustion = opt.reduceExhaustion && c.defenses.exhaustion > 0 ? c.defenses.exhaustion - 1 diff --git a/src/lib/rules/rules.test.ts b/src/lib/rules/rules.test.ts index dd5d560..7a220cb 100644 --- a/src/lib/rules/rules.test.ts +++ b/src/lib/rules/rules.test.ts @@ -72,6 +72,16 @@ describe('5e derived stats', () => { const ac = dnd5e.baseArmorClass(input); expect(ac).toBe(10 + 2); }); + + it('heavy armor (dexCap 0) ignores a negative DEX rather than penalising AC', () => { + // regression: Math.min(dexMod, 0) wrongly applied a negative DEX to heavy armor. + const lowDex: CharacterRulesInput = { + level: 1, + abilities: { str: 16, dex: 8, con: 14, int: 10, wis: 12, cha: 8 }, // DEX 8 = -1 + equippedArmor: { baseAc: 18, dexCap: 0 }, // plate + }; + expect(dnd5e.baseArmorClass(lowDex)).toBe(18); // not 17 + }); }); describe('pf2e proficiency', () => { diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index 61d178d..9bafadc 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -140,6 +140,27 @@ export const defensesSchema = z.object({ }); export type Defenses = z.infer; +/** + * One contribution to an ability score, kept so the sheet can show WHERE each point + * comes from (the old app showed only opaque finals). `kind: 'boost'` is a PF2e boost + * (+2 while below 18, else +1, applied in order); `kind: 'flat'` is an exact amount + * (5e racial/ASI bonus, a PF2e ancestry flaw of −2, or a manual tweak). + */ +export const abilityAdjustmentSchema = z.object({ + label: z.string().min(1).max(60), + ability: abilityKeySchema, + kind: z.enum(['boost', 'flat']), + amount: int.default(0), +}); +export type AbilityAdjustment = z.infer; + +/** A character's ability scores as base + ordered contributions (final = replay). */ +export const abilityBuildSchema = z.object({ + base: abilityScoresSchema, + adjustments: z.array(abilityAdjustmentSchema).default([]), +}); +export type AbilityBuild = z.infer; + export const characterSchema = z.object({ id: z.string(), /** Owning campaign; '' = unassigned (PCs can be created without a campaign and attached later). */ @@ -170,6 +191,10 @@ export const characterSchema = z.object({ personality: z.string().max(4000).default(''), abilities: abilityScoresSchema, + /** Optional source breakdown for `abilities` (base + per-source contributions), so the + * sheet can show where each point comes from. Absent on legacy characters (the sheet + * synthesizes a manual build from the finals). `abilities` stays the canonical total. */ + abilityBuild: abilityBuildSchema.optional(), hp: hpSchema, speed: int.nonnegative().default(30), /** shield / misc AC bonus on top of worn armor */ diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9eb80df..9941b6d 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/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.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/DirectorSettings.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/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.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/aiSession.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/directorStore.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/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.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/DirectorSettings.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/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.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/dying.test.ts","./src/lib/mechanics/dying.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/abilityBuild.test.ts","./src/lib/rules/abilityBuild.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/aiSession.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/directorStore.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