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:
@@ -1112,13 +1112,13 @@ class CombatEngine:
|
||||
return active_sides.pop()
|
||||
return None
|
||||
|
||||
def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||
def threatened_squares(self, state: CombatantState) -> frozenset[Pos]:
|
||||
"""Squares this combatant threatens with a melee weapon.
|
||||
|
||||
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 (PF1e reach dead zone). The union over usable melee weapons is
|
||||
returned.
|
||||
(API publique, lecture pour politique LLM.) 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 (PF1e
|
||||
reach dead zone). The union over usable melee weapons is returned.
|
||||
"""
|
||||
if not state.active:
|
||||
return frozenset()
|
||||
@@ -1169,17 +1169,57 @@ class CombatEngine:
|
||||
and self._grid.distance(center, (r + dr, c + dc)) <= radius
|
||||
}
|
||||
|
||||
def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||
"""Active enemies whose threatened squares include pos (in self._states order)."""
|
||||
def enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]:
|
||||
"""Active enemies whose threatened squares include pos (in self._states order).
|
||||
|
||||
(API publique, lecture pour politique LLM.)
|
||||
"""
|
||||
return [
|
||||
s
|
||||
for s in self._states
|
||||
if s is not state
|
||||
and s.active
|
||||
and s.side != state.side
|
||||
and pos in self._threatened_squares(s)
|
||||
and pos in self.threatened_squares(s)
|
||||
]
|
||||
|
||||
def reachable_ft(self, state: CombatantState, budget: int | None = None) -> dict[Pos, int]:
|
||||
"""Movement cost in feet from state's anchor to each reachable cell.
|
||||
|
||||
(API publique, lecture pour politique LLM.) Same cost model as
|
||||
``Grid.reachable``: 5-10-5 diagonals, terrain, elevation, blocked
|
||||
creatures excluded. ``budget`` is in feet; None = full field.
|
||||
"""
|
||||
blocked = self._blocked(state)
|
||||
if budget is None:
|
||||
field = self._grid.reachable(state.pos, None, blocked)
|
||||
else:
|
||||
budget_cells = budget // _SQUARE_FT
|
||||
if budget_cells < 1:
|
||||
return {}
|
||||
field = self._grid.reachable(state.pos, budget_cells, blocked)
|
||||
return {pos: cost * _SQUARE_FT for pos, cost in field.items()}
|
||||
|
||||
def movement_cost_ft(self, state: CombatantState, target: CombatantState) -> int | None:
|
||||
"""Minimal movement cost in feet for ``state`` to reach a cell from which
|
||||
it can melee ``target``, or None if unreachable under its speed.
|
||||
|
||||
(API publique, lecture pour politique LLM.) Uses the same cost model as
|
||||
``_move_path``: a full Dijkstra field from the target's anchor (other
|
||||
active states' cells blocked), then the cheapest field value over
|
||||
``state``'s footprint, compared against the combatant's land speed.
|
||||
Read-only: never mutates state nor consumes RNG.
|
||||
"""
|
||||
blocked = self._blocked(state, target)
|
||||
field = self._grid.reachable(target.pos, None, blocked)
|
||||
cost_cells = self._footprint_cost(state.occupied_cells(), field)
|
||||
if cost_cells is None:
|
||||
return None
|
||||
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
|
||||
if cost_cells > speed_cells:
|
||||
return None
|
||||
return cost_cells * _SQUARE_FT
|
||||
|
||||
def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool:
|
||||
"""Resolve one attack of opportunity (single melee attack at normal bonus).
|
||||
|
||||
@@ -1203,7 +1243,7 @@ class CombatEngine:
|
||||
by the per-round AoO allowance). Returns True if the mover dropped.
|
||||
"""
|
||||
for cell in mover.occupied_cells():
|
||||
for enemy in self._enemies_threatening(mover, cell):
|
||||
for enemy in self.enemies_threatening(mover, cell):
|
||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||
continue
|
||||
self._resolve_aoo(enemy, mover)
|
||||
@@ -1221,7 +1261,7 @@ class CombatEngine:
|
||||
if not self._is_ranged_attack(weapon):
|
||||
return False
|
||||
for cell in attacker.occupied_cells():
|
||||
for enemy in self._enemies_threatening(attacker, cell):
|
||||
for enemy in self.enemies_threatening(attacker, cell):
|
||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||
continue
|
||||
self._resolve_aoo(enemy, attacker)
|
||||
@@ -1235,7 +1275,7 @@ class CombatEngine:
|
||||
Returns True if the caster dropped or failed concentration (spell fizzles).
|
||||
"""
|
||||
for cell in caster.occupied_cells():
|
||||
for enemy in self._enemies_threatening(caster, cell):
|
||||
for enemy in self.enemies_threatening(caster, cell):
|
||||
if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
|
||||
continue
|
||||
hp_before = caster.hp
|
||||
|
||||
@@ -256,7 +256,7 @@ class TestThreatenedSquaresLarge:
|
||||
giant = make_combatant("g", size="Large")
|
||||
state = make_state(giant, "p", (2, 2))
|
||||
engine = make_engine([state])
|
||||
threatened = engine._threatened_squares(state)
|
||||
threatened = engine.threatened_squares(state)
|
||||
# Must contain cells adjacent to any of the 4 occupied cells
|
||||
# e.g. (0,2) is at distance 2 from (2,2), should be in
|
||||
assert (0, 2) in threatened
|
||||
@@ -287,7 +287,7 @@ class TestThreatenedSquaresLarge:
|
||||
)
|
||||
state = make_state(combatant, "p", (2, 2))
|
||||
engine = make_engine([state])
|
||||
threatened = engine._threatened_squares(state)
|
||||
threatened = engine.threatened_squares(state)
|
||||
# Ring at distance 2 from each occupied cell is included
|
||||
# (2,2) ring(2) includes (0,2) etc.
|
||||
# (3,3) ring(2) includes (1,3), (5,3), (3,1), (3,5) etc.
|
||||
|
||||
@@ -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()
|
||||
+5
-5
@@ -121,7 +121,7 @@ def test_medium_5ft_weapon_threatens_adjacent_only() -> None:
|
||||
fighter = make_combatant("fighter")
|
||||
state = make_state(fighter, "players", (3, 3))
|
||||
engine = make_engine([state])
|
||||
assert engine._threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||
|
||||
|
||||
def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
||||
@@ -129,7 +129,7 @@ def test_reach_weapon_threatens_ring_not_adjacent() -> None:
|
||||
spearman = make_combatant("spearman", reach_ft=10)
|
||||
state = make_state(spearman, "players", (3, 3))
|
||||
engine = make_engine([state])
|
||||
threatened = engine._threatened_squares(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
|
||||
@@ -178,7 +178,7 @@ def test_sword_plus_reach_weapon_union() -> None:
|
||||
)
|
||||
state = make_state(combatant, "players", (3, 3))
|
||||
engine = make_engine([state])
|
||||
assert engine._threatened_squares(state) == frozenset(
|
||||
assert engine.threatened_squares(state) == frozenset(
|
||||
_adjacent((3, 3)) | _ring((3, 3), 2)
|
||||
)
|
||||
|
||||
@@ -194,7 +194,7 @@ def test_large_creature_sword_threatens_disk_radius_2() -> None:
|
||||
for cell in footprint:
|
||||
expected |= _disk(cell, 2)
|
||||
expected -= footprint
|
||||
assert engine._threatened_squares(state) == frozenset(expected)
|
||||
assert engine.threatened_squares(state) == frozenset(expected)
|
||||
|
||||
|
||||
def test_unknown_size_defaults_to_5ft() -> None:
|
||||
@@ -202,7 +202,7 @@ def test_unknown_size_defaults_to_5ft() -> None:
|
||||
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)))
|
||||
assert engine.threatened_squares(state) == frozenset(_adjacent((3, 3)))
|
||||
|
||||
|
||||
def test_weapon_for_reach_weapon_at_distance_2_not_1() -> None:
|
||||
|
||||
Reference in New Issue
Block a user