6f784d3369
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>
324 lines
10 KiB
Python
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
|