Files
pf1e-simulator/src/pf1e_simulator/spells.py
T
ctan 6f784d3369 fix(rules): require a ranged touch attack for ray spells
PF1e CRB (Magic - Ranged Touch Attacks): spells delivered by ray require a
ranged touch attack (BAB + Dex vs touch AC, no range increment) to hit a
hostile target; a miss wastes the slot. SpellSpec gains requires_ranged_touch,
set on the 8 SRD-confirmed rays (Ray of Frost, Ray of Enfeeblement, Scorching
Ray, Acid Arrow, Acid Splash, Disrupt Undead, Shard of Chaos, Spear of Purity).
Magic Missile and area spells are unaffected. Simplification (documented): no
size modifier / Weapon Finesse on the roll.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 20:19:03 +02:00

324 lines
10 KiB
Python

"""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
@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)),
)
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