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:
@@ -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)
|
||||
Reference in New Issue
Block a user