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,83 @@
import { useState } from 'react';
import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules';
import type { CharacterResource } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
short: 'Short rest',
long: 'Long rest',
daily: 'Daily',
none: 'Manual',
};
export function ResourcesSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const sys = getSystem(c.system);
const addResource = () => {
if (name.trim() === '') return;
const r: CharacterResource = { id: newId(), name: name.trim(), current: 1, max: 1, recovery: 'long' };
update({ resources: [...c.resources, r] });
setName('');
};
const patch = (id: string, p: Partial<CharacterResource>) =>
update({ resources: c.resources.map((r) => (r.id === id ? { ...r, ...p } : r)) });
const remove = (id: string) => update({ resources: c.resources.filter((r) => r.id !== id) });
const rest = (optId: string) => {
const opt = sys.restOptions.find((o) => o.id === optId);
if (opt) update(applyRest(c, opt));
};
return (
<SheetSection
title="Resources & Rest"
actions={
<div className="flex gap-2">
{sys.restOptions.map((o) => (
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}>
{o.label}
</Button>
))}
</div>
}
>
<div className="mb-3 flex items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
New resource
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addResource()} placeholder="Ki Points, Rage, Focus…" />
</label>
<Button variant="primary" onClick={addResource}>Add</Button>
</div>
{c.resources.length === 0 ? (
<p className="text-sm text-muted">No tracked resources. Add rage, ki, sorcery points, focus points, etc.</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2">
{c.resources.map((r) => (
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.max(0, r.current - 1) })} aria-label="Spend"></Button>
<span className="w-12 text-center text-sm">
<span className="font-medium text-ink">{r.current}</span>
<span className="text-muted">/{r.max}</span>
</span>
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.min(r.max, r.current + 1) })} aria-label="Regain">+</Button>
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
))}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}></Button>
</li>
))}
</ul>
)}
</SheetSection>
);
}