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">
+61
View File
@@ -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 } : {};
}
+48
View File
@@ -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).`,
};
}
+128
View File
@@ -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 });
});
});
+4
View File
@@ -11,9 +11,13 @@ import { normalizeMonsterDefenses } from './normalize/monsterDefenses';
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
export * from './types';
export type { EnforceResult } from './result';
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
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 armorMechCache: Map<string, ArmorMechanics> | null = null;
+29
View File
@@ -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}.`],
};
}
+10
View File
@@ -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 };
+8
View File
@@ -98,4 +98,12 @@ describe('applyRest', () => {
expect(patch.defenses!.exhaustion).toBe(1);
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);
});
});
+3
View File
@@ -15,6 +15,9 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
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.
patch.resources = c.resources.map((r) =>
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
+7
View File
@@ -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);
});
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)', () => {
const parsed = partialCharacterDiffSchema.parse({
hp: { current: 1, max: 24, temp: 0 },
+6
View File
@@ -20,6 +20,10 @@ export const playerCharacterSchema = z.object({
imageId: z.string().optional(),
hp: hpSchema,
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({
@@ -94,6 +98,8 @@ export const partialCharacterDiffSchema = z.object({
spellcasting: spellcastingSchema.optional(),
resources: z.array(resourceSchema).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>;
+4 -1
View File
@@ -38,10 +38,13 @@ export function buildSnapshot(input: {
id: c.id,
name: c.name,
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) } : {}),
hp: c.hp,
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 })) } : {}),
};
});
+1
View File
@@ -93,6 +93,7 @@ function connect(): void {
...(d.spellcasting ? { spellcasting: d.spellcasting } : {}),
...(d.resources ? { resources: d.resources } : {}),
...(d.defenses ? { defenses: d.defenses } : {}),
...(d.concentration !== undefined ? { concentration: d.concentration } : {}),
});
}
} else if (msg.t === 'rollBroadcast') {
File diff suppressed because one or more lines are too long