994 lines
40 KiB
Python
994 lines
40 KiB
Python
"""Tests for spell system: loading, range, casting (damage, save, condition, heal, buff)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action
|
|
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 (
|
|
AreaEffect,
|
|
BuffEffect,
|
|
ConditionEffect,
|
|
DamageEffect,
|
|
HealEffect,
|
|
SpellSpec,
|
|
load_spell,
|
|
load_spell_registry,
|
|
spell_range_ft,
|
|
)
|
|
|
|
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
|
|
|
_SAVE_NEGATES_BOLT = SpellSpec(
|
|
name="Save Negates Bolt",
|
|
level=1,
|
|
school="evocation",
|
|
range="medium",
|
|
save="ref",
|
|
effects=(
|
|
DamageEffect(
|
|
formula=parse_dice("2d4"),
|
|
types=("force",),
|
|
half_on_save=False,
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
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],
|
|
casting_ability="int",
|
|
# B4 conformance: _cast_spell now refuses without a slot; test
|
|
# casters carry a generous pool so fixtures stay about spells.
|
|
spell_slots={0: 99, 1: 99, 2: 99, 3: 99, 4: 99, 5: 99, 6: 99},
|
|
)
|
|
|
|
|
|
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] = (3, 0),
|
|
spell_registry: dict[str, SpellSpec] | None = None,
|
|
target_side: str = "monsters",
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState]:
|
|
c = CombatantState(combatant=caster, side="players", pos=caster_pos, hp=caster.hp_max)
|
|
t = CombatantState(combatant=target, side=target_side, 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 108 JSON spell files
|
|
When: load_spell_registry is called
|
|
Then: all 108 spells are loaded with correct fields."""
|
|
|
|
def test_loads_all_10_spells(self) -> None:
|
|
reg = load_spell_registry(_SPELLS_DIR)
|
|
assert len(reg) == 108
|
|
|
|
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",
|
|
"Summon Monster II",
|
|
"Mage Armor",
|
|
}
|
|
assert expected <= set(reg)
|
|
assert len(reg) == 108
|
|
|
|
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 isinstance(spell.effects[0], AreaEffect)
|
|
area_eff = spell.effects[0]
|
|
assert isinstance(area_eff, AreaEffect)
|
|
assert area_eff.radius_ft == 20
|
|
assert area_eff.save_for_half is True
|
|
assert len(area_eff.effects) == 1
|
|
inner = area_eff.effects[0]
|
|
assert isinstance(inner, DamageEffect)
|
|
assert inner.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
|
|
assert eff.save_partial is None
|
|
|
|
def test_fear_condition_has_save_partial(self) -> None:
|
|
# PF1e (SRD spells/fear.md): a successful Will save imposes shaken 1
|
|
# round instead of panicked — the partial-save spec.
|
|
spell = load_spell(_SPELLS_DIR / "fear.json")
|
|
area = spell.effects[0]
|
|
assert isinstance(area, AreaEffect)
|
|
eff = area.effects[0]
|
|
assert isinstance(eff, ConditionEffect)
|
|
assert eff.condition_name == "panicked"
|
|
assert eff.save_partial == ("shaken", 1)
|
|
|
|
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)
|
|
# SRD bull's strength: +4 enhancement Str -> +2 melee attack, +2 melee
|
|
# damage, +2 CMB (three modifiers).
|
|
assert len(eff.modifiers) == 3
|
|
|
|
def test_summon_monster_casts_for_one_round(self) -> None:
|
|
# PF1e: Summon Monster I/II have a 1-round casting time.
|
|
for name in ("summon_monster_i", "summon_monster_ii"):
|
|
assert load_spell(_SPELLS_DIR / f"{name}.json").cast_time == "one_round"
|
|
|
|
|
|
class TestSpellCastTime:
|
|
"""Given: SpellSpec and the JSON loader casting-time field
|
|
When: specs are built or spell JSON parsed
|
|
Then: the default is "standard", RAW phrasings map onto budget codes,
|
|
and unknown values are rejected."""
|
|
|
|
def test_spec_defaults_to_standard(self) -> None:
|
|
spell = SpellSpec(name="X", level=1, school="evocation")
|
|
assert spell.cast_time == "standard"
|
|
|
|
def _load(self, tmp_path: Path, cast_time: str) -> SpellSpec:
|
|
spell_file = tmp_path / "test_spell.json"
|
|
spell_file.write_text(
|
|
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
|
|
f' "range": "medium", "save": "ref", "cast_time": "{cast_time}",'
|
|
' "effects": []}'
|
|
)
|
|
return load_spell(spell_file)
|
|
|
|
def test_loader_parses_raw_aliases(self, tmp_path: Path) -> None:
|
|
cases = {
|
|
"1 round": "one_round",
|
|
"1 minute": "long",
|
|
"3 rounds": "long",
|
|
"full-round": "full_round",
|
|
"swift action": "swift",
|
|
}
|
|
for raw, code in cases.items():
|
|
assert self._load(tmp_path, raw).cast_time == code, raw
|
|
|
|
def test_loader_accepts_code_values(self, tmp_path: Path) -> None:
|
|
for code in ("standard", "swift", "immediate", "free", "full_round",
|
|
"one_round", "long"):
|
|
assert self._load(tmp_path, code).cast_time == code, code
|
|
|
|
def test_loader_rejects_unknown(self, tmp_path: Path) -> None:
|
|
with pytest.raises(ValueError, match="unknown cast time"):
|
|
self._load(tmp_path, "2 hours")
|
|
|
|
def test_imported_spells_declare_their_cast_time(self) -> None:
|
|
"""Given: the imported spell data
|
|
When: the registry is loaded
|
|
Then: exactly the KB-verified spells carry a non-standard cast_time."""
|
|
expected = {
|
|
"Enhance Water": "one_round",
|
|
"Silence": "one_round",
|
|
"Sleep": "one_round",
|
|
"Summon Instrument": "one_round",
|
|
"Summon Monster I": "one_round",
|
|
"Summon Monster II": "one_round",
|
|
"Grasp": "immediate",
|
|
"Saving Finale": "immediate",
|
|
"Make Whole": "long",
|
|
"Masterwork Transformation": "long",
|
|
"Mending": "long",
|
|
"Restoration, Lesser": "long",
|
|
}
|
|
registry = load_spell_registry(_SPELLS_DIR)
|
|
non_standard = {n for n, s in registry.items() if s.cast_time != "standard"}
|
|
assert non_standard == set(expected)
|
|
for name, code in expected.items():
|
|
assert registry[name].cast_time == code, name
|
|
|
|
|
|
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; the level-5 caster fires the
|
|
SRD three missiles (1 + one per two levels beyond 1st), each 1d4+1."""
|
|
|
|
def test_deals_damage_no_save(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
# 3 missiles (level 5), each rolls 1d4+1 -> three scripted d4s of 3
|
|
engine, c, t = _make_engine(caster, target, [3, 3, 3])
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Magic Missile")
|
|
assert t.hp < hp_before
|
|
assert t.hp == hp_before - 3 * (3 + 1)
|
|
|
|
def test_transcript_shows_no_save(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
engine, c, t = _make_engine(caster, target, [3, 3, 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)
|
|
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
|
engine, c, t = _make_engine(caster, target, [2, 2, 3, 3, 3, 3, 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)
|
|
|
|
def test_successful_save_half_damage(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=200)
|
|
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
|
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Fireball")
|
|
full = 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()
|
|
# 2 save rolls (2 targets in area) + 5 damage dice (1d6/level, lvl 5)
|
|
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
|
engine._cast_spell(c, t, "Fireball")
|
|
assert any("SAVED" in line for line in engine._transcript)
|
|
|
|
|
|
class TestAreaSavesPerSpellSaveType:
|
|
"""B1 (PF1e conformance): every creature in an area rolls the save the
|
|
SPELL lists (Will/Fort/Ref) — "save negates" areas are no longer
|
|
save-free, and secondary creatures do not always roll Reflex."""
|
|
|
|
def test_sleep_secondary_creature_rolls_will_and_negates(self) -> None:
|
|
# Sleep (one-round cast, Will negates, save_for_half=False): casting
|
|
# records the pending cast; resolution (start of the caster's next
|
|
# turn, no damage taken) delivers it — primary target saves on
|
|
# natural 20 (first roll), the caster — second creature in the area —
|
|
# rolls its own Will and fails on natural 1 (second roll).
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
engine, c, t = _make_engine(
|
|
caster, target, [20, 1], target_pos=(2, 0)
|
|
)
|
|
engine._cast_spell(c, t, "Sleep")
|
|
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
|
|
engine._resolve_pending_cast(c)
|
|
assert not any(ac.condition.name == "asleep" for ac in t.active_conditions)
|
|
assert any(ac.condition.name == "asleep" for ac in c.active_conditions)
|
|
|
|
def test_color_spray_primary_save_is_not_discarded(self) -> None:
|
|
# Color Spray (Will negates): the primary's pre-rolled save decides —
|
|
# natural 1 fails (stunned), the caster's own natural 20 succeeds.
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
engine, c, t = _make_engine(
|
|
caster, target, [1, 20], target_pos=(2, 0)
|
|
)
|
|
engine._cast_spell(c, t, "Color Spray")
|
|
assert any(ac.condition.name == "stunned" for ac in t.active_conditions)
|
|
assert not any(ac.condition.name == "stunned" for ac in c.active_conditions)
|
|
|
|
def test_web_secondary_creature_rolls_reflex(self) -> None:
|
|
# Web (Reflex negates): secondary creature rolls its own Reflex.
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
engine, c, t = _make_engine(
|
|
caster, target, [20, 1], target_pos=(2, 0)
|
|
)
|
|
engine._cast_spell(c, t, "Web")
|
|
assert not any(ac.condition.name == "grappled" for ac in t.active_conditions)
|
|
assert any(ac.condition.name == "grappled" for ac in c.active_conditions)
|
|
|
|
def test_sound_burst_secondary_rolls_fortitude_not_reflex(self) -> None:
|
|
# Sound Burst (Fortitude partial): caster has ref +4, fort +3 and the
|
|
# DC is 16 — d20 12 passes Reflex but fails Fortitude. The caster
|
|
# being stunned proves the FORT save was rolled (B1's hardcoded ref
|
|
# would have "saved" and left the caster unstunned).
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=200)
|
|
# rolls: primary (target) fort 20 = save; caster fort 12 = fail;
|
|
# then the 1d8 damage dice for each creature in state order.
|
|
engine, c, t = _make_engine(
|
|
caster, target, [20, 12, 3, 3], target_pos=(2, 0)
|
|
)
|
|
engine._cast_spell(c, t, "Sound Burst")
|
|
assert any(ac.condition.name == "stunned" for ac in c.active_conditions)
|
|
# SRD spells/sound-burst.md: the save only avoids the stun — the 1d8
|
|
# sonic damage is full regardless (save_negates: false on the damage).
|
|
assert t.hp == 200 - 3 # saved: full damage, no stun
|
|
assert not any(ac.condition.name == "stunned" for ac in t.active_conditions)
|
|
|
|
|
|
class TestBleedCantrip:
|
|
"""Given: the Bleed cantrip (SRD spells/bleed.md: "you target a living
|
|
creature that has -1 or fewer hit points"; Will negates)
|
|
When: cast on a healthy target / applied on a dying target
|
|
Then: healthy - the requires_dying gate blocks the condition entirely;
|
|
dying - bleeding condition with 1 bleed damage."""
|
|
|
|
def test_healthy_target_is_not_a_valid_bleed_target(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=30)
|
|
# Will save d20=2 (+2) vs DC 14 -> fail; the hp gate still blocks.
|
|
engine, c, t = _make_engine(caster, target, [2])
|
|
engine._cast_spell(c, t, "Bleed")
|
|
assert not any(ac.condition.name == "bleeding" for ac in t.active_conditions)
|
|
assert t.bleed_damage == 0
|
|
assert t.hp == 30
|
|
|
|
def test_dying_target_begins_bleeding(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=30)
|
|
engine, c, t = _make_engine(caster, target, [])
|
|
t.hp = -3 # below 0 hp: the SRD-only valid target for this cantrip
|
|
spell = engine._spell_registry["Bleed"]
|
|
effect = spell.effects[0]
|
|
assert isinstance(effect, ConditionEffect)
|
|
result = engine._apply_condition_effect(
|
|
effect, t, saved=False, spell_name="Bleed",
|
|
)
|
|
assert "bleeding" in result
|
|
assert any(ac.condition.name == "bleeding" for ac in t.active_conditions)
|
|
assert t.bleed_damage == 1
|
|
|
|
def test_successful_save_negates_the_bleed(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=30)
|
|
engine, c, t = _make_engine(caster, target, [20])
|
|
t.hp = -3
|
|
spell = engine._spell_registry["Bleed"]
|
|
effect = spell.effects[0]
|
|
assert isinstance(effect, ConditionEffect)
|
|
result = engine._apply_condition_effect(
|
|
effect, t, saved=True, spell_name="Bleed",
|
|
)
|
|
assert "negated" in result
|
|
assert not t.active_conditions
|
|
assert t.bleed_damage == 0
|
|
|
|
|
|
class TestMirrorImages:
|
|
"""Given: Mirror Image (SRD spells/mirror-image.md: "1d4 images plus one
|
|
image per three caster levels (maximum eight images total)")
|
|
When: cast at level 5 with a rolled 1d4 of 3 / at level 30 with a 4
|
|
Then: 4 images -> 80% miss (images/(images+1)), 10 rounds per level;
|
|
the count caps at 8 images -> 88% miss."""
|
|
|
|
def test_image_count_scales_with_level(self) -> None:
|
|
caster = _make_caster(level=5)
|
|
target = _make_target()
|
|
# 1d4 images roll = 3 -> 3 + 5//3 = 4 images -> 4/(4+1) = 80% miss.
|
|
engine, c, t = _make_engine(caster, target, [3])
|
|
engine._cast_spell(c, c, "Mirror Image")
|
|
images = [ac for ac in c.active_conditions if ac.condition.name == "blurred"]
|
|
assert len(images) == 1
|
|
assert images[0].condition.miss_chance == 80
|
|
assert images[0].rounds_left == 50 # 1 min./level = 10 rounds x level 5
|
|
|
|
def test_image_count_caps_at_eight(self) -> None:
|
|
caster = _make_caster(level=30)
|
|
target = _make_target()
|
|
# 1d4 = 4 -> 4 + 30//3 = 14, capped at 8 -> 8/9 = 88% miss.
|
|
engine, c, t = _make_engine(caster, target, [4])
|
|
engine._cast_spell(c, c, "Mirror Image")
|
|
images = [ac for ac in c.active_conditions if ac.condition.name == "blurred"]
|
|
assert images[0].condition.miss_chance == 88
|
|
|
|
|
|
class TestTargetedSpellSaveNegates:
|
|
"""Given: a targeted spell with a save (half_on_save=False)
|
|
When: the target succeeds / fails its save
|
|
Then: full damage on failure, ZERO damage on success (save negates)."""
|
|
|
|
def test_successful_save_deals_no_damage(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
# 1 save roll (natural 20 = success) + 2 damage dice
|
|
engine, c, t = _make_engine(
|
|
caster, target, [20, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
|
|
)
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Save Negates Bolt")
|
|
assert t.hp == hp_before
|
|
assert any("0 damage" in line for line in engine._transcript)
|
|
|
|
def test_failed_save_deals_full_damage(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
# 1 save roll (natural 1 = failure) + 2 damage dice
|
|
engine, c, t = _make_engine(
|
|
caster, target, [1, 3, 3], spell_registry={"Save Negates Bolt": _SAVE_NEGATES_BOLT}
|
|
)
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Save Negates Bolt")
|
|
assert t.hp == hp_before - 6
|
|
|
|
|
|
class TestLightningBoltSave:
|
|
"""Given: Lightning Bolt (SRD spells/lightning-bolt.md: a 120-ft. line,
|
|
1d6 per caster level max 10d6, ref save for half) cast by the level-5
|
|
fixture at a target straight ahead
|
|
When: the primary target succeeds its save
|
|
Then: damage is halved — and the caster is NOT caught by their own line
|
|
(CRB, Magic — Aiming a Spell: the line "starts from any corner of your
|
|
square", so the caster's own square is outside the template)."""
|
|
|
|
def test_successful_save_still_halves(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=200)
|
|
# primary save (target, SAVED on a natural 20) + 5 damage dice
|
|
# (1d6/level, level 5); the caster is no longer in the line so no
|
|
# secondary save and no caster damage dice are rolled.
|
|
engine, c, t = _make_engine(
|
|
caster, target, [20] + [3] * 5
|
|
)
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Lightning Bolt")
|
|
full = 3 + 3 + 3 + 3 + 3
|
|
assert t.hp == hp_before - (full // 2)
|
|
assert c.hp == 20 # the caster's own square is outside the line
|
|
|
|
|
|
class TestDamageEffectHalfOnSaveDefault:
|
|
"""Given: a DamageEffect constructed without half_on_save
|
|
When: the field is read
|
|
Then: it defaults to False (save negates for targeted effects)."""
|
|
|
|
def test_defaults_false(self) -> None:
|
|
effect = DamageEffect(formula=parse_dice("1d4"), types=("fire",))
|
|
assert effect.half_on_save is False
|
|
|
|
def test_loader_defaults_false(self, tmp_path) -> None:
|
|
spell_file = tmp_path / "test_spell.json"
|
|
spell_file.write_text(
|
|
'{"name": "Test Bolt", "level": 1, "school": "evocation",'
|
|
' "range": "medium", "save": "ref", "effects": ['
|
|
'{"type": "damage", "formula": "1d4", "types": ["fire"]}]}'
|
|
)
|
|
spell = load_spell(spell_file)
|
|
assert isinstance(spell.effects[0], DamageEffect)
|
|
assert spell.effects[0].half_on_save is False
|
|
|
|
|
|
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(ac.condition.name == "paralyzed" for ac in t.active_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(ac.condition.name == "paralyzed" for ac in t.active_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 TestConditionDurationScaling:
|
|
"""Given: a ConditionEffect with duration_rounds_per_level (SRD hold person
|
|
"1 round/level", doom "1 min./level", vanish "1 round/level (maximum 5)")
|
|
When: the spell is cast
|
|
Then: the condition lasts caster_level * per_level rounds, capped at max."""
|
|
|
|
def test_hold_person_scales_with_caster_level(self) -> None:
|
|
caster = _make_caster(level=5)
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [2])
|
|
engine._cast_spell(c, t, "Hold Person")
|
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "paralyzed")
|
|
assert ac.rounds_left == 5 # 1 round/level
|
|
|
|
def test_vanish_caps_at_maximum(self) -> None:
|
|
caster = _make_caster(level=8)
|
|
target = _make_target()
|
|
# Vanish is range touch: the target must be adjacent (5 ft).
|
|
engine, c, t = _make_engine(
|
|
caster, target, [2], target_pos=(1, 0), target_side="players"
|
|
)
|
|
engine._cast_spell(c, t, "Vanish")
|
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "invisible")
|
|
assert ac.rounds_left == 5 # 1 round/level, maximum 5
|
|
|
|
def test_doom_scales_to_minutes(self) -> None:
|
|
caster = _make_caster(level=5)
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [2])
|
|
engine._cast_spell(c, t, "Doom")
|
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "shaken")
|
|
assert ac.rounds_left == 50 # 1 min/level = 10 rounds/level
|
|
|
|
|
|
class TestFear:
|
|
"""Given: a caster casting Fear (SRD spells/fear.md: panicked unless the
|
|
Will save succeeds; a successful save makes the creature shaken 1 round)
|
|
When: target fails / succeeds the save
|
|
Then: panicked is applied / shaken (partial save) is applied instead."""
|
|
|
|
def test_failed_save_applies_panicked(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [2])
|
|
engine._cast_spell(c, t, "Fear")
|
|
assert any(ac.condition.name == "panicked" for ac in t.active_conditions)
|
|
assert not any(ac.condition.name == "shaken" for ac in t.active_conditions)
|
|
|
|
def test_successful_save_applies_shaken(self) -> None:
|
|
"""Given: the target succeeds its Will save (natural 20)
|
|
When: Fear resolves
|
|
Then: it is shaken for 1 round (partial save), not panicked."""
|
|
caster = _make_caster()
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [20])
|
|
engine._cast_spell(c, t, "Fear")
|
|
shaken = [ac for ac in t.active_conditions if ac.condition.name == "shaken"]
|
|
assert len(shaken) == 1
|
|
assert shaken[0].rounds_left == 1
|
|
assert not any(ac.condition.name == "panicked" for ac in t.active_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)
|
|
# Willing same-side target of a (harmless) spell: no Will die is
|
|
# consumed (SRD saving-throw.md "(harmless)" + "Voluntarily Giving up
|
|
# a Saving Throw") — the queue holds only the heal die.
|
|
engine, c, t = _make_engine(caster, target, [5], target_pos=(1, 0),
|
|
target_side="players")
|
|
t.hp = 10
|
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
|
# Caster level 5: flat bonus = min(5, (5 // 1) * 1) = 5 (SRD
|
|
# cure-light-wounds.md: "+1 point per caster level (maximum +5)").
|
|
assert t.hp == 10 + (5 + 5)
|
|
|
|
def test_heal_capped_at_max(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=30)
|
|
engine, c, t = _make_engine(caster, target, [3], target_pos=(1, 0),
|
|
target_side="players")
|
|
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, [3, 3], target_pos=(1, 0),
|
|
target_side="players")
|
|
engine._cast_spell(c, t, "Bull's Strength")
|
|
buff_mods = [m for b in t.active_buffs for m in b.modifiers]
|
|
atk_mods = [e for e in buff_mods if e.target == "attack"]
|
|
dmg_mods = [e for e in buff_mods if e.target == "damage"]
|
|
cmb_mods = [e for e in buff_mods if e.target == "cmb"]
|
|
assert len(atk_mods) == 1
|
|
assert len(dmg_mods) == 1
|
|
assert len(cmb_mods) == 1
|
|
assert atk_mods[0].value == 2
|
|
assert dmg_mods[0].value == 2
|
|
assert cmb_mods[0].value == 2
|
|
|
|
def test_transcript_shows_buff(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
|
|
target_side="players")
|
|
engine._cast_spell(c, t, "Bull's Strength")
|
|
assert any("buff" in line for line in engine._transcript)
|
|
|
|
|
|
class TestShieldOfFaithScaling:
|
|
"""Given: SRD shield-of-faith "+2 deflection bonus to AC, +1 per six
|
|
caster levels (maximum +5)"
|
|
When: casters of different levels cast it
|
|
Then: the AC modifier is 2 + min(3, level // 6) — +2 at 5th, +3 at 6th,
|
|
+5 at 18th."""
|
|
|
|
def _ac_value(self, level: int) -> int:
|
|
caster = _make_caster(level=level)
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [3, 3], target_pos=(1, 0),
|
|
target_side="players")
|
|
engine._cast_spell(c, t, "Shield of Faith")
|
|
ac_mods = [m for b in t.active_buffs for m in b.modifiers if m.target == "ac"]
|
|
assert len(ac_mods) == 1
|
|
assert ac_mods[0].bonus_type == "deflection"
|
|
return ac_mods[0].value
|
|
|
|
def test_base_bonus_below_sixth_level(self) -> None:
|
|
assert self._ac_value(5) == 2
|
|
|
|
def test_first_step_at_sixth_level(self) -> None:
|
|
assert self._ac_value(6) == 3
|
|
|
|
def test_capped_at_five(self) -> None:
|
|
assert self._ac_value(18) == 5
|
|
assert self._ac_value(20) == 5
|
|
|
|
|
|
class TestBlessAlliesOnly:
|
|
"""Given: a caster casting Bless (SRD: 50-ft burst, "the caster and all
|
|
allies")
|
|
When: _cast_spell is called
|
|
Then: the caster and allies inside the burst are buffed, enemies inside
|
|
the burst are not."""
|
|
|
|
def _bless_engine(
|
|
self,
|
|
) -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
|
|
caster = _make_caster()
|
|
ally = _make_target("ally", hp_max=20)
|
|
enemy = _make_target("enemy", hp_max=20)
|
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
|
a = CombatantState(combatant=ally, side="players", pos=(1, 0), hp=ally.hp_max)
|
|
e = CombatantState(combatant=enemy, side="monsters", pos=(2, 0), hp=enemy.hp_max)
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
engine = CombatEngine(
|
|
ScriptedRng([]), grid, [c, a, e],
|
|
spell_registry=load_spell_registry(_SPELLS_DIR),
|
|
)
|
|
return engine, c, a, e
|
|
|
|
def test_allies_buffed_enemy_not(self) -> None:
|
|
engine, c, a, e = self._bless_engine()
|
|
engine._cast_spell(c, c, "Bless")
|
|
caster_buffs = [m for b in c.active_buffs for m in b.modifiers]
|
|
ally_buffs = [m for b in a.active_buffs for m in b.modifiers]
|
|
assert any(m.target == "attack" and m.value == 1 for m in caster_buffs)
|
|
assert any(m.target == "attack" and m.value == 1 for m in ally_buffs)
|
|
assert not e.active_buffs
|
|
|
|
def test_enemy_outside_burst_unaffected(self) -> None:
|
|
engine, c, a, e = self._bless_engine()
|
|
# Move the enemy beyond the 50-ft burst (10 cells) before casting.
|
|
e.pos = (11, 0)
|
|
engine._cast_spell(c, c, "Bless")
|
|
assert not e.active_buffs
|
|
assert any(m.target == "attack" and m.value == 1
|
|
for b in c.active_buffs for m in b.modifiers)
|
|
|
|
|
|
class TestBaneEnemiesOnly:
|
|
"""Given: a caster casting Bane (SRD: 50-ft burst, enemies, Will negates)
|
|
When: _cast_spell is called
|
|
Then: enemies in the burst are debuffed, allies and caster are not."""
|
|
|
|
def _bane_engine(
|
|
self,
|
|
targets: list[Combatant],
|
|
enemy: CombatantState,
|
|
rng_queue: list[int],
|
|
) -> CombatEngine:
|
|
caster = _make_caster()
|
|
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
|
|
states = [c, enemy, *targets]
|
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
|
grid = Grid.from_spec(spec)
|
|
return CombatEngine(
|
|
ScriptedRng(rng_queue), grid, states,
|
|
spell_registry=load_spell_registry(_SPELLS_DIR),
|
|
)
|
|
|
|
def test_enemy_debuffed_allies_unaffected(self) -> None:
|
|
ally = CombatantState(
|
|
combatant=_make_target("ally", hp_max=20), side="players",
|
|
pos=(1, 0), hp=20,
|
|
)
|
|
enemy = CombatantState(
|
|
combatant=_make_target("enemy", hp_max=20), side="monsters",
|
|
pos=(2, 0), hp=20,
|
|
)
|
|
engine = self._bane_engine([ally], enemy, [2]) # d20=2 → save fails
|
|
engine._cast_spell(
|
|
engine._states[0], enemy, "Bane",
|
|
)
|
|
assert any(c.condition.name == "bane" for c in enemy.active_conditions)
|
|
assert not ally.active_conditions
|
|
assert not engine._states[0].active_conditions
|
|
|
|
def test_successful_save_negates(self) -> None:
|
|
enemy = CombatantState(
|
|
combatant=_make_target("enemy", hp_max=20), side="monsters",
|
|
pos=(2, 0), hp=20,
|
|
)
|
|
engine = self._bane_engine([], enemy, [20]) # d20=20 → save succeeds
|
|
engine._cast_spell(engine._states[0], enemy, "Bane")
|
|
assert not enemy.active_conditions
|
|
|
|
|
|
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)
|
|
# level-5 caster fires 3 missiles (1d4+1 each)
|
|
engine, c, t = _make_engine(caster, target, [4, 4, 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 and casting ability mod M
|
|
When: a spell of level L is cast
|
|
Then: the DC is 10 + L + M."""
|
|
|
|
def test_dc_is_ten_plus_spell_level_plus_ability_mod(self) -> None:
|
|
caster = _make_caster(level=5) # int 18 -> mod +4
|
|
target = _make_target()
|
|
# 2 save rolls (2 targets in area) + 6 damage dice
|
|
engine, c, t = _make_engine(caster, target, [20, 20, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
|
engine._cast_spell(c, t, "Fireball") # fireball is level 3
|
|
# DC = 10 + spell_level(3) + int_mod(4) = 17
|
|
assert any("DC17" in line for line in engine._transcript)
|
|
|
|
|
|
class TestSpellDamageWithDr:
|
|
"""Given: a target with DR 5/- (bypassed by nothing)
|
|
When: a spell deals damage
|
|
Then: DR does NOT reduce spell damage (PF1e: spells/energy bypass DR)."""
|
|
|
|
def test_dr_does_not_reduce_spell_damage(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=200)
|
|
# DR 5/- does NOT apply to spell damage
|
|
target_with_dr = target.model_copy(update={
|
|
"dr": DamageReduction(amount=5, bypass=frozenset()),
|
|
})
|
|
# 2 save rolls (2 targets in area) + 5 damage dice each (level 5)
|
|
queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
|
|
engine, c, t = _make_engine(caster, target_with_dr, queue)
|
|
hp_before = t.hp
|
|
engine._cast_spell(c, t, "Fireball")
|
|
# Save fails (2), 5d6 rolls all 3 = 15 damage, DR ignored -> 15 damage
|
|
assert t.hp == hp_before - 15
|
|
|
|
|
|
class TestSummonMonster:
|
|
"""Given: a caster casting Summon Monster II (one-round cast)
|
|
When: _cast_spell is called and the pending cast resolves on the
|
|
caster's next turn (SRD "Casting Time")
|
|
Then: a celestial wolf is summoned adjacent to the caster."""
|
|
|
|
def test_summon_creature(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target(hp_max=100)
|
|
engine, c, t = _make_engine(caster, target, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3])
|
|
hp_before = len(engine._states)
|
|
engine._cast_spell(c, t, "Summon Monster II")
|
|
engine._resolve_pending_cast(c)
|
|
# Caster + target + summoned = 3 combatants
|
|
assert len(engine._states) == hp_before + 1
|
|
# Find the summoned creature
|
|
summoned = [s for s in engine._states if s.combatant.id == "celestial_wolf"]
|
|
assert len(summoned) == 1
|
|
assert summoned[0].side == "players"
|
|
# Check it has correct HP
|
|
assert summoned[0].hp == 13 # SRD wolf: 13 hp (2d8+4)
|
|
# Check transcript mentions summon
|
|
assert any("summon" in line.lower() for line in engine._transcript)
|
|
|
|
|
|
class TestSpellSlots:
|
|
"""Given: a caster with per-level spell_slots
|
|
When: a spell of a level is cast
|
|
Then: that level's slot count decrements and exhausted levels block further casts."""
|
|
|
|
def test_slot_depense(self) -> None:
|
|
caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}})
|
|
target = _make_target(hp_max=100)
|
|
# level-5 caster fires 3 missiles (1d4+1 each)
|
|
engine, c, t = _make_engine(caster, target, [3, 3, 3])
|
|
engine._cast_spell(c, t, "Magic Missile") # level 1
|
|
assert c.spell_slots_left[1] == 1
|
|
assert c.spell_slots_left[2] == 1
|
|
|
|
def test_slots_epuises(self) -> None:
|
|
caster = _make_caster().model_copy(
|
|
update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]}
|
|
)
|
|
target = _make_target(hp_max=100)
|
|
# level-5 caster fires 3 missiles (1d4+1 each)
|
|
engine, c, t = _make_engine(caster, target, [3, 3, 3])
|
|
assert _choose_spell_action(engine, c, t) is not None
|
|
engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot
|
|
assert c.spell_slots_left[1] == 0
|
|
assert _choose_spell_action(engine, c, t) is None
|
|
|
|
|
|
class TestActiveIsSingleSource:
|
|
"""Given: active_* is the single source of truth for conditions and buffs
|
|
When: _cast_spell applies a condition and a buff
|
|
Then: the legacy conditions/effects lists stay empty while the active_* lists are populated."""
|
|
|
|
def test_legacy_lists_stay_empty_active_lists_populated(self) -> None:
|
|
caster = _make_caster()
|
|
target = _make_target()
|
|
engine, c, t = _make_engine(caster, target, [2, 3, 3], target_pos=(1, 0),
|
|
target_side="players")
|
|
engine._cast_spell(c, t, "Hold Person")
|
|
engine._cast_spell(c, t, "Bull's Strength")
|
|
assert t.conditions == []
|
|
assert t.effects == []
|
|
assert any(ac.condition.name == "paralyzed" for ac in t.active_conditions)
|
|
assert any(b.source.startswith("spell:") for b in t.active_buffs)
|