"""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