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