8fd530df73
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>
169 lines
8.5 KiB
TypeScript
169 lines
8.5 KiB
TypeScript
|
||
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, heroPointRescue } 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 }) {
|
||
return (
|
||
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
||
{Array.from({ length: count }, (_, i) => (
|
||
<button
|
||
key={i}
|
||
onClick={() => onToggle(i + 1 === filled ? i : i + 1)}
|
||
aria-label={`${label} ${i + 1}`}
|
||
aria-pressed={i < filled}
|
||
className={
|
||
'h-5 w-5 rounded-full border ' +
|
||
(i < filled ? 'border-accent bg-accent' : 'border-line bg-surface')
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 } });
|
||
// 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: syncUnconscious(dying, c.hp.current) });
|
||
|
||
const doKnockOut = () => {
|
||
const { dying } = knockOut(d, fromCrit);
|
||
// 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: 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 (
|
||
<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>
|
||
{d.heroPoints >= 1 && (
|
||
<div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-2">
|
||
<span className="text-[11px] text-muted">Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize.</span>
|
||
<Button size="sm" variant="primary" onClick={doHeroRescue}>Spend & stabilize</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{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>
|
||
);
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
||
<div className="mb-1 smallcaps">{label}</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|