"""U6a: per-pair ``cover_bonus`` in the Observation snapshot. The pair dict now exposes the cover bonus the target would gain against an attack from the acting combatant, computed with the same ``cover_bonus_cells`` call ``resolve_attack`` applies to AC (terrain cover +2/+4/+8 via the corner rule, soft cover +4 from interposed creatures on ranged attacks only). ``weapon_for`` decides the ranged flag; no usable weapon means ``cover_bonus`` 0. Covers: - (a) pillar (``cover: true``) between actor and target -> 4; - (b) improved-cover terrain (``cover_bonus: 8``) -> 8; - (c) partial-cover terrain (``cover_bonus: 2``) -> 2; - (d) creature interposed on a ranged line -> soft cover 4 for a ranged weapon, 0 for a melee-only actor (melee ignores creatures); - (e) open field -> 0; - (f) pairs exist only for the acting combatant, each carrying the key; - (g) ``to_dict()`` stays JSON-serializable. """ from __future__ import annotations import json 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 _pair_for, _snapshot, make_state _LEGEND = { ".": TerrainType(type="floor", move_cost=1), "C": TerrainType(type="pillar", move_cost=None, cover=True), "W": TerrainType(type="ruin", move_cost=None, cover_bonus=2), "I": TerrainType(type="thicket", move_cost=1, cover_bonus=8), } def _grid(rows: list[str]) -> Grid: spec = MapSpec(name="cover-test", terrain=tuple(rows), legend=_LEGEND) return Grid.from_spec(spec) def _open_rows() -> list[str]: return ["........"] * 8 def _bow(cid: str) -> AttackSpec: return AttackSpec( id=f"{cid}-bow", name="short bow", kind="ranged", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["piercing"])], range_increment_ft=60, ) def _sword(cid: str) -> AttackSpec: return AttackSpec( id=f"{cid}-sword", name="short sword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], ) def _spear(cid: str) -> AttackSpec: return AttackSpec( id=f"{cid}-spear", name="longspear", kind="melee", attack_bonus=5, reach_ft=10, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], ) 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 _engine(states: list[CombatantState], rows: list[str]) -> CombatEngine: return CombatEngine(ScriptedRng([]), _grid(rows), states) def _row_with(marker: str, col: int) -> str: return "." * col + marker + "." * (7 - col) # (a) standard terrain cover: pillar between the archer and the target. def test_pair_cover_pillar_grants_four() -> None: """Given: an archer at (1,0), a pillar at (1,2), a target at (1,4) When: the Observation is built for the archer Then: the pair exposes cover_bonus 4 (standard terrain cover).""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) rows = _open_rows() rows[1] = _row_with("C", 2) engine = _engine([archer, foe], rows) obs = _snapshot(engine, archer) pair = _pair_for(obs, 1) assert pair["weapon_for"] == "short bow" assert pair["has_line_of_effect"] is True assert pair["cover_bonus"] == 4 # (b) improved cover terrain. def test_pair_cover_improved_grants_eight() -> None: """Given: an archer shooting through a thicket (cover_bonus: 8) When: the Observation is built Then: the pair exposes cover_bonus 8.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) rows = _open_rows() rows[1] = _row_with("I", 2) engine = _engine([archer, foe], rows) obs = _snapshot(engine, archer) assert _pair_for(obs, 1)["cover_bonus"] == 8 # (c) partial cover terrain. def test_pair_cover_partial_grants_two() -> None: """Given: an archer shooting past low ruins (cover_bonus: 2) When: the Observation is built Then: the pair exposes cover_bonus 2.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) rows = _open_rows() rows[1] = _row_with("W", 2) engine = _engine([archer, foe], rows) obs = _snapshot(engine, archer) assert _pair_for(obs, 1)["cover_bonus"] == 2 # (d) soft cover from an interposed creature. def test_pair_cover_soft_creature_grants_four_for_ranged() -> None: """Given: an archer at (1,0), a creature at (1,2), a target at (1,4) When: the Observation is built for the archer (ranged weapon) Then: the interposed creature grants soft cover 4.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 2)) engine = _engine([archer, foe, bystander], _open_rows()) obs = _snapshot(engine, archer) assert _pair_for(obs, 1)["cover_bonus"] == 4 def test_pair_cover_soft_creature_ignored_for_melee() -> None: """Given: a reach spear user at (1,0), a creature at (1,1), a target at (1,2) When: the Observation is built for the spear user (melee attack) Then: melee ignores creatures — cover_bonus 0.""" striker = make_state(_combatant("spear", _spear("spear")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 2)) bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 1)) engine = _engine([striker, foe, bystander], _open_rows()) obs = _snapshot(engine, striker) pair = _pair_for(obs, 1) assert pair["weapon_for"] == "longspear" assert pair["distance_ft"] == 10 assert pair["cover_bonus"] == 0 # (e) open field. def test_pair_cover_open_field_is_zero() -> None: """Given: an archer and a target with nothing between them When: the Observation is built Then: cover_bonus is 0.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) engine = _engine([archer, foe], _open_rows()) obs = _snapshot(engine, archer) assert _pair_for(obs, 1)["cover_bonus"] == 0 # (f) scope: pairs only exist for the acting combatant, each with the key. def test_pair_cover_only_actors_pairs_present() -> None: """Given: three combatants, the observation built for the bystander When: the pairs are inspected Then: there is one pair per other combatant (none for the actor itself) and every pair carries cover_bonus; the archer's own pairs are not exposed from the bystander's point of view.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) bystander = make_state(_combatant("guard", _sword("guard")), "monsters", (1, 2)) engine = _engine([archer, foe, bystander], _open_rows()) obs = _snapshot(engine, bystander) indices = [p["target_index"] for p in obs["pairs"]] assert indices == [0, 1] for pair in obs["pairs"]: assert "cover_bonus" in pair # From the bystander's seat the archer is 10 ft left and the foe 10 ft # right; nothing interposes on either line (the bystander itself is # excluded from the occupied set of its own pairs). assert _pair_for(obs, 0)["cover_bonus"] == 0 assert _pair_for(obs, 1)["cover_bonus"] == 0 def test_pair_cover_zero_when_no_usable_weapon() -> None: """Given: a melee-only actor facing a target 20 ft away behind a pillar When: the Observation is built Then: weapon_for is None and cover_bonus falls back to 0.""" striker = make_state(_combatant("swordsman", _sword("swordsman")), "players", (1, 0)) foe = make_state(_combatant("foe", _bow("foe")), "monsters", (1, 4)) rows = _open_rows() rows[1] = _row_with("C", 2) engine = _engine([striker, foe], rows) obs = _snapshot(engine, striker) pair = _pair_for(obs, 1) assert pair["weapon_for"] is None assert pair["cover_bonus"] == 0 # (g) JSON serializability of the extended snapshot. def test_pair_cover_snapshot_is_json_serializable() -> None: """Given: a snapshot whose pairs carry cover_bonus When: to_dict() is dumped to JSON Then: serialization round-trips and keeps the new key.""" archer = make_state(_combatant("archer", _bow("archer")), "players", (1, 0)) foe = make_state(_combatant("foe", _sword("foe")), "monsters", (1, 4)) rows = _open_rows() rows[1] = _row_with("C", 2) engine = _engine([archer, foe], rows) obs = _snapshot(engine, archer) data = json.loads(json.dumps(obs)) assert _pair_for(data, 1)["cover_bonus"] == 4