"""PF1e spells: spell definitions, effect types, and JSON loader. Spells are SpellSpec dataclasses with typed effects: - DamageEffect: dice damage with optional save for half - ConditionEffect: applies a condition (looked up in CONDITIONS_BY_NAME) - HealEffect: restores HP (capped at hp_max) - BuffEffect: grants StatModifiers as transient effects Range categories: personal, touch, close, medium, long. Save types: fort, ref, will, none. Sources: PRPG Core Rulebook, Magic chapter. """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Literal, cast from pf1e_simulator.dice import DiceExpr, parse_dice from pf1e_simulator.effects import BonusType, StatModifier if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json SpellRange = Literal["personal", "touch", "close", "medium", "long"] SaveType = Literal["fort", "ref", "will", "none"] @dataclass(frozen=True) class DamageEffect: formula: DiceExpr types: tuple[str, ...] half_on_save: bool = True @dataclass(frozen=True) class ConditionEffect: condition_name: str duration_rounds: int save_negates: bool = True @dataclass(frozen=True) class HealEffect: formula: DiceExpr @dataclass(frozen=True) class BuffEffect: modifiers: tuple[StatModifier, ...] duration_rounds: int SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect @dataclass(frozen=True) class SpellSpec: name: str level: int school: str range: SpellRange = "medium" save: SaveType = "none" base_dc: int = 10 effects: tuple[SpellEffect, ...] = () description: str = "" def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: if spell.range == "personal": return 0 if spell.range == "touch": return 5 if spell.range == "close": return 25 + 5 * (caster_level // 2) if spell.range == "medium": return 100 + 10 * caster_level return 400 + 40 * caster_level def _parse_effect(data: dict[str, Json]) -> SpellEffect: effect_type = str(data["type"]) if effect_type == "damage": return DamageEffect( formula=parse_dice(str(data["formula"])), types=tuple(str(t) for t in data["types"]), # type: ignore[index] half_on_save=bool(data.get("half_on_save", True)), ) if effect_type == "condition": return ConditionEffect( condition_name=str(data["condition_name"]), duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] save_negates=bool(data.get("save_negates", True)), ) if effect_type == "heal": return HealEffect( formula=parse_dice(str(data["formula"])), ) if effect_type == "buff": return BuffEffect( modifiers=tuple( StatModifier( target=str(m["target"]), # type: ignore[index] value=int(m["value"]), # type: ignore[arg-type] bonus_type=cast("BonusType", str(m.get("bonus_type", ""))), # type: ignore[union-attr] condition=m.get("condition"), # type: ignore[assignment] weapon_filter=m.get("weapon_filter"), # type: ignore[assignment] ) for m in cast("list[dict[str, Json]]", data["modifiers"]) ), duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] ) msg = f"Unknown effect type: {effect_type}" raise ValueError(msg) def load_spell(path: str | Path) -> SpellSpec: data: dict[str, Json] = json.loads(Path(path).read_text()) effects = tuple(_parse_effect(e) for e in data.get("effects", [])) # type: ignore[arg-type] return SpellSpec( name=str(data["name"]), level=int(data["level"]), # type: ignore[arg-type] school=str(data["school"]), range=str(data.get("range", "medium")), # type: ignore[assignment] save=str(data.get("save", "none")), # type: ignore[assignment] base_dc=int(data.get("base_dc", 10)), # type: ignore[arg-type] effects=effects, description=str(data.get("description", "")), ) def load_spell_registry(dir_path: str | Path) -> dict[str, SpellSpec]: registry: dict[str, SpellSpec] = {} for path in sorted(Path(dir_path).glob("*.json")): spell = load_spell(path) registry[spell.name] = spell return registry