Files
pf1e-simulator/tests/test_exposed_squares.py
T
ctan 4d89305881 fix(rules): distinguish natural reach from reach weapons
PF1e CRB 'Big and Little Creatures in Combat': a creature's natural
reach is a disk (a Large creature still threatens and strikes adjacent
squares), while a reach weapon doubles the wielder's natural reach but
'can't strike at their natural reach or less' (a ring whose dead zone is
the natural reach). The engine keyed the ring off reach_ft > 5, so
reach-10 natural attacks (giant belostomatid bite/claw, mounted
hooves) wrongly got a 5-ft dead zone.

AttackSpec gains reach_weapon (parsed from the combatant JSON); the
disk-vs-ring rule is centralized in _melee_reach_zone_cells and applied
consistently to weapon selection, melee usability, threatened squares
and charge end placement. Esha's Lance is the only reach weapon in the
shipped data; Large natural attackers keep the default (disk).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-11 22:06:46 +02:00

326 lines
11 KiB
Python

"""Tests for L5: public exposure of threatened squares, enemies threatening,
and movement-cost fields (read-only API for a future LLM policy).
Covers:
- ``threatened_squares`` (public, renamed from ``_threatened_squares``): the
disk of natural reach for plain/natural attacks and the ring (dead zone)
for reach weapons.
- ``enemies_threatening`` (public, renamed from ``_enemies_threatening``):
active enemies whose threatened squares include a given cell, in
``self._states`` order; allies never count.
- ``reachable_ft``: movement cost in feet from a state's anchor to each
reachable cell (same cost model as ``Grid.reachable``).
- ``movement_cost_ft``: minimal cost in feet to move ``state`` to a cell from
which it can melee ``target``, or ``None`` if unreachable under speed.
- Determinism + non-mutation: the public accessors never mutate state nor
consume RNG.
"""
from __future__ import annotations
from pf1e_simulator.combat import 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,
reach_weapon: bool = False,
hp: int = 20,
ac: int = 15,
attack_bonus: int = 5,
speed: int = 30,
) -> Combatant:
"""Build a single-melee-weapon combatant."""
attack = AttackSpec(
id=f"{cid}-w",
name="longspear" if reach_weapon else "short sword",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
reach_ft=reach_ft,
reach_weapon=reach_weapon,
)
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 _adjacent(pos: tuple[int, int]) -> set[tuple[int, int]]:
"""The 8 cells adjacent to ``pos``."""
r, c = pos
return {
(r + dr, c + dc)
for dr in (-1, 0, 1)
for dc in (-1, 0, 1)
if (dr, dc) != (0, 0)
}
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 max(abs(dr), abs(dc)) + min(abs(dr), abs(dc)) // 2 == radius
}
# ---------------------------------------------------------------------------
# 1. threatened_squares (public)
# ---------------------------------------------------------------------------
def test_threatened_squares_public_non_reach_disk() -> 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_threatened_squares_public_reach_dead_zone() -> None:
"""A longspear (reach weapon) threatens the ring at distance 2, not adjacent squares."""
spearman = make_combatant("spearman", reach_ft=10, reach_weapon=True)
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 (dead zone)
assert (3, 4) not in threatened
assert (2, 2) not in threatened # adjacent diagonal (dead zone)
assert (4, 4) not in threatened
# ---------------------------------------------------------------------------
# 2. enemies_threatening (public)
# ---------------------------------------------------------------------------
def test_enemies_threatening_public_order_and_allies() -> None:
"""Only active enemies whose threatened squares include pos appear, in
self._states order; allies never count."""
hero = make_combatant("hero")
foe_near = make_combatant("foe_near")
foe_far = make_combatant("foe_far")
ally = make_combatant("ally")
hero_state = make_state(hero, "players", (3, 3))
# foe_near adjacent to (3,3) -> threatens it
foe_near_state = make_state(foe_near, "monsters", (3, 4))
# foe_far far away -> does not threaten (3,3)
foe_far_state = make_state(foe_far, "monsters", (6, 6))
# ally adjacent but same side -> never counts
ally_state = make_state(ally, "players", (4, 3))
engine = make_engine([hero_state, foe_near_state, foe_far_state, ally_state])
result = engine.enemies_threatening(hero_state, (3, 3))
assert result == [foe_near_state]
def test_enemies_threatening_public_empty_when_none() -> None:
"""No enemies threatening a cell -> empty list."""
hero = make_combatant("hero")
foe_far = make_combatant("foe_far")
hero_state = make_state(hero, "players", (3, 3))
foe_far_state = make_state(foe_far, "monsters", (6, 6))
engine = make_engine([hero_state, foe_far_state])
assert engine.enemies_threatening(hero_state, (3, 3)) == []
# ---------------------------------------------------------------------------
# 3. reachable_ft
# ---------------------------------------------------------------------------
def test_reachable_ft_costs_and_budget() -> None:
"""reachable_ft returns movement cost in feet; budget is in feet."""
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (0, 0))
engine = make_engine([state])
field = engine.reachable_ft(state)
assert field[(0, 0)] == 0
assert field[(0, 1)] == 5 # orthogonal 1 cell
assert field[(1, 1)] == 5 # first diagonal = 1 cell (5-10-5)
assert field[(0, 2)] == 10 # orthogonal 2 cells
# budget in feet: cells with cost <= 15 present, beyond absent
budgeted = engine.reachable_ft(state, budget=15)
assert (0, 1) in budgeted
assert (1, 1) in budgeted
assert (0, 2) in budgeted
assert (0, 3) in budgeted # 3 orthogonal cells = 15 ft, within budget
assert (0, 4) not in budgeted # 4 cells = 20 ft > 15
def test_reachable_ft_difficult_terrain() -> None:
"""A difficult-terrain cell (move_cost 2) costs 10 ft."""
legend = {".": TerrainType(type="floor", move_cost=1),
"D": TerrainType(type="rubble", move_cost=2)}
spec = MapSpec(
name="test",
terrain=(
".D......",
"........",
"........",
"........",
"........",
"........",
"........",
"........",
),
legend=legend,
)
grid = Grid.from_spec(spec)
fighter = make_combatant("fighter")
state = make_state(fighter, "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), grid, [state])
field = engine.reachable_ft(state)
assert field[(0, 1)] == 10 # difficult terrain cell costs 2 cells = 10 ft
# ---------------------------------------------------------------------------
# 4. movement_cost_ft
# ---------------------------------------------------------------------------
def test_movement_cost_ft_distance() -> None:
"""movement_cost_ft returns the minimal cost in feet to reach a cell from
which state can melee target (state 2 cells away -> ~10 ft)."""
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (0, 2)) # 2 cells away
engine = make_engine([state, target_state])
cost = engine.movement_cost_ft(state, target_state)
assert cost == 10
def test_movement_cost_ft_unreachable_returns_none() -> None:
"""If the target is sealed behind impassable walls, movement_cost_ft is None."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None),
}
walled = MapSpec(
name="test",
terrain=(
"........",
"........",
"..####..",
"..#..#..",
"..#..#..",
"..####..",
"........",
"........",
),
legend=legend,
)
grid = Grid.from_spec(walled)
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
# state outside the box, target inside -> no path
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (3, 3))
engine = CombatEngine(ScriptedRng([]), grid, [state, target_state])
assert engine.movement_cost_ft(state, target_state) is None
# ---------------------------------------------------------------------------
# 5. Determinism + non-mutation
# ---------------------------------------------------------------------------
def test_public_accessors_do_not_mutate_state() -> None:
"""Calling the public accessors leaves state and engine unchanged."""
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
state = make_state(fighter, "players", (0, 0))
target_state = make_state(target, "monsters", (0, 2))
engine = make_engine([state, target_state])
pos_before = state.pos
hp_before = state.hp
states_before = list(engine._states)
engine.threatened_squares(state)
engine.enemies_threatening(state, (0, 1))
engine.reachable_ft(state)
engine.movement_cost_ft(state, target_state)
assert state.pos == pos_before
assert state.hp == hp_before
assert list(engine._states) == states_before
def test_public_accessors_deterministic_two_runs() -> None:
"""Two runs with the same seed and default policy produce identical
transcripts (the resolution engine still uses the renamed private logic)."""
def run() -> tuple:
fighter = make_combatant("fighter", speed=30)
target = make_combatant("target")
states = [
make_state(fighter, "players", (0, 0)),
make_state(target, "monsters", (0, 3)),
]
eng = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1,
)
return eng.run().transcript
assert run() == run()