Files
ttrpg_manager/src/features/characters/sheet/DefensesSection.tsx
T
NilsBriggen 8fd530df73 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>
2026-06-10 20:37:01 +02:00

169 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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 (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>
);
}
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>
);
}