Bug hunt + 5e/PF2e parity + character-build UX overhaul

Audited every calculation (26-agent adversarial workflow + hand-verification).
Core math was sound; fixed real bugs, closed parity gaps, and overhauled the
character-build UX. 396 -> 424 tests; lint clean; build green.

Correctness fixes:
- Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default)
- 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge
- PF2e dazzled no longer makes a creature easier to hit
- PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity)
- Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e
  disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened
- Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any)

PF2e survival subsystem (parity with 5e death saves):
- mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay)
- DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest;
  drained HP reduction

Character-build UX:
- Persisted abilityBuild (base + per-source contributions); sheet & wizard show
  every ability source; higher-level PF2e gains L5/10/15/20 boosts
- Creation surfaces granted skills (incl. new PF2e background skill parsing)
- LevelUpModal enumerates features gained + prompts every owed choice (subclass,
  feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:14:01 +02:00
parent cac5979820
commit b3914b1dda
34 changed files with 911 additions and 116 deletions
+39 -8
View File
@@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -104,6 +104,21 @@ export function CharacterSheet({ character }: { character: Character }) {
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
// Ability-score source breakdown: use the persisted build, or synthesize a manual
// one from the finals for legacy characters so the sheet still shows totals.
const abilityBuild = c.abilityBuild ?? synthManualBuild(c.abilities);
const abilityBuildView = abilityBreakdown(abilityBuild);
/** Edit an ability total on the sheet → records a Manual adjustment, keeps build+finals in sync. */
const setAbilityTotal = (a: AbilityKey, total: number) => {
const nb = setManualTotal(abilityBuild, a, total);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
/** Re-generated base scores (AbilityGenModal) replace the base, keeping all sources. */
const applyGeneratedScores = (scores: AbilityScores) => {
const nb = setBuildBase(abilityBuild, scores);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput);
@@ -221,6 +236,7 @@ export function CharacterSheet({ character }: { character: Character }) {
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
const breakdown = abilityBuildView[a];
return (
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
@@ -231,12 +247,21 @@ export function CharacterSheet({ character }: { character: Character }) {
min={1}
max={30}
aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
onChange={(v) => setAbilityTotal(a, v)}
/>
{breakdown.parts.length > 0 && (
<div className="mt-1 text-[9px] leading-tight text-muted" title={`${breakdown.base} base${breakdown.parts.map((p) => ` · ${p.delta >= 0 ? '+' : ''}${p.delta} ${p.label}`).join('')}`}>
{breakdown.base}
{breakdown.parts.map((p, i) => (
<span key={i}> {p.delta >= 0 ? '+' : ''}{p.delta}</span>
))}
</div>
)}
</div>
);
})}
</div>
<p className="mt-1 text-[10px] text-muted">Hover a score to see its sources. Editing a score records a Manual adjustment.</p>
</section>
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
@@ -359,7 +384,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</section>
{genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
<AbilityGenModal onClose={() => setGenScores(false)} onApply={applyGeneratedScores} />
)}
{levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
@@ -398,17 +423,23 @@ function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) =
}
setDelta(0);
};
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions });
const effMax = eff.max;
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span>
<span className="text-base text-muted"> / {effMax}</span>
{eff.reduction > 0 && <span className="ml-1 text-xs text-muted line-through">{c.hp.max}</span>}
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div>
{eff.reduction > 0 && (
<div className="mb-1 text-[11px] text-warning" title={eff.reasons.join('; ')}>{eff.reasons.join(' · ')}</div>
)}
<div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
<Meter value={c.hp.current} max={effMax} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
@@ -150,11 +150,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
};
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({
name: String(b.name),
desc: briefOverview(String((b.description ?? b.text) ?? '')),
backgroundBoosts: parseBackgroundBoosts(b.attribute),
})))));
const resolvePf2e = makeSkillResolver(sys.skills);
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => {
// PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the
// 16 core skills and are dropped here — they're tracked as custom Lore separately).
const rawSkills = Array.isArray((b as Record<string, unknown>).skill) ? ((b as Record<string, unknown>).skill as string[]) : [];
const skillGrants = rawSkills.map((s) => resolvePf2e(String(s))).filter((k): k is string => !!k);
return {
name: String(b.name),
desc: briefOverview(String((b.description ?? b.text) ?? '')),
backgroundBoosts: parseBackgroundBoosts(b.attribute),
...(skillGrants.length ? { skillGrants } : {}),
};
}))));
}
return () => { on = false; };
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
@@ -203,22 +211,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES });
if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts });
for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES });
// Higher-level characters also gained 4 ability boosts at levels 5/10/15/20.
for (const L of [5, 10, 15, 20]) if (L <= level) for (let i = 0; i < 4; i++) slots.push({ id: `lvl${L}-${i}`, label: `Level ${L} boost`, options: ABILITIES });
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass]);
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
// A boost slot's "source" — boosts within one source must target different abilities.
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : 'free');
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : id.startsWith('lvl') ? id.split('-')[0]! : 'free');
// Seed a legal default assignment (distinct within each source) when slots change.
useEffect(() => {
if (!boostSlots) return;
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
const favored = keyOpts[0] ?? 'str';
const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)];
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed), background: new Set(), class: new Set(), free: new Set() };
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed) };
const next: Record<string, AbilityKey> = {};
for (const s of boostSlots.slots) {
const u = used[boostSource(s.id)]!;
const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
next[s.id] = pick;
@@ -254,6 +264,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// Capture the ability scores as a source breakdown (base + every boost/ASI/flaw) so
// the sheet can show where each point came from — final = computeAbilities(build).
const buildAbilityBuild = (): AbilityBuild => {
if (system === 'pf2e' && boostSlots) {
const adjustments: AbilityAdjustment[] = [];
if (boostSlots.flaw) adjustments.push({ label: 'Ancestry flaw', ability: boostSlots.flaw, kind: 'flat', amount: -2 });
for (const ab of boostSlots.fixed) adjustments.push({ label: 'Ancestry', ability: ab, kind: 'boost', amount: 0 });
for (const s of boostSlots.slots) {
const ab = boostPicks[s.id];
if (ab) adjustments.push({ label: s.label, ability: ab, kind: 'boost', amount: 0 });
}
return { base: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, adjustments };
}
const base = {} as AbilityScores;
ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
const adjustments: AbilityAdjustment[] = [];
if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); }
return { base, adjustments };
};
// ---- skills ----
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
@@ -277,11 +308,22 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const toggleSkill = (key: string) =>
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
// 5e skill proficiencies granted by background + race (applied as trained, on top of class picks).
const grantedSkills = useMemo(
() => (system === '5e' ? [...(selectedOrigin?.skillGrants ?? []), ...(selectedBackground?.skillGrants ?? [])] : []),
[system, selectedOrigin, selectedBackground],
);
// Skill proficiencies granted (trained) by ancestry/race + background — shown to the
// user so they don't waste free picks, and merged on top of class picks at build time.
const grantedSkillSources = useMemo(() => {
const out: { key: string; source: string }[] = [];
const add = (keys: string[] | undefined, source: string) => {
for (const k of keys ?? []) if (!out.some((o) => o.key === k)) out.push({ key: k, source });
};
add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race'));
add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background');
return out;
}, [system, selectedOrigin, selectedBackground]);
const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]);
// Free picks exclude already-granted skills, so the user can't waste a choice on one.
const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]);
// Drop any free pick that becomes granted (e.g. after switching background).
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
// ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
@@ -348,7 +390,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
Class: !!selectedClass,
Origin: true,
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, skillOptions.length),
Skills: skills.length === Math.min(skillCount, freeSkillOptions.length),
Spells: true,
Details: true,
Review: true,
@@ -368,6 +410,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
await charactersRepo.update(created.id, {
...built,
abilityBuild: buildAbilityBuild(),
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
spellcasting: { ...built.spellcasting, spells },
@@ -651,8 +694,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Skills' && (
<div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.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).</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">
<div className="mb-1 smallcaps text-[11px]">Already Trained (from your origin)</div>
<div className="flex flex-wrap gap-1.5">
{grantedSkillSources.map((g) => (
<span key={g.key} className="rounded border border-accent/40 bg-accent/5 px-2 py-0.5 text-xs text-ink" title={`Granted by ${g.source}`}>
{skillLabel(g.key)} <span className="text-muted">· {g.source}</span>
</span>
))}
</div>
</div>
)}
{level > 1 && (
<p className="mb-2 text-xs text-warning">
{system === 'pf2e'
? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheets level-up flow.'
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheets level-up flow.'}
</p>
)}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
@@ -663,7 +725,7 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
) : null;
})()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => {
{freeSkillOptions.map((key) => {
const checked = skills.includes(key);
const disabled = !checked && skills.length >= skillCount;
return (
+110 -46
View File
@@ -1,7 +1,11 @@
import { Minus, Plus, Star } from 'lucide-react';
import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery } from '@/lib/mechanics';
import type { Degree } from '@/lib/dice/check';
import type { Condition } from '@/lib/schemas/common';
import { SheetSection, type SectionProps } from './common';
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
@@ -23,57 +27,117 @@ function Pips({ count, filled, onToggle, label }: { count: number; filled: numbe
);
}
/** PF2e dying/wounded/doomed with knock-out + human-rolled recovery checks. */
function Pf2eDefenses({ c, update }: SectionProps) {
const d = c.defenses;
const [fromCrit, setFromCrit] = useState(false);
const dead = isDead(d.dying, d.doomed);
const withUnconscious = (conds: Condition[]): Condition[] =>
conds.some((x) => x.name.trim().toLowerCase() === 'unconscious') ? conds : [...conds, { name: 'Unconscious' }];
const withoutUnconscious = (conds: Condition[]): Condition[] =>
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
/** Set dying and keep the Unconscious condition in sync (dying>0 ⇒ unconscious). */
const setDying = (dying: number) =>
update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
const doKnockOut = () => {
const { dying } = knockOut(d, fromCrit);
update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) });
};
const doRecover = (degree: Degree) => {
const res = applyRecovery(d, degree);
update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
};
return (
<div className="grid gap-3 sm:grid-cols-2">
<Field label={`Dying (0${maxDying(d.doomed)})`}>
<div className="flex items-center gap-2">
<NumberField className="w-16" value={d.dying} min={0} max={4} onChange={setDying} aria-label="Dying value" />
{dead && <span className="flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-xs font-semibold text-danger"><Skull size={12} aria-hidden /> Dead</span>}
</div>
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setDefenses({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} max={3} onChange={(v) => setDefenses({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setDefenses({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
<div className="sm:col-span-2 rounded-md border border-line bg-panel px-3 py-2">
{d.dying === 0 ? (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">Knocked to 0 HP? Enter dying (adds your Wounded value{d.wounded > 0 ? ` of ${d.wounded}` : ''}).</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-muted">
<input type="checkbox" checked={fromCrit} onChange={(e) => setFromCrit(e.target.checked)} /> crit
</label>
<Button size="sm" variant="danger" onClick={doKnockOut}>Knock out</Button>
</div>
</div>
) : (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">Recovery check</span>
<span className="font-mono text-sm text-ink">DC {recoveryDc(d.dying)}</span>
</div>
<p className="mb-2 text-[11px] text-muted">Roll a flat d20 vs the DC, then record the result (success lowers Dying; reaching 0 makes you Wounded):</p>
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4">
<Button size="sm" variant="secondary" onClick={() => doRecover('critical-success')}>Crit success 2</Button>
<Button size="sm" variant="secondary" onClick={() => doRecover('success')}>Success 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>
</div>
</div>
)}
</div>
</div>
);
}
export function DefensesSection({ c, update }: SectionProps) {
const d = c.defenses;
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
return (
<SheetSection title="Status & Defenses">
<div className="grid gap-3 sm:grid-cols-2">
{c.system === '5e' ? (
<>
<Field label="Death Saves">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1 text-xs text-success">
Successes
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
</span>
<span className="flex items-center gap-1 text-xs text-danger">
Failures
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
</span>
</div>
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
</Field>
</>
) : (
<>
<Field label="Dying (04)">
<NumberField className="w-20" value={d.dying} min={0} max={4} onChange={(v) => setD({ dying: v })} aria-label="Dying value" />
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setD({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} onChange={(v) => setD({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
</>
)}
</div>
{c.system === '5e' ? (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Death Saves">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1 text-xs text-success">
Successes
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
</span>
<span className="flex items-center gap-1 text-xs text-danger">
Failures
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
</span>
</div>
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
{d.exhaustion >= 4 && <span className="ml-2 text-[11px] text-warning">Max HP halved</span>}
</Field>
</div>
) : (
<Pf2eDefenses c={c} update={update} />
)}
</SheetSection>
);
}
+150 -18
View File
@@ -1,16 +1,17 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import {
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input';
import { Input, Select } from '@/components/ui/Input';
import { LevelUpAdvisor } from './LevelUpAdvisor';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -24,9 +25,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
const is5e = character.system === '5e';
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
const classList: ClassEntry[] = character.classes.length
? character.classes
: character.className ? [{ className: character.className, level: character.level }] : [];
const classList: ClassEntry[] = useMemo(
() => (character.classes.length
? character.classes
: character.className ? [{ className: character.className, level: character.level }] : []),
[character.classes, character.className, character.level],
);
const NEW = '__new__';
const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className);
const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter');
@@ -55,6 +59,72 @@ export function LevelUpModal({ character, onApply, onClose }: {
// Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
// Classes after this level-up — drives feature/choice enumeration.
const nextClasses: ClassEntry[] = useMemo(() => {
if (is5e) {
return levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
}
return classList.length
? classList.map((e, i) => (i === 0 ? { ...e, level: plan.nextLevel } : e))
: [{ className: character.className, level: plan.nextLevel }];
}, [is5e, levelClass, newClassName, classList, levelingName, plan.nextLevel, character.className]);
// Features GAINED at this level (read-only "what you get").
const featuresGained = useMemo(() => {
const k = (f: UnlockedFeature) => `${f.source}|${f.name}|${f.level}`;
const before = new Set(collectFeatures(character.system, classList).map(k));
return collectFeatures(character.system, nextClasses).filter((f) => !before.has(k(f)));
}, [character.system, classList, nextClasses]);
// How many of a choice the character has already recorded (subclass also counts the
// class entry's own subclass field, set before the choices array existed).
const resolvedCount = (key: string) => {
const fromChoices = character.choices.find((c) => c.key === key)?.values.length ?? 0;
if (key.endsWith(':subclass')) {
const cn = key.slice(0, -':subclass'.length).toLowerCase();
return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
}
return fromChoices;
};
// CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations, feats,
// pf2e subclass…) — the count not yet recorded.
const pendingChoices = useMemo(
() => collectChoices(character.system, nextClasses)
.map((choice) => ({ choice, pick: Math.max(0, choice.count - resolvedCount(choice.key)) }))
.filter((x) => x.pick > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[character.system, nextClasses, character.choices],
);
// 5e subclass selection (a class field, not a generic choice) when it first unlocks.
const sub5e = useMemo(() => {
if (!is5e) return undefined;
const p = subclassPrompt('5e', levelingName);
const entry = nextClasses.find((e) => e.className === levelingName);
if (!p || !entry || entry.level < p.level || entry.subclass) return undefined;
return p;
}, [is5e, levelingName, nextClasses]);
const [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
const setChoicePick = (key: string, i: number, val: string) =>
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
// Seed defaults for option-based choices so an untouched picker still records its pick.
useEffect(() => {
setChoicePicks((prev) => {
const next = { ...prev };
for (const { choice, pick } of pendingChoices) {
if (!choice.options) continue;
const arr = [...(next[choice.key] ?? [])];
for (let i = 0; i < pick; i++) if (arr[i] == null) arr[i] = choice.options[0]!;
next[choice.key] = arr;
}
return next;
});
}, [pendingChoices]);
const atMax = total >= 20;
const apply = () => {
const gain = character.system === 'pf2e'
@@ -65,19 +135,35 @@ export function LevelUpModal({ character, onApply, onClose }: {
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
const subFromChoice = Object.entries(choicePicks)
.find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim();
const subToApply = (sub5e ? (subclassPick || sub5e.options[0]) : undefined) ?? subFromChoice;
const nextClassesFinal = subToApply
? nextClasses.map((e) => (e.className === levelingName && !e.subclass ? { ...e, subclass: subToApply } : e))
: nextClasses;
// Merge newly-picked choices into the character's resolved-choices array.
const merged = character.choices.map((c) => ({ key: c.key, values: [...c.values] }));
for (const [key, vals] of Object.entries(choicePicks)) {
const clean = vals.map((v) => v.trim()).filter(Boolean);
if (!clean.length) continue;
const existing = merged.find((c) => c.key === key);
if (existing) existing.values.push(...clean);
else merged.push({ key, values: clean });
}
const patch: Partial<Character> = {
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
abilities,
classes: nextClassesFinal,
...normalizeClassMirror({ classes: nextClassesFinal }),
choices: merged,
};
if (is5e) {
// Bump the chosen class (or add a new one), re-derive the mirrors + combined slots.
const nextClasses: ClassEntry[] = levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
Object.assign(patch, normalizeClassMirror({ classes: nextClasses }));
patch.classes = nextClasses;
const derived = dnd5eClassesSlots(nextClasses);
// Re-derive combined spell slots, preserving current values.
const derived = dnd5eClassesSlots(nextClassesFinal);
const slots = derived.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
@@ -86,9 +172,8 @@ export function LevelUpModal({ character, onApply, onClose }: {
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
else delete spellcasting.pact;
patch.spellcasting = spellcasting;
} else {
patch.level = plan.nextLevel;
if (plan.slots) patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
} else if (plan.slots) {
patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
}
if (skillInc && skillKey) {
@@ -209,9 +294,56 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section>
)}
{plan.choices.filter((c) => c.kind === 'feat').map((c) => (
<p key={c.label} className="text-sm text-muted"> {c.label}</p>
))}
{/* What you gain at this level (read-only) */}
{featuresGained.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">What you gain</h3>
<ul className="space-y-1">
{featuresGained.map((f, i) => (
<li key={i} className="text-sm leading-snug">
<span className="text-ink">{f.name}</span>
{f.text ? <span className="text-muted"> {f.text}</span> : null}
</li>
))}
</ul>
</section>
)}
{/* 5e subclass selection (at its archetype level) */}
{sub5e && (
<section>
<h3 className="mb-1 smallcaps">{sub5e.label}</h3>
<Select value={subclassPick || sub5e.options[0]} onChange={(e) => setSubclassPick(e.target.value)} aria-label="Subclass">
{sub5e.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
</section>
)}
{/* Choices still owed at this level (feats, fighting style, pact boon, …) */}
{pendingChoices.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Choices to make</h3>
<div className="space-y-2">
{pendingChoices.map(({ choice, pick }) => (
<div key={choice.key} className="rounded-md border border-line bg-panel px-3 py-2">
<div className="text-sm text-ink">{choice.label}{pick > 1 ? ` — choose ${pick}` : ''}</div>
{choice.hint && <div className="mb-1 text-xs text-muted">{choice.hint}</div>}
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{Array.from({ length: pick }, (_, i) => (
choice.options ? (
<Select key={i} aria-label={`${choice.label} ${i + 1}`} value={choicePicks[choice.key]?.[i] ?? choice.options[0]} onChange={(e) => setChoicePick(choice.key, i, e.target.value)}>
{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)} />
)
))}
</div>
</div>
))}
</div>
</section>
)}
{/* Strategic build-route advice */}
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />