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:
@@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||||
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
||||||
</Labeled>
|
</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' ? (
|
{c.system === '5e' ? (
|
||||||
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
|
<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">
|
<section className="mb-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<SectionTitle>Ability Scores</SectionTitle>
|
<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>
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||||
{ABILITIES.map((a) => {
|
{ABILITIES.map((a) => {
|
||||||
@@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
||||||
const [delta, setDelta] = useState(0);
|
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') => {
|
const apply = (mode: 'damage' | 'heal') => {
|
||||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
if (!Number.isFinite(delta) || delta <= 0) return;
|
||||||
if (mode === 'damage') {
|
if (mode === 'damage') {
|
||||||
const absorbed = Math.min(c.hp.temp, delta);
|
const absorbed = Math.min(c.hp.temp, delta);
|
||||||
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
|
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
|
||||||
} else {
|
} 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);
|
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;
|
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { charactersRepo } from '@/lib/db/repositories';
|
|||||||
import {
|
import {
|
||||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||||
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
||||||
|
bumpRank, collectChoices,
|
||||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||||
} from '@/lib/rules';
|
} 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 { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||||
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
||||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||||
@@ -179,6 +181,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
const [classSlug, setClassSlug] = useState('');
|
const [classSlug, setClassSlug] = useState('');
|
||||||
const [subclass, setSubclass] = useState('');
|
const [subclass, setSubclass] = useState('');
|
||||||
const [ancestry, setAncestry] = 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 [background, setBackground] = useState('');
|
||||||
|
|
||||||
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
|
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).
|
// Drop any free pick that becomes granted (e.g. after switching background).
|
||||||
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
|
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 ----
|
// ---- spells ----
|
||||||
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
||||||
const [spellQuery, setSpellQuery] = useState('');
|
const [spellQuery, setSpellQuery] = useState('');
|
||||||
@@ -390,7 +450,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
Class: !!selectedClass,
|
Class: !!selectedClass,
|
||||||
Origin: true,
|
Origin: true,
|
||||||
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
|
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,
|
Spells: true,
|
||||||
Details: true,
|
Details: true,
|
||||||
Review: 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;
|
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)) }));
|
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, {
|
await charactersRepo.update(created.id, {
|
||||||
...built,
|
...built,
|
||||||
|
skillRanks,
|
||||||
abilityBuild: buildAbilityBuild(),
|
abilityBuild: buildAbilityBuild(),
|
||||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||||
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
||||||
spellcasting: { ...built.spellcasting, spells },
|
spellcasting: { ...built.spellcasting, spells },
|
||||||
...(background ? { background } : {}),
|
...(background ? { background } : {}),
|
||||||
|
...(heritage ? { heritage } : {}),
|
||||||
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||||
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||||
...(personality.trim() ? { personality: personality.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={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||||
</div>
|
</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 won’t be applied.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{selectedClass && selectedOrigin && (() => {
|
{selectedClass && selectedOrigin && (() => {
|
||||||
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
||||||
if (!synergy) return null;
|
if (!synergy) return null;
|
||||||
@@ -658,7 +744,17 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
const value = base + racial + asi;
|
const value = base + racial + asi;
|
||||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||||
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
|
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 (
|
return (
|
||||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
<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>
|
<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' && (
|
{stepName === 'Skills' && (
|
||||||
<div>
|
<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>
|
<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 && (
|
{grantedSkillSources.length > 0 && (
|
||||||
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
|
<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>
|
||||||
</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 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.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{selectedClass && (() => {
|
{selectedClass && (() => {
|
||||||
const tip = getClassTip(system, selectedClass.slug);
|
const tip = getClassTip(system, selectedClass.slug);
|
||||||
return tip?.skillSuggestions ? (
|
return tip?.skillSuggestions ? (
|
||||||
@@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stepName === 'Spells' && (
|
{stepName === 'Spells' && (
|
||||||
<div>
|
<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-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 you’ve 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')}>
|
<p className={cn('mb-2 text-xs', spellPicks.length > 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.' : ''}
|
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
</div>
|
</div>
|
||||||
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
|
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
|
||||||
{background && <Review label="Background" value={background} />}
|
{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(' ')} />}
|
{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(', ')} />}
|
{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>
|
<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 { Minus, Plus, Skull, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
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 { Degree } from '@/lib/dice/check';
|
||||||
import type { Condition } from '@/lib/schemas/common';
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
@@ -39,17 +39,27 @@ function Pf2eDefenses({ c, update }: SectionProps) {
|
|||||||
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
|
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
|
||||||
|
|
||||||
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
|
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) =>
|
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 doKnockOut = () => {
|
||||||
const { dying } = knockOut(d, fromCrit);
|
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 doRecover = (degree: Degree) => {
|
||||||
const res = applyRecovery(d, 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 (
|
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="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
|
||||||
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
|
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
|
||||||
</div>
|
</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 & stabilize</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { normalizeClassMirror, totalLevel } from '@/lib/schemas';
|
||||||
import {
|
import {
|
||||||
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
|
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
|
||||||
planLevelUp, applyIncreases, bumpRank, getClassDef,
|
planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
|
||||||
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
|
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
|
||||||
} from '@/lib/rules';
|
} from '@/lib/rules';
|
||||||
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
|
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
|
||||||
|
import { loadPf2e } from '@/lib/compendium';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { FeatPickerModal } from './FeatPickerModal';
|
||||||
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
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 keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
|
||||||
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
|
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
|
||||||
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
|
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.
|
// Boosts (pf2e): four distinct picks.
|
||||||
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
|
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
|
||||||
// Skill increase (pf2e): one skill to bump.
|
// 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 [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
|
||||||
const [subclassPick, setSubclassPick] = useState<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) =>
|
const setChoicePick = (key: string, i: number, val: string) =>
|
||||||
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
|
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.
|
// 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
|
? plan.hpGainAverage
|
||||||
: hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
|
: 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;
|
let abilities = character.abilities;
|
||||||
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
|
let abilityBuild = character.abilityBuild;
|
||||||
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
|
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.
|
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
|
||||||
const subFromChoice = Object.entries(choicePicks)
|
const subFromChoice = Object.entries(choicePicks)
|
||||||
@@ -156,6 +177,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
const patch: Partial<Character> = {
|
const patch: Partial<Character> = {
|
||||||
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
||||||
abilities,
|
abilities,
|
||||||
|
...(abilityBuild ? { abilityBuild } : {}),
|
||||||
classes: nextClassesFinal,
|
classes: nextClassesFinal,
|
||||||
...normalizeClassMirror({ classes: nextClassesFinal }),
|
...normalizeClassMirror({ classes: nextClassesFinal }),
|
||||||
choices: merged,
|
choices: merged,
|
||||||
@@ -180,6 +202,31 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
|
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
|
||||||
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
|
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);
|
onApply(patch);
|
||||||
onClose();
|
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>
|
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
|
||||||
|
|
||||||
{/* Boosts (pf2e) */}
|
{/* Boosts (pf2e) */}
|
||||||
{boosts && (
|
{boosts && (
|
||||||
@@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
@@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
</Modal>
|
</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',
|
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) {
|
export function ResourcesSection({ c, update }: SectionProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const sys = getSystem(c.system);
|
const sys = getSystem(c.system);
|
||||||
@@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{sys.restOptions.map((o) => (
|
{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}
|
{o.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
||||||
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
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 { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||||
import { useConditionGlossary } from './useConditionGlossary';
|
import { useConditionGlossary } from './useConditionGlossary';
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +82,9 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
*/
|
*/
|
||||||
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
||||||
if (damage <= 0) return;
|
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 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).
|
// the linked Character's concentration (set when a seated player casts a spell).
|
||||||
const ch = pcCharacter(combatant);
|
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}.`));
|
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. */
|
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
|
||||||
const advanceTurn = () => {
|
const advanceTurn = () => {
|
||||||
mutate((e) => nextTurn(e, campaign.system));
|
mutate((e) => nextTurn(e, campaign.system));
|
||||||
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
||||||
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
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,12 +266,18 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||||
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
|
: `${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
|
// 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;
|
let reminder: string | null = null;
|
||||||
if (c.kind !== 'monster' && hpLoss > 0) {
|
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.`;
|
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).`;
|
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) => {
|
mutate((e) => {
|
||||||
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
||||||
if (reminder) next = logEvent(next, reminder);
|
if (reminder) next = logEvent(next, reminder);
|
||||||
@@ -259,7 +285,20 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
});
|
});
|
||||||
noteConcentrationCheck(c, hpLoss);
|
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))}
|
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
|
||||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||||
/>
|
/>
|
||||||
@@ -489,7 +528,11 @@ function CombatantRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
|
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
|
||||||
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
|
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-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`}
|
||||||
aria-pressed={!!c.concentrating}
|
aria-pressed={!!c.concentrating}
|
||||||
>
|
>
|
||||||
@@ -513,17 +556,41 @@ function CombatantRow({
|
|||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<button
|
<span
|
||||||
key={`${cond.name}-${i}`}
|
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"
|
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()) || 'Click to remove'}
|
title={glossary.get(cond.name.toLowerCase()) || undefined}
|
||||||
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
|
||||||
>
|
>
|
||||||
{cond.name}
|
{cond.name}
|
||||||
{cond.value ? ` ${cond.value}` : ''}
|
{cond.value ? ` ${cond.value}` : ''}
|
||||||
{cond.duration ? ` (${cond.duration}r)` : ''}
|
{cond.duration ? ` (${cond.duration}r)` : ''}
|
||||||
|
{/* 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 />
|
<X size={11} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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, 'fire').hp.current).toBe(30); // immune to all → no change
|
||||||
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
|
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', () => {
|
describe('endEncounter', () => {
|
||||||
|
|||||||
@@ -267,10 +267,15 @@ export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boole
|
|||||||
return currentAfter <= 0 && -currentAfter >= hpMax;
|
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;
|
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. */
|
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
|
import { synthManualBuild } from '@/lib/rules/abilityBuild';
|
||||||
|
|
||||||
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
|
/** 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 ?? '',
|
ancestry: b.ancestry ?? '',
|
||||||
level,
|
level,
|
||||||
abilities,
|
abilities,
|
||||||
|
// Imports carry only finals — persist a manual build so the sheet stays consistent.
|
||||||
|
abilityBuild: synthManualBuild(abilities),
|
||||||
hp: { current: hpMax, max: hpMax, temp: 0 },
|
hp: { current: hpMax, max: hpMax, temp: 0 },
|
||||||
saveRanks: saveRanks as Character['saveRanks'],
|
saveRanks: saveRanks as Character['saveRanks'],
|
||||||
skillRanks: skillRanks as Character['skillRanks'],
|
skillRanks: skillRanks as Character['skillRanks'],
|
||||||
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
|
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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
|
|||||||
extraBadges.push('Quickened (+1 action)');
|
extraBadges.push('Quickened (+1 action)');
|
||||||
continue;
|
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];
|
const eff = table[name];
|
||||||
if (!eff) continue;
|
if (!eff) continue;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
|
|
||||||
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
|
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', () => {
|
describe('pf2eRestDecay', () => {
|
||||||
it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => {
|
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)]);
|
const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]);
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, degree: De
|
|||||||
return { dying, wounded };
|
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
|
* 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
|
* cleared (you wake at full HP), Drained −1, and Fatigued removed. Dying is assumed
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
|
|||||||
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
|
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
|
||||||
export { spendResource, regainResource } from './resources';
|
export { spendResource, regainResource } from './resources';
|
||||||
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
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 { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
|
||||||
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
|
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import type { AbilityBuild } from '@/lib/schemas/character';
|
||||||
|
|
||||||
const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
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);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AbilityKey, AbilityScores } from './types';
|
import type { AbilityKey, AbilityScores, SystemId } from './types';
|
||||||
import { ABILITY_KEYS } from './types';
|
import { ABILITY_KEYS } from './types';
|
||||||
import type { AbilityBuild } from '@/lib/schemas/character';
|
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 {
|
export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild {
|
||||||
return { base: { ...base }, adjustments: build.adjustments };
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,4 +106,20 @@ describe('applyRest', () => {
|
|||||||
// no concentration → no concentration key in the patch
|
// no concentration → no concentration key in the patch
|
||||||
expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export * from './types';
|
|||||||
export * from './progression';
|
export * from './progression';
|
||||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||||
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/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 { applyRest } from './rest';
|
||||||
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
||||||
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
|
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
|
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
|
||||||
import { abilityModifier } from './abilities';
|
import { abilityModifier } from './abilities';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
|
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
|
||||||
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
|
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
|
||||||
|
|
||||||
@@ -57,6 +58,12 @@ export interface BuiltCharacter {
|
|||||||
spellcastingAbility?: SpellAbility;
|
spellcastingAbility?: SpellAbility;
|
||||||
spellcastingRank?: ProficiencyRank;
|
spellcastingRank?: ProficiencyRank;
|
||||||
spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] };
|
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 };
|
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',
|
perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained',
|
||||||
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
|
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
|
||||||
spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] },
|
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)) {
|
if (dnd5eAsiLevels(className).includes(nextLevel)) {
|
||||||
choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' });
|
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 };
|
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)) {
|
if (pf2eSkillIncreaseLevels().includes(nextLevel)) {
|
||||||
choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' });
|
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.' });
|
// (Feat slots are enumerated per type — class/skill/general/ancestry — by collectChoices,
|
||||||
if (slots.length) notes.push('Spell slots updated for the new level.');
|
// 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 };
|
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-15
@@ -1,6 +1,7 @@
|
|||||||
import type { Character } from '@/lib/schemas/character';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
import type { RestOption } from './types';
|
import type { RestOption } from './types';
|
||||||
import { pf2eRestDecay } from '@/lib/mechanics/dying';
|
import { pf2eRestDecay } from '@/lib/mechanics/dying';
|
||||||
|
import { deriveEffectiveMaxHp } from '@/lib/mechanics/creatureState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the character changes a rest produces. Pure — returns a partial patch
|
* 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 };
|
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).
|
// Any rest ends ongoing concentration (you stop holding the spell to rest).
|
||||||
if (c.concentration) patch.concentration = null;
|
if (c.concentration) patch.concentration = null;
|
||||||
|
|
||||||
// Resources refresh when their recovery tag is covered by this rest. A rest with
|
// Resources refresh when their recovery tag is covered by this rest. A step —
|
||||||
// `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than
|
// per-resource (5e Hit Dice recover ceil(level/2)) or per-rest (PF2e Refocus
|
||||||
// refilling the whole pool — so Refocus returns one Focus Point, not all of them.
|
// returns one Focus Point) — restores by that amount, clamped to max; otherwise
|
||||||
|
// the pool refills entirely.
|
||||||
patch.resources = c.resources.map((r) => {
|
patch.resources = c.resources.map((r) => {
|
||||||
if (!opt.recovers.includes(r.recovery)) return 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 };
|
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
|
// 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.
|
// (you wake at full HP) and ends fatigued. Dying is assumed already 0.
|
||||||
if (c.system === 'pf2e') {
|
if (c.system === 'pf2e') {
|
||||||
if (opt.restoresHp) {
|
if (pf2eDecayed) {
|
||||||
const decay = pf2eRestDecay(c.defenses, c.conditions);
|
patch.defenses = { ...c.defenses, ...pf2eDecayed.defenses };
|
||||||
patch.defenses = { ...c.defenses, ...decay.defenses };
|
patch.conditions = pf2eDecayed.conditions;
|
||||||
patch.conditions = decay.conditions;
|
|
||||||
}
|
}
|
||||||
return patch;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exhaustion =
|
if (opt.restoresHp || restedExhaustion !== c.defenses.exhaustion) {
|
||||||
opt.reduceExhaustion && c.defenses.exhaustion > 0
|
|
||||||
? c.defenses.exhaustion - 1
|
|
||||||
: c.defenses.exhaustion;
|
|
||||||
|
|
||||||
if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) {
|
|
||||||
patch.defenses = {
|
patch.defenses = {
|
||||||
...c.defenses,
|
...c.defenses,
|
||||||
exhaustion,
|
exhaustion: restedExhaustion,
|
||||||
// recovering from 0 HP clears death saves (5e)
|
// recovering from 0 HP clears death saves (5e)
|
||||||
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,3 +168,24 @@ describe('pf2e Refocus', () => {
|
|||||||
expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export const resourceSchema = z.object({
|
|||||||
current: int.min(0).default(0),
|
current: int.min(0).default(0),
|
||||||
max: int.min(0).default(0),
|
max: int.min(0).default(0),
|
||||||
recovery: recoverySchema.default('long'),
|
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>;
|
export type CharacterResource = z.infer<typeof resourceSchema>;
|
||||||
|
|
||||||
@@ -173,6 +176,8 @@ export const characterSchema = z.object({
|
|||||||
portrait: z.string().optional(),
|
portrait: z.string().optional(),
|
||||||
/** ancestry (PF2e) / race (5e), free text for MVP */
|
/** ancestry (PF2e) / race (5e), free text for MVP */
|
||||||
ancestry: z.string().max(80).default(''),
|
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). */
|
/** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */
|
||||||
className: z.string().max(80).default(''),
|
className: z.string().max(80).default(''),
|
||||||
/** Total character level — a mirror of sum(classes[].level). */
|
/** 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. */
|
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||||
export function characterDefaults(): Pick<
|
export function characterDefaults(): Pick<
|
||||||
Character,
|
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 {
|
return {
|
||||||
classes: [],
|
classes: [],
|
||||||
choices: [],
|
choices: [],
|
||||||
background: '',
|
background: '',
|
||||||
|
heritage: '',
|
||||||
alignment: '',
|
alignment: '',
|
||||||
appearance: '',
|
appearance: '',
|
||||||
personality: '',
|
personality: '',
|
||||||
|
|||||||
Reference in New Issue
Block a user