Files
ttrpg_manager/src/features/characters/sheet/SpellcastingSection.tsx
T
NilsBriggen 2651ac03b7 V2 Phase 2: spell + resource enforcement (sheet → combat → live)
The Slice-0 vertical that proves the enforcement architecture end-to-end.

- kernel: castSpell (slot consumption + concentration set/replace, upcasting,
  warlock pact slots), spendResource/regainResource, rollDeathSave — pure
  functions returning Partial<Character> patches. 13 unit tests.
- applyRest now ends concentration on any rest (+ test).
- character sheet SpellcastingSection: per-spell Cast button, concentration
  banner with Drop, concentration markers; ResourcesSection −/+ routed through
  the kernel.
- player MyCharacterPanel: castable spell list + concentration banner, so a
  seated player casts and it broadcasts via the existing playerPatch flow.
- live sync: partialCharacterDiffSchema carries concentration; GM applies it;
  playerCharacterSchema mirrors slots/concentration/resources (PC-only) and the
  player-facing AC now respects equippedArmor. Wire round-trip test added.

Verified live: casting decrements the right slot, switching concentration logs
"Concentration on X ends", banner + Drop render. Build + 258 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:10:07 +02:00

174 lines
8.3 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 { Sparkles, BrainCircuit } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
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 = newSpellEntry({ id: newId(), name: spellName.trim(), level: spellLevel });
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) } });
// Casting a spell enforces slot consumption + concentration via the kernel.
const [castMsg, setCastMsg] = useState<{ text: string; ok: boolean } | null>(null);
const cast = (id: string) => {
const res = castSpell(c, id);
if (res.ok) {
update(res.patch);
setCastMsg({ text: res.log.join(' '), ok: true });
} else {
setCastMsg({ text: res.reason, ok: false });
}
};
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>
{/* Concentration banner — enforced: one spell at a time. */}
{c.concentration && (
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/40 bg-accent/5 px-3 py-1.5 text-sm">
<BrainCircuit size={15} className="shrink-0 text-accent" aria-hidden />
<span className="text-ink">Concentrating on <span className="font-medium">{c.concentration.spellName}</span></span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => update(dropConcentration(c))}>Drop</Button>
</div>
)}
{castMsg && (
<p className={`mb-3 text-xs ${castMsg.ok ? 'text-success' : 'text-danger'}`} aria-live="polite">{castMsg.text}</p>
)}
{/* 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}
{s.concentration && <BrainCircuit size={12} className="ml-1 inline text-accent" aria-label="Concentration" />}
</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="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}></Button>
</li>
))}
</ul>
)}
</SheetSection>
);
}