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