diff --git a/README.md b/README.md index 3978302..282aa4a 100644 --- a/README.md +++ b/README.md @@ -726,7 +726,13 @@ Règles modélisées : raison de `ChargeResult` verbatim (`no_line_of_effect` / `no_charge_path`) ou `no_weapon` si aucune arme de mêlée utilisable). - 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 courant — le premier est le prochain à agir ; les invocations héritent de la case de leur conjurateur), diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b639bef..1d7e32b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -507,7 +507,14 @@ class Observation: 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 + 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 ``{type, move_cost, blocks_los, cover}`` — derived from ``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, ...], ...] elevation: tuple[tuple[int, ...], ...] threatened: tuple[tuple[bool, ...], ...] = () + ranged_threatened: tuple[tuple[bool, ...], ...] = () terrain_semantics: tuple[dict[str, object], ...] = () condition_definitions: tuple[tuple[str, dict[str, object]], ...] = () initiative_order: tuple[str, ...] = () @@ -537,6 +545,7 @@ class Observation: "terrain": self.terrain, "elevation": self.elevation, "threatened": self.threatened, + "ranged_threatened": self.ranged_threatened, "terrain_semantics": self.terrain_semantics, "condition_definitions": self.condition_definitions, "initiative_order": self.initiative_order, @@ -839,6 +848,7 @@ class CombatEngine: else: elevation = () threatened = self._threat_grid(state) + ranged_threatened = self._ranged_threat_grid(state) terrain_semantics = self._terrain_semantics() condition_definitions = tuple( (name, _condition_definition_dict(cond)) @@ -855,6 +865,7 @@ class CombatEngine: terrain=terrain, elevation=elevation, threatened=threatened, + ranged_threatened=ranged_threatened, terrain_semantics=terrain_semantics, condition_definitions=condition_definitions, initiative_order=initiative_order, @@ -884,6 +895,74 @@ class CombatEngine: 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], ...]: """One dict per distinct ``TerrainType`` on the map (sorted by type). diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index 5a81a50..938bc61 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -1042,8 +1042,9 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None: """Given: an Observation built for the L5 fields When: the top-level dict is inspected Then: all pre-existing top-level keys remain (round, acting_combatant, - combatants, pairs, terrain, elevation) and the two new keys - (threatened, terrain_semantics) are added — exact key set.""" + combatants, pairs, terrain, elevation) and the new keys + (threatened, ranged_threatened, terrain_semantics, + condition_definitions, initiative_order) are added — exact key set.""" hero = make_combatant("hero") foe = make_combatant("foe") hero_state = make_state(hero, "players", (0, 0)) @@ -1059,6 +1060,7 @@ def test_observation_enriched_e5_existing_keys_preserved() -> None: "terrain", "elevation", "threatened", + "ranged_threatened", "terrain_semantics", "condition_definitions", "initiative_order", diff --git a/tests/test_ranged_threat.py b/tests/test_ranged_threat.py new file mode 100644 index 0000000..d605ac7 --- /dev/null +++ b/tests/test_ranged_threat.py @@ -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