Files
ttrpg_manager/src/lib/mechanics/index.ts
T
NilsBriggen 2651ac03b7 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>
2026-06-09 01:10:07 +02:00

63 lines
2.5 KiB
TypeScript

/**
* The mechanics kernel: structured, enforceable game rules derived on-demand
* from the already-cached compendium JSON. No new assets are loaded — these
* wrap the existing lazy loaders in src/lib/compendium, so the PWA cache config
* is unchanged. Derived mechanics are memoized per process.
*/
import { loadSpells, loadArmor5e, loadMonsters } from '@/lib/compendium';
import { normalizeSpell5e } from './normalize/spell';
import { normalizeArmor5e } from './normalize/armor';
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;
/** Mechanics for a single 5e spell by slug (undefined if not in the SRD set). */
export async function spellMechanics5e(slug: string): Promise<SpellMechanics | undefined> {
if (!spellMechCache) {
const raw = await loadSpells();
spellMechCache = new Map();
for (const s of raw) {
const m = normalizeSpell5e(s);
if (m) spellMechCache.set(m.slug, m);
}
}
return spellMechCache.get(slug);
}
/** Mechanics for a single 5e armor by name (case-insensitive). */
export async function armorMechanics5e(name: string): Promise<ArmorMechanics | undefined> {
if (!armorMechCache) {
const raw = await loadArmor5e();
armorMechCache = new Map();
for (const a of raw) {
const m = normalizeArmor5e(a);
if (m) armorMechCache.set(m.name.toLowerCase(), m);
}
}
return armorMechCache.get(name.toLowerCase());
}
/** All 5e armor mechanics (for the "equip" picker). */
export async function allArmorMechanics5e(): Promise<ArmorMechanics[]> {
await armorMechanics5e('');
return [...(armorMechCache?.values() ?? [])];
}
/** Damage/condition defenses for a 5e monster by slug. */
export async function monsterDefenses5e(slug: string): Promise<DamageDefenses | undefined> {
const raw = await loadMonsters();
const m = raw.find((x) => x.slug === slug);
return m ? normalizeMonsterDefenses(m) : undefined;
}