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:
@@ -11,6 +11,11 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { InventorySection } from './sheet/InventorySection';
|
||||
import { AttacksSection } from './sheet/AttacksSection';
|
||||
import { ResourcesSection } from './sheet/ResourcesSection';
|
||||
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
||||
import { DefensesSection } from './sheet/DefensesSection';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
|
||||
@@ -28,23 +33,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||
const [c, setC] = useState<Character>(character);
|
||||
const save = useDebouncedCallback((next: Character) => {
|
||||
void charactersRepo.update(next.id, {
|
||||
name: next.name,
|
||||
ancestry: next.ancestry,
|
||||
className: next.className,
|
||||
level: next.level,
|
||||
kind: next.kind,
|
||||
abilities: next.abilities,
|
||||
hp: next.hp,
|
||||
speed: next.speed,
|
||||
armorBonus: next.armorBonus,
|
||||
skillRanks: next.skillRanks,
|
||||
saveRanks: next.saveRanks,
|
||||
perceptionRank: next.perceptionRank,
|
||||
...(next.spellcastingAbility ? { spellcastingAbility: next.spellcastingAbility } : {}),
|
||||
...(next.spellcastingRank ? { spellcastingRank: next.spellcastingRank } : {}),
|
||||
notes: next.notes,
|
||||
});
|
||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
|
||||
void charactersRepo.update(id, rest);
|
||||
}, 350);
|
||||
|
||||
const update = (patch: Partial<Character>) => {
|
||||
@@ -192,13 +183,28 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Status & defenses (system-specific) */}
|
||||
<DefensesSection c={c} update={update} />
|
||||
|
||||
{/* Attacks + passive perception */}
|
||||
<AttacksSection c={c} update={update} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
<SpellcastingSection c={c} update={update} />
|
||||
|
||||
{/* Class resources + rest */}
|
||||
<ResourcesSection c={c} update={update} />
|
||||
|
||||
{/* Inventory, currency, encumbrance */}
|
||||
<InventorySection c={c} update={update} />
|
||||
|
||||
{/* Notes */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
<textarea
|
||||
value={c.notes}
|
||||
onChange={(e) => update({ notes: e.target.value })}
|
||||
placeholder="Backstory, bonds, inventory, reminders…"
|
||||
placeholder="Backstory, bonds, reminders…"
|
||||
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
import type { Attack } from '@/lib/schemas';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
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 ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
||||
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
|
||||
|
||||
export function AttacksSection({ c, update }: SectionProps) {
|
||||
const [name, setName] = useState('');
|
||||
const sys = getSystem(c.system);
|
||||
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
||||
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
|
||||
|
||||
const add = () => {
|
||||
if (name.trim() === '') return;
|
||||
const atk: Attack = {
|
||||
id: newId(), name: name.trim(), ability: 'str', rank: 'trained',
|
||||
damageDice: '1d8', damageType: '', itemBonus: 0, addAbilityToDamage: true,
|
||||
};
|
||||
update({ attacks: [...c.attacks, atk] });
|
||||
setName('');
|
||||
};
|
||||
const patch = (id: string, p: Partial<Attack>) =>
|
||||
update({ attacks: c.attacks.map((a) => (a.id === id ? { ...a, ...p } : a)) });
|
||||
const remove = (id: string) => update({ attacks: c.attacks.filter((a) => a.id !== id) });
|
||||
|
||||
return (
|
||||
<SheetSection title="Attacks">
|
||||
<p className="mb-2 text-sm text-muted">
|
||||
Passive Perception <span className="font-display text-lg font-semibold text-accent">{sys.passivePerception({ ...rulesInput, perceptionRank: c.perceptionRank })}</span>
|
||||
</p>
|
||||
|
||||
<div className="mb-3 flex items-end gap-2">
|
||||
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||
New attack
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
|
||||
</label>
|
||||
<Button variant="primary" onClick={add}>Add</Button>
|
||||
</div>
|
||||
|
||||
{c.attacks.length === 0 ? (
|
||||
<p className="text-sm text-muted">No attacks defined.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{c.attacks.map((a) => {
|
||||
const result = sys.weaponAttack(rulesInput, {
|
||||
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus,
|
||||
damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
|
||||
});
|
||||
return (
|
||||
<li key={a.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||
<Input className="h-8 min-w-28 flex-1" value={a.name} onChange={(e) => patch(a.id, { name: e.target.value })} aria-label="Attack name" />
|
||||
<Select className="w-auto py-1" value={a.ability} onChange={(e) => patch(a.id, { ability: e.target.value as AbilityKey })} aria-label="Attack ability">
|
||||
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
|
||||
</Select>
|
||||
<Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency">
|
||||
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</Select>
|
||||
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
|
||||
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
|
||||
<span className="ml-auto rounded bg-elevated px-2 py-1 text-sm">
|
||||
<span className="text-muted">to hit </span><span className="font-display font-semibold text-accent">{formatModifier(result.toHit)}</span>
|
||||
<span className="text-muted"> · dmg </span><span className="font-mono text-ink">{result.damage}{a.damageType ? ` ${a.damageType}` : ''}</span>
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}>✕</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (0–6)">
|
||||
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label="Dying (0–4)">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import type { InventoryItem } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { SheetSection, type SectionProps } from './common';
|
||||
|
||||
const COINS = [
|
||||
{ key: 'pp', label: 'PP' },
|
||||
{ key: 'gp', label: 'GP' },
|
||||
{ key: 'ep', label: 'EP' },
|
||||
{ key: 'sp', label: 'SP' },
|
||||
{ key: 'cp', label: 'CP' },
|
||||
] as const;
|
||||
|
||||
export function InventorySection({ c, update }: SectionProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [qty, setQty] = useState(1);
|
||||
const [weight, setWeight] = useState(0);
|
||||
|
||||
const sys = getSystem(c.system);
|
||||
const capacity = sys.carryingCapacity({ level: c.level, abilities: c.abilities });
|
||||
const totalWeight = c.inventory.reduce((sum, i) => sum + i.quantity * i.weight, 0);
|
||||
const encumbered = totalWeight > capacity.encumbered;
|
||||
const overMax = totalWeight > capacity.max;
|
||||
|
||||
const addItem = () => {
|
||||
if (name.trim() === '') return;
|
||||
const item: InventoryItem = {
|
||||
id: newId(),
|
||||
name: name.trim(),
|
||||
quantity: qty,
|
||||
weight,
|
||||
equipped: false,
|
||||
attuned: false,
|
||||
description: '',
|
||||
};
|
||||
update({ inventory: [...c.inventory, item] });
|
||||
setName('');
|
||||
setQty(1);
|
||||
setWeight(0);
|
||||
};
|
||||
|
||||
const patchItem = (id: string, patch: Partial<InventoryItem>) => {
|
||||
update({ inventory: c.inventory.map((i) => (i.id === id ? { ...i, ...patch } : i)) });
|
||||
};
|
||||
const removeItem = (id: string) => update({ inventory: c.inventory.filter((i) => i.id !== id) });
|
||||
|
||||
const setCoin = (key: (typeof COINS)[number]['key'], value: number) =>
|
||||
update({ currency: { ...c.currency, [key]: value } });
|
||||
|
||||
return (
|
||||
<SheetSection title="Inventory & Equipment">
|
||||
{/* Currency */}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{COINS.filter((coin) => coin.key !== 'ep' || c.system === '5e').map((coin) => (
|
||||
<label key={coin.key} className="flex items-center gap-1 rounded-md border border-line bg-panel px-2 py-1 text-xs text-muted">
|
||||
{coin.label}
|
||||
<NumberField
|
||||
className="w-16"
|
||||
value={c.currency[coin.key]}
|
||||
min={0}
|
||||
onChange={(v) => setCoin(coin.key, v)}
|
||||
aria-label={`${coin.label} coins`}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Encumbrance */}
|
||||
<div className={cn('mb-3 text-sm', overMax ? 'text-danger' : encumbered ? 'text-warning' : 'text-muted')}>
|
||||
Carried: <span className="font-medium">{totalWeight.toLocaleString()}</span> / {capacity.max.toLocaleString()} {capacity.unit}
|
||||
{overMax ? ' — over maximum!' : encumbered ? ' — encumbered' : ''}
|
||||
</div>
|
||||
|
||||
{/* Add item */}
|
||||
<div className="mb-3 flex flex-wrap items-end gap-2">
|
||||
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||
Item
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addItem()} placeholder="Longsword" />
|
||||
</label>
|
||||
<label className="text-xs text-muted">
|
||||
Qty
|
||||
<NumberField className="w-16" value={qty} min={0} onChange={setQty} aria-label="Quantity" />
|
||||
</label>
|
||||
<label className="text-xs text-muted">
|
||||
Wt
|
||||
<NumberField className="w-16" value={weight} min={0} onChange={setWeight} aria-label="Weight" />
|
||||
</label>
|
||||
<Button variant="primary" onClick={addItem}>Add</Button>
|
||||
</div>
|
||||
|
||||
{/* Item list */}
|
||||
{c.inventory.length === 0 ? (
|
||||
<p className="text-sm text-muted">No items yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{c.inventory.map((item) => (
|
||||
<li key={item.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||
<Input
|
||||
className="h-8 min-w-32 flex-1"
|
||||
value={item.name}
|
||||
onChange={(e) => patchItem(item.id, { name: e.target.value })}
|
||||
aria-label="Item name"
|
||||
/>
|
||||
<label className="text-xs text-muted">×<NumberField className="ml-1 w-14 inline-block" value={item.quantity} min={0} onChange={(v) => patchItem(item.id, { quantity: v })} aria-label="Quantity" /></label>
|
||||
<label className="text-xs text-muted">{item.weight * item.quantity} {capacity.unit}</label>
|
||||
<label className="flex items-center gap-1 text-xs text-muted">
|
||||
<input type="checkbox" checked={item.equipped} onChange={(e) => patchItem(item.id, { equipped: e.target.checked })} />
|
||||
Equipped
|
||||
</label>
|
||||
{c.system === '5e' && (
|
||||
<label className="flex items-center gap-1 text-xs text-muted">
|
||||
<input type="checkbox" checked={item.attuned} onChange={(e) => patchItem(item.id, { attuned: e.target.checked })} />
|
||||
Attuned
|
||||
</label>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}>✕</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
import type { SpellEntry } from '@/lib/schemas';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
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 CASTING_ABILITIES: { key: AbilityKey; label: string }[] = [
|
||||
{ key: 'int', label: 'Intelligence' },
|
||||
{ key: 'wis', label: 'Wisdom' },
|
||||
{ key: 'cha', label: 'Charisma' },
|
||||
];
|
||||
|
||||
export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
const [spellName, setSpellName] = useState('');
|
||||
const [spellLevel, setSpellLevel] = useState(0);
|
||||
const sys = getSystem(c.system);
|
||||
const sc = c.spellcasting;
|
||||
|
||||
const ability = c.spellcastingAbility;
|
||||
const rulesInput: CharacterRulesInput = {
|
||||
level: c.level,
|
||||
abilities: c.abilities,
|
||||
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank as ProficiencyRank } : {}),
|
||||
};
|
||||
const dc = ability ? sys.spellSaveDc?.(rulesInput, ability) : undefined;
|
||||
const atk = ability ? sys.spellAttackBonus(rulesInput, ability) : undefined;
|
||||
|
||||
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
|
||||
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
|
||||
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
|
||||
const addSlotLevel = () => {
|
||||
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
|
||||
if (next > 9) return;
|
||||
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
||||
};
|
||||
|
||||
const addSpell = () => {
|
||||
if (spellName.trim() === '') return;
|
||||
const entry: SpellEntry = { id: newId(), name: spellName.trim(), level: spellLevel, prepared: false, notes: '' };
|
||||
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
|
||||
setSpellName('');
|
||||
};
|
||||
const patchSpell = (id: string, p: Partial<SpellEntry>) =>
|
||||
update({ spellcasting: { ...sc, spells: sc.spells.map((s) => (s.id === id ? { ...s, ...p } : s)) } });
|
||||
const removeSpell = (id: string) =>
|
||||
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
|
||||
|
||||
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<SheetSection title="Spellcasting">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||
<label className="text-xs text-muted">
|
||||
Casting ability
|
||||
<Select
|
||||
className="ml-2 w-auto"
|
||||
value={ability ?? ''}
|
||||
onChange={(e) => update({ spellcastingAbility: (e.target.value || undefined) as typeof c.spellcastingAbility })}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{CASTING_ABILITIES.map((a) => (
|
||||
<option key={a.key} value={a.key}>{a.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
{ability && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
|
||||
<span className="text-muted">Attack <span className="font-display text-lg font-semibold text-accent">{atk !== undefined ? formatModifier(atk) : '—'}</span></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spell slots */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Spell slots</span>
|
||||
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
||||
</div>
|
||||
{sc.slots.length === 0 ? (
|
||||
<p className="text-sm text-muted">No spell slots configured.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sc.slots.map((s) => (
|
||||
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
|
||||
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}>−</Button>
|
||||
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spell list */}
|
||||
<div className="mb-2 flex items-end gap-2">
|
||||
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||
Add spell
|
||||
<Input value={spellName} onChange={(e) => setSpellName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSpell()} placeholder="Fireball" />
|
||||
</label>
|
||||
<label className="text-xs text-muted">
|
||||
Level
|
||||
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
|
||||
<option value={0}>Cantrip</option>
|
||||
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<Button variant="primary" onClick={addSpell}>Add</Button>
|
||||
</div>
|
||||
{spellsByLevel.length === 0 ? (
|
||||
<p className="text-sm text-muted">No spells added.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{spellsByLevel.map((s) => (
|
||||
<li key={s.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||
<span className="w-16 text-xs text-muted">{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}</span>
|
||||
<span className="flex-1 text-ink">{s.name}</span>
|
||||
{s.level > 0 && (
|
||||
<label className="flex items-center gap-1 text-xs text-muted">
|
||||
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
|
||||
Prepared
|
||||
</label>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}>✕</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SheetSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
|
||||
/** Shared props for every character-sheet section. `update` merges a shallow patch. */
|
||||
export interface SectionProps {
|
||||
c: Character;
|
||||
update: (patch: Partial<Character>) => void;
|
||||
}
|
||||
|
||||
export function SheetSection({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="font-display text-lg font-semibold text-ink">{title}</h2>
|
||||
{actions}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user