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:
@@ -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 (0–6)">
|
||||
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Dying (0–4)">
|
||||
<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 (0–6)">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user