Files
pf1e-simulator/tests/test_summoning.py
T

478 lines
18 KiB
Python

"""Tests for summoning: summoned creatures act in combat and expire after duration."""
from __future__ import annotations
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
default_policy,
)
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,
Saves,
)
from pf1e_simulator.rng import ScriptedRng, SeededRng
from pf1e_simulator.spells import SPELLS_DIR, SpellSpec, SummonEffect, load_spell
def _make_caster() -> Combatant:
attack = AttackSpec(
id="caster-w",
name="dagger",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])],
)
return Combatant(
id="caster",
name="caster",
level=5,
size="Medium",
abilities=AbilityScores(
str_score=10, dex_score=14, con_score=12,
int_score=18, wis_score=12, cha_score=10,
),
hp_max=50,
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",
spells=["Summon Monster II"],
spell_slots={1: 3, 2: 3},
)
def _make_enemy() -> Combatant:
attack = AttackSpec(
id="enemy-w",
name="club",
kind="melee",
attack_bonus=1,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
)
return Combatant(
id="enemy",
name="enemy",
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=200,
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_summon_spell(duration_rounds: int = 2) -> SpellSpec:
return SpellSpec(
name="Summon Monster II",
level=2,
school="conjuration",
range="close",
save="none",
base_dc=10,
effects=(
SummonEffect(
creature="data/monsters/celestial_wolf.json",
duration_rounds=duration_rounds,
),
),
)
def _summon_policy(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
"""Force the caster to summon on first turn with slots available."""
if (
state.combatant.id == "caster"
and state.combatant.spells
and state.spell_slots_left.get(2, 0) > 0
):
target = engine.nearest_enemy(state)
if target is not None:
return (
Action(
kind="cast_spell",
target_id=target.combatant.id,
spell_name="Summon Monster II",
),
)
return default_policy(engine, state)
def _build_engine(seed: int, duration_rounds: int = 2) -> CombatEngine:
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(
combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max,
)
e = CombatantState(
combatant=enemy, side="monsters", pos=(5, 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)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
return CombatEngine(
SeededRng(seed), grid, [c, e],
policy=_summon_policy,
spell_registry=reg,
)
class TestSummonedCreatureActs:
"""Given: a caster who summons a celestial wolf
When: combat runs for several rounds
Then: the summoned creature takes at least one turn (acts)."""
def test_summoned_creature_acts_in_combat(self) -> None:
engine = _build_engine(seed=42)
result = engine.run()
wolf_lines = [
line for line in result.transcript if "celestial_wolf" in line
]
action_keywords = (
"charge", "attack", "move", "full_attack", "bite",
"5-foot", "wait",
)
action_lines = [
line for line in wolf_lines
if any(kw in line for kw in action_keywords)
]
assert len(action_lines) >= 1, (
f"Summoned creature never acted. All wolf lines: {wolf_lines}"
)
def _build_engine_with_walls(seed: int = 42, duration_rounds: int = 2) -> CombatEngine:
"""Caster at (1,1) under a walled row 0, like sample_arena's border walls.
The spawn search must skip wall cells and pick the first passable one.
"""
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(
combatant=caster, side="players", pos=(1, 1), hp=caster.hp_max,
)
e = CombatantState(
combatant=enemy, side="monsters", pos=(9, 1), hp=enemy.hp_max,
)
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None),
}
terrain = tuple(
"#" * 12 if r == 0 else "#..........." if r == 1 else "." * 12
for r in range(12)
)
spec = MapSpec(name="test", terrain=terrain, legend=legend)
grid = Grid.from_spec(spec)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds)}
return CombatEngine(
SeededRng(seed), grid, [c, e],
policy=_summon_policy,
spell_registry=reg,
)
class TestSummonSpawnsOnPassableCell:
"""Given: a caster whose adjacent ring contains wall cells (arena border)
When: a summons resolves
Then: the creature spawns on a passable cell and acts (not just waits)."""
def test_summon_spawns_on_passable_cell_and_acts(self) -> None:
engine = _build_engine_with_walls(seed=42)
result = engine.run()
wolf_lines = [
line for line in result.transcript if "celestial_wolf" in line
]
spawn_line = next(
(ln for ln in wolf_lines if "summon celestial_wolf at" in ln),
None,
)
assert spawn_line is not None, (
f"Wolf was never summoned. Wolf lines: {wolf_lines}"
)
# "summon celestial_wolf at (r, c) (2r)" — parse the spawn position.
pos_str = spawn_line.split("at (")[1].split(")")[0]
spawn_pos = tuple(int(part) for part in pos_str.split(", "))
assert engine._grid.passable(spawn_pos), (
f"Wolf spawned on impassable cell {spawn_pos}: {spawn_line!r}"
)
action_keywords = (
"charge", "attack", "move", "full_attack", "bite", "5-foot",
)
acted_lines = [
line for line in wolf_lines
if any(kw in line for kw in action_keywords)
]
assert len(acted_lines) >= 1, (
f"Wolf never acted (only waited). Wolf lines: {wolf_lines}"
)
class TestSummonedCreatureExpires:
"""Given: a caster who summons with duration 2
When: enough rounds pass
Then: the summoned creature expires and the transcript logs it."""
def test_summoned_creature_expires_after_duration(self) -> None:
engine = _build_engine(seed=42, duration_rounds=2)
result = engine.run()
expire_lines = [
line for line in result.transcript
if "summon expired" in line.lower()
]
summon_lines = [
ln for ln in result.transcript
if "celestial" in ln.lower() or "summon" in ln.lower()
]
assert len(expire_lines) >= 1, (
f"Summoned creature never expired. "
f"Transcript excerpt: {summon_lines}"
)
wolf_states = [
s for s in engine._states if s.combatant.id == "celestial_wolf"
]
if wolf_states:
assert not wolf_states[0].active, (
"Wolf should be inactive after expiration"
)
class TestSummonDurationScalesWithCasterLevel:
"""SRD spells/summon-monster-1.md: "Duration 1 round/level (D)" - the
summon's rounds resolve at cast time from the caster's level, mirroring
the ConditionEffect per-level duration convention."""
def _cast(self, caster_level: int, *, per_level: int) -> CombatEngine:
caster = _make_caster()
caster = caster.model_copy(update={"level": caster_level})
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
spell = SpellSpec(
name="Summon Monster II",
level=2,
school="conjuration",
range="close",
effects=(
SummonEffect(
creature="data/monsters/celestial_wolf.json",
duration_rounds=1,
duration_rounds_per_level=per_level,
),
),
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e],
spell_registry={"Summon Monster II": spell},
)
engine._cast_spell(c, e, "Summon Monster II")
return engine
def test_duration_equals_caster_level_rounds(self) -> None:
# Given/When: a level 5 caster summons with 1 round/level
# Then: the wolf lives for 5 rounds.
engine = self._cast(5, per_level=1)
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
assert wolf.summon_duration == 5
def test_duration_scales_for_a_higher_caster(self) -> None:
# Given: a level 9 caster
# Then: 9 rounds - the duration is not the fixed JSON value.
engine = self._cast(9, per_level=1)
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
assert wolf.summon_duration == 9
def test_fixed_duration_still_used_without_per_level_field(self) -> None:
# Given: a summon declaring only a fixed duration (per_level = 0)
# Then: the fixed value applies unchanged (pre-conformance fallback).
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=3)}
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
)
engine._cast_spell(c, e, "Summon Monster II")
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
assert wolf.summon_duration == 3
def test_shipped_summon_spells_declare_per_level_duration(self) -> None:
# Given: the shipped spell data
# When: Summon Monster I/II are loaded
# Then: they carry the SRD 1 round/level duration (no fixed value).
for name in ("summon_monster_i.json", "summon_monster_ii.json"):
spell = load_spell(SPELLS_DIR / name)
effect = spell.effects[0]
assert isinstance(effect, SummonEffect)
assert effect.duration_rounds_per_level == 1
class TestDuplicateSummonIds:
"""Given: a caster with two slots casting the same summon twice
When: both summons resolve
Then: the second creature gets a build_states-style disambiguated id
(celestial_wolf, celestial_wolf-2) and its own stats slot, so attrition
never merges (or resets) between two summons of the same creature."""
def _engine_with_two_wolves(self) -> tuple[CombatEngine, list[CombatantState]]:
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=5)}
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
)
engine._cast_spell(c, e, "Summon Monster II")
engine._cast_spell(c, e, "Summon Monster II")
wolves = [
s for s in engine._states if s.combatant.id.startswith("celestial_wolf")
]
return engine, wolves
def test_second_summon_id_is_disambiguated(self) -> None:
# Given/When: the same creature is summoned twice
# Then: ids follow the build_states convention (bare, then -2).
_, wolves = self._engine_with_two_wolves()
# Each new summon is inserted right after the caster, so the roster
# order is newest-first; compare ids as a set.
assert {s.combatant.id for s in wolves} == {"celestial_wolf", "celestial_wolf-2"}
assert wolves[0] is not wolves[1]
def test_each_summon_owns_a_distinct_stats_slot(self) -> None:
# Given: two summons of the same creature
# Then: each owns its own _LiveStats slot (the pre-fix engine reset
# the shared slot on the second summon, wiping the first one's rows).
engine, _ = self._engine_with_two_wolves()
assert engine._stats["celestial_wolf"] is not engine._stats["celestial_wolf-2"]
def test_damage_to_the_second_summon_does_not_merge(self) -> None:
# Given: two summoned wolves
# When: a scripted hit lands on the second wolf only
# Then: the damage is booked to celestial_wolf-2's slot, leaving the
# first wolf's counters untouched (no shared-slot merge).
caster = _make_caster()
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=5)}
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
)
engine._cast_spell(c, e, "Summon Monster II")
engine._cast_spell(c, e, "Summon Monster II")
wolves = [
s for s in engine._states if s.combatant.id.startswith("celestial_wolf")
]
second = next(s for s in wolves if s.combatant.id == "celestial_wolf-2")
hp_before = second.hp
# d20=15 + attack_bonus 1 = 16 >= AC 14 -> hit; 1d6=6 - DR 5/magic = 1.
engine._rng = ScriptedRng([15, 6])
# _resolve_swing is the combat path that books the stats slots.
engine._resolve_swing(e, second, enemy.attacks[0], label="club")
dealt = hp_before - second.hp
assert dealt > 0
assert engine._stats["celestial_wolf-2"].damage_taken == dealt
assert engine._stats["celestial_wolf"].damage_taken == 0
class TestExtendSpellOnSummon:
"""SRD feats/extend-spell.md: "An extended spell lasts twice as long as
normal. A spell with a duration of concentration, instantaneous, or
permanent is not affected" - summon monster's "1 round/level (D)" is
none of those, so Extend must double it."""
def test_extend_doubles_the_per_level_summon_duration(self) -> None:
# Given: a level 5 caster who knows Extend Spell
# When: summoning with Extend Spell (1 round/level -> 2 rounds/level)
# Then: the wolf is bound for 10 rounds, and the level-3 slot burns.
caster = _make_caster().model_copy(
update={
"metamagic_feats": ["Extend Spell"],
"spell_slots": {1: 3, 2: 3, 3: 3},
}
)
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
spell = SpellSpec(
name="Summon Monster II",
level=2,
school="conjuration",
range="close",
effects=(
SummonEffect(
creature="data/monsters/celestial_wolf.json",
duration_rounds=1,
duration_rounds_per_level=1,
),
),
)
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e],
spell_registry={"Summon Monster II": spell},
)
engine._cast_spell(c, e, "Summon Monster II", metamagic=("Extend Spell",))
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
assert wolf.summon_duration == 10
assert c.spell_slots_left[3] == 2 # extended: level-3 slot
def test_extend_doubles_a_fixed_summon_duration(self) -> None:
# Given: a summon with only a fixed duration (per_level = 0)
# When: the summon is cast extended (feat applied to this cast)
# Then: the fixed rounds double (2 -> 4).
caster = _make_caster().model_copy(
update={
"metamagic_feats": ["Extend Spell"],
"spell_slots": {1: 3, 2: 3, 3: 3},
}
)
enemy = _make_enemy()
c = CombatantState(combatant=caster, side="players", pos=(0, 0), hp=caster.hp_max)
e = CombatantState(combatant=enemy, side="monsters", pos=(5, 0), hp=enemy.hp_max)
reg = {"Summon Monster II": _make_summon_spell(duration_rounds=2)}
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
engine = CombatEngine(
SeededRng(1), Grid.from_spec(spec), [c, e], spell_registry=reg
)
engine._cast_spell(c, e, "Summon Monster II", metamagic=("Extend Spell",))
wolf = next(s for s in engine._states if s.combatant.id == "celestial_wolf")
assert wolf.summon_duration == 4