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:
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Multi-cell creatures move at the difficult rate under MIXED terrain (SRD).
|
||||
|
||||
SRD "Movement, Position, and Distance" (movement-position-and-distance-2.md):
|
||||
"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 whose
|
||||
ANCHOR square is plain floor but whose TRAILING footprint square sits in rubble
|
||||
is hampered exactly like a 1x1 creature standing in it.
|
||||
|
||||
The charge, the run and the 5-foot step already test the whole footprint
|
||||
(``_footprint_touches_difficult``); the ``move``/withdraw DESCENTS previously
|
||||
priced every step by the anchor cell alone (``grid.step_cost``), so a Large
|
||||
creature straddling rubble crossed the board at full speed. The descent now
|
||||
prices a step by the most difficult square under the footprint
|
||||
(``_footprint_step_cost`` -> ``grid.step_cost_with_terrain``), keeping the
|
||||
5-10-5 + elevation formula in ``grid.py``. For a 1x1 creature the footprint is
|
||||
the anchor cell, so the cost is bit-identical to the old anchor-only pricing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.combat import CombatantState, CombatEngine
|
||||
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 ScriptedRng
|
||||
|
||||
|
||||
def _combatant(cid: str, *, size: str, speed: int = 30) -> Combatant:
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="club",
|
||||
kind="melee",
|
||||
attack_bonus=0,
|
||||
damage=[DamageComponent(formula="1d6", types=["bludgeoning"])],
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size=size,
|
||||
abilities=AbilityScores(
|
||||
str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10,
|
||||
cha_score=10,
|
||||
),
|
||||
hp_max=30,
|
||||
ac=ACProfile(total=10, touch=10, flat_footed=10),
|
||||
bab=0,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=speed,
|
||||
saves=Saves(fort=0, ref=0, will=0),
|
||||
attacks=[attack],
|
||||
)
|
||||
|
||||
|
||||
def _state(c: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||
return CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
|
||||
|
||||
|
||||
def _rubble_at(cells: set[tuple[int, int]], size: int = 12) -> Grid:
|
||||
"""Open size x size grid; the listed cells are rubble (cost 2)."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"~": TerrainType(type="rubble", move_cost=2),
|
||||
}
|
||||
rows = [list("." * size) for _ in range(size)]
|
||||
for r, c in cells:
|
||||
rows[r][c] = "~"
|
||||
return Grid.from_spec(
|
||||
MapSpec(name="rubble", terrain=tuple("".join(r) for r in rows), legend=legend),
|
||||
)
|
||||
|
||||
|
||||
def _rubble_cols(cols: set[int], size: int = 12) -> Grid:
|
||||
"""Open grid with FULL-HEIGHT rubble in the given columns (no escape
|
||||
around the difficult lane: every footprint touching those columns is
|
||||
hampered)."""
|
||||
return _rubble_at(
|
||||
{(r, c) for r in range(size) for c in cols},
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
# ── the descent step cost itself ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_large_step_priced_by_trailing_rubble_square() -> None:
|
||||
"""Large anchor at (0,0) covers rows 0-1, cols 0-1. Rubble at (1,1) is a
|
||||
TRAILING footprint square: the anchor (0,1) is floor, so the old
|
||||
anchor-only pricing charged 1 for the step (0,0)->(0,1); the SRD 'most
|
||||
difficult terrain you occupy' rule charges the difficult rate (2)."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
|
||||
engine = CombatEngine(ScriptedRng([]), _rubble_at({(1, 1)}), [large])
|
||||
cost = engine._footprint_step_cost(large, (0, 0), (0, 1), 0)
|
||||
assert cost == 2
|
||||
|
||||
|
||||
def test_large_step_clean_footprint_stays_normal() -> None:
|
||||
"""Same creature, rubble far off every footprint: the step is priced
|
||||
normally (1) - the difficult rate only applies when the footprint touches
|
||||
rubble."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
|
||||
engine = CombatEngine(ScriptedRng([]), _rubble_at({(9, 9)}), [large])
|
||||
cost = engine._footprint_step_cost(large, (0, 0), (0, 1), 0)
|
||||
assert cost == 1
|
||||
|
||||
|
||||
def test_medium_step_bit_identical_to_anchor_pricing() -> None:
|
||||
"""1x1 regression: the footprint IS the anchor cell, so the footprint-aware
|
||||
cost equals ``grid.step_cost`` exactly - into floor and into rubble."""
|
||||
med = _state(_combatant("orc", size="Medium"), "players", (0, 0))
|
||||
engine = CombatEngine(ScriptedRng([]), _rubble_at({(0, 2)}), [med])
|
||||
assert engine._footprint_step_cost(med, (0, 0), (0, 1), 0) == engine._grid.step_cost(
|
||||
(0, 0), (0, 1), 0
|
||||
)
|
||||
assert engine._footprint_step_cost(med, (0, 1), (0, 2), 0) == engine._grid.step_cost(
|
||||
(0, 1), (0, 2), 0
|
||||
)
|
||||
|
||||
|
||||
def test_large_diagonal_step_priced_by_trailing_rubble() -> None:
|
||||
"""Diagonal step: a footprint straddling rubble prices the diagonal at the
|
||||
difficult-diagonal rate (3), matching grid.step_cost's own diagonal-into-
|
||||
difficult rule (SRD: 'Each diagonal move into a difficult terrain square
|
||||
counts as 3 squares')."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
|
||||
engine = CombatEngine(ScriptedRng([]), _rubble_at({(1, 2)}), [large])
|
||||
# (0,0)->(1,1): footprint rows 1-2, cols 1-2 covers the rubble cell (1,2).
|
||||
cost = engine._footprint_step_cost(large, (0, 0), (1, 1), 0)
|
||||
assert cost == 3
|
||||
|
||||
|
||||
# ── the observable effect: the move budget ────────────────────────────────────
|
||||
|
||||
|
||||
def test_large_straddling_rubble_cannot_cross_at_full_speed() -> None:
|
||||
"""A Large creature (30 ft = 6 squares) advancing east through a full-
|
||||
height rubble lane is hampered the whole way: it covers only 3 squares on a
|
||||
6-square move (cost 2 per square), where the anchor-only pricing let it
|
||||
cross 6. The foe far east keeps the move auto-directed straight down the
|
||||
lane (no south escape: the rubble spans every row)."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
|
||||
foe = _state(_combatant("archer", size="Medium"), "monsters", (0, 11))
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([15, 1]),
|
||||
_rubble_cols({0, 1, 2, 3, 4, 5, 6, 7}),
|
||||
[large, foe],
|
||||
)
|
||||
engine._move(large, foe)
|
||||
# 6-square budget / cost-2 squares = 3 squares of advance (cols 0 -> 3).
|
||||
assert large.pos[1] == 3
|
||||
|
||||
|
||||
def test_large_clean_lane_still_crosses_at_full_speed() -> None:
|
||||
"""Same geometry with the rubble removed: the Large creature crosses its
|
||||
full 6-square speed (the difficult rate never applies)."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
|
||||
foe = _state(_combatant("archer", size="Medium"), "monsters", (0, 11))
|
||||
engine = CombatEngine(ScriptedRng([15, 1]), _rubble_at(set()), [large, foe])
|
||||
engine._move(large, foe)
|
||||
assert large.pos[1] == 6
|
||||
|
||||
|
||||
def test_large_straddling_rubble_withdraw_is_hampered() -> None:
|
||||
"""The withdraw ascent shares the footprint-aware cost: a Large creature
|
||||
retreating through a full-height rubble lane spends double the budget per
|
||||
square, so its 2x-speed flee covers FEWER squares than the same flee on
|
||||
clean ground (the difficult rate bites on every step)."""
|
||||
large = _state(_combatant("ogre", size="Large"), "players", (5, 8))
|
||||
threat = _state(_combatant("brute", size="Medium"), "monsters", (5, 10))
|
||||
rubble_engine = CombatEngine(
|
||||
ScriptedRng([]),
|
||||
_rubble_cols(set(range(16)), size=16),
|
||||
[large, threat],
|
||||
)
|
||||
rubble_path = rubble_engine._flee_path(large, threat)
|
||||
|
||||
large2 = _state(_combatant("ogre", size="Large"), "players", (5, 8))
|
||||
threat2 = _state(_combatant("brute", size="Medium"), "monsters", (5, 10))
|
||||
clean_engine = CombatEngine(
|
||||
ScriptedRng([]), _rubble_at(set(), size=16), [large2, threat2]
|
||||
)
|
||||
clean_path = clean_engine._flee_path(large2, threat2)
|
||||
|
||||
# Same 12-square (2x30ft) budget; the rubble lane halves the squares.
|
||||
assert len(rubble_path) < len(clean_path)
|
||||
assert len(rubble_path) == 4 # 4 diagonal steps x cost-3 = 12 budget
|
||||
Reference in New Issue
Block a user