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)
|
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
step = self._grid.step_cost(current, nxt, diagonals_used)
|
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
|
||||||
if step > budget:
|
if step > budget:
|
||||||
continue
|
continue
|
||||||
score = step + nxt_cost
|
score = step + nxt_cost
|
||||||
@@ -8424,6 +8424,30 @@ class CombatEngine:
|
|||||||
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
"""True if any footprint cell is difficult terrain (move cost > 1)."""
|
||||||
return any(self._is_difficult_cell(cell) for cell in footprint)
|
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:
|
def _is_difficult_cell(self, pos: Pos) -> bool:
|
||||||
"""True for a passable cell whose terrain costs more than one square."""
|
"""True for a passable cell whose terrain costs more than one square."""
|
||||||
if not self._grid.in_bounds(pos):
|
if not self._grid.in_bounds(pos):
|
||||||
@@ -8494,7 +8518,7 @@ class CombatEngine:
|
|||||||
nxt_cost = self._footprint_cost(footprint, from_threat)
|
nxt_cost = self._footprint_cost(footprint, from_threat)
|
||||||
if nxt_cost is None or nxt_cost <= current_cost:
|
if nxt_cost is None or nxt_cost <= current_cost:
|
||||||
continue
|
continue
|
||||||
step = self._grid.step_cost(current, nxt, diagonals_used)
|
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
|
||||||
if step > budget:
|
if step > budget:
|
||||||
continue
|
continue
|
||||||
score = -(nxt_cost * 100) + step
|
score = -(nxt_cost * 100) + step
|
||||||
|
|||||||
@@ -108,14 +108,29 @@ class Grid:
|
|||||||
if cost is None:
|
if cost is None:
|
||||||
msg = f"cannot step into impassable square {to}"
|
msg = f"cannot step into impassable square {to}"
|
||||||
raise ValueError(msg)
|
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]
|
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:
|
if not is_diagonal:
|
||||||
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
return cost + climb
|
||||||
if cost >= _DIFFICULT_TERRAIN_COST:
|
if cost >= _DIFFICULT_TERRAIN_COST:
|
||||||
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
|
return 3 + climb
|
||||||
return 1 + (diagonals_used % 2) + max(
|
return 1 + (diagonals_used % 2) + climb
|
||||||
0, self.elevation_at(to) - self.elevation_at(frm)
|
|
||||||
)
|
|
||||||
|
|
||||||
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
|
||||||
flank_a = (frm[0], to[1])
|
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