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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user