Files
pf1e-simulator/tests/test_reach.py
T
ctan 56c8970d5b feat(combat): expose threatened squares and movement cost fields for LLM policy
Rename _threatened_squares -> threatened_squares and
_enemies_threatening -> enemies_threatening (public read-only API for a
future LLM policy), and add reachable_ft(state, budget) and
movement_cost_ft(state, target) accessors returning movement costs in
feet. Pure exposure: no behavior change, resolution engine untouched,
goldens bit-identical.
2026-09-06 10:36:48 +02:00

293 lines
10 KiB
Python

"""Tests for B5.3: melee reach depends on weapon reach and creature size.
Covers the PF1e reach rules implemented in this phase:
- non-reach weapons (``reach_ft == 5``) threaten the disk of effective reach;
- reach weapons (``reach_ft > 5``) threaten only the ring at exactly their
reach (the PF1e reach dead zone: a longspear cannot hit an adjacent target);
- a Large creature's natural reach (10 ft) extends its non-reach weapons;
- ``weapon_for`` respects the reach dead zone;
- AoOs trigger from ring squares threatened by a reach weapon.
"""
from __future__ import annotations
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
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 make_combatant(
cid: str,
*,
size: str = "Medium",
reach_ft: int = 5,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
) -> Combatant:
"""Build a single-melee-weapon combatant (used by most reach tests)."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_ft > 5 else "short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=attack_bonus,
initiative_mod=0,
speed_land_ft=speed,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def make_engine(states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), make_grid(), states)
def _grid_distance(a: tuple[int, int], b: tuple[int, int]) -> int:
"""5-10-5 diagonal distance in squares (mirrors Grid.distance)."""
d_row = abs(a[0] - b[0])
d_col = abs(a[1] - b[1])
return max(d_row, d_col) + min(d_row, d_col) // 2
def _ring(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
"""Cells at exactly ``radius`` squares from ``pos`` (5-10-5 distance)."""
r, c = pos
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) == radius
}
def _disk(pos: tuple[int, int], radius: int) -> set[tuple[int, int]]:
"""Cells within ``radius`` squares of ``pos`` (5-10-5 distance), excluding pos."""
r, c = pos
return {
(r + dr, c + dc)
for dr in range(-radius, radius + 1)
for dc in range(-radius, radius + 1)
if (dr, dc) != (0, 0) and _grid_distance(pos, (r + dr, c + dc)) <= radius
}
def _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
"""The 8 cells adjacent to ``pos``."""
return _disk(pos, 1)
def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
"""A Medium combatant with a 5 ft weapon threatens exactly the 8 adjacent squares."""
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
"""A longspear (reach 10 ft) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10)
state = make_state(spearman, "players", (3, 3))
engine = make_engine([state])
threatened = engine.threatened_squares(state)
assert threatened == frozenset(_ring((3, 3), 2))
assert (3, 2) not in threatened # adjacent orthogonal
assert (3, 4) not in threatened
assert (2, 2) not in threatened # adjacent diagonal
assert (4, 4) not in threatened
def test_sword_plus_reach_weapon_union() -> None:
"""A combatant with a 5 ft sword and a reach weapon threatens disk(1) plus ring(2)."""
sword = AttackSpec(
id="sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
reach_ft=5,
)
spear = AttackSpec(
id="spear",
name="longspear",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=10,
)
combatant = Combatant(
id="dual",
name="dual",
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=5,
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[sword, spear],
)
state = make_state(combatant, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(
_adjacent((3, 3)) | _ring((3, 3), 2)
)
def test_large_creature_sword_threatens_disk_radius_2() -> None:
"""A Large creature (2x2 footprint, natural reach 10 ft) with a 5 ft sword
threatens the union of radius-2 disks around each occupied cell."""
giant = make_combatant("giant", size="Large")
state = make_state(giant, "players", (3, 3))
engine = make_engine([state])
footprint = state.occupied_cells()
expected = set()
for cell in footprint:
expected |= _disk(cell, 2)
expected -= footprint
assert engine.threatened_squares(state) == frozenset(expected)
def test_unknown_size_defaults_to_5ft() -> None:
"""An unlisted size falls back to the current 5 ft natural reach."""
weird = make_combatant("weird", size="Weird")
state = make_state(weird, "players", (3, 3))
engine = make_engine([state])
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
"""weapon_for selects a reach weapon at distance 2 but not at distance 1 (dead zone)."""
spearman = make_combatant("spearman", reach_ft=10)
target = make_combatant("target")
attacker = make_state(spearman, "players", (3, 3))
foe_at_ring = make_state(target, "monsters", (3, 5)) # distance 2
engine_ring = make_engine([attacker, foe_at_ring])
weapon = engine_ring.weapon_for(attacker, foe_at_ring)
assert weapon is not None
assert weapon.reach_ft == 10
foe_adjacent = make_state(target, "monsters", (3, 4)) # distance 1
engine_adjacent = make_engine([attacker, foe_adjacent])
assert engine_adjacent.weapon_for(attacker, foe_adjacent) is None
def test_weapon_for_large_creature_sword_at_distance_2() -> None:
"""A Large creature's 5 ft sword is selectable at distance 2 (natural reach 10 ft)."""
giant = make_combatant("giant", size="Large")
target = make_combatant("target")
attacker = make_state(giant, "players", (3, 3))
foe = make_state(target, "monsters", (3, 5)) # distance 2
engine = make_engine([attacker, foe])
weapon = engine.weapon_for(attacker, foe)
assert weapon is not None
assert weapon.reach_ft == 5
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Test policy: always move toward the 'dest' combatant."""
return (Action(kind="move", target_id="dest"),)
def test_aoo_from_reach_ring() -> None:
"""Moving through a ring square threatened by a longspear provokes an AoO."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 3)),
make_state(spearman, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 5)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: spearman d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 spearman: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 runner: move (4,3)->(3,4)",
"round 1 spearman: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)
def test_no_aoo_from_adjacent_to_reach_weapon() -> None:
"""A longspear does not threaten adjacent squares: no AoO when leaving one."""
runner = make_combatant("runner", hp=20, ac=12, speed=30)
spearman = make_combatant("spearman", hp=20, ac=15, attack_bonus=5, speed=0, reach_ft=10)
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
states = [
make_state(runner, "players", (4, 1)),
make_state(spearman, "monsters", (3, 1)),
make_state(dest, "monsters", (4, 3)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_move_to_dest,
)
result = engine.run()
assert result.transcript == (
"initiative: runner d20=10+0=10",
"initiative: spearman d20=9+0=9",
"initiative: dest d20=8+0=8",
"round 1 runner: move (4,1)->(3,2)",
"round 1 spearman: wait",
"round 1 dest: wait",
"battle over: draw after 1 rounds",
)