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
+7 -1
View File
@@ -726,7 +726,13 @@ Règles modélisées :
raison de `ChargeResult` verbatim (`no_line_of_effect` / `no_charge_path`) raison de `ChargeResult` verbatim (`no_line_of_effect` / `no_charge_path`)
ou `no_weapon` si aucune arme de mêlée utilisable). ou `no_weapon` si aucune arme de mêlée utilisable).
- Top-level : round, acting_combatant, combatants, pairs, terrain, elevation, - Top-level : round, acting_combatant, combatants, pairs, terrain, elevation,
threatened (grille bool des cases menacées par les ennemis), threatened (grille bool des cases menacées au contact par les ennemis —
AoO), ranged_threatened (U6d : grille bool des cases sous tir ennemi à
distance — champ de tir des armes à distance utilisables des ennemis
actifs (portée maximale + ligne d'effet, bornée à la carte) ; les armes à
distance ne menacent pas pour les AoO en PF1e : c'est une carte de champ
de tir, pas une menace d'AoO ; la grille `threatened` de mêlée reste
inchangée),
initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round
courant — le premier est le prochain à agir ; les invocations héritent de la courant — le premier est le prochain à agir ; les invocations héritent de la
case de leur conjurateur), case de leur conjurateur),
+80 -1
View File
@@ -507,7 +507,14 @@ class Observation:
width boolean grid whose cells are True when threatened by at least one width boolean grid whose cells are True when threatened by at least one
ENEMY of the acting combatant (active, opposite side, with a melee ENEMY of the acting combatant (active, opposite side, with a melee
weapon); the union is bounded to the grid — out-of-grid threatened cells 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 on the map (sorted by type for determinism), carrying
``{type, move_cost, blocks_los, cover}`` — derived from ``{type, move_cost, blocks_los, cover}`` — derived from
``grid.terrain_at((r, c))`` over the entire map (the Grid does not expose ``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, ...], ...] terrain: tuple[tuple[str | None, ...], ...]
elevation: tuple[tuple[int, ...], ...] elevation: tuple[tuple[int, ...], ...]
threatened: tuple[tuple[bool, ...], ...] = () threatened: tuple[tuple[bool, ...], ...] = ()
ranged_threatened: tuple[tuple[bool, ...], ...] = ()
terrain_semantics: tuple[dict[str, object], ...] = () terrain_semantics: tuple[dict[str, object], ...] = ()
condition_definitions: tuple[tuple[str, dict[str, object]], ...] = () condition_definitions: tuple[tuple[str, dict[str, object]], ...] = ()
initiative_order: tuple[str, ...] = () initiative_order: tuple[str, ...] = ()
@@ -537,6 +545,7 @@ class Observation:
"terrain": self.terrain, "terrain": self.terrain,
"elevation": self.elevation, "elevation": self.elevation,
"threatened": self.threatened, "threatened": self.threatened,
"ranged_threatened": self.ranged_threatened,
"terrain_semantics": self.terrain_semantics, "terrain_semantics": self.terrain_semantics,
"condition_definitions": self.condition_definitions, "condition_definitions": self.condition_definitions,
"initiative_order": self.initiative_order, "initiative_order": self.initiative_order,
@@ -839,6 +848,7 @@ class CombatEngine:
else: else:
elevation = () elevation = ()
threatened = self._threat_grid(state) threatened = self._threat_grid(state)
ranged_threatened = self._ranged_threat_grid(state)
terrain_semantics = self._terrain_semantics() terrain_semantics = self._terrain_semantics()
condition_definitions = tuple( condition_definitions = tuple(
(name, _condition_definition_dict(cond)) (name, _condition_definition_dict(cond))
@@ -855,6 +865,7 @@ class CombatEngine:
terrain=terrain, terrain=terrain,
elevation=elevation, elevation=elevation,
threatened=threatened, threatened=threatened,
ranged_threatened=ranged_threatened,
terrain_semantics=terrain_semantics, terrain_semantics=terrain_semantics,
condition_definitions=condition_definitions, condition_definitions=condition_definitions,
initiative_order=initiative_order, initiative_order=initiative_order,
@@ -884,6 +895,74 @@ class CombatEngine:
for r in range(self._grid.height) 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], ...]: def _terrain_semantics(self) -> tuple[dict[str, object], ...]:
"""One dict per distinct ``TerrainType`` on the map (sorted by type). """One dict per distinct ``TerrainType`` on the map (sorted by type).
+4 -2
View File
@@ -1042,8 +1042,9 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
"""Given: an Observation built for the L5 fields """Given: an Observation built for the L5 fields
When: the top-level dict is inspected When: the top-level dict is inspected
Then: all pre-existing top-level keys remain (round, acting_combatant, Then: all pre-existing top-level keys remain (round, acting_combatant,
combatants, pairs, terrain, elevation) and the two new keys combatants, pairs, terrain, elevation) and the new keys
(threatened, terrain_semantics) are added — exact key set.""" (threatened, ranged_threatened, terrain_semantics,
condition_definitions, initiative_order) are added — exact key set."""
hero = make_combatant("hero") hero = make_combatant("hero")
foe = make_combatant("foe") foe = make_combatant("foe")
hero_state = make_state(hero, "players", (0, 0)) hero_state = make_state(hero, "players", (0, 0))
@@ -1059,6 +1060,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None:
"terrain", "terrain",
"elevation", "elevation",
"threatened", "threatened",
"ranged_threatened",
"terrain_semantics", "terrain_semantics",
"condition_definitions", "condition_definitions",
"initiative_order", "initiative_order",
+284
View File
@@ -0,0 +1,284 @@
"""U6d: top-level ``ranged_threatened`` grid in the Observation snapshot.
The snapshot gains one additive top-level key: a height x width boolean grid
marking each cell that is 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 (max range = increment x 10 increments,
x 5 for touch, same rule as ``_weapon_usable_against``) 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,
and the melee ``threatened`` grid is untouched by it.
Covers:
- (a) an enemy with a ranged weapon marks cells within range with LoE;
- (b) cells beyond the weapon's maximum range are not marked;
- (c) a wall blocking line of effect prevents marking;
- (d) no active ranged enemy -> all-False grid;
- (e) a touch weapon uses the 5-increment touch cap (and melee touch, without
a range increment, marks nothing);
- (f) ``to_dict()`` stays JSON-serializable with the new key;
- (g) the melee ``threatened`` grid is unchanged by a ranged enemy.
"""
from __future__ import annotations
import json
from typing import Literal
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from tests.test_observation_enriched import _snapshot, make_state
_LEGEND = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
def _grid(rows: list[str]) -> Grid:
spec = MapSpec(name="ranged-threat-test", terrain=tuple(rows), legend=_LEGEND)
return Grid.from_spec(spec)
def _open_rows(size: int = 8) -> list[str]:
return ["." * size] * size
def _engine(states: list[CombatantState], rows: list[str]) -> CombatEngine:
return CombatEngine(ScriptedRng([]), _grid(rows), states)
def _ranged_weapon(
cid: str, kind: Literal["ranged", "touch"], increment_ft: int | None
) -> AttackSpec:
return AttackSpec(
id=f"{cid}-w",
name="short bow" if kind == "ranged" else "dagger",
kind=kind,
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])],
range_increment_ft=increment_ft,
)
def _sword(cid: str) -> AttackSpec:
return AttackSpec(
id=f"{cid}-w",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
)
def _combatant(cid: str, attack: AttackSpec) -> Combatant:
return Combatant(
id=cid,
name=cid,
level=1,
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=10,
int_score=10,
wis_score=10,
cha_score=10,
),
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def _squares(a: tuple[int, int], b: tuple[int, int]) -> int:
"""Independent 5-10-5 distance in squares (max + min // 2)."""
d_row = abs(a[0] - b[0])
d_col = abs(a[1] - b[1])
return max(d_row, d_col) + min(d_row, d_col) // 2
# (a) + (b) range coverage: a 5-ft-increment bow (max 10 x 5 = 50 ft = 10
# squares) fired from (0, 0) on a 12x12 open grid marks exactly the cells at
# 5-10-5 distance <= 10 — and nothing beyond.
def test_ranged_threat_marks_cells_within_range() -> None:
"""Given: an archer at (0, 0) with a 5-ft-increment ranged weapon
When: the Observation is built for the melee actor at (6, 6)
Then: every cell at 5-10-5 distance <= 10 squares (50 ft) from the archer
is marked, every farther cell is not; the grid is height x width."""
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 5)), "monsters", (0, 0)
)
hero = make_state(_combatant("hero", _sword("hero")), "players", (6, 6))
engine = _engine([hero, archer], _open_rows(12))
obs = _snapshot(engine, hero)
grid = obs["ranged_threatened"]
assert len(grid) == 12
assert all(len(row) == 12 for row in grid)
for r in range(12):
for c in range(12):
expected = _squares((0, 0), (r, c)) <= 10
assert grid[r][c] is expected, f"cell {(r, c)}: expected {expected}"
# Spot checks: the archer's own square is under its own field of fire
# (a shooter can target a square it occupies), the boundary at exactly
# 50 ft is in, the next square out is not.
assert grid[0][0] is True
assert grid[1][10] is True
assert grid[2][10] is False
assert grid[11][11] is False
# (c) line of effect: a full wall row between the archer and the far side
# prevents marking even though every cell is well within range.
def test_ranged_threat_wall_blocks_line_of_effect() -> None:
"""Given: an archer at (0, 0) and a wall row at r=3 across the whole map
When: the Observation is built for an actor behind the wall
Then: near-side cells are marked, far-side cells are not (no LoE), while
the melee threat grid semantics stay out of scope here."""
rows = _open_rows()
rows[3] = "#" * 8
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
hero = make_state(_combatant("hero", _sword("hero")), "players", (7, 0))
engine = _engine([hero, archer], rows)
grid = _snapshot(engine, hero)["ranged_threatened"]
# Near side (rows 0..2): within 600 ft and open line of effect.
assert grid[0][0] is True
assert grid[2][2] is True
assert grid[0][7] is True
# Far side (rows 4..7): the wall row blocks every corner line.
assert grid[4][0] is False
assert grid[5][0] is False
assert grid[7][7] is False
# (d) no ranged enemy: a melee-only enemy and an inactive archer both leave
# the grid all-False.
def test_ranged_threat_empty_without_ranged_enemies() -> None:
"""Given: one melee-only enemy and one dead (inactive) archer
When: the Observation is built for the actor
Then: ranged_threatened is a full False grid (inactive enemies are
ignored; melee weapons are out of scope)."""
melee_foe = make_state(_combatant("brute", _sword("brute")), "monsters", (7, 7))
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
archer.hp = 0 # inactive: hp <= 0
hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
engine = _engine([hero, melee_foe, archer], _open_rows())
grid = _snapshot(engine, hero)["ranged_threatened"]
assert len(grid) == 8
for row in grid:
assert all(cell is False for cell in row)
# (e) touch cap: a thrown touch weapon reaches 5 increments, not 10; a melee
# touch (no range increment) is not ranged-style and marks nothing.
def test_ranged_threat_touch_uses_touch_increment_cap() -> None:
"""Given: a thrower at (0, 0) with a 10-ft-increment touch weapon
When: the Observation is built on a 12x12 grid
Then: coverage stops at 5 increments (50 ft = 10 squares) — the cell at
11 squares is NOT marked, unlike the same weapon as kind ranged (10
increments = 100 ft); a melee touch weapon marks nothing."""
thrower = make_state(
_combatant("thrower", _ranged_weapon("thrower", "touch", 10)), "monsters", (0, 0)
)
hero = make_state(_combatant("hero", _sword("hero")), "players", (6, 6))
engine = _engine([hero, thrower], _open_rows(12))
grid = _snapshot(engine, hero)["ranged_threatened"]
assert grid[1][10] is True # 10 squares = 50 ft, exactly at the cap
assert grid[2][10] is False # 11 squares = 55 ft, beyond 5 increments
shooter = make_state(
_combatant("thrower", _ranged_weapon("thrower", "ranged", 10)), "monsters", (0, 0)
)
engine2 = _engine([hero, shooter], _open_rows(12))
grid2 = _snapshot(engine2, hero)["ranged_threatened"]
assert grid2[2][10] is True # ranged gets 10 increments = 100 ft
assert grid2[11][11] is True # 16 squares = 80 ft, still in range
melee_touch = make_state(
_combatant("lich", _ranged_weapon("lich", "touch", None)), "monsters", (0, 0)
)
engine3 = _engine([hero, melee_touch], _open_rows(12))
grid3 = _snapshot(engine3, hero)["ranged_threatened"]
assert all(cell is False for row in grid3 for cell in row)
# (f) the new key is JSON-serializable like the rest of the snapshot.
def test_ranged_threat_to_dict_json_serializable() -> None:
"""Given: a snapshot taken while an enemy archer covers the map
When: to_dict() is serialized to JSON
Then: ranged_threatened is present, round-trips, and holds plain bools."""
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
engine = _engine([hero, archer], _open_rows())
obs = _snapshot(engine, hero)
assert "ranged_threatened" in obs
round_tripped = json.loads(json.dumps(obs))
assert round_tripped["ranged_threatened"] == [
list(row) for row in obs["ranged_threatened"]
]
assert all(isinstance(cell, bool) for row in obs["ranged_threatened"] for cell in row)
# (g) the melee threat grid is untouched: adding a ranged-only enemy changes
# only ranged_threatened, never threatened.
def test_ranged_threat_melee_grid_unchanged() -> None:
"""Given: a knight (melee) and an archer (ranged) as enemies of the actor
When: the Observation is built with and without the archer
Then: the threatened grids are identical (the archer adds no melee threat,
the knight's disk is unchanged) while ranged_threatened differs."""
knight = make_state(_combatant("knight", _sword("knight")), "monsters", (7, 7))
hero = make_state(_combatant("hero", _sword("hero")), "players", (4, 4))
archer = make_state(
_combatant("archer", _ranged_weapon("archer", "ranged", 60)), "monsters", (0, 0)
)
with_archer = _snapshot(_engine([hero, knight, archer], _open_rows()), hero)
without_archer = _snapshot(_engine([hero, knight], _open_rows()), hero)
assert with_archer["threatened"] == without_archer["threatened"]
melee = with_archer["threatened"]
# The knight's 1-cell melee disk, and nothing from the archer's field.
assert melee[6][6] is True
assert melee[7][7] is False # own square is never threatened
assert melee[0][0] is False
assert without_archer["ranged_threatened"] == tuple(
tuple(False for _ in range(8)) for _ in range(8)
)
assert with_archer["ranged_threatened"][0][0] is True