diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 25bda40..bf18567 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) { update({ ancestry: e.target.value })} /> + {c.system === 'pf2e' && ( + + {/* pre-migration rows lack the field — keep the input controlled */} + update({ heritage: e.target.value })} /> + + )} {c.system === '5e' ? (
) : ( @@ -231,7 +237,8 @@ export function CharacterSheet({ character }: { character: Character }) {
Ability Scores - + {/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */} + {c.system === '5e' && }
{ABILITIES.map((a) => { @@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) { function HpCard({ c, update }: { c: Character; update: (p: Partial) => void }) { const [delta, setDelta] = useState(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 apply = (mode: 'damage' | 'heal') => { if (!Number.isFinite(delta) || delta <= 0) return; if (mode === 'damage') { const absorbed = Math.min(c.hp.temp, delta); update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } }); } else { - update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } }); + // Heal caps at the EFFECTIVE max (drained / exhaustion 4 reduce it). + const current = Math.min(effMax, c.hp.current + delta); + const patch: Partial = { hp: { ...c.hp, current } }; + // pf2e: healing above 0 HP ends dying and wakes the character (pure bookkeeping). + // Losing the dying condition always increases wounded by 1. + if (c.system === 'pf2e' && c.hp.current <= 0 && current > 0) { + if (c.defenses.dying > 0) patch.defenses = { ...c.defenses, dying: 0, wounded: c.defenses.wounded + 1 }; + patch.conditions = c.conditions.filter((x) => x.name.trim().toLowerCase() !== 'unconscious'); + } + update(patch); } setDelta(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 (
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 8a500c6..a83e9b1 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -6,8 +6,10 @@ import { charactersRepo } from '@/lib/db/repositories'; import { getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS, pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, + bumpRank, collectChoices, type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId, } from '@/lib/rules'; +import { pf2eSkillIncreaseLevels } from '@/lib/rules/pf2e/progression'; import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium'; import type { RulesetClass } from '@/lib/ruleset/normalize'; @@ -179,6 +181,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const [classSlug, setClassSlug] = useState(''); const [subclass, setSubclass] = useState(''); const [ancestry, setAncestry] = useState(''); + // PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data + // carries no ancestry link, so we match by name ("Ancient-Blooded Dwarf") and always + // include versatile heritages (selectable by any ancestry). + const [heritage, setHeritage] = useState(''); + const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; versatile: boolean }[]>([]); + useEffect(() => { + if (system !== 'pf2e' || allHeritages.length) return; + let on = true; + void loadPf2e('heritages').then((hs) => on && setAllHeritages(hs.map((h) => ({ + name: String(h.name), + summary: briefOverview(String((h.summary ?? h.text) ?? '')), + versatile: /versatile heritage/i.test(String(h.text ?? h.summary ?? '')), + })))); + return () => { on = false; }; + }, [system, allHeritages.length]); + const heritageOptions = useMemo(() => { + if (system !== 'pf2e' || !ancestry.trim()) return []; + const want = ancestry.trim().toLowerCase(); + return allHeritages.filter((h) => h.name.toLowerCase().includes(want) || h.versatile); + }, [system, ancestry, allHeritages]); + useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage const [background, setBackground] = useState(''); const selectedClass = classes.find((c) => c.slug === classSlug) ?? null; @@ -325,6 +348,43 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un // Drop any free pick that becomes granted (e.g. after switching background). useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]); + // Higher-level creation owes the picks earned along the way — collect them here + // rather than deferring to the level-up flow: PF2e skill increases (levels 3,5,7…) + // and 5e Expertise (Bard/Rogue, scaling with level). + const skillIncLevels = useMemo( + () => (system === 'pf2e' ? pf2eSkillIncreaseLevels().filter((l) => l <= level) : []), + [system, level], + ); + const expertiseCount = useMemo(() => { + if (system !== '5e' || !selectedClass) return 0; + const ch = collectChoices('5e', [{ className: selectedClass.name, level }]).find((x) => x.key.endsWith(':expertise')); + return ch?.count ?? 0; + }, [system, selectedClass, level]); + const [skillIncPicks, setSkillIncPicks] = useState([]); + const [expertisePicks, setExpertisePicks] = useState([]); + useEffect(() => { setSkillIncPicks([]); setExpertisePicks([]); }, [classSlug, level, system]); + // Seed sensible defaults from the chosen/granted skills (kept once the user edits). + useEffect(() => { + setExpertisePicks((prev) => Array.from({ length: expertiseCount }, (_, i) => prev[i] || skills[i] || skills[0] || '')); + }, [expertiseCount, skills]); + useEffect(() => { + const pool = [...skills, ...grantedSkills]; + setSkillIncPicks((prev) => skillIncLevels.map((_, i) => prev[i] || pool[i % Math.max(1, pool.length)] || '')); + }, [skillIncLevels, skills, grantedSkills]); + /** Skill ranks after base training + the first `uptoIdx` increases (for previews). */ + const ranksAfterIncreases = (uptoIdx: number): Record => { + const r: Record = {}; + for (const k of [...skills, ...grantedSkills]) r[k] = 'trained'; + for (let i = 0; i < uptoIdx; i++) { const k = skillIncPicks[i]; if (k) r[k] = bumpRank(r[k] ?? 'untrained'); } + return r; + }; + /** PF2e rank caps: master needs the increase earned at level 7+, legendary 15+. */ + const incAllowed = (rank: ProficiencyRank, earnedAt: number): boolean => + rank === 'untrained' || rank === 'trained' ? true + : rank === 'expert' ? earnedAt >= 7 + : rank === 'master' ? earnedAt >= 15 + : false; + // ---- spells ---- const [spellPicks, setSpellPicks] = useState([]); const [spellQuery, setSpellQuery] = useState(''); @@ -390,7 +450,9 @@ 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, freeSkillOptions.length), + Skills: skills.length === Math.min(skillCount, freeSkillOptions.length) + && skillIncPicks.every(Boolean) + && expertisePicks.every(Boolean), Spells: true, Details: true, Review: true, @@ -408,13 +470,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank; } const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) })); + // Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks. + const skillRanks: Record = { ...built.skillRanks }; + for (const k of expertisePicks) if (k) skillRanks[k] = 'expert'; + for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained'); await charactersRepo.update(created.id, { ...built, + skillRanks, abilityBuild: buildAbilityBuild(), ...(Object.keys(saveRanks).length ? { saveRanks } : {}), classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }], spellcasting: { ...built.spellcasting, spells }, ...(background ? { background } : {}), + ...(heritage ? { heritage } : {}), ...(alignment.trim() ? { alignment: alignment.trim() } : {}), ...(appearance.trim() ? { appearance: appearance.trim() } : {}), ...(personality.trim() ? { personality: personality.trim() } : {}), @@ -540,6 +608,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
+ {system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && ( +
+ + + + {heritage && ( +

{heritageOptions.find((h) => h.name === heritage)?.summary}

+ )} +
+ )} + {!ancestry.trim() && ( +

+ No {system === 'pf2e' ? 'ancestry' : 'race'} selected — you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills won’t be applied. +

+ )} {selectedClass && selectedOrigin && (() => { const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities); if (!synergy) return null; @@ -658,7 +744,17 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const value = base + racial + asi; const isKey = selectedClass?.keyAbilities.includes(a); const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; - const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30; + // Point-buy caps each field at what the remaining budget can afford, + // so the user can't overspend and wonder why Next is disabled. + const fMax = method === 'pointbuy' + ? (() => { + let best = pb[i]!; + for (let cand = pb[i]! + 1; cand <= POINT_BUY_MAX; cand++) { + if (pointBuyRemaining(pb.map((x, j) => (j === i ? cand : x))) >= 0) best = cand; else break; + } + return best; + })() + : 30; return (
{ABILITY_ABBR[a]}{isKey && }
@@ -694,7 +790,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un {stepName === 'Skills' && (
-

Choose {Math.min(skillCount, freeSkillOptions.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{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}). +

{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 && (
@@ -708,13 +806,6 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
)} - {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 ? ( @@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un ); })}
+ {skills.length < Math.min(skillCount, freeSkillOptions.length) && ( +

Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.

+ )} + + {/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */} + {skillIncLevels.length > 0 && ( +
+

Skill increases (earned at levels {skillIncLevels.join(', ')})

+

Each increase raises one skill a rank (Master from level 7, Legendary from level 15).

+
+ {skillIncLevels.map((earnedAt, i) => { + const ranks = ranksAfterIncreases(i); + return ( + + ); + })} +
+
+ )} + + {/* 5e: Expertise picks (Bard/Rogue) — double proficiency on chosen skills. */} + {expertiseCount > 0 && ( +
+

Expertise (choose {expertiseCount} — proficiency doubled)

+
+ {Array.from({ length: expertiseCount }, (_, i) => ( + + ))} +
+
+ )}
)} {stepName === 'Spells' && (

Pick your cantrips and a few starting spells — about {suggestedSpells} is a good start at level {level}. Search by name; you can always adjust on the sheet later.

-

These are the spells you {system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells you’ve learned)'}. You prepare or cast them from slots on the character sheet.

+

+ {(() => { + // Prepared casters re-select daily from a book/list; known casters have a + // fixed repertoire. Different mental model, so spell out which applies. + const prepared = (system === '5e' ? ['cleric', 'druid', 'paladin', 'wizard', 'artificer'] : ['wizard', 'cleric', 'druid', 'witch', 'magus', 'animist']) + .includes((selectedClass?.name ?? '').toLowerCase()); + return prepared ? ( + <>As a prepared caster, these go into your {system === 'pf2e' ? 'spellbook/list' : 'spellbook'} — each day you prepare a subset on the sheet, so pick a versatile starting set. + ) : ( + <>These are the spells you know (your repertoire) — a fixed list you cast from slots; you can swap picks when you level up. + ); + })()} +

suggestedSpells + 4 ? 'text-warning' : 'text-muted')}> {spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}

@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
`${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} /> {background && } - + {heritage && } + `${skillLabel(g.key)} (${g.source})`), + ].join(', ') || '—'} /> {built.spellcasting.slots.length > 0 && `${s.max}×L${s.level}`).join(' ')} />} {spellPicks.length > 0 && s.name).join(', ')} />}

You can fine-tune equipment, feats, and more on the sheet next.

diff --git a/src/features/characters/sheet/DefensesSection.tsx b/src/features/characters/sheet/DefensesSection.tsx index 587501d..5e95eed 100644 --- a/src/features/characters/sheet/DefensesSection.tsx +++ b/src/features/characters/sheet/DefensesSection.tsx @@ -3,7 +3,7 @@ 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 { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue } from '@/lib/mechanics'; import type { Degree } from '@/lib/dice/check'; import type { Condition } from '@/lib/schemas/common'; import { SheetSection, type SectionProps } from './common'; @@ -39,17 +39,27 @@ function Pf2eDefenses({ c, update }: SectionProps) { 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). */ + // You stay Unconscious at 0 HP even after recovering out of dying — only healing + // above 0 HP (or the GM) wakes you. So the sync is: dying>0 OR hp<=0 ⇒ unconscious. + const syncUnconscious = (dying: number, hpCurrent: number) => + dying > 0 || hpCurrent <= 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions); + /** Set dying and keep the Unconscious condition in sync. */ const setDying = (dying: number) => - update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + update({ defenses: { ...d, dying }, conditions: syncUnconscious(dying, c.hp.current) }); const doKnockOut = () => { const { dying } = knockOut(d, fromCrit); - update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) }); + // Knocked out = at 0 HP and dying; drop HP to 0 so the sheet state is coherent. + update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions), hp: { ...c.hp, current: Math.min(0, c.hp.current) } }); }; const doRecover = (degree: Degree) => { const res = applyRecovery(d, degree); - update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + update({ defenses: { ...d, ...res }, conditions: syncUnconscious(res.dying, c.hp.current) }); + }; + const doHeroRescue = () => { + const res = heroPointRescue(d); + if (!res) return; + update({ defenses: { ...d, ...res }, conditions: syncUnconscious(0, c.hp.current) }); }; return ( @@ -98,6 +108,12 @@ function Pf2eDefenses({ c, update }: SectionProps) { + {d.heroPoints >= 1 && ( +
+ Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize. + +
+ )} )} diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx index 43eb0d7..fc80eeb 100644 --- a/src/features/characters/sheet/LevelUpModal.tsx +++ b/src/features/characters/sheet/LevelUpModal.tsx @@ -1,17 +1,19 @@ import { useEffect, useMemo, useState } from 'react'; -import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas'; +import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas'; import { normalizeClassMirror, totalLevel } from '@/lib/schemas'; import { abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, - planLevelUp, applyIncreases, bumpRank, getClassDef, + planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource, collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature, } from '@/lib/rules'; import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression'; +import { loadPf2e } from '@/lib/compendium'; import { rollDice } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; +import { FeatPickerModal } from './FeatPickerModal'; import { LevelUpAdvisor } from './LevelUpAdvisor'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; @@ -54,6 +56,9 @@ export function LevelUpModal({ character, onApply, onClose }: { const keyDefaults = def?.keyAbilities ?? ['str', 'dex']; const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi'); const [asiPicks, setAsiPicks] = useState([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']); + // Feat-instead-of-ASI (5e): picked right here, recorded onto the sheet on apply. + const [featPick, setFeatPick] = useState(null); + const [featBrowse, setFeatBrowse] = useState(false); // Boosts (pf2e): four distinct picks. const [boostPicks, setBoostPicks] = useState(['str', 'dex', 'con', 'wis']); // Skill increase (pf2e): one skill to bump. @@ -109,6 +114,15 @@ export function LevelUpModal({ character, onApply, onClose }: { const [choicePicks, setChoicePicks] = useState>({}); const [subclassPick, setSubclassPick] = useState(''); + // PF2e feat-slot inputs autocomplete against the compendium so names land typo-free. + const [pf2eFeatNames, setPf2eFeatNames] = useState([]); + const wantsFeatSuggestions = !is5e && pendingChoices.some(({ choice }) => !choice.options && /-feat$/.test(choice.key.split(':')[1] ?? '')); + useEffect(() => { + if (!wantsFeatSuggestions || pf2eFeatNames.length) return; + let on = true; + void loadPf2e('feats').then((fs) => on && setPf2eFeatNames(fs.map((f) => String(f.name)).filter(Boolean))); + return () => { on = false; }; + }, [wantsFeatSuggestions, pf2eFeatNames.length]); 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. @@ -131,9 +145,16 @@ export function LevelUpModal({ character, onApply, onClose }: { ? plan.hpGainAverage : hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod); + // Ability increases go through the build so the sheet's per-source breakdown + // stays in sync with the totals (the build is the single source of truth). let abilities = character.abilities; - if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); - if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); + let abilityBuild = character.abilityBuild; + if (asi && asiMode === 'asi') { + ({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, asiPicks, '5e', `ASI L${plan.nextLevel}`)); + } + if (boosts) { + ({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`)); + } // Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry. const subFromChoice = Object.entries(choicePicks) @@ -156,6 +177,7 @@ export function LevelUpModal({ character, onApply, onClose }: { const patch: Partial = { hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, abilities, + ...(abilityBuild ? { abilityBuild } : {}), classes: nextClassesFinal, ...normalizeClassMirror({ classes: nextClassesFinal }), choices: merged, @@ -180,6 +202,31 @@ export function LevelUpModal({ character, onApply, onClose }: { const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained'; patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) }; } + // 5e Expertise picks aren't just recorded — they raise the skill rank to expert + // so the doubled proficiency actually lands in the math. + const expertisePicks = Object.entries(choicePicks).filter(([k, v]) => k.endsWith(':expertise') && v.some((x) => x.trim())); + if (expertisePicks.length) { + const ranks: Record = { ...(patch.skillRanks ?? character.skillRanks) as Record }; + for (const [, vals] of expertisePicks) { + for (const label of vals) { + const want = label.trim().toLowerCase(); + const key = sys.skills.find((s) => s.label.toLowerCase() === want || s.key === want)?.key; + if (key) ranks[key] = 'expert'; + } + } + patch.skillRanks = ranks; + } + // 5e: each level grants a Hit Die — keep the tracked resource in step. + if (is5e) { + const newTotal = Math.min(20, total + 1); + const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice'); + patch.resources = i >= 0 + ? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.ceil(newTotal / 2) } : r)) + : [...character.resources, hitDiceResource(newTotal)]; + } + if (asi && asiMode === 'feat' && featPick) { + patch.feats = [...character.feats, featPick]; + } onApply(patch); onClose(); }; @@ -262,10 +309,18 @@ export function LevelUpModal({ character, onApply, onClose }: { pick the same twice for +2 ) : ( -

Add your chosen feat on the sheet after leveling.

+
+ {featPick ? ( + {featPick.name} + ) : ( + No feat chosen yet. + )} + +
)}
)} + {featBrowse && setFeatPick(f)} onClose={() => setFeatBrowse(false)} />} {/* Boosts (pf2e) */} {boosts && ( @@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: { {choice.options.map((o) => )} ) : ( - setChoicePick(choice.key, i, e.target.value)} /> + setChoicePick(choice.key, i, v)} + suggestions={/-feat$/.test(choice.key.split(':')[1] ?? '') ? pf2eFeatNames : []} + /> ) ))} @@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: { ); } + +/** Free-text input with a lightweight suggestion dropdown (used for PF2e feat names). */ +function SuggestInput({ value, onChange, suggestions, ariaLabel, placeholder }: { + value: string; + onChange: (v: string) => void; + suggestions: string[]; + ariaLabel: string; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const q = value.trim().toLowerCase(); + const matches = q.length >= 2 ? suggestions.filter((n) => n.toLowerCase().includes(q) && n !== value).slice(0, 8) : []; + return ( +
+ { onChange(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + {open && matches.length > 0 && ( +
+ {matches.map((n) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/characters/sheet/ResourcesSection.tsx b/src/features/characters/sheet/ResourcesSection.tsx index 83dd923..f6dbd84 100644 --- a/src/features/characters/sheet/ResourcesSection.tsx +++ b/src/features/characters/sheet/ResourcesSection.tsx @@ -17,6 +17,17 @@ const RECOVERY_LABEL: Record = { none: 'Manual', }; +/** Tooltip spelling out EVERYTHING the rest does, not just resource tags. */ +function restTitle(system: string, optId: string, recovers: readonly string[]): string { + if (system === 'pf2e' && optId === 'rest') { + return 'Rest for the Night: restore HP (capped by Drained), refill spell slots, clear Wounded, reduce Drained and Doomed by 1, remove Fatigued.'; + } + if (system === 'pf2e' && optId === 'refocus') return 'Refocus (10 min): regain 1 Focus Point.'; + if (optId === 'long') return 'Long rest: restore HP, spell slots, and long-rest resources (Hit Dice recover half your level); exhaustion −1; death saves reset.'; + if (optId === 'short') return 'Short rest: regain pact slots and short-rest resources; spend Hit Dice to heal.'; + return `Restore: ${recovers.join(', ')}`; +} + export function ResourcesSection({ c, update }: SectionProps) { const [name, setName] = useState(''); const sys = getSystem(c.system); @@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) { actions={
{sys.restOptions.map((o) => ( - ))} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index f289c33..5068ae8 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -23,7 +23,7 @@ import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { getSystem, getConditions, type SystemId } from '@/lib/rules'; import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; -import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics'; +import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics'; import { useCharacters, useAllPcs } from '@/features/characters/hooks'; import { useConditionGlossary } from './useConditionGlossary'; import { @@ -82,6 +82,9 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter */ const noteConcentrationCheck = (combatant: Combatant, damage: number) => { if (damage <= 0) return; + // 5e only: damage forces a Con save to hold concentration. PF2e has no such save — + // sustained spells simply require the Sustain action, so there is nothing to roll. + if (campaign.system !== '5e') return; // The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to // the linked Character's concentration (set when a seated player casts a spell). const ch = pcCharacter(combatant); @@ -92,12 +95,29 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`)); }; + /** Effective max HP for a combatant — drained / exhaustion 4 reduce it. */ + const effMaxOf = (c: Combatant) => deriveEffectiveMaxHp({ + system: campaign.system, + baseMaxHp: c.hp.max, + level: c.level ?? 1, + exhaustion: c.conditions.find((x) => x.name.trim().toLowerCase() === 'exhaustion')?.value ?? 0, + conditions: c.conditions, + }).max; + /** Advance the turn; remind (don't roll) when a downed PC's turn begins. */ const advanceTurn = () => { mutate((e) => nextTurn(e, campaign.system)); const upcoming = currentCombatant(nextTurn(encounter, campaign.system)); if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) { - void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`)); + const reminder = campaign.system === 'pf2e' + ? (() => { + const dying = pcCharacter(upcoming)?.defenses.dying ?? 0; + return dying > 0 + ? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.` + : `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`; + })() + : `${upcoming.name} is down — make a death saving throw.`; + void encountersRepo.mutate(encounter.id, (e) => logEvent(e, reminder)); } }; @@ -246,11 +266,17 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter ? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage` : `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`; // Surface (don't auto-apply) the death rules for downed PCs — the player - // owns their character's death-save count, so we remind rather than mutate it. + // owns their character's death state, so we remind rather than mutate it. let reminder: string | null = null; if (c.kind !== 'monster' && hpLoss > 0) { - if (isMassiveDamageDeath(c.hp.max, after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`; - else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`; + if (campaign.system === 'pf2e') { + if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`; + else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`; + } else { + // Massive damage compares against the (possibly reduced) effective max. + if (isMassiveDamageDeath(effMaxOf(c), after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`; + else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`; + } } mutate((e) => { let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note); @@ -259,7 +285,20 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter }); noteConcentrationCheck(c, hpLoss); }} - onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))} + onHeal={(amt) => { + // Cap at the effective max (drained / exhaustion 4), and for pf2e let + // healing above 0 do the wake-up bookkeeping (Dying ends → Wounded +1). + const healed = applyHealing(c, amt, effMaxOf(c)); + const woke = campaign.system === 'pf2e' && c.hp.current <= 0 && healed.hp.current > 0; + const conditions = woke + ? c.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase())) + : c.conditions; + mutate((e) => { + let next = logEvent(updateCombatant(e, c.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${amt}`); + if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`); + return next; + }); + }} onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))} onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))} /> @@ -489,7 +528,11 @@ function CombatantRow({ type="button" onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })} className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')} - title={c.concentrating ? `Concentrating on ${c.concentrating} — click to clear` : 'Mark as concentrating (prompts a Con save when damaged)'} + title={c.concentrating + ? `Concentrating on ${c.concentrating} — click to clear` + : system === 'pf2e' + ? 'Mark as sustaining a spell (a reminder, in case they stop Sustaining)' + : 'Mark as concentrating (prompts a Con save when damaged)'} aria-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`} aria-pressed={!!c.concentrating} > @@ -513,17 +556,41 @@ function CombatantRow({ {c.conditions.length > 0 && (
{c.conditions.map((cond, i) => ( - + {/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */} + {cond.value !== undefined && ( + <> + + + + )} + + ))}
)} diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index 8e266d2..2ab52cb 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -189,6 +189,13 @@ describe('HP transitions', () => { expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change expect(applyDamage(im, 10, 'cold').hp.current).toBe(30); }); + + it('healing honors a reduced effective-max cap (drained / exhaustion 4)', () => { + const c = { ...mk('a', 0, 30), hp: { current: 10, max: 30, temp: 0 } }; + expect(applyHealing(c, 25, 20).hp.current).toBe(20); // capped at effective max + expect(applyHealing(c, 25).hp.current).toBe(30); // no cap → stored max + expect(applyHealing(c, 5, 40).hp.current).toBe(15); // cap never exceeds stored max + }); }); describe('endEncounter', () => { diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index 4b6884e..0866b73 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -267,10 +267,15 @@ export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boole return currentAfter <= 0 && -currentAfter >= hpMax; } -/** Heal up to max. Does not touch temporary HP. */ -export function applyHealing(c: Combatant, amount: number): Combatant { +/** + * Heal up to max. Does not touch temporary HP. `capMax` overrides the cap when the + * effective maximum is reduced by conditions (5e exhaustion 4, pf2e drained) — pass + * `deriveEffectiveMaxHp(...).max` so healing can't exceed the reduced maximum. + */ +export function applyHealing(c: Combatant, amount: number, capMax?: number): Combatant { if (!Number.isFinite(amount) || amount <= 0) return c; - return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } }; + const cap = capMax !== undefined ? Math.min(capMax, c.hp.max) : c.hp.max; + return { ...c, hp: { ...c.hp, current: Math.min(cap, c.hp.current + amount) } }; } /** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */ diff --git a/src/lib/io/pathbuilder.ts b/src/lib/io/pathbuilder.ts index a69a236..201e314 100644 --- a/src/lib/io/pathbuilder.ts +++ b/src/lib/io/pathbuilder.ts @@ -1,4 +1,5 @@ import type { Character } from '@/lib/schemas'; +import { synthManualBuild } from '@/lib/rules/abilityBuild'; /** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */ @@ -43,10 +44,14 @@ export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial (value !== undefined ? { name, value } : { name }); @@ -50,6 +50,16 @@ describe('applyRecovery', () => { }); }); +describe('heroPointRescue', () => { + it('spends all hero points to drop to dying 0 and increases wounded', () => { + expect(heroPointRescue({ dying: 3, heroPoints: 2, wounded: 1 })).toEqual({ dying: 0, heroPoints: 0, wounded: 2 }); + }); + it('returns null when not dying or no points to spend', () => { + expect(heroPointRescue({ dying: 0, heroPoints: 2, wounded: 0 })).toBeNull(); + expect(heroPointRescue({ dying: 2, heroPoints: 0, wounded: 0 })).toBeNull(); + }); +}); + 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)]); diff --git a/src/lib/mechanics/dying.ts b/src/lib/mechanics/dying.ts index 35b9586..9acd263 100644 --- a/src/lib/mechanics/dying.ts +++ b/src/lib/mechanics/dying.ts @@ -45,6 +45,16 @@ export function applyRecovery(d: Pick, degree: De return { dying, wounded }; } +/** + * Heroic Recovery: spend ALL remaining Hero Points (minimum 1) to avoid death — + * dying drops to 0 and you stabilize (you stay unconscious at 0 HP until healed). + * Losing the dying condition always increases wounded by 1. + */ +export function heroPointRescue(d: Pick): { dying: number; heroPoints: number; wounded: number } | null { + if (d.heroPoints < 1 || d.dying <= 0) return null; + return { dying: 0, heroPoints: 0, wounded: d.wounded + 1 }; +} + /** * 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 diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts index 973079d..58e6798 100644 --- a/src/lib/mechanics/index.ts +++ b/src/lib/mechanics/index.ts @@ -18,7 +18,7 @@ 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 { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying'; export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects'; export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState'; diff --git a/src/lib/rules/abilityBuild.test.ts b/src/lib/rules/abilityBuild.test.ts index f8ec87a..6b011bd 100644 --- a/src/lib/rules/abilityBuild.test.ts +++ b/src/lib/rules/abilityBuild.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal } from './abilityBuild'; +import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, appendLevelIncreases } from './abilityBuild'; import type { AbilityBuild } from '@/lib/schemas/character'; const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; @@ -66,3 +66,36 @@ describe('setManualTotal', () => { expect(computeAbilities(reset).str).toBe(12); }); }); + +describe('appendLevelIncreases', () => { + it('5e ASI picks append as flat +1 each and totals match', () => { + const build: AbilityBuild = { base: { ...base10, str: 15 }, adjustments: [{ label: 'Race', ability: 'str', kind: 'flat', amount: 2 }] }; + const current = computeAbilities(build); // str 17 + const r = appendLevelIncreases(build, current, ['str', 'str'], '5e', 'ASI L4'); + expect(r.abilities.str).toBe(19); + expect(computeAbilities(r.build)).toEqual(r.abilities); // breakdown stays in sync + expect(r.build.adjustments.filter((a) => a.label === 'ASI L4')).toHaveLength(2); + }); + + it('pf2e boosts append with the <18 (+2) / ≥18 (+1) rule', () => { + const build: AbilityBuild = { base: base10, adjustments: [ + { label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Class', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, + ] }; + const current = computeAbilities(build); // str 16 + const r = appendLevelIncreases(build, current, ['str', 'dex'], 'pf2e', 'L5 boost'); + expect(r.abilities.str).toBe(18); // 16 → +2 + expect(r.abilities.dex).toBe(12); + const r2 = appendLevelIncreases(r.build, r.abilities, ['str'], 'pf2e', 'L10 boost'); + expect(r2.abilities.str).toBe(19); // 18 → +1 + expect(computeAbilities(r2.build)).toEqual(r2.abilities); + }); + + it('synthesizes a manual build when the stored one is stale', () => { + const stale: AbilityBuild = { base: base10, adjustments: [] }; // says 10s + const current = { ...base10, str: 16 }; // but totals were edited elsewhere + const r = appendLevelIncreases(stale, current, ['str'], '5e', 'ASI L4'); + expect(r.abilities.str).toBe(17); // 16 + 1, not 11 + }); +}); diff --git a/src/lib/rules/abilityBuild.ts b/src/lib/rules/abilityBuild.ts index 48e4cda..a226e13 100644 --- a/src/lib/rules/abilityBuild.ts +++ b/src/lib/rules/abilityBuild.ts @@ -1,4 +1,4 @@ -import type { AbilityKey, AbilityScores } from './types'; +import type { AbilityKey, AbilityScores, SystemId } from './types'; import { ABILITY_KEYS } from './types'; import type { AbilityBuild } from '@/lib/schemas/character'; @@ -69,3 +69,31 @@ export function setManualTotal(build: AbilityBuild, ability: AbilityKey, target: export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild { return { base: { ...base }, adjustments: build.adjustments }; } + +function sameScores(a: AbilityScores, b: AbilityScores): boolean { + return ABILITY_KEYS.every((k) => a[k] === b[k]); +} + +/** + * Append level-up ability increases to a build so the per-source breakdown stays in + * sync with the totals (a 5e ASI pick is flat +1; a PF2e pick is a boost). If the + * stored build no longer reproduces `current` (legacy/imported character), a manual + * build is synthesized from `current` first so nothing jumps. Callers patch BOTH + * `abilities` and `abilityBuild` from the result. + */ +export function appendLevelIncreases( + build: AbilityBuild | undefined, + current: AbilityScores, + picks: readonly AbilityKey[], + system: SystemId, + label: string, +): { build: AbilityBuild; abilities: AbilityScores } { + const base = build && sameScores(computeAbilities(build), current) ? build : synthManualBuild(current); + const added = picks.map((ability) => ( + system === 'pf2e' + ? { label, ability, kind: 'boost' as const, amount: 0 } + : { label, ability, kind: 'flat' as const, amount: 1 } + )); + const next: AbilityBuild = { base: { ...base.base }, adjustments: [...base.adjustments, ...added] }; + return { build: next, abilities: computeAbilities(next) }; +} diff --git a/src/lib/rules/depth.test.ts b/src/lib/rules/depth.test.ts index 3fcef46..8050d03 100644 --- a/src/lib/rules/depth.test.ts +++ b/src/lib/rules/depth.test.ts @@ -106,4 +106,20 @@ describe('applyRest', () => { // no concentration → no concentration key in the patch expect('concentration' in applyRest(makeCharacter(), short)).toBe(false); }); + + it('long rest caps restored HP at the effective max while exhaustion stays 4+', () => { + const c = { ...makeCharacter(), defenses: { ...makeCharacter().defenses, exhaustion: 5 } }; + const long = dnd5e.restOptions.find((o) => o.id === 'long')!; + const patch = applyRest(c, long); + // exhaustion 5 → 4 after the rest; max HP still halved (30 → 15) + expect(patch.defenses!.exhaustion).toBe(4); + expect(patch.hp!.current).toBe(15); + }); + + it('a per-resource recoverStep restores by that amount, not to max (Hit Dice)', () => { + const base = makeCharacter(); + const c = { ...base, resources: [{ id: 'hd', name: 'Hit Dice', current: 0, max: 5, recovery: 'long' as const, recoverStep: 3 }] }; + const long = dnd5e.restOptions.find((o) => o.id === 'long')!; + expect(applyRest(c, long).resources![0]!.current).toBe(3); // 0 + ceil(5/2)=3, not 5 + }); }); diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index e8a0961..c30c7ff 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -20,7 +20,7 @@ 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 { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, appendLevelIncreases, type AbilityBreakdown } from './abilityBuild'; export { applyRest } from './rest'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect'; diff --git a/src/lib/rules/progression.ts b/src/lib/rules/progression.ts index 5f1f7b5..30dd689 100644 --- a/src/lib/rules/progression.ts +++ b/src/lib/rules/progression.ts @@ -1,5 +1,6 @@ import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types'; import { abilityModifier } from './abilities'; +import { newId } from '@/lib/ids'; import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression'; import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression'; @@ -57,6 +58,12 @@ export interface BuiltCharacter { spellcastingAbility?: SpellAbility; spellcastingRank?: ProficiencyRank; spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] }; + resources: { id: string; name: string; current: number; max: number; recovery: 'short' | 'long' | 'daily' | 'none'; recoverStep?: number }[]; +} + +/** 5e Hit Dice as a tracked resource: one die per level; a long rest returns half (2014 PHB). */ +export function hitDiceResource(level: number): BuiltCharacter['resources'][number] { + return { id: newId(), name: 'Hit Dice', current: level, max: level, recovery: 'long', recoverStep: Math.ceil(level / 2) }; } const TABLES: Record = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES }; @@ -123,6 +130,8 @@ export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCh perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained', ...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}), spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] }, + // 5e characters track Hit Dice from day one; PF2e has no equivalent pool. + resources: system === '5e' ? [hitDiceResource(choices.level)] : [], }; } @@ -159,7 +168,11 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n if (dnd5eAsiLevels(className).includes(nextLevel)) { choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' }); } - if (slots.length) notes.push('Spell slots updated for the new level.'); + // Proficiency bonus thresholds (PHB): +3 at 5, +4 at 9, +5 at 13, +6 at 17. + if ([5, 9, 13, 17].includes(nextLevel)) { + notes.push(`Proficiency bonus increases to +${2 + Math.floor((nextLevel - 1) / 4)} — attacks, saves, and trained skills all improve.`); + } + if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.'); return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices }; } @@ -172,8 +185,10 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n if (pf2eSkillIncreaseLevels().includes(nextLevel)) { choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' }); } - if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' }); - if (slots.length) notes.push('Spell slots updated for the new level.'); + // (Feat slots are enumerated per type — class/skill/general/ancestry — by collectChoices, + // which the level-up modal renders with real owed counts; no duplicate entry here.) + notes.push('Proficiency rises with your level — every trained statistic improves by 1.'); + if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.'); return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices }; } diff --git a/src/lib/rules/rest.ts b/src/lib/rules/rest.ts index c4cb314..c383cf8 100644 --- a/src/lib/rules/rest.ts +++ b/src/lib/rules/rest.ts @@ -1,6 +1,7 @@ import type { Character } from '@/lib/schemas/character'; import type { RestOption } from './types'; import { pf2eRestDecay } from '@/lib/mechanics/dying'; +import { deriveEffectiveMaxHp } from '@/lib/mechanics/creatureState'; /** * Compute the character changes a rest produces. Pure — returns a partial patch @@ -16,15 +17,33 @@ export function applyRest(c: Character, opt: RestOption): Partial { patch.hp = { ...c.hp, current: c.hp.max }; } + // Post-rest condition state, for capping restored HP at the EFFECTIVE max: a long + // rest steps 5e exhaustion down by 1 (level 4+ halves max); a pf2e night's rest + // decays drained by 1 (each remaining point still costs level HP off the max). + const restedExhaustion = opt.reduceExhaustion && c.defenses.exhaustion > 0 ? c.defenses.exhaustion - 1 : c.defenses.exhaustion; + const pf2eDecayed = c.system === 'pf2e' && opt.restoresHp ? pf2eRestDecay(c.defenses, c.conditions) : null; + if (opt.restoresHp && patch.hp) { + const eff = deriveEffectiveMaxHp({ + system: c.system, + baseMaxHp: c.hp.max, + level: c.level, + exhaustion: restedExhaustion, + conditions: pf2eDecayed ? pf2eDecayed.conditions : c.conditions, + }); + patch.hp = { ...patch.hp, current: Math.min(patch.hp.current, eff.max) }; + } + // Any rest ends ongoing concentration (you stop holding the spell to rest). if (c.concentration) patch.concentration = null; - // Resources refresh when their recovery tag is covered by this rest. A rest with - // `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than - // refilling the whole pool — so Refocus returns one Focus Point, not all of them. + // Resources refresh when their recovery tag is covered by this rest. A step — + // per-resource (5e Hit Dice recover ceil(level/2)) or per-rest (PF2e Refocus + // returns one Focus Point) — restores by that amount, clamped to max; otherwise + // the pool refills entirely. patch.resources = c.resources.map((r) => { if (!opt.recovers.includes(r.recovery)) return r; - const next = opt.recoverStep !== undefined ? Math.min(r.max, r.current + opt.recoverStep) : r.max; + const step = r.recoverStep ?? opt.recoverStep; + const next = step !== undefined ? Math.min(r.max, r.current + step) : r.max; return { ...r, current: next }; }); @@ -47,23 +66,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; + if (pf2eDecayed) { + patch.defenses = { ...c.defenses, ...pf2eDecayed.defenses }; + patch.conditions = pf2eDecayed.conditions; } return patch; } - const exhaustion = - opt.reduceExhaustion && c.defenses.exhaustion > 0 - ? c.defenses.exhaustion - 1 - : c.defenses.exhaustion; - - if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) { + if (opt.restoresHp || restedExhaustion !== c.defenses.exhaustion) { patch.defenses = { ...c.defenses, - exhaustion, + exhaustion: restedExhaustion, // recovering from 0 HP clears death saves (5e) ...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}), }; diff --git a/src/lib/rules/rules.test.ts b/src/lib/rules/rules.test.ts index 7a220cb..161ef9b 100644 --- a/src/lib/rules/rules.test.ts +++ b/src/lib/rules/rules.test.ts @@ -168,3 +168,24 @@ describe('pf2e Refocus', () => { expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3 }); }); + +describe('pf2e Rest for the Night', () => { + it('decays drained/doomed, clears wounded/fatigued, and caps HP at the post-decay effective max', () => { + const rest = pf2e.restOptions.find((o) => o.id === 'rest')!; + const c = { + system: 'pf2e', level: 6, + resources: [], + hp: { current: 5, max: 50, temp: 0 }, + concentration: null, + spellcasting: { slots: [], spells: [] }, + conditions: [{ name: 'Drained', value: 2 }, { name: 'Fatigued' }], + defenses: { exhaustion: 0, deathSaves: { successes: 0, failures: 0 }, dying: 0, wounded: 2, doomed: 1, heroPoints: 1, inspiration: false }, + } as unknown as Parameters[0]; + const patch = applyRest(c, rest); + expect(patch.conditions).toEqual([{ name: 'Drained', value: 1 }]); // drained −1, fatigued gone + expect(patch.defenses!.wounded).toBe(0); + expect(patch.defenses!.doomed).toBe(0); + // drained 1 remains after decay → effective max 50 − 6 = 44, not 50 + expect(patch.hp!.current).toBe(44); + }); +}); diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index 9bafadc..1805bc8 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -34,6 +34,9 @@ export const resourceSchema = z.object({ current: int.min(0).default(0), max: int.min(0).default(0), recovery: recoverySchema.default('long'), + /** How much a matching rest restores. Omitted = refill to max. 5e Hit Dice use + * ceil(level/2) — a long rest returns only half your level in dice (2014 PHB). */ + recoverStep: int.positive().optional(), }); export type CharacterResource = z.infer; @@ -173,6 +176,8 @@ export const characterSchema = z.object({ portrait: z.string().optional(), /** ancestry (PF2e) / race (5e), free text for MVP */ ancestry: z.string().max(80).default(''), + /** PF2e heritage (chosen at level 1, refines the ancestry); '' for 5e. */ + heritage: z.string().max(80).default(''), /** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */ className: z.string().max(80).default(''), /** Total character level — a mirror of sum(classes[].level). */ @@ -277,12 +282,13 @@ export type CharacterDraft = z.infer; /** Default values for the Phase-1 depth fields. Used by create + DB migration. */ export function characterDefaults(): Pick< Character, - 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices' + 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'heritage' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices' > { return { classes: [], choices: [], background: '', + heritage: '', alignment: '', appearance: '', personality: '',