"""U6b: per-pair attack/charge failure reasons in the Observation snapshot. The pair dict now exposes WHY an attack or a charge is unavailable, not just that it is: ``weapon_reason`` is None when ``weapon_for`` selected a weapon, otherwise the blocking cause (``no_weapon`` / ``no_line_of_effect`` / ``out_of_range``, drawn from the U1 ``ACTION_RESULT_REASONS`` vocabulary); ``charge_reason`` is None when ``can_charge`` is True, otherwise the ``ChargeResult.reason`` verbatim (``no_line_of_effect`` / ``no_charge_path``) or ``no_weapon`` when the actor has no usable melee weapon at all. Covers: - (a) unarmed actor -> ``weapon_reason`` / ``charge_reason`` ``no_weapon``; - (b) wall column blocking the line -> ``no_line_of_effect``; - (c) melee-only actor facing a distant target -> ``out_of_range``; - (d) valid adjacent target -> ``weapon_reason`` None; - (e) creatures interposed on every straight charge line -> the engine's ``no_charge_path``; - (f) valid charge line -> ``charge_reason`` None; ranged-only actor -> ``no_weapon``; - (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), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } def _grid(rows: list[str]) -> Grid: spec = MapSpec(name="reasons-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 _combatant(cid: str, attacks: list[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=attacks, ) def _engine(states: list[CombatantState], rows: list[str]) -> CombatEngine: return CombatEngine(ScriptedRng([]), _grid(rows), states) # (a) no usable weapon at all. def test_weapon_reason_no_weapon_when_actor_unarmed() -> None: """Given: an actor with no attacks at all facing an adjacent foe When: the Observation is built for the actor Then: weapon_for is None with weapon_reason no_weapon, and the charge helper's no-melee-weapon None maps to charge_reason no_weapon too.""" actor = make_state(_combatant("unarmed", []), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 1)) engine = _engine([actor, foe], _open_rows()) obs = _snapshot(engine, actor) pair = _pair_for(obs, 1) assert pair["weapon_for"] is None assert pair["weapon_reason"] == "no_weapon" assert pair["can_charge"] is False assert pair["charge_reason"] == "no_weapon" # (b) line of effect blocked by a wall column. def test_weapon_reason_no_line_of_effect_behind_wall_column() -> None: """Given: an archer at (4, 0) and a foe at (4, 4) behind a full wall column When: the Observation is built for the archer Then: weapon_reason and charge_reason are both no_line_of_effect.""" archer = make_state(_combatant("archer", [_bow("archer")]), "players", (4, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (4, 4)) rows = ["..#....."] * 8 engine = _engine([archer, foe], rows) obs = _snapshot(engine, archer) pair = _pair_for(obs, 1) assert pair["has_line_of_effect"] is False assert pair["weapon_for"] is None assert pair["weapon_reason"] == "no_line_of_effect" assert pair["can_charge"] is False assert pair["charge_reason"] == "no_line_of_effect" # (c) weapon usable but the target out of reach. def test_weapon_reason_out_of_range_with_clear_line() -> None: """Given: a melee-only actor at (1, 0) and a foe at (1, 4) on open ground When: the Observation is built for the actor Then: weapon_reason is out_of_range (weapon exists, LoE is clear).""" striker = make_state(_combatant("swordsman", [_sword("swordsman")]), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4)) engine = _engine([striker, foe], _open_rows()) obs = _snapshot(engine, striker) pair = _pair_for(obs, 1) assert pair["has_line_of_effect"] is True assert pair["weapon_for"] is None assert pair["weapon_reason"] == "out_of_range" # (d) a valid attack carries no reason. def test_weapon_reason_none_for_valid_adjacent_attack() -> None: """Given: an armed actor adjacent to a foe on open ground When: the Observation is built Then: weapon_for names the weapon and weapon_reason is None.""" actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 1)) engine = _engine([actor, foe], _open_rows()) obs = _snapshot(engine, actor) pair = _pair_for(obs, 1) assert pair["weapon_for"] == "short sword" assert pair["weapon_reason"] is None # (e) charge blocked by interposed creatures despite a clear line of effect. def test_charge_reason_no_charge_path_when_creatures_block_the_line() -> None: """Given: an actor at (1, 0), a foe at (1, 3), creatures at (1, 1), (0, 2) and (2, 2) — every straight Bresenham line to any attackable anchor passes through one of them, while terrain LoE stays clear When: the Observation is built for the actor Then: can_charge is False and charge_reason is the engine's no_charge_path.""" actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 3)) guard_a = make_state(_combatant("guard-a", [_sword("guard-a")]), "monsters", (1, 1)) guard_b = make_state(_combatant("guard-b", [_sword("guard-b")]), "monsters", (0, 2)) guard_c = make_state(_combatant("guard-c", [_sword("guard-c")]), "monsters", (2, 2)) engine = _engine([actor, foe, guard_a, guard_b, guard_c], _open_rows()) obs = _snapshot(engine, actor) pair = _pair_for(obs, 1) assert pair["has_line_of_effect"] is True assert pair["can_charge"] is False assert pair["charge_reason"] == "no_charge_path" # (f) valid charge, and the ranged-only charge refusal. def test_charge_reason_none_for_valid_charge() -> None: """Given: an armed actor at (1, 0) and a foe at (1, 4) on an open straight line When: the Observation is built Then: can_charge is True and charge_reason is None.""" actor = make_state(_combatant("hero", [_sword("hero")]), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4)) engine = _engine([actor, foe], _open_rows()) obs = _snapshot(engine, actor) pair = _pair_for(obs, 1) assert pair["can_charge"] is True assert pair["charge_reason"] is None def test_charge_reason_no_weapon_for_ranged_only_actor() -> None: """Given: a bow-only actor facing a foe 20 ft away on open ground When: the Observation is built Then: the attack is valid (weapon_reason None) but the charge helper returns None (no melee weapon) -> charge_reason no_weapon.""" 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) pair = _pair_for(obs, 1) assert pair["weapon_for"] == "short bow" assert pair["weapon_reason"] is None assert pair["can_charge"] is False assert pair["charge_reason"] == "no_weapon" # (g) JSON serializability of the extended snapshot. def test_pair_failure_reasons_snapshot_is_json_serializable() -> None: """Given: a snapshot whose pairs carry weapon_reason and charge_reason When: to_dict() is dumped to JSON Then: serialization round-trips and keeps both new keys.""" actor = make_state(_combatant("swordsman", [_sword("swordsman")]), "players", (1, 0)) foe = make_state(_combatant("foe", [_sword("foe")]), "monsters", (1, 4)) engine = _engine([actor, foe], _open_rows()) obs = _snapshot(engine, actor) data = json.loads(json.dumps(obs)) pair = _pair_for(data, 1) assert pair["weapon_reason"] == "out_of_range" assert pair["charge_reason"] is None