fix(rules): a multi-cell creature moving under mixed terrain pays the difficult rate for its whole footprint - SRD Movement, Position and Distance: 'If you occupy squares with different kinds of terrain, you can move only as fast as the most difficult terrain you occupy will allow'; the charge/run/5-foot-step already gated on the whole footprint (_footprint_touches_difficult) but the move/withdraw DESCENT priced every step by the anchor cell alone (grid.step_cost), so a 2x2 creature straddling rubble crossed at full speed; the descent now prices a step by the most difficult square under the footprint via _footprint_step_cost -> a new grid.step_cost_with_terrain (the 5-10-5 + elevation formula stays centralized in grid.py, step_cost delegates to it); 1x1 creatures are bit-identical (footprint == anchor cell); the direction Dijkstra field remains anchor-priced (making it creature-aware rewrites grid.reachable wholesale - the documented deferral); golden untouched (open-floor arena, no difficult terrain); +7 tests (trailing-square orthogonal/diagonal cost, 1x1 bit-identical regression, full-lane move budget 3-vs-6 squares, withdraw hampering)

This commit is contained in:
2026-09-20 00:29:36 +02:00
parent b775d46850
commit d347dde3fc
3 changed files with 239 additions and 7 deletions
+26 -2
View File
@@ -8378,7 +8378,7 @@ class CombatEngine:
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
):
continue
step = self._grid.step_cost(current, nxt, diagonals_used)
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
if step > budget:
continue
score = step + nxt_cost
@@ -8424,6 +8424,30 @@ class CombatEngine:
"""True if any footprint cell is difficult terrain (move cost > 1)."""
return any(self._is_difficult_cell(cell) for cell in footprint)
def _footprint_step_cost(
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int
) -> int:
"""Cost of one anchor step priced by the WHOLE destination footprint.
SRD "Movement, Position, and Distance": "If you occupy squares with
different kinds of terrain, you can move only as fast as the most
difficult terrain you occupy will allow." A 2x2 creature straddling
rubble is hampered even when its ANCHOR square is plain floor, so the
step is priced by the most difficult square the footprint would cover
at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a
1x1 creature the footprint is the anchor cell, so this is exactly
``grid.step_cost`` - bit-identical to the old anchor-only pricing.
"""
footprint = self._footprint_at(to, state)
hardest = 1
for cell in footprint:
if not self._grid.in_bounds(cell):
continue
cost = self._grid.terrain_at(cell).move_cost
if cost is not None and cost > hardest:
hardest = cost
return self._grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
def _is_difficult_cell(self, pos: Pos) -> bool:
"""True for a passable cell whose terrain costs more than one square."""
if not self._grid.in_bounds(pos):
@@ -8494,7 +8518,7 @@ class CombatEngine:
nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, diagonals_used)
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
if step > budget:
continue
score = -(nxt_cost * 100) + step
+20 -5
View File
@@ -108,14 +108,29 @@ class Grid:
if cost is None:
msg = f"cannot step into impassable square {to}"
raise ValueError(msg)
return self.step_cost_with_terrain(frm, to, diagonals_used, cost)
def step_cost_with_terrain(
self, frm: Pos, to: Pos, diagonals_used: int, cost: int
) -> int:
"""Cost of one step given an explicit destination terrain ``cost``.
The same 5-10-5 + elevation rule as ``step_cost`` (the single home of
that formula), but the caller supplies the terrain cost instead of
reading it from the anchor cell. This lets a multi-cell creature price
a step by the MOST DIFFICULT square its whole footprint would occupy
(SRD "Movement, Position, and Distance": "If you occupy squares with
different kinds of terrain, you can move only as fast as the most
difficult terrain you occupy will allow") rather than by the anchor
square alone.
"""
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
climb = max(0, self.elevation_at(to) - self.elevation_at(frm))
if not is_diagonal:
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
return cost + climb
if cost >= _DIFFICULT_TERRAIN_COST:
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
return 1 + (diagonals_used % 2) + max(
0, self.elevation_at(to) - self.elevation_at(frm)
)
return 3 + climb
return 1 + (diagonals_used % 2) + climb
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
flank_a = (frm[0], to[1])