From 7843756de299de9592492276a15ad4a0ea6710ec Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 6 Sep 2026 10:47:36 +0200 Subject: [PATCH] feat(combat): expose charge failure reasons via ChargeResult --- src/pf1e_simulator/combat.py | 48 +++++-- tests/test_charge_result.py | 244 +++++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 12 deletions(-) create mode 100644 tests/test_charge_result.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e0932a0..aef1c20 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 ( diff --git a/tests/test_charge_result.py b/tests/test_charge_result.py new file mode 100644 index 0000000..50dbfa3 --- /dev/null +++ b/tests/test_charge_result.py @@ -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]