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
+51 -11
View File
@@ -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