fix(combat): summon spawns only on passable cells
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user