feat(observation): expose ranged threat grid

This commit is contained in:
2026-09-10 17:12:54 +02:00
parent f637f87a8c
commit 582b6c60d1
4 changed files with 375 additions and 4 deletions
+80 -1
View File
@@ -507,7 +507,14 @@ class Observation:
width boolean grid whose cells are True when threatened by at least one
ENEMY of the acting combatant (active, opposite side, with a melee
weapon); the union is bounded to the grid — out-of-grid threatened cells
are dropped. ``terrain_semantics`` is one dict per distinct terrain type
are dropped. ``ranged_threatened`` is a height x width boolean grid whose
cells are True when under enemy RANGED fire: reachable within a usable
ranged-style weapon (``ranged``, or ``touch`` with a range increment) of
an active enemy of the acting combatant, with a line of effect from that
enemy's occupied cells; bounded to the grid. PF1e ranged weapons do NOT
threaten for attacks of opportunity — this is a tactical field-of-fire
map, not an AoO threat, and it leaves ``threatened`` untouched.
``terrain_semantics`` is one dict per distinct terrain type
on the map (sorted by type for determinism), carrying
``{type, move_cost, blocks_los, cover}`` — derived from
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose
@@ -523,6 +530,7 @@ class Observation:
terrain: tuple[tuple[str | None, ...], ...]
elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = ()
ranged_threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = ()
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
initiative_order: tuple[str, ...] = ()
@@ -537,6 +545,7 @@ class Observation:
"terrain": self.terrain,
"elevation": self.elevation,
"threatened": self.threatened,
"ranged_threatened": self.ranged_threatened,
"terrain_semantics": self.terrain_semantics,
"condition_definitions": self.condition_definitions,
"initiative_order": self.initiative_order,
@@ -839,6 +848,7 @@ class CombatEngine:
else:
elevation = ()
threatened = self._threat_grid(state)
ranged_threatened = self._ranged_threat_grid(state)
terrain_semantics = self._terrain_semantics()
condition_definitions = tuple(
(name, _condition_definition_dict(cond))
@@ -855,6 +865,7 @@ class CombatEngine:
terrain=terrain,
elevation=elevation,
threatened=threatened,
ranged_threatened=ranged_threatened,
terrain_semantics=terrain_semantics,
condition_definitions=condition_definitions,
initiative_order=initiative_order,
@@ -884,6 +895,74 @@ class CombatEngine:
for r in range(self._grid.height)
)
def _ranged_threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
"""Build the height x width bool grid of cells under enemy ranged fire.
Sibling of ``_threat_grid`` for RANGED weapons: the union over every
active enemy ``e`` of ``state`` (opposite side) of the cells each of
e's usable ranged-style weapons can reach (see
``_cell_under_ranged_fire``). Bounded to the grid. Read-only, no RNG.
PF1e ranged weapons grant no attacks of opportunity — this is a
tactical field-of-fire map, not an AoO threat; the melee
``threatened`` grid is unchanged by it.
"""
covered = self._ranged_covered_cells(state)
return tuple(
tuple((r, c) in covered for c in range(self._grid.width))
for r in range(self._grid.height)
)
def _ranged_covered_cells(self, state: CombatantState) -> set[Pos]:
"""Cells covered by any usable ranged-style weapon of state's enemies."""
covered: set[Pos] = set()
for enemy in self._states:
if enemy is state or not enemy.active or enemy.side == state.side:
continue
weapons = [w for w in enemy.usable_attacks() if self._is_ranged_attack(w)]
if not weapons:
continue
occupied = enemy.occupied_cells()
for r in range(self._grid.height):
for c in range(self._grid.width):
cell = (r, c)
if cell not in covered and self._cell_under_ranged_fire(
occupied, cell, weapons
):
covered.add(cell)
return covered
def _cell_under_ranged_fire(
self,
occupied: frozenset[Pos],
cell: Pos,
weapons: list[AttackSpec],
) -> bool:
"""True if some ranged weapon in ``weapons`` reaches ``cell`` with LoE.
Reach uses the same maximum-range rule as ``_weapon_usable_against``:
``range_increment_ft`` x 10 increments (x 5 for touch), measured from
the shooter's closest occupied cell under the 5-10-5 rule; the line of
effect runs from the shooter's footprint to the bare cell.
"""
dist_ft = (
min(self._grid.distance(from_cell, cell) for from_cell in occupied)
* _SQUARE_FT
)
for weapon in weapons:
increment = weapon.range_increment_ft
if increment is None:
continue
max_increments = (
_MAX_TOUCH_RANGE_INCREMENTS
if weapon.kind == "touch"
else _MAX_RANGE_INCREMENTS
)
if dist_ft > increment * max_increments:
continue
if has_line_of_effect_cells(self._grid, occupied, frozenset({cell})):
return True
return False
def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
"""One dict per distinct ``TerrainType`` on the map (sorted by type).