feat(observation): expose ranged threat grid
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user