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'}>
|
||||
<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 won’t 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 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 && (() => {
|
||||
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 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')}>
|
||||
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s 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 & stabilize</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user