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
+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])