"""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, TypeGuard, get_args from pf1e_simulator.dice import DiceExpr, parse_dice from pf1e_simulator.effects import BonusType, StatModifier, StatTarget if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells" SpellRange = Literal["personal", "touch", "close", "medium", "long"] SaveType = Literal["fort", "ref", "will", "none"] # PF1e casting times, normalized to budget codes (see combat._cast_time_slot). CastTime = Literal["standard", "swift", "immediate", "free", "full_round", "one_round", "long"] @dataclass(frozen=True) class DamageEffect: formula: DiceExpr types: tuple[str, ...] # False = save negates (targeted); True = half damage on save (ray/line/cone/AoE) half_on_save: bool = False # Only consulted when half_on_save is False. True = a successful save deals # no damage (save negates, the targeted-spell default); False = the save has # no effect on this damage at all (SRD Sound Burst: "Fortitude partial" # negates only the stun — every creature takes the full dice regardless). save_negates: 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 @dataclass(frozen=True) class AreaEffect: """Area of effect spell (burst only for Phase 0). - shape: only "burst" supported (circle around target point) - radius_ft: radius in feet (e.g., 20 for fireball) - effects: the effects applied to each creature in the area - save_for_half: if True, successful save halves damage """ shape: Literal["burst"] = "burst" radius_ft: int = 20 effects: tuple[SpellEffect, ...] = () save_for_half: bool = True @dataclass(frozen=True) class SummonEffect: """Summoning spell effect. - creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json") - duration_rounds: how long the summoned creature stays """ creature: str duration_rounds: int SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect @dataclass(frozen=True) class SpellSpec: name: str level: int school: str range: SpellRange = "medium" save: SaveType = "none" base_dc: int = 10 # PF1e (CRB, Magic — Ranged Touch Attacks): the spell requires a ranged # touch attack to deliver (rays). Auto-hit spells (Magic Missile) and # area/save-only spells keep this False. requires_ranged_touch: bool = False cast_time: CastTime = "standard" 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 _FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering"}) def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]: descriptors: set[str] = set() for effect in effects: if isinstance(effect, DamageEffect): descriptors.update(effect.types) elif isinstance(effect, ConditionEffect) and effect.condition_name in _FEAR_CONDITIONS: descriptors.add("fear") elif isinstance(effect, AreaEffect): descriptors.update(save_descriptors_for_effects(effect.effects)) return frozenset(descriptors) def spell_save_descriptors(spell: SpellSpec) -> frozenset[str]: """Spell save descriptors: effect-derived plus CRB school tags. Enchantment is mind_affecting, divination is divination. """ descriptors = set(save_descriptors_for_effects(spell.effects)) if spell.school == "enchantment": descriptors.add("mind_affecting") elif spell.school == "divination": descriptors.add("divination") return frozenset(descriptors) def _as_dict(value: Json) -> dict[str, Json]: if isinstance(value, dict): return value msg = f"expected object, got {value!r}" raise ValueError(msg) def _as_list(value: Json) -> list[Json]: if isinstance(value, list): return value msg = f"expected list, got {value!r}" raise ValueError(msg) def _as_str(value: Json) -> str: if isinstance(value, str): return value msg = f"expected string, got {value!r}" raise ValueError(msg) def _as_int(value: Json) -> int: if isinstance(value, int): return value if isinstance(value, float): return int(value) if isinstance(value, str): return int(value) msg = f"expected integer, got {value!r}" raise ValueError(msg) def _as_optional_str(value: Json) -> str | None: if value is None: return None return _as_str(value) def _is_spell_range(value: str) -> TypeGuard[SpellRange]: return value in ("personal", "touch", "close", "medium", "long") def _is_save_type(value: str) -> TypeGuard[SaveType]: return value in ("fort", "ref", "will", "none") def _is_cast_time(value: str) -> TypeGuard[CastTime]: return value in get_args(CastTime) # RAW phrasings accepted by the JSON loader, mapped onto the budget codes. _CAST_TIME_ALIASES: dict[str, CastTime] = { "1 round": "one_round", "one round": "one_round", "1 full-round action": "full_round", "full-round action": "full_round", "full-round": "full_round", "1 minute": "long", "3 rounds": "long", "10 minutes": "long", "1 hour": "long", "standard action": "standard", "swift action": "swift", "immediate action": "immediate", "free action": "free", } def _parse_cast_time(value: str) -> CastTime: """Casting time as a budget code, accepting the codes and RAW aliases.""" code = value.strip().lower() if _is_cast_time(code): return code if code in _CAST_TIME_ALIASES: return _CAST_TIME_ALIASES[code] msg = f"unknown cast time: {value!r} (expected one of {get_args(CastTime)})" raise ValueError(msg) def _is_stat_target(value: str) -> TypeGuard[StatTarget]: return value in get_args(StatTarget) def _is_bonus_type(value: str) -> TypeGuard[BonusType]: return value in get_args(BonusType) def _parse_modifier(value: Json) -> StatModifier: m = _as_dict(value) target = _as_str(m["target"]) if not _is_stat_target(target): msg = f"unknown stat target: {target!r}" raise ValueError(msg) bonus = _as_str(m.get("bonus_type", "")) if not _is_bonus_type(bonus): msg = f"unknown bonus type: {bonus!r}" raise ValueError(msg) return StatModifier( target=target, value=_as_int(m["value"]), bonus_type=bonus, condition=_as_optional_str(m.get("condition")), weapon_filter=_as_optional_str(m.get("weapon_filter")), ) def _parse_effect(data: Json) -> SpellEffect: effect = _as_dict(data) effect_type = _as_str(effect["type"]) if effect_type == "damage": return DamageEffect( formula=parse_dice(_as_str(effect["formula"])), types=tuple(_as_str(t) for t in _as_list(effect["types"])), half_on_save=bool(effect.get("half_on_save", False)), save_negates=bool(effect.get("save_negates", True)), ) if effect_type == "condition": return ConditionEffect( condition_name=_as_str(effect["condition_name"]), duration_rounds=_as_int(effect["duration_rounds"]), save_negates=bool(effect.get("save_negates", True)), ) if effect_type == "heal": return HealEffect( formula=parse_dice(_as_str(effect["formula"])), ) if effect_type == "buff": return BuffEffect( modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])), duration_rounds=_as_int(effect.get("duration_rounds", 0)), ) if effect_type == "area": return AreaEffect( radius_ft=_as_int(effect.get("radius_ft", 20)), effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])), save_for_half=bool(effect.get("save_for_half", True)), ) if effect_type == "summon": return SummonEffect( creature=_as_str(effect["creature"]), duration_rounds=_as_int(effect["duration_rounds"]), ) msg = f"Unknown effect type: {effect_type}" raise ValueError(msg) def load_spell(path: str | Path) -> SpellSpec: data = _as_dict(json.loads(Path(path).read_text())) effects = tuple(_parse_effect(e) for e in _as_list(data.get("effects", []))) range_raw = _as_str(data.get("range", "medium")) if not _is_spell_range(range_raw): msg = f"unknown spell range: {range_raw!r}" raise ValueError(msg) save_raw = _as_str(data.get("save", "none")) if not _is_save_type(save_raw): msg = f"unknown save type: {save_raw!r}" raise ValueError(msg) cast_time = _parse_cast_time(_as_str(data.get("cast_time", "standard"))) return SpellSpec( name=_as_str(data["name"]), level=_as_int(data["level"]), school=_as_str(data["school"]), range=range_raw, save=save_raw, base_dc=_as_int(data.get("base_dc", 10)), requires_ranged_touch=bool(data.get("requires_ranged_touch", False)), cast_time=cast_time, effects=effects, description=_as_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