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 { useState } from 'react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem, applyRest } from '@/lib/rules';
|
import { getSystem, applyRest } from '@/lib/rules';
|
||||||
|
import { spendResource, regainResource } from '@/lib/mechanics';
|
||||||
import type { CharacterResource } from '@/lib/schemas';
|
import type { CharacterResource } from '@/lib/schemas';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -61,12 +62,12 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
|||||||
{c.resources.map((r) => (
|
{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">
|
<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" />
|
<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="w-12 text-center text-sm">
|
||||||
<span className="font-medium text-ink">{r.current}</span>
|
<span className="font-medium text-ink">{r.current}</span>
|
||||||
<span className="text-muted">/{r.max}</span>
|
<span className="text-muted">/{r.max}</span>
|
||||||
</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" />
|
<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'] })}>
|
<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) => (
|
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Sparkles, BrainCircuit } from 'lucide-react';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||||
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -50,6 +52,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
const removeSpell = (id: string) =>
|
const removeSpell = (id: string) =>
|
||||||
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
|
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));
|
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +90,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
)}
|
)}
|
||||||
</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 */}
|
{/* Spell slots */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
@@ -124,13 +150,19 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
{spellsByLevel.map((s) => (
|
{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">
|
<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="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 && (
|
{s.level > 0 && (
|
||||||
<label className="flex items-center gap-1 text-xs text-muted">
|
<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 })} />
|
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
|
||||||
Prepared
|
Prepared
|
||||||
</label>
|
</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>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}>✕</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Sparkles, BrainCircuit } from 'lucide-react';
|
||||||
import type { Character, Defenses } from '@/lib/schemas';
|
import type { Character, Defenses } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||||
import { ActionGuide } from './ActionGuide';
|
import { ActionGuide } from './ActionGuide';
|
||||||
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
import { rollDice, applyRollMode } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
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>
|
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
|
||||||
</div>
|
</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">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div className="rounded-lg border border-line bg-panel p-3">
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
@@ -94,6 +104,27 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
|||||||
</div>
|
</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 */}
|
{/* Resources */}
|
||||||
{c.resources.length > 0 && (
|
{c.resources.length > 0 && (
|
||||||
<div className="rounded-lg border border-line bg-panel p-3">
|
<div className="rounded-lg border border-line bg-panel p-3">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Character, Spellcasting } from '@/lib/schemas/character';
|
||||||
|
import type { EnforceResult } from './result';
|
||||||
|
|
||||||
|
/** Consume one spell slot at `level` (regular slots first, then matching pact slot). */
|
||||||
|
function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcasting; usedLevel: number } | null {
|
||||||
|
const slot = sc.slots.find((s) => s.level === level);
|
||||||
|
if (slot && slot.current > 0) {
|
||||||
|
return {
|
||||||
|
spellcasting: { ...sc, slots: sc.slots.map((s) => (s.level === level ? { ...s, current: s.current - 1 } : s)) },
|
||||||
|
usedLevel: level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Warlock pact magic — a single pooled slot at its own level.
|
||||||
|
if (sc.pact && sc.pact.level === level && sc.pact.current > 0) {
|
||||||
|
return {
|
||||||
|
spellcasting: { ...sc, pact: { ...sc.pact, current: sc.pact.current - 1 } },
|
||||||
|
usedLevel: level,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast a spell the character knows, at a chosen slot level (≥ the spell's own
|
||||||
|
* level, to allow upcasting). Pure: decrements a slot, sets/replaces
|
||||||
|
* concentration, and returns the patch + log. Cantrips cost no slot.
|
||||||
|
*
|
||||||
|
* Fails (no mutation) when no slot of the requested level is available — the UI
|
||||||
|
* surfaces `reason` and lets the player pick a different level.
|
||||||
|
*/
|
||||||
|
export function castSpell(c: Character, spellId: string, atLevel?: number): EnforceResult {
|
||||||
|
const spell = c.spellcasting.spells.find((s) => s.id === spellId);
|
||||||
|
if (!spell) return { ok: false, reason: 'That spell is not on this character.' };
|
||||||
|
|
||||||
|
const log: string[] = [];
|
||||||
|
const patch: Partial<Character> = {};
|
||||||
|
|
||||||
|
if (spell.level > 0) {
|
||||||
|
const useLevel = Math.max(atLevel ?? spell.level, spell.level);
|
||||||
|
const consumed = consumeSlot(c.spellcasting, useLevel);
|
||||||
|
if (!consumed) return { ok: false, reason: `No level-${useLevel} slot available for ${spell.name}.` };
|
||||||
|
patch.spellcasting = consumed.spellcasting;
|
||||||
|
log.push(`Cast ${spell.name} using a level-${consumed.usedLevel} slot.`);
|
||||||
|
} else {
|
||||||
|
log.push(`Cast ${spell.name} (cantrip).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spell.concentration) {
|
||||||
|
if (c.concentration && c.concentration.spellId !== spell.id) {
|
||||||
|
log.push(`Concentration on ${c.concentration.spellName} ends.`);
|
||||||
|
}
|
||||||
|
patch.concentration = { spellId: spell.id, spellName: spell.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, patch, log };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear active concentration (e.g. the player drops it, or it breaks). */
|
||||||
|
export function dropConcentration(c: Character): Partial<Character> {
|
||||||
|
return c.concentration ? { concentration: null } : {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
|
||||||
|
export interface DeathSaveOutcome {
|
||||||
|
patch: Partial<Character>;
|
||||||
|
/** 'pending' = still rolling; 'stable' = 3 successes; 'dead' = 3 failures; 'revived' = nat 20 */
|
||||||
|
outcome: 'pending' | 'stable' | 'dead' | 'revived';
|
||||||
|
log: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply one 5e death saving throw given a raw d20 face. Pure:
|
||||||
|
* - nat 20 → regain 1 HP, clear the death-save clock (back in the fight)
|
||||||
|
* - nat 1 → two failures
|
||||||
|
* - ≥10 → one success (3 → stable)
|
||||||
|
* - <10 → one failure (3 → dead)
|
||||||
|
*/
|
||||||
|
export function rollDeathSave(c: Character, d20: number): DeathSaveOutcome {
|
||||||
|
const ds = c.defenses.deathSaves;
|
||||||
|
|
||||||
|
if (d20 === 20) {
|
||||||
|
return {
|
||||||
|
patch: {
|
||||||
|
hp: { ...c.hp, current: 1 },
|
||||||
|
defenses: { ...c.defenses, deathSaves: { successes: 0, failures: 0 } },
|
||||||
|
},
|
||||||
|
outcome: 'revived',
|
||||||
|
log: `${c.name} rolls a natural 20 and regains 1 HP!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let successes = ds.successes;
|
||||||
|
let failures = ds.failures;
|
||||||
|
if (d20 === 1) failures = Math.min(3, failures + 2);
|
||||||
|
else if (d20 >= 10) successes = Math.min(3, successes + 1);
|
||||||
|
else failures = Math.min(3, failures + 1);
|
||||||
|
|
||||||
|
const patch: Partial<Character> = { defenses: { ...c.defenses, deathSaves: { successes, failures } } };
|
||||||
|
|
||||||
|
if (failures >= 3) return { patch, outcome: 'dead', log: `${c.name} fails a third death save and dies.` };
|
||||||
|
if (successes >= 3) return { patch, outcome: 'stable', log: `${c.name} stabilizes.` };
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
outcome: 'pending',
|
||||||
|
log: d20 >= 10
|
||||||
|
? `${c.name} succeeds a death save (${successes}/3).`
|
||||||
|
: `${c.name} fails a death save (${failures}/3).`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
|
||||||
|
import { castSpell, dropConcentration } from './cast';
|
||||||
|
import { spendResource, regainResource } from './resources';
|
||||||
|
import { rollDeathSave } from './deathSaves';
|
||||||
|
|
||||||
|
function mk(over: Partial<Character> = {}): Character {
|
||||||
|
return {
|
||||||
|
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
|
||||||
|
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||||
|
hp: { current: 0, max: 24, temp: 0 }, speed: 30, armorBonus: 0,
|
||||||
|
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||||
|
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('castSpell', () => {
|
||||||
|
const fireball = newSpellEntry({ id: 's1', name: 'Fireball', level: 3, save: { ability: 'dex', basis: 'half' } });
|
||||||
|
const haste = newSpellEntry({ id: 's2', name: 'Haste', level: 3, concentration: true });
|
||||||
|
const bless = newSpellEntry({ id: 's3', name: 'Bless', level: 1, concentration: true });
|
||||||
|
const firebolt = newSpellEntry({ id: 's4', name: 'Fire Bolt', level: 0 });
|
||||||
|
|
||||||
|
const base = mk({
|
||||||
|
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }, { level: 3, max: 3, current: 2 }], spells: [fireball, haste, bless, firebolt] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrements the slot of the spell level', () => {
|
||||||
|
const r = castSpell(base, 's1');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.slots.find((s) => s.level === 3)!.current).toBe(1);
|
||||||
|
expect(r.log[0]).toMatch(/level-3 slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upcasts: casting a level-3 spell with a higher slot consumes that slot', () => {
|
||||||
|
const withHigh = mk({ spellcasting: { slots: [{ level: 3, max: 1, current: 0 }, { level: 5, max: 1, current: 1 }], spells: [fireball] } });
|
||||||
|
const r = castSpell(withHigh, 's1', 5);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.slots.find((s) => s.level === 5)!.current).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when no slot of the requested level remains', () => {
|
||||||
|
const empty = mk({ spellcasting: { slots: [{ level: 3, max: 2, current: 0 }], spells: [fireball] } });
|
||||||
|
const r = castSpell(empty, 's1');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (r.ok) return;
|
||||||
|
expect(r.reason).toMatch(/No level-3 slot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cantrips cost no slot', () => {
|
||||||
|
const r = castSpell(base, 's4');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting).toBeUndefined();
|
||||||
|
expect(r.log[0]).toMatch(/cantrip/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets concentration and replaces a prior concentration spell', () => {
|
||||||
|
const concentrating = mk({
|
||||||
|
concentration: { spellId: 's3', spellName: 'Bless' },
|
||||||
|
spellcasting: { slots: [{ level: 3, max: 3, current: 2 }], spells: [haste, bless] },
|
||||||
|
});
|
||||||
|
const r = castSpell(concentrating, 's2');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.concentration).toEqual({ spellId: 's2', spellName: 'Haste' });
|
||||||
|
expect(r.log.some((l) => /Concentration on Bless ends/.test(l))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumes a warlock pact slot when no regular slot matches', () => {
|
||||||
|
const warlock = mk({ spellcasting: { slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [haste] } });
|
||||||
|
const r = castSpell(warlock, 's2', 3);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) return;
|
||||||
|
expect(r.patch.spellcasting!.pact!.current).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dropConcentration clears the slot', () => {
|
||||||
|
const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
|
||||||
|
expect(dropConcentration(concentrating)).toEqual({ concentration: null });
|
||||||
|
expect(dropConcentration(mk())).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('spendResource / regainResource', () => {
|
||||||
|
const base = mk({ resources: [{ id: 'ki', name: 'Ki', current: 2, max: 5, recovery: 'short' }] });
|
||||||
|
|
||||||
|
it('spends and fails when too low', () => {
|
||||||
|
const ok = spendResource(base, 'ki', 2);
|
||||||
|
expect(ok.ok).toBe(true);
|
||||||
|
if (ok.ok) expect(ok.patch.resources![0]!.current).toBe(0);
|
||||||
|
const bad = spendResource(base, 'ki', 3);
|
||||||
|
expect(bad.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regains up to max', () => {
|
||||||
|
const r = regainResource(base, 'ki', 10);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (r.ok) expect(r.patch.resources![0]!.current).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rollDeathSave', () => {
|
||||||
|
it('nat 20 revives at 1 HP and clears the clock', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 1, failures: 2 } } }), 20);
|
||||||
|
expect(r.outcome).toBe('revived');
|
||||||
|
expect(r.patch.hp!.current).toBe(1);
|
||||||
|
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nat 1 adds two failures → dead at three', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 1 } } }), 1);
|
||||||
|
expect(r.outcome).toBe('dead');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('≥10 succeeds; third success stabilizes', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 2, failures: 0 } } }), 12);
|
||||||
|
expect(r.outcome).toBe('stable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('<10 is a single failure, still pending', () => {
|
||||||
|
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 0 } } }), 7);
|
||||||
|
expect(r.outcome).toBe('pending');
|
||||||
|
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,9 +11,13 @@ import { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
|||||||
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
|
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export type { EnforceResult } from './result';
|
||||||
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
|
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
|
||||||
export { normalizeArmor5e } from './normalize/armor';
|
export { normalizeArmor5e } from './normalize/armor';
|
||||||
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
||||||
|
export { castSpell, dropConcentration } from './cast';
|
||||||
|
export { spendResource, regainResource } from './resources';
|
||||||
|
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
||||||
|
|
||||||
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
let spellMechCache: Map<string, SpellMechanics> | null = null;
|
||||||
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
let armorMechCache: Map<string, ArmorMechanics> | null = null;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
import type { EnforceResult } from './result';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend `n` of a tracked resource pool (ki, rage, channel divinity, sorcery
|
||||||
|
* points, …). Pure; fails without mutation when the pool is too low.
|
||||||
|
*/
|
||||||
|
export function spendResource(c: Character, resourceId: string, n = 1): EnforceResult {
|
||||||
|
const r = c.resources.find((x) => x.id === resourceId);
|
||||||
|
if (!r) return { ok: false, reason: 'That resource does not exist.' };
|
||||||
|
if (n <= 0) return { ok: false, reason: 'Amount must be positive.' };
|
||||||
|
if (r.current < n) return { ok: false, reason: `Not enough ${r.name} (have ${r.current}, need ${n}).` };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
patch: { resources: c.resources.map((x) => (x.id === resourceId ? { ...x, current: x.current - n } : x)) },
|
||||||
|
log: [`Spent ${n} ${r.name}.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore `n` to a resource pool, capped at its max. */
|
||||||
|
export function regainResource(c: Character, resourceId: string, n = 1): EnforceResult {
|
||||||
|
const r = c.resources.find((x) => x.id === resourceId);
|
||||||
|
if (!r) return { ok: false, reason: 'That resource does not exist.' };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
patch: { resources: c.resources.map((x) => (x.id === resourceId ? { ...x, current: Math.min(x.max, x.current + n) } : x)) },
|
||||||
|
log: [`Regained ${n} ${r.name}.`],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The result of an enforcement action. Pure functions return a `Partial<Character>`
|
||||||
|
* patch the caller persists (mirroring `applyRest`), plus log lines for the combat
|
||||||
|
* feed — or a failure with a human-readable reason the UI surfaces.
|
||||||
|
*/
|
||||||
|
export type EnforceResult =
|
||||||
|
| { ok: true; patch: Partial<Character>; log: string[] }
|
||||||
|
| { ok: false; reason: string };
|
||||||
@@ -98,4 +98,12 @@ describe('applyRest', () => {
|
|||||||
expect(patch.defenses!.exhaustion).toBe(1);
|
expect(patch.defenses!.exhaustion).toBe(1);
|
||||||
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('any rest ends ongoing concentration', () => {
|
||||||
|
const c = { ...makeCharacter(), concentration: { spellId: 's', spellName: 'Bless' } };
|
||||||
|
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
|
||||||
|
expect(applyRest(c, short).concentration).toBeNull();
|
||||||
|
// no concentration → no concentration key in the patch
|
||||||
|
expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
|
|||||||
patch.hp = { ...c.hp, current: c.hp.max };
|
patch.hp = { ...c.hp, current: c.hp.max };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any rest ends ongoing concentration (you stop holding the spell to rest).
|
||||||
|
if (c.concentration) patch.concentration = null;
|
||||||
|
|
||||||
// Resources refresh when their recovery tag is covered by this rest.
|
// Resources refresh when their recovery tag is covered by this rest.
|
||||||
patch.resources = c.resources.map((r) =>
|
patch.resources = c.resources.map((r) =>
|
||||||
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
|
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ describe('sync protocol', () => {
|
|||||||
expect(serverMessageSchema.safeParse({ t: 'rollBroadcast', playerName: 'Lia', label: 'd20', expression: '1d20', total: 9, breakdown: '[9]' }).success).toBe(true);
|
expect(serverMessageSchema.safeParse({ t: 'rollBroadcast', playerName: 'Lia', label: 'd20', expression: '1d20', total: 9, breakdown: '[9]' }).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries concentration in a player diff (set and cleared)', () => {
|
||||||
|
const set = partialCharacterDiffSchema.parse({ concentration: { spellId: 's1', spellName: 'Bless' } });
|
||||||
|
expect(set.concentration).toEqual({ spellId: 's1', spellName: 'Bless' });
|
||||||
|
const cleared = partialCharacterDiffSchema.parse({ concentration: null });
|
||||||
|
expect(cleared.concentration).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('strips identity fields from a player diff (players can never rewrite name/abilities)', () => {
|
it('strips identity fields from a player diff (players can never rewrite name/abilities)', () => {
|
||||||
const parsed = partialCharacterDiffSchema.parse({
|
const parsed = partialCharacterDiffSchema.parse({
|
||||||
hp: { current: 1, max: 24, temp: 0 },
|
hp: { current: 1, max: 24, temp: 0 },
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export const playerCharacterSchema = z.object({
|
|||||||
imageId: z.string().optional(),
|
imageId: z.string().optional(),
|
||||||
hp: hpSchema,
|
hp: hpSchema,
|
||||||
conditions: z.array(condSchema).default([]),
|
conditions: z.array(condSchema).default([]),
|
||||||
|
// enforced-state mirrors, so a player sees their own slots/concentration live
|
||||||
|
slots: z.array(z.object({ level: int, current: int, max: int })).optional(),
|
||||||
|
concentration: z.string().nullable().optional(),
|
||||||
|
resources: z.array(z.object({ name: z.string(), current: int, max: int })).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const playerCombatantSchema = z.object({
|
export const playerCombatantSchema = z.object({
|
||||||
@@ -94,6 +98,8 @@ export const partialCharacterDiffSchema = z.object({
|
|||||||
spellcasting: spellcastingSchema.optional(),
|
spellcasting: spellcastingSchema.optional(),
|
||||||
resources: z.array(resourceSchema).optional(),
|
resources: z.array(resourceSchema).optional(),
|
||||||
defenses: defensesSchema.optional(),
|
defenses: defensesSchema.optional(),
|
||||||
|
/** the spell a seated player is concentrating on (or null when they drop it) */
|
||||||
|
concentration: characterSchema.shape.concentration.optional(),
|
||||||
});
|
});
|
||||||
export type PartialCharacterDiff = z.infer<typeof partialCharacterDiffSchema>;
|
export type PartialCharacterDiff = z.infer<typeof partialCharacterDiffSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ export function buildSnapshot(input: {
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
level: c.level,
|
level: c.level,
|
||||||
ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus }),
|
ac: sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) }),
|
||||||
...(c.portrait ? { imageId: charImageId(c.id) } : {}),
|
...(c.portrait ? { imageId: charImageId(c.id) } : {}),
|
||||||
hp: c.hp,
|
hp: c.hp,
|
||||||
conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })),
|
conditions: c.conditions.map((x) => ({ name: x.name, ...(x.value !== undefined ? { value: x.value } : {}) })),
|
||||||
|
...(c.spellcasting.slots.length ? { slots: c.spellcasting.slots.map((s) => ({ level: s.level, current: s.current, max: s.max })) } : {}),
|
||||||
|
...(c.concentration ? { concentration: c.concentration.spellName } : {}),
|
||||||
|
...(c.resources.length ? { resources: c.resources.map((r) => ({ name: r.name, current: r.current, max: r.max })) } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ function connect(): void {
|
|||||||
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
|
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
|
||||||
...(d.resources ? { resources: d.resources } : {}),
|
...(d.resources ? { resources: d.resources } : {}),
|
||||||
...(d.defenses ? { defenses: d.defenses } : {}),
|
...(d.defenses ? { defenses: d.defenses } : {}),
|
||||||
|
...(d.concentration !== undefined ? { concentration: d.concentration } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (msg.t === 'rollBroadcast') {
|
} else if (msg.t === 'rollBroadcast') {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user