From fc62a19c9e69acbcc63e4b51aaf6e9317b28b940 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:56:05 +0200 Subject: [PATCH] fix(combat): summon spawns only on passable cells --- src/pf1e_simulator/combat.py | 3 +- tests/test_summoning.py | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6d5f837..d658856 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2040,8 +2040,7 @@ class CombatEngine: if dr == 0 and dc == 0: continue pos = (center[0] + dr, center[1] + dc) - if (0 <= pos[0] < self._grid.height and - 0 <= pos[1] < self._grid.width and + if (self._grid.passable(pos) and not any(s.pos == pos for s in self._states if s.active)): spawn_pos = pos break diff --git a/tests/test_summoning.py b/tests/test_summoning.py index e3d55e1..7c15c98 100644 --- a/tests/test_summoning.py +++ b/tests/test_summoning.py @@ -163,6 +163,75 @@ class TestSummonedCreatureActs: ) +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