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