"""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]