Phase 1: player character depth

- Inventory + currency + encumbrance vs carrying capacity
- Class resources with correct short/long/daily rest recovery (applyRest)
- Spellcasting: slots (+pact), known/prepared spells, derived DC + attack
- Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points
- Derived weapon attacks + passive perception via extended RulesSystem
- Dexie v2 migration backfills new fields; debounced save flushes on pagehide
- 9 new unit tests, new character-depth e2e; all green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:30:34 +02:00
parent 1a9e5e2c18
commit 9ecd817bc6
20 changed files with 1069 additions and 25 deletions
@@ -0,0 +1,86 @@
import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField';
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>
);
}
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 ? '★ 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"></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">+</Button>
</div>
</Field>
</>
)}
</div>
</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 text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
{children}
</div>
);
}