feat(spells): add spell system with 10 combat spells, cast_spell action
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
This commit is contained in:
@@ -79,7 +79,14 @@ from collections.abc import Callable
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
|
||||
from pf1e_simulator.effects import StatModifier, resolve_modifiers
|
||||
from pf1e_simulator.spells import (
|
||||
ConditionEffect,
|
||||
DamageEffect,
|
||||
HealEffect,
|
||||
spell_range_ft,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
@@ -89,6 +96,7 @@ if TYPE_CHECKING:
|
||||
from pf1e_simulator.map import Pos
|
||||
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
|
||||
from pf1e_simulator.rng import Rng
|
||||
from pf1e_simulator.spells import SpellSpec
|
||||
|
||||
from pf1e_simulator.los import has_cover, has_line_of_effect
|
||||
|
||||
@@ -106,6 +114,13 @@ _CHARGE_ATTACK_BONUS = 2 # PF1e: +2 attack roll on a charge
|
||||
_CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging
|
||||
_CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares)
|
||||
|
||||
|
||||
def _save_label(result: SaveResult | None) -> str:
|
||||
if result is None:
|
||||
return "NO SAVE"
|
||||
return "SAVED" if result.success else "FAILED"
|
||||
|
||||
|
||||
_STEP_DELTAS: tuple[Pos, ...] = (
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
@@ -218,9 +233,10 @@ class Action:
|
||||
|
||||
kind: Literal[
|
||||
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
|
||||
"full_attack", "charge", "withdraw", "5foot_step",
|
||||
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
|
||||
]
|
||||
target_id: str | None = None
|
||||
spell_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -252,12 +268,14 @@ class CombatEngine:
|
||||
*,
|
||||
round_cap: int = 100,
|
||||
policy: Policy | None = None,
|
||||
spell_registry: dict[str, SpellSpec] | None = None,
|
||||
) -> None:
|
||||
self._rng = rng
|
||||
self._grid = grid
|
||||
self._states = states
|
||||
self._round_cap = round_cap
|
||||
self._policy = policy if policy is not None else default_policy
|
||||
self._spell_registry = spell_registry or {}
|
||||
self._transcript: list[str] = []
|
||||
self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
|
||||
self._current_round = 0
|
||||
@@ -450,6 +468,16 @@ class CombatEngine:
|
||||
return damage
|
||||
return max(0, damage - dr.amount)
|
||||
|
||||
def _apply_dr_for_types(
|
||||
self, damage: int, damage_types: frozenset[str], target: CombatantState
|
||||
) -> int:
|
||||
dr: DamageReduction | None = target.combatant.dr
|
||||
if dr is None:
|
||||
return damage
|
||||
if damage_types & dr.bypass:
|
||||
return damage
|
||||
return max(0, damage - dr.amount)
|
||||
|
||||
def resolve_save(
|
||||
self, state: CombatantState, save_type: Literal["fort", "ref", "will"], dc: int
|
||||
) -> SaveResult:
|
||||
@@ -714,6 +742,18 @@ class CombatEngine:
|
||||
self._move(state, target)
|
||||
elif action.kind == "wait":
|
||||
self._log(f"round {self._current_round} {state.combatant.id}: wait")
|
||||
elif action.kind == "cast_spell":
|
||||
self._execute_cast_spell(state, target, action.spell_name)
|
||||
|
||||
def _execute_cast_spell(
|
||||
self,
|
||||
state: CombatantState,
|
||||
target: CombatantState | None,
|
||||
spell_name: str | None,
|
||||
) -> None:
|
||||
if spell_name is None:
|
||||
return
|
||||
self._cast_spell(state, target, spell_name)
|
||||
|
||||
def _execute_attack(
|
||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||
@@ -738,6 +778,93 @@ class CombatEngine:
|
||||
return
|
||||
self._charge(state, target, weapon)
|
||||
|
||||
def _cast_spell(
|
||||
self, caster: CombatantState, target: CombatantState | None, spell_name: str
|
||||
) -> None:
|
||||
spell = self._spell_registry.get(spell_name)
|
||||
if spell is None:
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} -> UNKNOWN SPELL"
|
||||
)
|
||||
return
|
||||
if target is None or not target.active:
|
||||
return
|
||||
dist_ft = self._grid.distance(caster.pos, target.pos) * _SQUARE_FT
|
||||
max_range = spell_range_ft(spell, caster.combatant.level)
|
||||
if dist_ft > max_range:
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} -> OUT OF RANGE"
|
||||
)
|
||||
return
|
||||
if not has_line_of_effect(self._grid, caster.pos, target.pos):
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} -> NO LINE OF EFFECT"
|
||||
)
|
||||
return
|
||||
dc = spell.base_dc + caster.combatant.level
|
||||
save_result = (
|
||||
self.resolve_save(target, spell.save, dc) if spell.save != "none" else None
|
||||
)
|
||||
caster_live = self._stats[caster.combatant.id]
|
||||
target_live = self._stats[target.combatant.id]
|
||||
hp_before = target.hp
|
||||
parts = self._apply_spell_effects(spell, target, save_result, caster_live, target_live)
|
||||
save_str = _save_label(save_result)
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"cast {spell_name} vs {target.combatant.id} "
|
||||
f"DC{dc} {save_str} "
|
||||
f"-> {'; '.join(parts)} "
|
||||
f"({hp_before}->{target.hp})"
|
||||
)
|
||||
if target.dead:
|
||||
self._log(
|
||||
f"round {self._current_round} {caster.combatant.id}: "
|
||||
f"{target.combatant.id} dead"
|
||||
)
|
||||
|
||||
def _apply_spell_effects(
|
||||
self,
|
||||
spell: SpellSpec,
|
||||
target: CombatantState,
|
||||
save_result: SaveResult | None,
|
||||
caster_live: _LiveStats,
|
||||
target_live: _LiveStats,
|
||||
) -> list[str]:
|
||||
parts: list[str] = []
|
||||
saved = save_result is not None and save_result.success
|
||||
for effect in spell.effects:
|
||||
if isinstance(effect, DamageEffect):
|
||||
raw = effect.formula.roll(self._rng)
|
||||
dmg = max(0, raw // 2) if saved and effect.half_on_save else raw
|
||||
dmg = self._apply_dr_for_types(dmg, frozenset(effect.types), target)
|
||||
target.hp -= dmg
|
||||
caster_live.damage_dealt += dmg
|
||||
target_live.damage_taken += dmg
|
||||
parts.append(f"{dmg} damage")
|
||||
elif isinstance(effect, ConditionEffect):
|
||||
if saved and effect.save_negates:
|
||||
parts.append(f"{effect.condition_name} negated")
|
||||
else:
|
||||
cond = CONDITIONS_BY_NAME.get(effect.condition_name)
|
||||
if cond is not None:
|
||||
target.conditions.append(cond)
|
||||
parts.append(f"{effect.condition_name} ({effect.duration_rounds}r)")
|
||||
elif isinstance(effect, HealEffect):
|
||||
heal = effect.formula.roll(self._rng)
|
||||
old_hp = target.hp
|
||||
target.hp = min(target.hp + heal, target.combatant.hp_max)
|
||||
actual = target.hp - old_hp
|
||||
parts.append(f"+{actual} HP")
|
||||
else:
|
||||
for mod in effect.modifiers:
|
||||
target.effects.append(mod)
|
||||
parts.append(f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)")
|
||||
return parts
|
||||
|
||||
def _resolve_swing(
|
||||
self,
|
||||
attacker: CombatantState,
|
||||
|
||||
@@ -208,3 +208,13 @@ DAZED = Condition(
|
||||
STAGGERED = Condition(
|
||||
name="staggered",
|
||||
)
|
||||
|
||||
# ── Registry ─────────────────────────────────────────────────────────────────
|
||||
CONDITIONS_BY_NAME: dict[str, Condition] = {
|
||||
c.name: c
|
||||
for c in [
|
||||
SHAKEN, SICKENED, FATIGUED, EXHAUSTED, ENTANGLED, DAZZLED,
|
||||
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
||||
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -112,3 +112,4 @@ class Combatant(_FrozenModel):
|
||||
source: str = ""
|
||||
notes: str = ""
|
||||
features: list[AbilitySpec] = []
|
||||
spells: list[str] = []
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user