feat(combat): expose charge failure reasons via ChargeResult

This commit is contained in:
2026-09-06 10:47:36 +02:00
parent 56c8970d5b
commit 7843756de2
2 changed files with 280 additions and 12 deletions
+36 -12
View File
@@ -330,6 +330,20 @@ class AttackResult:
base_bonus: int = 0
@dataclass(frozen=True)
class ChargeResult:
"""Outcome of a charge feasibility check (public, for policy/LLM reading).
weapon is the melee weapon that would be used when the charge is possible
(reason None); otherwise weapon is None and reason explains the failure.
distance_ft is 0 on failure.
"""
weapon: AttackSpec | None
distance_ft: int
reason: str | None
@dataclass(frozen=True)
class SaveResult:
"""Outcome of one saving throw roll."""
@@ -1344,8 +1358,8 @@ class CombatEngine:
state.pos = orig
return weapon is not None
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
def can_charge(self, state: CombatantState, target: CombatantState) -> ChargeResult | None:
"""Return a ``ChargeResult`` if ``state`` can charge ``target`` this turn, else None.
PF1e charge requirements (CRB):
- Must have line of sight to the target at the start of the turn.
@@ -1354,25 +1368,34 @@ class CombatEngine:
- The path must not pass through blocking terrain, difficult terrain, or creatures.
- Must end adjacent to the target (within melee reach).
- Only a single melee attack is allowed.
Returns ``None`` only when the attacker has no usable melee weapon at all.
Otherwise returns a ``ChargeResult``: ``weapon`` set and ``reason`` None on
success, or ``weapon`` None with ``reason`` explaining the failure
(``no_line_of_effect`` or ``no_charge_path``). ``distance_ft`` is the
straight-line distance in feet to the chosen anchor on success, 0 on
failure. Never mutates state nor consumes RNG.
"""
if not has_line_of_effect_cells(
self._grid, state.occupied_cells(), target.occupied_cells()
):
return None
return ChargeResult(weapon=None, distance_ft=0, reason="no_line_of_effect")
speed_cells = state.combatant.speed_land_ft // _SQUARE_FT
max_cells = 2 * speed_cells
blocked = self._blocked(state, target)
for weapon in state.usable_attacks():
if weapon.kind != "melee":
continue
melee_weapons = [w for w in state.usable_attacks() if w.kind == "melee"]
if not melee_weapons:
return None
for weapon in melee_weapons:
reach_cells = _effective_reach_ft(state, weapon) // _SQUARE_FT
min_reach_cells = weapon.reach_ft // _SQUARE_FT if weapon.reach_ft > _SQUARE_FT else 0
end = self._charge_end(
state, target, reach_cells, max_cells, blocked, min_reach_cells=min_reach_cells
)
if end is not None:
return weapon
return None
distance_ft = self._grid.distance(state.pos, end) * _SQUARE_FT
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
def _charge_end(
self,
@@ -2348,10 +2371,10 @@ class CombatEngine:
if self.has_condition_flag(state, "cant_charge"):
self._log(f"round {self._current_round} {state.combatant.id}: wait (cannot charge)")
return
weapon = self.can_charge(state, target)
if weapon is None:
result = self.can_charge(state, target)
if result is None or result.weapon is None:
return
self._charge(state, target, weapon)
self._charge(state, target, result.weapon)
def _cast_spell(
self, caster: CombatantState, target: CombatantState | None, spell_name: str
@@ -3425,7 +3448,8 @@ def _melee_action(
"""Choose a non-spell action: full-attack, charge, step, or approach."""
if engine.weapon_for(state, target) is not None:
return (Action(kind="full_attack", target_id=target.combatant.id),)
if engine.can_charge(state, target) is not None:
charge = engine.can_charge(state, target)
if charge is not None and charge.weapon is not None:
return (Action(kind="charge", target_id=target.combatant.id),)
if engine.can_step5_to_attack(state, target):
return (