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