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:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user