feat(observation): expose attack failure reasons

This commit is contained in:
2026-09-10 13:57:34 +02:00
parent 8f3bd0d0e0
commit 6abd29cbc5
4 changed files with 303 additions and 6 deletions
+8 -1
View File
@@ -660,7 +660,14 @@ Règles modélisées :
cover_bonus (int — bonus de couvert que la cible gagne contre une attaque 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 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 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, - 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 par les ennemis),
initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round initiative_order (U2 : tuple d'ids dans l'ordre d'initiative du round
+40 -5
View File
@@ -576,8 +576,9 @@ class Action:
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
# missing_target and the downgraded:* reasons; no_weapon, out_of_range, # missing_target and the downgraded:* reasons; no_weapon, out_of_range,
# no_line_of_effect, no_slot and insufficient_resource are declared here for # no_line_of_effect are reused by U6b to explain, per pair, why an attack or
# the deeper execution-feedback package (U6b). U3 adds delayed / # 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. # 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 # U5b adds not_prone / already_prone for the prone actions. U5c adds
# unreachable_destination for move / 5foot_step with an explicit destination. # 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_ALREADY_PRONE = "already_prone"
_REASON_UNREACHABLE_DESTINATION = "unreachable_destination" _REASON_UNREACHABLE_DESTINATION = "unreachable_destination"
_REASON_WEAPON_UNAVAILABLE = "weapon_unavailable" _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 # Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops). # them as explicit rejections instead of silent no-ops).
@@ -1016,6 +1020,16 @@ class CombatEngine:
charge = self.can_charge(state, target) charge = self.can_charge(state, target)
can_charge = charge is not None and charge.weapon is not None can_charge = charge is not None and charge.weapon is not None
occupied = state.occupied_cells() 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_threatens_me = bool(
target.active target.active
and any(w.kind == "melee" for w in target.usable_attacks()) and any(w.kind == "melee" for w in target.usable_attacks())
@@ -1044,12 +1058,12 @@ class CombatEngine:
"target_index": self._states.index(target), "target_index": self._states.index(target),
"distance_ft": distance_ft, "distance_ft": distance_ft,
"weapon_for": weapon_name, "weapon_for": weapon_name,
"weapon_reason": weapon_reason,
"range_penalty": range_penalty, "range_penalty": range_penalty,
"can_charge": can_charge, "can_charge": can_charge,
"charge_reason": charge_reason,
"can_step5_to_attack": self.can_step5_to_attack(state, target), "can_step5_to_attack": self.can_step5_to_attack(state, target),
"has_line_of_effect": has_line_of_effect_cells( "has_line_of_effect": line_of_effect,
self._grid, state.occupied_cells(), target.occupied_cells()
),
"flanking": self._flanking_bonus(state, target) > 0, "flanking": self._flanking_bonus(state, target) > 0,
"movement_cost_ft": self.movement_cost_ft(state, target), "movement_cost_ft": self.movement_cost_ft(state, target),
"target_threatens_me": target_threatens_me, "target_threatens_me": target_threatens_me,
@@ -1057,6 +1071,27 @@ class CombatEngine:
"cover_bonus": cover_bonus, "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: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """Execute one combatant's full turn; return True if the battle is over."""
self._decrement_durations(state) self._decrement_durations(state)
+2
View File
@@ -676,8 +676,10 @@ def test_observation_enriched_pair_existing_keys_preserved() -> None:
"target_index", "target_index",
"distance_ft", "distance_ft",
"weapon_for", "weapon_for",
"weapon_reason",
"range_penalty", "range_penalty",
"can_charge", "can_charge",
"charge_reason",
"can_step5_to_attack", "can_step5_to_attack",
"has_line_of_effect", "has_line_of_effect",
"flanking", "flanking",
+253
View File
@@ -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