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
|
||||
|
||||
Reference in New Issue
Block a user