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:
2026-08-17 22:49:50 +02:00
parent 97c0431479
commit 828afc15c0
17 changed files with 862 additions and 8 deletions
+128 -1
View File
@@ -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,