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(' · ')}
+ )}
-
+
Cur update({ hp: { ...c.hp, current } })} aria-label="Current HP" />
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" />
+
+
+
+
setDefenses({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point">
+
{d.heroPoints}
+
setDefenses({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">
+
+
+
+
+ {d.dying === 0 ? (
+
+
Knocked to 0 HP? Enter dying (adds your Wounded value{d.wounded > 0 ? ` of ${d.wounded}` : ''}).
+
+
+ setFromCrit(e.target.checked)} /> crit
+
+ Knock out
+
+
+ ) : (
+
+
+ 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):
+
+ doRecover('critical-success')}>Crit success −2
+ doRecover('success')}>Success −1
+ doRecover('failure')}>Failure +1
+ doRecover('critical-failure')}>Crit fail +2
+
+
+ )}
+
+
+ );
+}
+
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({ inspiration: !d.inspiration })}>
- {d.inspiration ? <> Inspired> : 'Grant inspiration'}
-
-
-
- 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" />
-
-
-
-
setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point">
-
{d.heroPoints}
-
setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">
-
-
- >
- )}
-
+ {c.system === '5e' ? (
+
+
+
+
+ Successes
+ setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
+
+
+ Failures
+ setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
+
+
+
+
+ setD({ inspiration: !d.inspiration })}>
+ {d.inspiration ? <> Inspired> : 'Grant inspiration'}
+
+
+
+ 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}
+ setSubclassPick(e.target.value)} aria-label="Subclass">
+ {sub5e.options.map((o) => {o} )}
+
+
+ )}
+
+ {/* 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)}>
+ {choice.options.map((o) => {o} )}
+
+ ) : (
+ 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.
- void useCloud()}>Use cloud version (reload)
+ void acceptCloud()}>Use cloud version (reload)
void keepMine()}>Keep this device (overwrite cloud)
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