828afc15c0
Spells are SpellSpec dataclasses with typed effects: DamageEffect (dice + types, half-on-save), ConditionEffect (condition lookup + save negates), HealEffect (capped at hp_max), BuffEffect (transient StatModifiers). Range categories (personal/touch/close/medium/long) scale with caster level via spell_range_ft. JSON loader (load_spell/load_spell_registry) reads data/spells/*.json. The engine integrates spells via Action(kind='cast_spell') and _cast_spell: range check, line-of-effect gate, save resolution (resolve_save), effect application (_apply_spell_effects with DR via _apply_dr_for_types), transcript logging. The spell_registry is passed to CombatEngine; Combatant.spells holds known spell names. - spells.py: SpellSpec, 4 effect types, SpellRange/SaveType literals, spell_range_ft, JSON loader (uses Json type from foundry.py) - conditions.py: CONDITIONS_BY_NAME registry for condition lookup - combat.py: cast_spell Action kind, _execute_cast_spell dispatch, _cast_spell (range/LoE/save/effects/log), _apply_spell_effects, _apply_dr_for_types, _save_label helper, SpellSpec/BuffEffect/etc imports, spell_registry parameter on CombatEngine - models.py: spells field on Combatant - data/spells/: 10 spells (Magic Missile, Burning Hands, Fireball, Lightning Bolt, Acid Arrow, Cure Light/Moderate Wounds, Hold Person, Fear, Bull's Strength) - tests/test_spells.py: 29 tests (loading, range, damage/save/condition/ heal/buff/DR, out-of-range, unknown spell, dispatch, DC scaling) - pyproject.toml: PLR0913 ignore for combat.py (CombatEngine.__init__) - README.md: spells.py architecture section, Sorts removed from non-modeled list, 336 tests
141 lines
4.4 KiB
Python
141 lines
4.4 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
|
|
|
|
|
|
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
|
|
|
|
|
@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["duration_rounds"]), # type: ignore[arg-type]
|
|
)
|
|
msg = f"Unknown effect type: {effect_type}"
|
|
raise ValueError(msg)
|
|
|
|
|
|
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
|