Files
pf1e-simulator/src/pf1e_simulator/spells.py
T

161 lines
5.2 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, 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
@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
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect
@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.get("duration_rounds", 0)), # type: ignore[arg-type]
)
if effect_type == "area":
return AreaEffect(
radius_ft=int(data.get("radius_ft", 20)), # type: ignore[arg-type]
effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index]
save_for_half=bool(data.get("save_for_half", True)),
)
raise ValueError(f"Unknown effect type: {effect_type}")
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