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
+32 -5
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -99,6 +100,7 @@ export function CharacterSheet({ character }: { character: Character }) {
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
@@ -184,10 +186,13 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
<StatCard label="Armor Class" value={ac} hint={c.equippedArmor ? `${c.equippedArmor.name}${c.armorBonus ? ` + ${c.armorBonus}` : ''}` : `10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
{c.system === '5e' && <ArmorPicker c={c} update={update} />}
<div className="flex items-center gap-2">
<span>Shield/misc bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</div>
</StatCard>
@@ -428,6 +433,28 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
);
}
function ArmorPicker({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [armors, setArmors] = useState<ArmorMechanics[]>([]);
useEffect(() => { void allArmorMechanics5e().then(setArmors); }, []);
// Body armor only — shields are handled via the misc bonus field.
const body = armors.filter((a) => a.category !== 'shield');
return (
<Select
className="w-auto py-1 text-xs"
aria-label="Equipped armor"
value={c.equippedArmor?.name ?? ''}
onChange={(e) => {
const name = e.target.value;
const found = body.find((a) => a.name === name);
update({ equippedArmor: found ? { name: found.name, category: found.category, baseAc: found.baseAc, dexCap: found.dexCap, stealthDisadvantage: found.stealthDisadvantage } : null });
}}
>
<option value="">Unarmored</option>
{body.map((a) => <option key={a.name} value={a.name}>{a.name} (AC {a.baseAc}{a.dexCap === 0 ? '' : a.dexCap === null ? ' + Dex' : ` + Dex≤${a.dexCap}`})</option>)}
</Select>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
}
+1 -1
View File
@@ -98,7 +98,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
function CharacterCard({ c }: { c: Character }) {
const [confirming, setConfirming] = useState(false);
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
return (
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb } from 'lucide-react';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
@@ -231,7 +231,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => ({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)), prepared: false, notes: '' }));
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
await charactersRepo.update(created.id, {
...built,
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import type { SpellEntry } from '@/lib/schemas';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -41,7 +41,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
const addSpell = () => {
if (spellName.trim() === '') return;
const entry: SpellEntry = { id: newId(), name: spellName.trim(), level: spellLevel, prepared: false, notes: '' };
const entry: SpellEntry = newSpellEntry({ id: newId(), name: spellName.trim(), level: spellLevel });
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
setSpellName('');
};