From 735642a607208f87dbe43a0520b9a3ed9421f46e Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:55 +0200 Subject: [PATCH] fix(combat): summoned creatures act and expire --- src/pf1e_simulator/combat.py | 88 ++++++++++++++-- tests/test_summoning.py | 193 +++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 11 deletions(-) create mode 100644 tests/test_summoning.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index c70746d..cf1adf6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -339,6 +339,8 @@ class CombatEngine: self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 self._aoo_used: set[str] = set() + self._initiative_keys: dict[str, tuple[int, int, int]] = {} + self._pending_summons: list[CombatantState] = [] @property def states(self) -> list[CombatantState]: @@ -410,17 +412,13 @@ class CombatEngine: def run(self) -> CombatResult: """Run the battle and return the final result and transcript.""" - order = self._roll_initiative() + self._roll_initiative() round_no = 1 while round_no <= self._round_cap: self._current_round = round_no self._aoo_used.clear() - for state in order: - if not state.active: - continue - if self._take_turn(state): - break - if self._winner_side() is not None: + self._expire_summons() + if self._run_round(): break round_no += 1 winner = self._winner_side() @@ -436,6 +434,31 @@ class CombatEngine: stats=stats, ) + def _run_round(self) -> bool: + """Execute one round; return True if the battle ended.""" + order = self._build_round_order() + for state in order: + if not state.active: + continue + if self._take_turn(state): + return True + if self._drive_pending_summons(): + return True + self._pending_summons.clear() + return self._winner_side() is not None + + def _drive_pending_summons(self) -> bool: + """Give summoned creatures their turn immediately; return True if battle ended.""" + while self._pending_summons: + summoned = self._pending_summons.pop(0) + if ( + summoned in self._states + and summoned.active + and self._take_turn(summoned) + ): + return True + return False + def nearest_enemy(self, state: CombatantState) -> CombatantState | None: """Closest active enemy by grid distance; ties keep list order.""" enemies = [s for s in self._states if s.side != state.side and s.active] @@ -658,13 +681,17 @@ class CombatEngine: for state in self._states: roll = self._rng.d20() rolls[state.combatant.id] = (roll, roll + state.combatant.initiative_mod) - order = sorted( - self._states, - key=lambda s: ( + self._initiative_keys = { + s.combatant.id: ( rolls[s.combatant.id][1], s.combatant.initiative_mod, self._states.index(s), - ), + ) + for s in self._states + } + order = sorted( + self._states, + key=lambda s: self._initiative_keys[s.combatant.id], reverse=True, ) for state in order: @@ -675,6 +702,42 @@ class CombatEngine: ) return order + def _build_round_order(self) -> list[CombatantState]: + """Build initiative order for this round from current ``_states``. + + Summoned creatures (not present at the start of combat) inherit their + caster's initiative key so they act immediately after the caster via + stable sort. + """ + + def sort_key(s: CombatantState) -> tuple[int, int, int]: + key = self._initiative_keys.get(s.combatant.id) + if key is not None: + return key + caster_id = s.summon_caster_id + if caster_id and caster_id in self._initiative_keys: + ck = self._initiative_keys[caster_id] + return (ck[0], ck[1], ck[2]) + return (0, 0, -999) + + return sorted(self._states, key=sort_key, reverse=True) + + def _expire_summons(self) -> None: + """Decrement summon durations and remove expired summoned creatures.""" + expired: list[CombatantState] = [] + for state in list(self._states): + if state.summon_duration > 0: + state.summon_duration -= 1 + if state.summon_duration <= 0: + expired.append(state) + for state in expired: + self._log( + f"round {self._current_round} {state.combatant.id}: summon expired" + ) + self._states.remove(state) + if state in self._pending_summons: + self._pending_summons.remove(state) + def _winner_side(self) -> str | None: active_sides = {s.side for s in self._states if s.active} if len(active_sides) == 1: @@ -1860,6 +1923,9 @@ class CombatEngine: summoned_state.summon_duration = effect.duration_rounds summoned_state.summon_caster_id = state.combatant.id + self._stats[summoned_state.combatant.id] = _LiveStats() + self._pending_summons.append(summoned_state) + self._log( f"round {self._current_round} {state.combatant.id}: " f"summon {summoned_combatant.id} at {spawn_pos} ({effect.duration_rounds}r)" diff --git a/tests/test_summoning.py b/tests/test_summoning.py new file mode 100644 index 0000000..e3d55e1 --- /dev/null +++ b/tests/test_summoning.py @@ -0,0 +1,193 @@ +"""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" + )