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 { Link } from '@tanstack/react-router';
|
||||||
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
|
||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
|
||||||
import { getSystem, ABILITY_ABBR } 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 { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
|
||||||
import { encodeClaim } from '@/lib/sync/playerLink';
|
import { encodeClaim } from '@/lib/sync/playerLink';
|
||||||
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
|
||||||
@@ -99,6 +100,7 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
||||||
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
||||||
armorBonus: c.armorBonus,
|
armorBonus: c.armorBonus,
|
||||||
|
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
|
||||||
perceptionRank: c.perceptionRank,
|
perceptionRank: c.perceptionRank,
|
||||||
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
||||||
};
|
};
|
||||||
@@ -184,10 +186,13 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
{/* Vital stats */}
|
{/* Vital stats */}
|
||||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||||
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
|
<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 items-center justify-center gap-2 text-xs text-muted">
|
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
|
||||||
<span>Armor/shield bonus</span>
|
{c.system === '5e' && <ArmorPicker c={c} update={update} />}
|
||||||
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
|
<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>
|
</div>
|
||||||
</StatCard>
|
</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 }) {
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
|
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 }) {
|
function CharacterCard({ c }: { c: Character }) {
|
||||||
const [confirming, setConfirming] = useState(false);
|
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 (
|
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)]">
|
<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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { Lightbulb } from 'lucide-react';
|
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 { charactersRepo } from '@/lib/db/repositories';
|
||||||
import {
|
import {
|
||||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
|
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) {
|
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
|
||||||
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
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');
|
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
|
||||||
await charactersRepo.update(created.id, {
|
await charactersRepo.update(created.id, {
|
||||||
...built,
|
...built,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { getSystem } from '@/lib/rules';
|
import { getSystem } from '@/lib/rules';
|
||||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } 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 { formatModifier } from '@/lib/format';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -41,7 +41,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
|||||||
|
|
||||||
const addSpell = () => {
|
const addSpell = () => {
|
||||||
if (spellName.trim() === '') return;
|
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] } });
|
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
|
||||||
setSpellName('');
|
setSpellName('');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ function AddCombatantBar({
|
|||||||
characterId: ch.id,
|
characterId: ch.id,
|
||||||
initiative: rollInitiativeFor(ch),
|
initiative: rollInitiativeFor(ch),
|
||||||
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
|
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 },
|
hp: { ...ch.hp },
|
||||||
conditions: [],
|
conditions: [],
|
||||||
notes: '',
|
notes: '',
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { addCombatant } from '@/lib/combat/engine';
|
import { addCombatant } from '@/lib/combat/engine';
|
||||||
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
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 { useUiStore } from '@/stores/uiStore';
|
||||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
import { useCharacters } from '@/features/characters/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);
|
const c = await charactersRepo.get(characterId);
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (kind === 'spell') {
|
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 level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
|
||||||
const spell: SpellEntry = {
|
const spell: SpellEntry = newSpellEntry({
|
||||||
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
|
id: newId(), name: entry.name,
|
||||||
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
|
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] } });
|
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
|
||||||
} else {
|
} else {
|
||||||
const item: InventoryItem = {
|
const item: InventoryItem = {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
|
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 ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||||
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export interface Spell {
|
|||||||
range?: string;
|
range?: string;
|
||||||
duration?: string;
|
duration?: string;
|
||||||
concentration?: boolean | string;
|
concentration?: boolean | string;
|
||||||
|
requires_concentration?: boolean;
|
||||||
ritual?: boolean | string;
|
ritual?: boolean | string;
|
||||||
|
can_be_cast_as_ritual?: boolean;
|
||||||
components?: string;
|
components?: string;
|
||||||
material?: string;
|
material?: string;
|
||||||
dnd_class?: string;
|
dnd_class?: string;
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
|
|
||||||
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
|
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
|
||||||
this.version(13).stores({ sessionLog: 'id, campaignId, ts' });
|
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) {
|
baseArmorClass(input) {
|
||||||
// Unarmored AC. Heavy armor etc. is represented as armorBonus on top.
|
const dex = abilityModifier(input.abilities.dex);
|
||||||
return 10 + abilityModifier(input.abilities.dex) + (input.armorBonus ?? 0);
|
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) {
|
spellSaveDc(input, ability) {
|
||||||
|
|||||||
@@ -76,8 +76,14 @@ export const pf2e: RulesSystem = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
baseArmorClass(input) {
|
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).
|
// 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) {
|
spellSaveDc(input, ability) {
|
||||||
|
|||||||
@@ -122,8 +122,14 @@ export interface CharacterRulesInput {
|
|||||||
skillRanks?: Record<string, ProficiencyRank>;
|
skillRanks?: Record<string, ProficiencyRank>;
|
||||||
/** rank per save ability; missing = untrained */
|
/** rank per save ability; missing = untrained */
|
||||||
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
|
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
|
||||||
/** flat AC bonus from armor/shield/etc. */
|
/** flat AC bonus from shield / misc on top of armor. */
|
||||||
armorBonus?: number;
|
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). */
|
/** PF2e: Perception proficiency (drives initiative). */
|
||||||
perceptionRank?: ProficiencyRank;
|
perceptionRank?: ProficiencyRank;
|
||||||
/** PF2e: spellcasting proficiency. */
|
/** PF2e: spellcasting proficiency. */
|
||||||
|
|||||||
@@ -37,6 +37,25 @@ export const resourceSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CharacterResource = z.infer<typeof resourceSchema>;
|
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({
|
export const spellEntrySchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
@@ -45,9 +64,23 @@ export const spellEntrySchema = z.object({
|
|||||||
prepared: z.boolean().default(false),
|
prepared: z.boolean().default(false),
|
||||||
compendiumRef: z.string().optional(),
|
compendiumRef: z.string().optional(),
|
||||||
notes: z.string().max(2000).default(''),
|
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>;
|
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({
|
export const spellSlotSchema = z.object({
|
||||||
level: int.min(1).max(10),
|
level: int.min(1).max(10),
|
||||||
max: int.min(0).default(0),
|
max: int.min(0).default(0),
|
||||||
@@ -62,8 +95,6 @@ export const spellcastingSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type Spellcasting = z.infer<typeof spellcastingSchema>;
|
export type Spellcasting = z.infer<typeof spellcastingSchema>;
|
||||||
|
|
||||||
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
|
|
||||||
|
|
||||||
export const attackSchema = z.object({
|
export const attackSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
@@ -109,7 +140,10 @@ export const characterSchema = z.object({
|
|||||||
abilities: abilityScoresSchema,
|
abilities: abilityScoresSchema,
|
||||||
hp: hpSchema,
|
hp: hpSchema,
|
||||||
speed: int.nonnegative().default(30),
|
speed: int.nonnegative().default(30),
|
||||||
|
/** shield / misc AC bonus on top of worn armor */
|
||||||
armorBonus: int.default(0),
|
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({}),
|
skillRanks: z.record(z.string(), proficiencyRankSchema).default({}),
|
||||||
saveRanks: 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([]),
|
attacks: z.array(attackSchema).default([]),
|
||||||
resources: z.array(resourceSchema).default([]),
|
resources: z.array(resourceSchema).default([]),
|
||||||
spellcasting: spellcastingSchema.default({ slots: [], spells: [] }),
|
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({
|
defenses: defensesSchema.default({
|
||||||
deathSaves: { successes: 0, failures: 0 },
|
deathSaves: { successes: 0, failures: 0 },
|
||||||
inspiration: false,
|
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. */
|
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||||
export function characterDefaults(): Pick<
|
export function characterDefaults(): Pick<
|
||||||
Character,
|
Character,
|
||||||
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'defenses' | 'conditions'
|
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||||
@@ -162,6 +201,8 @@ export function characterDefaults(): Pick<
|
|||||||
attacks: [],
|
attacks: [],
|
||||||
resources: [],
|
resources: [],
|
||||||
spellcasting: { slots: [], spells: [] },
|
spellcasting: { slots: [], spells: [] },
|
||||||
|
concentration: null,
|
||||||
|
equippedArmor: null,
|
||||||
defenses: {
|
defenses: {
|
||||||
deathSaves: { successes: 0, failures: 0 },
|
deathSaves: { successes: 0, failures: 0 },
|
||||||
inspiration: false,
|
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