diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index e7cbbe5..a39ec29 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -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, saveRanks: c.saveRanks as Partial>, 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 */}
- -
- Armor/shield bonus - update({ armorBonus })} aria-label="Armor bonus" /> + +
+ {c.system === '5e' && } +
+ Shield/misc bonus + update({ armorBonus })} aria-label="Armor bonus" /> +
@@ -428,6 +433,28 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri ); } +function ArmorPicker({ c, update }: { c: Character; update: (p: Partial) => void }) { + const [armors, setArmors] = useState([]); + 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 ( + + ); +} + function SectionTitle({ children }: { children: React.ReactNode }) { return

{children}

; } diff --git a/src/features/characters/CharactersPage.tsx b/src/features/characters/CharactersPage.tsx index 2087028..263fd92 100644 --- a/src/features/characters/CharactersPage.tsx +++ b/src/features/characters/CharactersPage.tsx @@ -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 (
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index d5200f6..8517043 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -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, diff --git a/src/features/characters/sheet/SpellcastingSection.tsx b/src/features/characters/sheet/SpellcastingSection.tsx index a3c1a1b..9fb0b9a 100644 --- a/src/features/characters/sheet/SpellcastingSection.tsx +++ b/src/features/characters/sheet/SpellcastingSection.tsx @@ -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(''); }; diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index 8ff2713..6083a8a 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -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: '', diff --git a/src/features/compendium/CompendiumPage.tsx b/src/features/compendium/CompendiumPage.tsx index 7e36df4..704f87b 100644 --- a/src/features/compendium/CompendiumPage.tsx +++ b/src/features/compendium/CompendiumPage.tsx @@ -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 = { diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx index 4562238..b8ef0f8 100644 --- a/src/features/world/DashboardPage.tsx +++ b/src/features/world/DashboardPage.tsx @@ -163,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) { } function PartyRow({ c, sys }: { c: Character; sys: ReturnType }) { - 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 ( diff --git a/src/lib/compendium/types.ts b/src/lib/compendium/types.ts index f030ee3..464695f 100644 --- a/src/lib/compendium/types.ts +++ b/src/lib/compendium/types.ts @@ -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; diff --git a/src/lib/db/db.ts b/src/lib/db/db.ts index 63e6be4..64440b6 100644 --- a/src/lib/db/db.ts +++ b/src/lib/db/db.ts @@ -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) => { + if (c.concentration === undefined) c.concentration = null; + if (c.equippedArmor === undefined) c.equippedArmor = null; + const sc = c.spellcasting as { spells?: Record[] } | 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 = []; + } + }); + }); } } diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts new file mode 100644 index 0000000..be564d9 --- /dev/null +++ b/src/lib/mechanics/index.ts @@ -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 | null = null; +let armorMechCache: Map | null = null; + +/** Mechanics for a single 5e spell by slug (undefined if not in the SRD set). */ +export async function spellMechanics5e(slug: string): Promise { + 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 { + 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 { + await armorMechanics5e(''); + return [...(armorMechCache?.values() ?? [])]; +} + +/** Damage/condition defenses for a 5e monster by slug. */ +export async function monsterDefenses5e(slug: string): Promise { + const raw = await loadMonsters(); + const m = raw.find((x) => x.slug === slug); + return m ? normalizeMonsterDefenses(m) : undefined; +} diff --git a/src/lib/mechanics/normalize/armor.ts b/src/lib/mechanics/normalize/armor.ts new file mode 100644 index 0000000..08ab9e5 --- /dev/null +++ b/src/lib/mechanics/normalize/armor.ts @@ -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, + }; +} diff --git a/src/lib/mechanics/normalize/monsterDefenses.ts b/src/lib/mechanics/normalize/monsterDefenses.ts new file mode 100644 index 0000000..68161e2 --- /dev/null +++ b/src/lib/mechanics/normalize/monsterDefenses.ts @@ -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], + }; +} diff --git a/src/lib/mechanics/normalize/normalize.test.ts b/src/lib/mechanics/normalize/normalize.test.ts new file mode 100644 index 0000000..db2bd88 --- /dev/null +++ b/src/lib/mechanics/normalize/normalize.test.ts @@ -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); + }); +}); diff --git a/src/lib/mechanics/normalize/spell.ts b/src/lib/mechanics/normalize/spell.ts new file mode 100644 index 0000000..9ff158c --- /dev/null +++ b/src/lib/mechanics/normalize/spell.ts @@ -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 = { + 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(); + 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; + 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; +} diff --git a/src/lib/mechanics/types.ts b/src/lib/mechanics/types.ts new file mode 100644 index 0000000..bd2fc69 --- /dev/null +++ b/src/lib/mechanics/types.ts @@ -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(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, + 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'; diff --git a/src/lib/rules/dnd5e/index.ts b/src/lib/rules/dnd5e/index.ts index 0b71320..724f75b 100644 --- a/src/lib/rules/dnd5e/index.ts +++ b/src/lib/rules/dnd5e/index.ts @@ -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) { diff --git a/src/lib/rules/pf2e/index.ts b/src/lib/rules/pf2e/index.ts index 13295e8..304afc4 100644 --- a/src/lib/rules/pf2e/index.ts +++ b/src/lib/rules/pf2e/index.ts @@ -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) { diff --git a/src/lib/rules/types.ts b/src/lib/rules/types.ts index 99e2b0e..b085122 100644 --- a/src/lib/rules/types.ts +++ b/src/lib/rules/types.ts @@ -122,8 +122,14 @@ export interface CharacterRulesInput { skillRanks?: Record; /** rank per save ability; missing = untrained */ saveRanks?: Partial>; - /** 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. */ diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index 5d06569..136f5d1 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -37,6 +37,25 @@ export const resourceSchema = z.object({ }); export type CharacterResource = z.infer; +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; + +/** 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; +/** Build a spell entry with all mechanics fields defaulted. Callers override what they know. */ +export function newSpellEntry( + partial: Pick & Partial, +): 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; -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; /** 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, diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index bc9b46a..c9b6064 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/SessionControl.tsx","./src/features/play/useSessionBroadcaster.ts","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 413b4fd..72c3504 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts","./vitest.config.ts","./scripts/fetch_pf2e.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./vite.config.ts","./vitest.config.ts","./scripts/fetch_foundry_pf2e.ts","./scripts/fetch_open5e.ts","./scripts/fetch_pf2e.ts"],"version":"5.9.3"} \ No newline at end of file