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:
@@ -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()
|
||||
Reference in New Issue
Block a user