"""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 SeededRng from pf1e_simulator.spells import SpellSpec, SummonEffect 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}" ) 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" )