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
+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>
);
}