252 lines
7.3 KiB
Python
252 lines
7.3 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"]
|
|
|
|
|
|
@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
|
|
|
|
@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
|
|
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 _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_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", 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)
|
|
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)),
|
|
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
|