Perfection pass: complete level-up/build flows + dying polish

Second adversarial audit of the freshly-shipped code; fixed bugs + completeness gaps.

- abilityBuild stays in sync with totals on level-up (appendLevelIncreases)
- 5e feat picker in level-up; PF2e feat-name autocomplete; expertise sets ranks
- Higher-level creation collects PF2e skill increases + 5e expertise
- PF2e heritage: schema field, wizard picker, sheet, Pathbuilder import
- 5e Hit Dice as a tracked resource (half-level long-rest recovery via recoverStep)
- Dying flow: knockout sets HP 0, stays unconscious at 0 HP, healing wakes +Wounded,
  Heroic Recovery (spend hero points); damage-while-dying + recovery reminders (pf2e)
- Healing caps at effective max (drained/exhaustion-4) on sheet, tracker, and rest;
  massive-damage death uses effective max; persistent-damage badge
- UX: in-place valued-condition steppers, point-buy budget caps, prepared-vs-known
  spell guidance, granted skills in review, system-gated score generator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:37:01 +02:00
parent 4ee004e25f
commit 8fd530df73
21 changed files with 609 additions and 69 deletions
+20 -4
View File
@@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
{c.system === 'pf2e' && (
<Labeled label="Heritage">
{/* pre-migration rows lack the field — keep the input controlled */}
<Input value={c.heritage ?? ''} onChange={(e) => update({ heritage: e.target.value })} />
</Labeled>
)}
{c.system === '5e' ? (
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
) : (
@@ -231,7 +237,8 @@ export function CharacterSheet({ character }: { character: Character }) {
<section className="mb-6">
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
{/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */}
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>}
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
@@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) {
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => 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<Character> = { 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 (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
@@ -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<string[]>([]);
const [expertisePicks, setExpertisePicks] = useState<string[]>([]);
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<string, ProficiencyRank> => {
const r: Record<string, ProficiencyRank> = {};
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<SpellOpt[]>([]);
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<string, ProficiencyRank> = { ...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
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && (
<div>
<Field label="Heritage (chosen at 1st level)">
<Select value={heritage} onChange={(e) => setHeritage(e.target.value)} aria-label="Heritage">
<option value=""> pick a heritage </option>
{heritageOptions.map((h) => <option key={h.name} value={h.name}>{h.name}{h.versatile ? ' (versatile)' : ''}</option>)}
</Select>
</Field>
{heritage && (
<p className="mt-1 text-xs text-muted">{heritageOptions.find((h) => h.name === heritage)?.summary}</p>
)}
</div>
)}
{!ancestry.trim() && (
<p className="text-xs text-warning">
No {system === 'pf2e' ? 'ancestry' : 'race'} selected you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills wont be applied.
</p>
)}
{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 (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
@@ -694,7 +790,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Skills' && (
<div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-1 text-sm text-muted">
Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}).
</p>
<p className="mb-2 text-xs text-muted">{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.'}</p>
{grantedSkillSources.length > 0 && (
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
@@ -708,13 +806,6 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
</div>
</div>
)}
{level > 1 && (
<p className="mb-2 text-xs text-warning">
{system === 'pf2e'
? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheets level-up flow.'
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheets level-up flow.'}
</p>
)}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
@@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
);
})}
</div>
{skills.length < Math.min(skillCount, freeSkillOptions.length) && (
<p className="mt-2 text-xs text-warning">Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.</p>
)}
{/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */}
{skillIncLevels.length > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Skill increases <span className="text-muted">(earned at levels {skillIncLevels.join(', ')})</span></p>
<p className="mb-2 text-xs text-muted">Each increase raises one skill a rank (Master from level 7, Legendary from level 15).</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillIncLevels.map((earnedAt, i) => {
const ranks = ranksAfterIncreases(i);
return (
<label key={i} className="text-xs text-muted">
Level {earnedAt} increase
<Select className="mt-0.5" aria-label={`Skill increase at level ${earnedAt}`} value={skillIncPicks[i] ?? ''} onChange={(e) => setSkillIncPicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
{sys.skills.map((s) => {
const cur = ranks[s.key] ?? 'untrained';
const ok = incAllowed(cur, earnedAt);
return <option key={s.key} value={s.key} disabled={!ok}>{s.label}: {cur} {ok ? bumpRank(cur) : '(capped)'}</option>;
})}
</Select>
</label>
);
})}
</div>
</div>
)}
{/* 5e: Expertise picks (Bard/Rogue) — double proficiency on chosen skills. */}
{expertiseCount > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Expertise <span className="text-muted">(choose {expertiseCount} proficiency doubled)</span></p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: expertiseCount }, (_, i) => (
<Select key={i} aria-label={`Expertise skill ${i + 1}`} value={expertisePicks[i] ?? ''} onChange={(e) => setExpertisePicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
<option value=""> pick a skill </option>
{[...skills, ...grantedSkills].map((k) => (
<option key={k} value={k} disabled={expertisePicks.includes(k) && expertisePicks[i] !== k}>{skillLabel(k)}</option>
))}
</Select>
))}
</div>
</div>
)}
</div>
)}
{stepName === 'Spells' && (
<div>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className="mb-1 text-xs text-muted">
{(() => {
// 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 <span className="text-ink">prepared caster</span>, 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 <span className="text-ink">know (your repertoire)</span> a fixed list you cast from slots; you can swap picks when you level up.</>
);
})()}
</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — thats quite a few; you can trim some later if you like.' : ''}
</p>
@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
</div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{background && <Review label="Background" value={background} />}
<Review label="Trained skills" value={skills.map(skillLabel).join(', ') || '—'} />
{heritage && <Review label="Heritage" value={heritage} />}
<Review label="Trained skills" value={[
...skills.map(skillLabel),
...grantedSkillSources.map((g) => `${skillLabel(g.key)} (${g.source})`),
].join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
<p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p>
@@ -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<typeof d>) => 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) {
<Button size="sm" variant="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
</div>
{d.heroPoints >= 1 && (
<div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-2">
<span className="text-[11px] text-muted">Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize.</span>
<Button size="sm" variant="primary" onClick={doHeroRescue}>Spend &amp; stabilize</Button>
</div>
)}
</div>
)}
</div>
+108 -6
View File
@@ -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<AbilityKey[]>([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<Feat | null>(null);
const [featBrowse, setFeatBrowse] = useState(false);
// Boosts (pf2e): four distinct picks.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['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<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
// PF2e feat-slot inputs autocomplete against the compendium so names land typo-free.
const [pf2eFeatNames, setPf2eFeatNames] = useState<string[]>([]);
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<Character> = {
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<string, ProficiencyRank> = { ...(patch.skillRanks ?? character.skillRanks) as Record<string, ProficiencyRank> };
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 }: {
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
</div>
) : (
<p className="text-sm text-muted">Add your chosen feat on the sheet after leveling.</p>
<div className="flex flex-wrap items-center gap-2">
{featPick ? (
<span className="rounded-md border border-accent/50 bg-accent/5 px-2 py-1 text-sm text-ink">{featPick.name}</span>
) : (
<span className="text-sm text-muted">No feat chosen yet.</span>
)}
<Button size="sm" variant="secondary" onClick={() => setFeatBrowse(true)}>{featPick ? 'Change feat…' : 'Browse feats…'}</Button>
</div>
)}
</section>
)}
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
{/* Boosts (pf2e) */}
{boosts && (
@@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: {
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<Input key={i} aria-label={`${choice.label} ${i + 1}`} placeholder="Type your choice…" value={choicePicks[choice.key]?.[i] ?? ''} onChange={(e) => setChoicePick(choice.key, i, e.target.value)} />
<SuggestInput
key={i}
ariaLabel={`${choice.label} ${i + 1}`}
placeholder="Type your choice…"
value={choicePicks[choice.key]?.[i] ?? ''}
onChange={(v) => setChoicePick(choice.key, i, v)}
suggestions={/-feat$/.test(choice.key.split(':')[1] ?? '') ? pf2eFeatNames : []}
/>
)
))}
</div>
@@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
</Modal>
);
}
/** 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 (
<div className="relative">
<Input
value={value}
aria-label={ariaLabel}
{...(placeholder ? { placeholder } : {})}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && matches.length > 0 && (
<div className="absolute z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-md border border-line bg-panel shadow-xl">
{matches.map((n) => (
<button
key={n}
type="button"
className="block w-full px-2 py-1 text-left text-sm text-ink hover:bg-elevated"
// mousedown beats the input's blur so the click still lands
onMouseDown={(e) => { e.preventDefault(); onChange(n); setOpen(false); }}
>
{n}
</button>
))}
</div>
)}
</div>
);
}
@@ -17,6 +17,17 @@ const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
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={
<div className="flex gap-2">
{sys.restOptions.map((o) => (
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}>
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={restTitle(c.system, o.id, o.recovers)}>
{o.label}
</Button>
))}
+80 -13
View File
@@ -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 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
<span
key={`${cond.name}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors hover:line-through"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris"
title={glossary.get(cond.name.toLowerCase()) || undefined}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
<X size={11} aria-hidden />
</button>
{/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */}
{cond.value !== undefined && (
<>
<button
aria-label={`Decrease ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({
conditions: (cond.value ?? 1) <= 1
? c.conditions.filter((_, j) => j !== i)
: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 1) - 1 } : x)),
})}
></button>
<button
aria-label={`Increase ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({ conditions: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 0) + 1 } : x)) })}
>+</button>
</>
)}
<button
aria-label={`Remove ${cond.name}`}
className="transition-opacity hover:opacity-60"
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
<X size={11} aria-hidden />
</button>
</span>
))}
</div>
)}
+7
View File
@@ -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', () => {
+8 -3
View File
@@ -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. */
+6 -1
View File
@@ -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<Chara
ancestry: b.ancestry ?? '',
level,
abilities,
// Imports carry only finals — persist a manual build so the sheet stays consistent.
abilityBuild: synthManualBuild(abilities),
hp: { current: hpMax, max: hpMax, temp: 0 },
saveRanks: saveRanks as Character['saveRanks'],
skillRanks: skillRanks as Character['skillRanks'],
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
...(b.background ? { notes: `Background: ${b.background}${b.heritage ? `\nHeritage: ${b.heritage}` : ''}` } : {}),
// Background and heritage are first-class fields now — no more stuffing them in notes.
...(b.background ? { background: b.background } : {}),
...(b.heritage ? { heritage: b.heritage } : {}),
};
}
+7
View File
@@ -109,6 +109,13 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
extraBadges.push('Quickened (+1 action)');
continue;
}
// --- pf2e Persistent Damage: the human rolls it; we surface the procedure. ---
if (system === 'pf2e' && name === 'persistent damage') {
extraBadges.push(cond.value
? `Persistent damage ${cond.value} (apply at end of turn, then DC 15 flat check to end)`
: 'Persistent damage (apply at end of turn, then DC 15 flat check to end)');
continue;
}
const eff = table[name];
if (!eff) continue;
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying';
import type { Condition } from '@/lib/schemas/common';
const cond = (name: string, value?: number): Condition => (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)]);
+10
View File
@@ -45,6 +45,16 @@ export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, 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<Defenses, 'dying' | 'heroPoints' | 'wounded'>): { 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
+1 -1
View File
@@ -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';
+34 -1
View File
@@ -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
});
});
+29 -1
View File
@@ -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) };
}
+16
View File
@@ -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
});
});
+1 -1
View File
@@ -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';
+18 -3
View File
@@ -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<SystemId, ClassDef[]> = { '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 };
}
+28 -15
View File
@@ -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<Character> {
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<Character> {
// 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 } } : {}),
};
+21
View File
@@ -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<typeof applyRest>[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);
});
});
+7 -1
View File
@@ -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<typeof resourceSchema>;
@@ -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<typeof characterDraftSchema>;
/** 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: '',