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>
This commit is contained in:
2026-06-09 01:10:07 +02:00
parent 026927b5f5
commit 2651ac03b7
16 changed files with 377 additions and 5 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules';
import { spendResource, regainResource } from '@/lib/mechanics';
import type { CharacterResource } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -61,12 +62,12 @@ export function ResourcesSection({ c, update }: SectionProps) {
{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>
<Button size="icon" variant="ghost" onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} 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>
<Button size="icon" variant="ghost" onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} 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) => (
@@ -1,8 +1,10 @@
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';
@@ -50,6 +52,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
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 (
@@ -76,6 +90,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
)}
</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">
@@ -124,13 +150,19 @@ export function SpellcastingSection({ c, update }: SectionProps) {
{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>
<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>
))}
+31
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
@@ -28,6 +30,14 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
</div>
{c.concentration && (
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/50 bg-accent/10 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={() => onPatch(dropConcentration(c))}>Drop</Button>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* HP */}
<div className="rounded-lg border border-line bg-panel p-3">
@@ -94,6 +104,27 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div>
)}
{/* Spells — cast enforces slot consumption + concentration */}
{c.spellcasting.spells.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Spells</div>
<div className="max-h-44 space-y-1 overflow-y-auto">
{[...c.spellcasting.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)).map((s) => (
<div key={s.id} className="flex items-center gap-2 text-sm">
<span className="w-12 shrink-0 text-xs text-muted">{s.level === 0 ? 'Cant' : `Lv ${s.level}`}</span>
<span className="flex-1 truncate text-ink">
{s.name}
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
</span>
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) onPatch(r.patch); }} aria-label={`Cast ${s.name}`}>
<Sparkles size={12} aria-hidden /> Cast
</Button>
</div>
))}
</div>
</div>
)}
{/* Resources */}
{c.resources.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">