feat(combat): expose threat grid and terrain semantics in observation

This commit is contained in:
2026-09-06 14:10:16 +02:00
parent cfeb28e336
commit bfb8d0a30c
2 changed files with 405 additions and 2 deletions
+66 -2
View File
@@ -107,7 +107,7 @@ if TYPE_CHECKING:
from typing import Literal
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos
from pf1e_simulator.map import Pos, TerrainType
from pf1e_simulator.models import AttackSpec, Combatant, DamageReduction
from pf1e_simulator.rng import Rng
from pf1e_simulator.spells import SpellSpec
@@ -381,7 +381,15 @@ class Observation:
``pairs`` holds one dict per (acting combatant x each other combatant) with
``target_index`` disambiguating the target. ``terrain`` and ``elevation``
are height x width layers aligned on the same dimensions (``elevation`` is
``()`` when the grid has no elevation layer).
``()`` when the grid has no elevation layer). ``threatened`` is a height x
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
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
``MapSpec.legend``).
"""
round: int
@@ -390,6 +398,8 @@ class Observation:
pairs: tuple[dict[str, object], ...]
terrain: tuple[tuple[str | None, ...], ...]
elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = ()
def to_dict(self) -> dict[str, object]:
"""Return a JSON-serializable dict with the pinned top-level keys."""
@@ -400,6 +410,8 @@ class Observation:
"pairs": self.pairs,
"terrain": self.terrain,
"elevation": self.elevation,
"threatened": self.threatened,
"terrain_semantics": self.terrain_semantics,
}
@@ -587,6 +599,8 @@ class CombatEngine:
)
else:
elevation = ()
threatened = self._threat_grid(state)
terrain_semantics = self._terrain_semantics()
return Observation(
round=self._current_round,
acting_combatant=acting_index,
@@ -594,6 +608,56 @@ class CombatEngine:
pairs=pairs,
terrain=terrain,
elevation=elevation,
threatened=threatened,
terrain_semantics=terrain_semantics,
)
def _threat_grid(self, state: CombatantState) -> tuple[tuple[bool, ...], ...]:
"""Build the height x width bool grid of cells threatened by enemies.
Union of ``threatened_squares(e)`` for every enemy ``e`` of ``state``
(active, opposite side, with a usable melee weapon). The union is
bounded to the grid: cells produced by the public helper that lie
outside the map are dropped (helper output is in 5-ft-square coords
and may overflow on small maps).
"""
enemies = (
s for s in self._states
if s is not state
and s.active
and s.side != state.side
and any(w.kind == "melee" for w in s.usable_attacks())
)
threatened: set[Pos] = set()
for enemy in enemies:
threatened |= self.threatened_squares(enemy)
return tuple(
tuple((r, c) in threatened for c in range(self._grid.width))
for r in range(self._grid.height)
)
def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
"""One dict per distinct ``TerrainType`` on the map (sorted by type).
Derived by deduplicating ``grid.terrain_at`` over the entire map: the
Grid stores resolved ``TerrainType`` objects in ``_cells`` but does
not expose ``MapSpec.legend`` directly, so iterating the cells is
the safest source. The Grid is guaranteed to hold at least one
cell (constructor enforces non-empty ``cells``).
"""
seen: dict[str, TerrainType] = {}
for r in range(self._grid.height):
for c in range(self._grid.width):
cell = self._grid.terrain_at((r, c))
seen.setdefault(cell.type, cell)
return tuple(
{
"type": cell.type,
"move_cost": cell.move_cost,
"blocks_los": cell.blocks_los,
"cover": cell.cover,
}
for cell in (seen[name] for name in sorted(seen))
)
def _combatant_snapshot(self, state: CombatantState) -> dict[str, object]: