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
+398
View File
@@ -0,0 +1,398 @@
"""Tests for spell system: loading, range, casting (damage, save, condition, heal, buff)."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
BuffEffect,
ConditionEffect,
DamageEffect,
HealEffect,
SpellSpec,
load_spell,
load_spell_registry,
spell_range_ft,
)
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
def _make_caster(cid: str = "caster", *, level: int = 5) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id=cid,
name=cid,
level=level,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=12, touch=12, flat_footed=10),
bab=2,
initiative_mod=2,
speed_land_ft=30,
saves=Saves(fort=3, ref=4, will=4),
attacks=[attack],
)
def _make_target(cid: str = "target", *, hp_max: int = 30) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="club",
kind="melee",
attack_bonus=3,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=hp_max,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=2, ref=2, will=2),
attacks=[attack],
)
def _make_engine(
caster: Combatant,
target: Combatant,
queue: list[int],
*,
caster_pos: tuple[int, int] = (0, 0),
target_pos: tuple[int, int] = (1, 1),
spell_registry: dict[str, SpellSpec] | None = None,
) -> tuple[CombatEngine, CombatantState, CombatantState]:
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=target_pos, hp=target.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
grid = Grid.from_spec(spec)
reg = spell_registry if spell_registry is not None else load_spell_registry(_SPELLS_DIR)
engine = CombatEngine(ScriptedRng(queue), grid, [c, t], spell_registry=reg)
return engine, c, t
class TestSpellLoading:
"""Given: data/spells/ directory with 10 JSON spell files
When: load_spell_registry is called
Then: all 10 spells are loaded with correct fields."""
def test_loads_all_10_spells(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
assert len(reg) == 10
def test_spell_names_present(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
expected = {
"Magic Missile", "Burning Hands", "Fireball", "Lightning Bolt",
"Cure Light Wounds", "Cure Moderate Wounds", "Hold Person",
"Fear", "Bull's Strength", "Acid Arrow",
}
assert set(reg) == expected
def test_fireball_fields(self) -> None:
spell = load_spell(_SPELLS_DIR / "fireball.json")
assert spell.name == "Fireball"
assert spell.level == 3
assert spell.school == "evocation"
assert spell.range == "long"
assert spell.save == "ref"
assert spell.base_dc == 10
assert len(spell.effects) == 1
assert isinstance(spell.effects[0], DamageEffect)
assert spell.effects[0].half_on_save is True
def test_hold_person_has_condition_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "hold_person.json")
eff = spell.effects[0]
assert isinstance(eff, ConditionEffect)
assert eff.condition_name == "paralyzed"
assert eff.save_negates is True
def test_cure_light_has_heal_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json")
assert isinstance(spell.effects[0], HealEffect)
def test_bulls_strength_has_buff_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "bulls_strength.json")
eff = spell.effects[0]
assert isinstance(eff, BuffEffect)
assert len(eff.modifiers) == 2
class TestSpellRangeFt:
"""Given: a spell with a range category
When: spell_range_ft is called
Then: returns the correct range in feet."""
def test_personal_is_zero(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="personal")
assert spell_range_ft(spell, 10) == 0
def test_touch_is_five(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="touch")
assert spell_range_ft(spell, 10) == 5
def test_close_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="close")
assert spell_range_ft(spell, 2) == 25 + 5 * 1
assert spell_range_ft(spell, 4) == 25 + 5 * 2
def test_medium_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="medium")
assert spell_range_ft(spell, 3) == 100 + 30
def test_long_scales_with_level(self) -> None:
spell = SpellSpec(name="t", level=1, school="x", range="long")
assert spell_range_ft(spell, 2) == 400 + 80
class TestMagicMissile:
"""Given: a caster casting Magic Missile (no save, force damage)
When: _cast_spell is called
Then: target takes full damage with no save."""
def test_deals_damage_no_save(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [3])
hp_before = t.hp
engine._cast_spell(c, t, "Magic Missile")
assert t.hp < hp_before
assert t.hp == hp_before - (3 + 1)
def test_transcript_shows_no_save(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [3])
engine._cast_spell(c, t, "Magic Missile")
assert any("NO SAVE" in line for line in engine._transcript)
class TestFireballSave:
"""Given: a caster casting Fireball (ref save for half)
When: target saves / fails
Then: damage is halved / full."""
def test_failed_save_full_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [2, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
assert t.hp == hp_before - (3 + 3 + 3 + 3 + 3 + 3)
def test_successful_save_half_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
engine, c, t = _make_engine(caster, target, [20, 3, 3, 3, 3, 3, 3])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 3 + 3 + 3 + 3 + 3 + 3
assert t.hp == hp_before - (full // 2)
def test_transcript_shows_saved(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1])
engine._cast_spell(c, t, "Fireball")
assert any("SAVED" in line for line in engine._transcript)
class TestHoldPerson:
"""Given: a caster casting Hold Person (will negates, paralyzed)
When: target fails save
Then: paralyzed condition is applied."""
def test_failed_save_applies_condition(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Hold Person")
assert any(cond.name == "paralyzed" for cond in t.conditions)
def test_successful_save_no_condition(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Hold Person")
assert not any(cond.name == "paralyzed" for cond in t.conditions)
def test_transcript_shows_negated_on_save(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Hold Person")
assert any("negated" in line for line in engine._transcript)
class TestFear:
"""Given: a caster casting Fear (will negates, frightened)
When: target fails save
Then: frightened condition is applied."""
def test_failed_save_applies_frightened(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Fear")
assert any(cond.name == "frightened" for cond in t.conditions)
class TestCureLightWounds:
"""Given: a caster casting Cure Light Wounds on a wounded target
When: _cast_spell is called
Then: HP is restored, capped at hp_max."""
def test_heals_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [20, 5])
t.hp = 10
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 10 + (5 + 1)
def test_heal_capped_at_max(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [20, 6])
t.hp = 28
engine._cast_spell(c, t, "Cure Light Wounds")
assert t.hp == 30
class TestBullsStrength:
"""Given: a caster casting Bull's Strength on a target
When: _cast_spell is called
Then: buff modifiers are added to target's effects."""
def test_applies_buff_effects(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Bull's Strength")
atk_mods = [e for e in t.effects if e.target == "attack"]
dmg_mods = [e for e in t.effects if e.target == "damage"]
assert len(atk_mods) == 1
assert len(dmg_mods) == 1
assert atk_mods[0].value == 1
assert dmg_mods[0].value == 1
def test_transcript_shows_buff(self) -> None:
caster = _make_caster()
target = _make_target()
engine, c, t = _make_engine(caster, target, [20])
engine._cast_spell(c, t, "Bull's Strength")
assert any("buff" in line for line in engine._transcript)
class TestOutOfRange:
"""Given: a target beyond the spell's maximum range
When: _cast_spell is called
Then: no damage is dealt and transcript shows OUT OF RANGE."""
def test_out_of_range_no_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(
caster, target, [], caster_pos=(0, 0), target_pos=(11, 11)
)
hp_before = t.hp
engine._cast_spell(c, t, "Burning Hands")
assert t.hp == hp_before
assert any("OUT OF RANGE" in line for line in engine._transcript)
class TestUnknownSpell:
"""Given: a spell name not in the registry
When: _cast_spell is called
Then: transcript shows UNKNOWN SPELL and no damage."""
def test_unknown_spell_logged(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [])
hp_before = t.hp
engine._cast_spell(c, t, "Nonexistent Spell")
assert t.hp == hp_before
assert any("UNKNOWN SPELL" in line for line in engine._transcript)
class TestSpellDispatchViaExecute:
"""Given: an Action(kind='cast_spell') with a spell_name
When: _execute processes it
Then: the spell is cast via _cast_spell."""
def test_execute_cast_spell(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [4])
hp_before = t.hp
engine._execute(
c, Action(kind="cast_spell", target_id="target", spell_name="Magic Missile")
)
assert t.hp < hp_before
class TestSpellDcScalesWithLevel:
"""Given: a caster with level N
When: a spell with base_dc 10 is cast
Then: the DC is 10 + N."""
def test_dc_is_base_plus_level(self) -> None:
caster = _make_caster(level=5)
target = _make_target()
engine, c, t = _make_engine(caster, target, [20, 1, 1, 1, 1, 1, 1])
engine._cast_spell(c, t, "Fireball")
assert any("DC15" in line for line in engine._transcript)
class TestSpellDamageWithDr:
"""Given: a target with DR vs the spell's damage type
When: a spell deals damage
Then: DR is applied after save halving."""
def test_dr_reduces_spell_damage(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=200)
target_with_dr = target.model_copy(update={
"dr": DamageReduction(amount=5, bypass=frozenset({"bludgeoning"})),
})
engine, c, t = _make_engine(caster, target_with_dr, [2, 6, 6, 6, 6, 6, 6])
hp_before = t.hp
engine._cast_spell(c, t, "Fireball")
full = 6 * 6
assert t.hp == hp_before - max(0, full - 5)