"""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 = -1 # inactive: hp < 0 (0 hp would be disabled-but-active, B11) 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