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,11 +186,14 @@ 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>
|
||||
<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>
|
||||
|
||||
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
|
||||
@@ -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('');
|
||||
};
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user