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('');
};
+1 -1
View File
@@ -283,7 +283,7 @@ function AddCombatantBar({
characterId: ch.id,
initiative: rollInitiativeFor(ch),
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }),
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus, ...(ch.equippedArmor ? { equippedArmor: ch.equippedArmor } : {}) }),
hp: { ...ch.hp },
conditions: [],
notes: '',
+16 -5
View File
@@ -9,7 +9,9 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
import { type Character, type InventoryItem, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import type { Spell, CompendiumEntry } from '@/lib/compendium/types';
import { normalizeSpell5e, normalizeSpellPf2e } from '@/lib/mechanics';
import { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
@@ -334,11 +336,20 @@ function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry
const c = await charactersRepo.get(characterId);
if (!c) return;
if (kind === 'spell') {
// Snapshot the spell's mechanics (concentration / save / damage) so combat
// enforcement never needs to re-load the compendium.
const mech = system === '5e'
? normalizeSpell5e(entry as unknown as Spell)
: normalizeSpellPf2e(entry as unknown as CompendiumEntry);
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
const spell: SpellEntry = {
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
};
const spell: SpellEntry = newSpellEntry({
id: newId(), name: entry.name,
level: mech?.level ?? (Number.isFinite(level) ? level : 0),
concentration: mech?.concentration ?? false,
save: mech?.save ?? null,
damage: mech?.damage ?? [],
...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
});
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
} else {
const item: InventoryItem = {
+1 -1
View File
@@ -163,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
}
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
return (
+2
View File
@@ -57,7 +57,9 @@ export interface Spell {
range?: string;
duration?: string;
concentration?: boolean | string;
requires_concentration?: boolean;
ritual?: boolean | string;
can_be_cast_as_ritual?: boolean;
components?: string;
material?: string;
dnd_class?: string;
+16
View File
@@ -127,6 +127,22 @@ export class TtrpgDatabase extends Dexie {
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
this.version(13).stores({ sessionLog: 'id, campaignId, ts' });
// v14 — V2 rules kernel: characters gain a top-level `concentration` slot and
// each spell entry gains snapshotted mechanics (concentration/save/damage).
// All defaulted, so this backfill is belt-and-suspenders (repos re-parse on save).
this.version(14).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.concentration === undefined) c.concentration = null;
if (c.equippedArmor === undefined) c.equippedArmor = null;
const sc = c.spellcasting as { spells?: Record<string, unknown>[] } | undefined;
for (const s of sc?.spells ?? []) {
if (s.concentration === undefined) s.concentration = false;
if (s.save === undefined) s.save = null;
if (s.damage === undefined) s.damage = [];
}
});
});
}
}
+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;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Armor5e } from '@/lib/compendium/types';
import type { ArmorMechanics } from '../types';
/**
* 5e equipment armor → unified mechanics.
*
* The source `ac` is a string ("11 + Dex", "14 + Dex (max 2)", "18", "+2" for a
* shield) and `dexCap` is null (light/unlimited), a number (medium cap), or 0
* (heavy). We trust `dexCap` and parse the leading number out of `ac`.
*/
export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null {
if (!raw.name) return null;
const type = (raw.type ?? '').toLowerCase();
const isShield = type === 'shield';
const acStr = raw.ac ?? '';
// Shields read "+2"; armor reads "11 + Dex" or "18".
const baseAc = Number(/-?\d+/.exec(acStr)?.[0] ?? (isShield ? 2 : 10));
const category: ArmorMechanics['category'] = isShield
? 'shield'
: type === 'light' ? 'light'
: type === 'medium' ? 'medium'
: type === 'heavy' ? 'heavy'
: 'unarmored';
// Shields don't cap Dex (they're additive); honor the source dexCap otherwise.
const dexCap = isShield ? null : (raw.dexCap ?? null);
return {
name: raw.name,
category,
baseAc,
dexCap,
stealthDisadvantage: !!raw.stealthPenalty,
};
}
@@ -0,0 +1,55 @@
import type { Monster } from '@/lib/compendium/types';
import { asDamageType, type DamageDefenses, type DamageType } from '../types';
/**
* Parse a free-text defense string like "cold, fire" or
* "bludgeoning, piercing, and slashing from nonmagical attacks".
*
* We deliberately only enforce *clean* lists: a clause that carries a qualifier
* ("from nonmagical attacks", "that is silvered", etc.) is dropped into `notes`
* for the GM to read, never auto-applied — matching the "don't over-promise
* automation" rule. Returns the clean damage types plus any leftover notes.
*/
function parseDamageList(raw: string | undefined): { types: DamageType[]; notes: string[] } {
const types: DamageType[] = [];
const notes: string[] = [];
const text = (raw ?? '').trim();
if (!text) return { types, notes };
// Split on ';' first (distinct clauses), then commas/"and" within a clause.
for (const clause of text.split(/;/)) {
const c = clause.trim();
if (!c) continue;
// A qualifier ("from", "that aren't", "except") means it's conditional — note it.
if (/\b(from|that|except|unless|nonmagical|magical|silvered|adamantine)\b/i.test(c)) {
notes.push(c);
continue;
}
for (const word of c.split(/,|\band\b/)) {
const t = asDamageType(word);
if (t && !types.includes(t)) types.push(t);
}
}
return { types, notes };
}
function parseConditionList(raw: string | undefined): string[] {
return (raw ?? '')
.split(/,|\band\b|;/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/** Open5e monster → structured damage/condition defenses. */
export function normalizeMonsterDefenses(raw: Monster): DamageDefenses {
const resist = parseDamageList(raw.damage_resistances);
const immune = parseDamageList(raw.damage_immunities);
const vulnerable = parseDamageList(raw.damage_vulnerabilities);
return {
resist: resist.types,
immune: immune.types,
vulnerable: vulnerable.types,
conditionImmune: parseConditionList(raw.condition_immunities),
notes: [...resist.notes, ...immune.notes, ...vulnerable.notes],
};
}
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { normalizeSpell5e, normalizeSpellPf2e } from './spell';
import { normalizeArmor5e } from './armor';
import { normalizeMonsterDefenses } from './monsterDefenses';
import { effectiveArmorClass } from '../types';
describe('normalizeSpell5e', () => {
it('reads concentration from requires_concentration and parses a Dex half-save + damage', () => {
const m = normalizeSpell5e({
slug: 'fireball', name: 'Fireball', level_int: 3, school: 'Evocation',
concentration: 'no', requires_concentration: false, ritual: 'no',
desc: 'Each creature in a 20-foot-radius sphere must make a dexterity saving throw. A target takes 8d6 fire damage on a failed save, or half as much damage on a successful one.',
} as never);
expect(m).not.toBeNull();
expect(m!.level).toBe(3);
expect(m!.concentration).toBe(false);
expect(m!.save).toEqual({ ability: 'dex', basis: 'half' });
expect(m!.damage).toContainEqual({ dice: '8d6', type: 'fire' });
});
it('treats the "yes" string and requires_concentration flag as concentration', () => {
const m = normalizeSpell5e({
slug: 'hold-person', name: 'Hold Person', level_int: 2, school: 'Enchantment',
concentration: 'yes', requires_concentration: true, ritual: 'no',
desc: 'The target must succeed on a wisdom saving throw or be paralyzed.',
} as never);
expect(m!.concentration).toBe(true);
expect(m!.save).toEqual({ ability: 'wis', basis: 'negates' });
expect(m!.damage).toEqual([]);
});
it('returns null without a slug or name', () => {
expect(normalizeSpell5e({ name: 'x' } as never)).toBeNull();
});
it('normalizes a pf2e Foundry spell with explicit save + concentrate trait', () => {
const m = normalizeSpellPf2e({
name: 'Fear', slug: 'fear',
system: {
level: { value: 1 },
traits: { value: ['concentrate', 'emotion', 'enchantment', 'fear'] },
defense: { save: { statistic: 'will' } },
},
} as never);
expect(m!.level).toBe(1);
expect(m!.concentration).toBe(true);
expect(m!.school).toBe('enchantment');
expect(m!.save).toEqual({ ability: 'wis', basis: 'half' });
});
});
describe('normalizeArmor5e', () => {
it('parses heavy armor with no dex bonus', () => {
const m = normalizeArmor5e({ name: 'Plate', type: 'Heavy', ac: '18', dexCap: 0, stealthPenalty: true });
expect(m).toMatchObject({ category: 'heavy', baseAc: 18, dexCap: 0, stealthDisadvantage: true });
expect(effectiveArmorClass(m!, 3)).toBe(18); // dex ignored
});
it('caps medium armor dex at 2', () => {
const m = normalizeArmor5e({ name: 'Half Plate', type: 'Medium', ac: '15 + Dex (max 2)', dexCap: 2 });
expect(effectiveArmorClass(m!, 4)).toBe(17); // 15 + min(4,2)
expect(effectiveArmorClass(m!, 1)).toBe(16); // 15 + 1
});
it('lets light armor take the full dex bonus', () => {
const m = normalizeArmor5e({ name: 'Leather', type: 'Light', ac: '11 + Dex', dexCap: null });
expect(effectiveArmorClass(m!, 5)).toBe(16);
});
it('models a shield as a flat additive bonus', () => {
const m = normalizeArmor5e({ name: 'Shield', type: 'Shield', ac: '+2', dexCap: null });
expect(m).toMatchObject({ category: 'shield', baseAc: 2 });
// leather (16) + shield (+2)
const leather = normalizeArmor5e({ name: 'Leather', type: 'Light', ac: '11 + Dex', dexCap: null })!;
expect(effectiveArmorClass(leather, 5, m!.baseAc)).toBe(18);
});
});
describe('normalizeMonsterDefenses', () => {
it('parses a clean immunity list and condition immunities', () => {
const d = normalizeMonsterDefenses({
slug: 'x', name: 'X', damage_immunities: 'fire, poison', condition_immunities: 'poisoned, charmed',
} as never);
expect(d.immune).toEqual(['fire', 'poison']);
expect(d.conditionImmune).toEqual(['poisoned', 'charmed']);
expect(d.notes).toEqual([]);
});
it('drops qualified clauses into notes instead of enforcing them', () => {
const d = normalizeMonsterDefenses({
slug: 'x', name: 'X',
damage_resistances: 'bludgeoning, piercing, and slashing from nonmagical attacks',
} as never);
expect(d.resist).toEqual([]); // qualified — not auto-applied
expect(d.notes[0]).toMatch(/nonmagical/);
});
it('separates a clean type from a qualified clause split by semicolon', () => {
const d = normalizeMonsterDefenses({
slug: 'x', name: 'X', damage_resistances: 'cold; bludgeoning from nonmagical attacks',
} as never);
expect(d.resist).toEqual(['cold']);
expect(d.notes).toHaveLength(1);
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { Spell } from '@/lib/compendium/types';
import type { CompendiumEntry } from '@/lib/compendium/types';
import { asDamageType, type AbilityKey, type DamageType, type SpellMechanics, type SpellSave } from '../types';
const ABILITY_WORD: Record<string, AbilityKey> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
/** A truthy "yes"/"true"/boolean flag from the messy SRD fields. */
function flag(v: unknown): boolean {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') return /^(yes|true|1)$/i.test(v.trim());
return false;
}
/** Pull the save ability + basis out of a spell's prose description. */
function parseSave(desc: string): SpellSave | null {
const m = /(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+saving throw/i.exec(desc);
if (!m) return null;
const ability = ABILITY_WORD[m[1]!.toLowerCase()];
if (!ability) return null;
const half = /half as much|half the damage|half damage|halve the damage/i.test(desc);
return { ability, basis: half ? 'half' : 'negates' };
}
/** Best-effort dice/type pairs, e.g. "12d6 fire damage" or "takes 1d8 cold damage". */
function parseDamage(desc: string): { dice: string; type: DamageType }[] {
const out: { dice: string; type: DamageType }[] = [];
const seen = new Set<string>();
const re = /(\d+d\d+)\s+(\w+)\s+damage/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(desc)) !== null) {
const dice = m[1]!.toLowerCase();
const type = asDamageType(m[2]!);
if (!type) continue;
const key = `${dice}:${type}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ dice, type });
}
return out;
}
/** Open5e 5e spell → unified mechanics. Returns null if it lacks a slug/name. */
export function normalizeSpell5e(raw: Spell): SpellMechanics | null {
if (!raw.slug || !raw.name) return null;
const desc = raw.desc ?? '';
return {
slug: raw.slug,
name: raw.name,
level: typeof raw.level_int === 'number' ? raw.level_int : 0,
school: raw.school ?? '',
concentration: flag(raw.requires_concentration) || flag(raw.concentration),
ritual: flag(raw.can_be_cast_as_ritual) || flag(raw.ritual),
save: parseSave(desc),
damage: parseDamage(desc),
};
}
/**
* PF2e Foundry spell entry → unified mechanics. The pf2e shape nests under
* `system`; saves are explicit (`system.save.value`) rather than prose-parsed.
*/
export function normalizeSpellPf2e(raw: CompendiumEntry): SpellMechanics | null {
const name = typeof raw.name === 'string' ? raw.name : '';
const slug = typeof raw.slug === 'string' ? raw.slug : name.toLowerCase().replace(/\s+/g, '-');
if (!name || !slug) return null;
const sys = (raw.system ?? {}) as Record<string, unknown>;
const level = Number((sys.level as { value?: number } | undefined)?.value ?? 0) || 0;
const traits = ((sys.traits as { value?: string[] } | undefined)?.value ?? []) as string[];
const concentration = traits.includes('concentrate') || /sustained/i.test(String((sys.duration as { value?: string } | undefined)?.value ?? ''));
const saveAbilityWord = String((sys.defense as { save?: { statistic?: string } } | undefined)?.save?.statistic ?? '');
const saveAbil = ABILITY_WORD[saveAbilityWord.toLowerCase()] ?? pf2eSaveToAbility(saveAbilityWord);
return {
slug,
name,
level,
school: traits.find((t) => SCHOOLS.has(t)) ?? '',
concentration,
ritual: traits.includes('ritual'),
save: saveAbil ? { ability: saveAbil, basis: 'half' } : null,
damage: [],
};
}
const SCHOOLS = new Set(['abjuration', 'conjuration', 'divination', 'enchantment', 'evocation', 'illusion', 'necromancy', 'transmutation']);
/** pf2e saves are fortitude/reflex/will → their keyed ability. */
function pf2eSaveToAbility(s: string): AbilityKey | undefined {
const k = s.toLowerCase();
if (k === 'fortitude') return 'con';
if (k === 'reflex') return 'dex';
if (k === 'will') return 'wis';
return undefined;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Structured game mechanics derived from the loosely-typed compendium data.
*
* The raw SRD/Foundry records (src/lib/compendium/types.ts) stay as-is; these
* are the *mechanical* projections the rules-enforcement kernel reads. One
* unified model across 5e and pf2e — per-system divergence is handled inside
* the normalizers, and genuinely system-specific fields are optional.
*/
export type AbilityKey = 'str' | 'dex' | 'con' | 'int' | 'wis' | 'cha';
/** Damage types across both systems. 5e core + the pf2e additions + 'untyped'. */
export const DAMAGE_TYPES = [
'acid', 'bludgeoning', 'cold', 'fire', 'force', 'lightning', 'necrotic',
'piercing', 'poison', 'psychic', 'radiant', 'slashing', 'thunder',
// pf2e
'bleed', 'mental', 'spirit', 'vitality', 'void', 'precision',
'untyped',
] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number];
const DAMAGE_SET = new Set<string>(DAMAGE_TYPES);
/** Narrow an arbitrary string to a DamageType, or undefined if unrecognized. */
export function asDamageType(s: string): DamageType | undefined {
const k = s.trim().toLowerCase();
return DAMAGE_SET.has(k) ? (k as DamageType) : undefined;
}
/** How a spell's saving throw resolves. */
export interface SpellSave {
ability: AbilityKey;
/** 'negates' = save avoids the effect; 'half' = save halves the damage. */
basis: 'negates' | 'half';
}
export interface SpellMechanics {
slug: string;
name: string;
/** 0 = cantrip */
level: number;
school: string;
concentration: boolean;
ritual: boolean;
/** parsed from the description; null when there is no save or it is unclear */
save: SpellSave | null;
/** best-effort dice/type pairs parsed from the description (not enforced yet) */
damage: { dice: string; type: DamageType }[];
}
export interface ArmorMechanics {
name: string;
category: 'light' | 'medium' | 'heavy' | 'shield' | 'unarmored';
/** for shields this is the flat AC bonus (e.g. +2) */
baseAc: number;
/** null = no cap on the Dex bonus; 0 = Dex does not apply (heavy) */
dexCap: number | null;
stealthDisadvantage: boolean;
}
/**
* Effective AC from a worn armor + Dex modifier, optionally adding a shield and
* miscellaneous bonus. Pure; used by the sheet and combat. Unarmored callers
* should pass `{ baseAc: 10, dexCap: null }`-style mechanics.
*/
export function effectiveArmorClass(
armor: Pick<ArmorMechanics, 'baseAc' | 'dexCap'>,
dexMod: number,
shieldBonus = 0,
miscBonus = 0,
): number {
const dex = armor.dexCap === null ? dexMod : Math.min(dexMod, armor.dexCap);
return armor.baseAc + dex + shieldBonus + miscBonus;
}
export interface DamageDefenses {
resist: DamageType[];
immune: DamageType[];
vulnerable: DamageType[];
/** condition slugs the creature is immune to (lowercased) */
conditionImmune: string[];
/** qualified clauses we could not cleanly parse — surfaced to the GM, never enforced */
notes: string[];
}
/** Advantage state used by the combat condition-effects engine (a later phase). */
export type AdvState = 'normal' | 'advantage' | 'disadvantage';
+8 -2
View File
@@ -79,8 +79,14 @@ export const dnd5e: RulesSystem = {
},
baseArmorClass(input) {
// Unarmored AC. Heavy armor etc. is represented as armorBonus on top.
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
const dex = abilityModifier(input.abilities.dex);
const misc = input.armorBonus ?? 0;
if (input.equippedArmor) {
const { baseAc, dexCap } = input.equippedArmor;
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + misc;
}
// Unarmored AC. Shield/misc bonuses are represented as armorBonus on top.
return 10 + dex + misc;
},
spellSaveDc(input, ability) {
+7 -1
View File
@@ -76,8 +76,14 @@ export const pf2e: RulesSystem = {
},
baseArmorClass(input) {
const dex = abilityModifier(input.abilities.dex);
const misc = input.armorBonus ?? 0;
if (input.equippedArmor) {
const { baseAc, dexCap } = input.equippedArmor;
return baseAc + (dexCap === null ? dex : Math.min(dex, dexCap)) + misc;
}
// 10 + dex + (item/proficiency folded into armorBonus for the MVP sheet).
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
return 10 + dex + misc;
},
spellSaveDc(input, ability) {
+7 -1
View File
@@ -122,8 +122,14 @@ export interface CharacterRulesInput {
skillRanks?: Record<string, ProficiencyRank>;
/** rank per save ability; missing = untrained */
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
/** flat AC bonus from armor/shield/etc. */
/** flat AC bonus from shield / misc on top of armor. */
armorBonus?: number;
/**
* Worn body armor mechanics. When set, AC = baseAc + (Dex capped by dexCap) +
* armorBonus. When absent, the unarmored 10 + Dex + armorBonus formula applies.
* `dexCap: null` = no cap (light); `0` = Dex ignored (heavy).
*/
equippedArmor?: { baseAc: number; dexCap: number | null };
/** PF2e: Perception proficiency (drives initiative). */
perceptionRank?: ProficiencyRank;
/** PF2e: spellcasting proficiency. */
+44 -3
View File
@@ -37,6 +37,25 @@ export const resourceSchema = z.object({
});
export type CharacterResource = z.infer<typeof resourceSchema>;
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
/** Worn body armor, snapshotted from the compendium so AC is computed correctly. */
export const equippedArmorSchema = z.object({
name: z.string().max(120),
category: z.enum(['light', 'medium', 'heavy', 'shield', 'unarmored']),
baseAc: int,
/** null = no Dex cap (light); 0 = Dex ignored (heavy) */
dexCap: int.nullable(),
stealthDisadvantage: z.boolean().default(false),
});
export type EquippedArmor = z.infer<typeof equippedArmorSchema>;
/** A spell's saving throw, snapshotted from the compendium at link-time. */
export const spellSaveSchema = z.object({
ability: abilityKeySchema,
basis: z.enum(['negates', 'half']),
});
export const spellEntrySchema = z.object({
id: z.string(),
name: z.string().min(1).max(120),
@@ -45,9 +64,23 @@ export const spellEntrySchema = z.object({
prepared: z.boolean().default(false),
compendiumRef: z.string().optional(),
notes: z.string().max(2000).default(''),
// --- mechanics, snapshotted from the compendium so combat needs no async load ---
concentration: z.boolean().default(false),
save: spellSaveSchema.nullable().default(null),
damage: z.array(z.object({ dice: z.string(), type: z.string() })).default([]),
});
export type SpellEntry = z.infer<typeof spellEntrySchema>;
/** Build a spell entry with all mechanics fields defaulted. Callers override what they know. */
export function newSpellEntry(
partial: Pick<SpellEntry, 'id' | 'name'> & Partial<SpellEntry>,
): SpellEntry {
return {
level: 0, prepared: false, notes: '', concentration: false, save: null, damage: [],
...partial,
};
}
export const spellSlotSchema = z.object({
level: int.min(1).max(10),
max: int.min(0).default(0),
@@ -62,8 +95,6 @@ export const spellcastingSchema = z.object({
});
export type Spellcasting = z.infer<typeof spellcastingSchema>;
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
export const attackSchema = z.object({
id: z.string(),
name: z.string().min(1).max(120),
@@ -109,7 +140,10 @@ export const characterSchema = z.object({
abilities: abilityScoresSchema,
hp: hpSchema,
speed: int.nonnegative().default(30),
/** shield / misc AC bonus on top of worn armor */
armorBonus: int.default(0),
/** worn body armor (sets AC base + Dex cap); null = unarmored */
equippedArmor: equippedArmorSchema.nullable().default(null),
skillRanks: z.record(z.string(), proficiencyRankSchema).default({}),
saveRanks: z.record(z.string(), proficiencyRankSchema).default({}),
@@ -123,6 +157,11 @@ export const characterSchema = z.object({
attacks: z.array(attackSchema).default([]),
resources: z.array(resourceSchema).default([]),
spellcasting: spellcastingSchema.default({ slots: [], spells: [] }),
/** the single spell this character is concentrating on, if any (enforced in combat) */
concentration: z
.object({ spellId: z.string(), spellName: z.string(), startedRound: int.optional() })
.nullable()
.default(null),
defenses: defensesSchema.default({
deathSaves: { successes: 0, failures: 0 },
inspiration: false,
@@ -154,7 +193,7 @@ export type CharacterDraft = z.infer<typeof characterDraftSchema>;
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
export function characterDefaults(): Pick<
Character,
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'defenses' | 'conditions'
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
> {
return {
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
@@ -162,6 +201,8 @@ export function characterDefaults(): Pick<
attacks: [],
resources: [],
spellcasting: { slots: [], spells: [] },
concentration: null,
equippedArmor: null,
defenses: {
deathSaves: { successes: 0, failures: 0 },
inspiration: false,
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"root":["./vite.config.ts","./vitest.config.ts","./scripts/fetch_pf2e.ts"],"version":"5.9.3"}
{"root":["./vite.config.ts","./vitest.config.ts","./scripts/fetch_foundry_pf2e.ts","./scripts/fetch_open5e.ts","./scripts/fetch_pf2e.ts"],"version":"5.9.3"}