From 6abd29cbc53c209dc7793dc73c196d0de74d4d73 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 10 Sep 2026 13:57:34 +0200 Subject: [PATCH] feat(observation): expose attack failure reasons --- README.md | 9 +- src/pf1e_simulator/combat.py | 45 ++++- tests/test_observation_enriched.py | 2 + tests/test_pair_failure_reasons.py | 253 +++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 tests/test_pair_failure_reasons.py diff --git a/README.md b/README.md index 1641638..615e947 100644 --- a/README.md +++ b/README.md @@ -660,7 +660,14 @@ Règles modélisées : cover_bonus (int — bonus de couvert que la cible gagne contre une attaque de l'acteur, calculé comme dans `resolve_attack` : terrain +2/+4/+8 via la règle des coins, couvert mou +4 des créatures interposées pour les seules - attaques à distance ; 0 si `weapon_for` est None). + attaques à distance ; 0 si `weapon_for` est None), weapon_reason (str | + None — None si `weapon_for` a choisi une arme ; sinon la raison normalisée + de l'indisponibilité d'attaque, dans le vocabulaire `ACTION_RESULT_REASONS` : + `no_weapon` (aucune arme utilisable), `no_line_of_effect` (ligne d'effet + bloquée), `out_of_range` (arme existante mais cible hors de portée)), + charge_reason (str | None — None si `can_charge` est vrai ; sinon la + 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), initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4ed4b84..c0c24e6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -576,8 +576,9 @@ class Action: # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # missing_target and the downgraded:* reasons; no_weapon, out_of_range, -# no_line_of_effect, no_slot and insufficient_resource are declared here for -# the deeper execution-feedback package (U6b). U3 adds delayed / +# no_line_of_effect are reused by U6b to explain, per pair, why an attack or +# a charge is unavailable. no_slot and insufficient_resource are declared +# here for the deeper execution-feedback package. U3 adds delayed / # already_delayed for the delay action. U4 adds ready for the ready action. # U5b adds not_prone / already_prone for the prone actions. U5c adds # unreachable_destination for move / 5foot_step with an explicit destination. @@ -604,6 +605,9 @@ _REASON_NOT_PRONE = "not_prone" _REASON_ALREADY_PRONE = "already_prone" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination" _REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" +_REASON_NO_WEAPON = "no_weapon" +_REASON_OUT_OF_RANGE = "out_of_range" +_REASON_NO_LINE_OF_EFFECT = "no_line_of_effect" # Action kinds that are declared but have no engine dispatch (U1 surfaces # them as explicit rejections instead of silent no-ops). @@ -1016,6 +1020,16 @@ class CombatEngine: charge = self.can_charge(state, target) can_charge = charge is not None and charge.weapon is not None occupied = state.occupied_cells() + line_of_effect = has_line_of_effect_cells( + self._grid, occupied, target.occupied_cells() + ) + weapon_reason = self._weapon_failure_reason( + state, weapon, line_of_effect=line_of_effect + ) + charge_reason: str | None = None + if not can_charge: + # can_charge() returns None only when no melee weapon is usable. + charge_reason = charge.reason if charge is not None else _REASON_NO_WEAPON target_threatens_me = bool( target.active and any(w.kind == "melee" for w in target.usable_attacks()) @@ -1044,12 +1058,12 @@ class CombatEngine: "target_index": self._states.index(target), "distance_ft": distance_ft, "weapon_for": weapon_name, + "weapon_reason": weapon_reason, "range_penalty": range_penalty, "can_charge": can_charge, + "charge_reason": charge_reason, "can_step5_to_attack": self.can_step5_to_attack(state, target), - "has_line_of_effect": has_line_of_effect_cells( - self._grid, state.occupied_cells(), target.occupied_cells() - ), + "has_line_of_effect": line_of_effect, "flanking": self._flanking_bonus(state, target) > 0, "movement_cost_ft": self.movement_cost_ft(state, target), "target_threatens_me": target_threatens_me, @@ -1057,6 +1071,27 @@ class CombatEngine: "cover_bonus": cover_bonus, } + def _weapon_failure_reason( + self, + attacker: CombatantState, + weapon: AttackSpec | None, + *, + line_of_effect: bool, + ) -> str | None: + """Why ``weapon_for`` found no weapon against a target, or None if it did. + + U6b: the cause is drawn from the ``ACTION_RESULT_REASONS`` vocabulary + — no usable weapon at all, no line of effect, or every usable weapon + out of range — so a policy learns which obstacle to remove. + """ + if weapon is not None: + return None + if not attacker.usable_attacks(): + return _REASON_NO_WEAPON + if not line_of_effect: + return _REASON_NO_LINE_OF_EFFECT + return _REASON_OUT_OF_RANGE + def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" self._decrement_durations(state) diff --git a/tests/test_observation_enriched.py b/tests/test_observation_enriched.py index a000f50..9a26e32 100644 --- a/tests/test_observation_enriched.py +++ b/tests/test_observation_enriched.py @@ -676,8 +676,10 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None: "target_index", "distance_ft", "weapon_for", + "weapon_reason", "range_penalty", "can_charge", + "charge_reason", "can_step5_to_attack", "has_line_of_effect", "flanking", diff --git a/tests/test_pair_failure_reasons.py b/tests/test_pair_failure_reasons.py new file mode 100644 index 0000000..49b32b6 --- /dev/null +++ b/tests/test_pair_failure_reasons.py @@ -0,0 +1,253 @@ +"""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