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