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.
This commit is contained in:
2026-09-06 10:36:48 +02:00
parent 9c49f85af8
commit 56c8970d5b
4 changed files with 381 additions and 18 deletions
+323
View File
@@ -0,0 +1,323 @@
"""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 effective reach for non-reach weapons 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,
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_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 _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 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 (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()