Files
pf1e-simulator/tests/test_move_footprint_terrain.py
T

194 lines
8.1 KiB
Python

"""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