V2 Phase 1: mechanics kernel + compendium→sheet integration

Lay the foundation for full rules enforcement: a new pure, testable
mechanics kernel (src/lib/mechanics/) that derives structured, enforceable
game data from the loosely-typed compendium, plus the first user-facing
payoff (accurate armor AC + spell mechanics on the sheet).

- normalizers: spell (5e + pf2e), armor, monster defenses → typed
  SpellMechanics / ArmorMechanics / DamageDefenses, with memoized derivers
  wrapping the existing lazy compendium loaders (no new assets). 11 unit tests.
- character schema: spell entries snapshot concentration/save/damage at
  compendium-link time; new top-level `concentration` slot and structured
  `equippedArmor` (additive, defaulted). Dexie v14 migration backfills.
- AC model: baseArmorClass now consults equippedArmor (heavy = flat, medium =
  Dex-capped) and falls back to the old 10+Dex formula when unarmored. Threaded
  through every AC call site; armor picker added to the sheet (5e).
- compendium "Add spell to character" snapshots real mechanics via the kernel.

Backward-compatible: un-enriched spells/characters behave exactly as before.
Build + 244 unit tests green; armor→AC verified live in preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 00:58:31 +02:00
parent 1b275ed5e6
commit 026927b5f5
21 changed files with 577 additions and 26 deletions
+58
View File
@@ -0,0 +1,58 @@
/**
* 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 { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
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;
}