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 (
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Tests for L4: ``ChargeResult`` — exposing the cause of a charge failure.
|
||||
|
||||
``can_charge`` no longer returns ``AttackSpec | None`` but a ``ChargeResult``
|
||||
that carries the weapon, the straight-line distance in feet, and a ``reason``
|
||||
(None = success, otherwise an explanatory string). The engine behaviour is
|
||||
unchanged: a result without a weapon means no charge (same decision as before),
|
||||
a result with a weapon means charge (same weapon as before).
|
||||
|
||||
Covers:
|
||||
- Success: ``ChargeResult`` with a weapon, ``reason is None``, ``distance_ft``
|
||||
aligned on 5 ft.
|
||||
- ``_execute_charge`` migration: a successful charge still deals damage and
|
||||
logs ``charge``.
|
||||
- LoE failure: ``reason == "no_line_of_effect"`` and ``_melee_action`` does not
|
||||
pick ``charge``.
|
||||
- ``no_charge_path``: target too close (adjacent) and too far (> 2x speed).
|
||||
- No melee weapon: ``can_charge`` returns literal ``None``.
|
||||
- Determinism: two runs with the same seed produce the same transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
Action,
|
||||
ChargeResult,
|
||||
CombatantState,
|
||||
CombatEngine,
|
||||
_melee_action,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def make_combatant(
|
||||
cid: str,
|
||||
*,
|
||||
size: str = "Medium",
|
||||
reach_ft: int = 5,
|
||||
hp: int = 20,
|
||||
ac: int = 15,
|
||||
attack_bonus: int = 5,
|
||||
speed: int = 30,
|
||||
kind: str = "melee",
|
||||
) -> Combatant:
|
||||
"""Build a single-weapon combatant (melee by default)."""
|
||||
attack = AttackSpec(
|
||||
id=f"{cid}-w",
|
||||
name="longspear" if reach_ft > 5 else "short sword",
|
||||
kind=kind,
|
||||
attack_bonus=attack_bonus,
|
||||
damage=[DamageComponent(formula="1d6", types=["piercing"])],
|
||||
reach_ft=reach_ft,
|
||||
)
|
||||
return Combatant(
|
||||
id=cid,
|
||||
name=cid,
|
||||
level=1,
|
||||
size=size,
|
||||
abilities=AbilityScores(
|
||||
str_score=10,
|
||||
dex_score=10,
|
||||
con_score=10,
|
||||
int_score=10,
|
||||
wis_score=10,
|
||||
cha_score=10,
|
||||
),
|
||||
hp_max=hp,
|
||||
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
|
||||
bab=attack_bonus,
|
||||
initiative_mod=0,
|
||||
speed_land_ft=speed,
|
||||
saves=Saves(fort=0, ref=0, will=0),
|
||||
attacks=[attack],
|
||||
)
|
||||
|
||||
|
||||
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
|
||||
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
|
||||
|
||||
|
||||
def make_grid(n: int = 8) -> Grid:
|
||||
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||
spec = MapSpec(name="test", terrain=tuple(["." * n] * n), legend=legend)
|
||||
return Grid.from_spec(spec)
|
||||
|
||||
|
||||
def make_engine(states: list[CombatantState], grid: Grid | None = None) -> CombatEngine:
|
||||
return CombatEngine(ScriptedRng([]), grid or make_grid(), states)
|
||||
|
||||
|
||||
def _wall_grid() -> Grid:
|
||||
"""8x8 grid with a wall cell at (0, 2) blocking LoS between (0,0) and (0,4)."""
|
||||
legend = {
|
||||
".": TerrainType(type="floor", move_cost=1),
|
||||
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
|
||||
}
|
||||
rows = []
|
||||
for r in range(8):
|
||||
row = list("........")
|
||||
if r == 0:
|
||||
row[2] = "#"
|
||||
rows.append("".join(row))
|
||||
spec = MapSpec(name="test", terrain=tuple(rows), legend=legend)
|
||||
return Grid.from_spec(spec)
|
||||
|
||||
|
||||
# --- Test 1: success ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_charge_result_success() -> None:
|
||||
"""A clear straight-line charge returns a ChargeResult with a weapon."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 2))
|
||||
engine = make_engine([a, b])
|
||||
result = engine.can_charge(a, b)
|
||||
assert isinstance(result, ChargeResult)
|
||||
assert result.weapon is not None
|
||||
assert result.reason is None
|
||||
assert result.distance_ft > 0
|
||||
assert result.distance_ft % 5 == 0
|
||||
|
||||
|
||||
# --- Test 2: _execute_charge migration ---------------------------------------
|
||||
|
||||
|
||||
def _charge_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
if state.combatant.id == "a":
|
||||
return (Action(kind="charge", target_id="b"),)
|
||||
return (Action(kind="attack", target_id="a"),)
|
||||
|
||||
|
||||
def test_execute_charge_still_deals_damage() -> None:
|
||||
"""A successful charge still moves, hits, and logs ``charge``."""
|
||||
a = make_state(make_combatant("a", hp=20, ac=15, attack_bonus=2, speed=30), "players", (1, 1))
|
||||
b = make_state(make_combatant("b", hp=20, ac=15, attack_bonus=0, speed=0), "monsters", (1, 5))
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 12, 3, 11, 2]),
|
||||
make_grid(),
|
||||
[a, b],
|
||||
round_cap=1,
|
||||
policy=_charge_policy,
|
||||
)
|
||||
result = engine.run()
|
||||
assert b.hp < b.combatant.hp_max
|
||||
assert any("charge" in line for line in result.transcript)
|
||||
|
||||
|
||||
# --- Test 3: LoE failure -----------------------------------------------------
|
||||
|
||||
|
||||
def test_charge_result_no_line_of_effect() -> None:
|
||||
"""A wall blocking LoS yields reason ``no_line_of_effect``."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 4))
|
||||
engine = make_engine([a, b], _wall_grid())
|
||||
result = engine.can_charge(a, b)
|
||||
assert isinstance(result, ChargeResult)
|
||||
assert result.weapon is None
|
||||
assert result.reason == "no_line_of_effect"
|
||||
assert result.distance_ft == 0
|
||||
|
||||
|
||||
def test_melee_action_not_charge_on_no_line_of_effect() -> None:
|
||||
"""With LoS blocked, _melee_action falls back to move+attack, not charge."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 4))
|
||||
engine = make_engine([a, b], _wall_grid())
|
||||
actions = _melee_action(engine, a, b)
|
||||
assert all(action.kind != "charge" for action in actions)
|
||||
|
||||
|
||||
# --- Test 4: no_charge_path --------------------------------------------------
|
||||
|
||||
|
||||
def test_charge_result_no_charge_path_adjacent() -> None:
|
||||
"""An adjacent target (already in reach) yields reason ``no_charge_path``."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 1))
|
||||
engine = make_engine([a, b])
|
||||
result = engine.can_charge(a, b)
|
||||
assert isinstance(result, ChargeResult)
|
||||
assert result.weapon is None
|
||||
assert result.reason == "no_charge_path"
|
||||
assert result.distance_ft == 0
|
||||
|
||||
|
||||
def test_charge_result_no_charge_path_too_far() -> None:
|
||||
"""A target beyond 2x speed yields reason ``no_charge_path``."""
|
||||
a = make_state(make_combatant("a"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 20))
|
||||
engine = make_engine([a, b], make_grid(30))
|
||||
result = engine.can_charge(a, b)
|
||||
assert isinstance(result, ChargeResult)
|
||||
assert result.weapon is None
|
||||
assert result.reason == "no_charge_path"
|
||||
assert result.distance_ft == 0
|
||||
|
||||
|
||||
# --- Test 5: no melee weapon -------------------------------------------------
|
||||
|
||||
|
||||
def test_charge_result_none_without_melee_weapon() -> None:
|
||||
"""A ranged-only combatant yields literal ``None`` (nothing to charge with)."""
|
||||
a = make_state(make_combatant("a", kind="ranged"), "players", (0, 0))
|
||||
b = make_state(make_combatant("b"), "monsters", (0, 2))
|
||||
engine = make_engine([a, b])
|
||||
assert engine.can_charge(a, b) is None
|
||||
|
||||
|
||||
# --- Test 6: determinism -----------------------------------------------------
|
||||
|
||||
|
||||
def test_charge_result_deterministic_transcript() -> None:
|
||||
"""Two runs with the same seed produce the same transcript."""
|
||||
transcripts = []
|
||||
for _ in range(2):
|
||||
a = make_state(
|
||||
make_combatant("a", hp=20, ac=15, attack_bonus=2, speed=30),
|
||||
"players",
|
||||
(1, 1),
|
||||
)
|
||||
b = make_state(
|
||||
make_combatant("b", hp=20, ac=15, attack_bonus=0, speed=0),
|
||||
"monsters",
|
||||
(1, 5),
|
||||
)
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 12, 3, 11, 2]),
|
||||
make_grid(),
|
||||
[a, b],
|
||||
round_cap=1,
|
||||
policy=_charge_policy,
|
||||
)
|
||||
transcripts.append(engine.run().transcript)
|
||||
assert transcripts[0] == transcripts[1]
|
||||
Reference in New Issue
Block a user