2651ac03b7
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>
174 lines
8.3 KiB
TypeScript
174 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|