feat(observation): expose attack failure reasons
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user