Files
pf1e-simulator/tests/test_charge_result.py
T

270 lines
9.4 KiB
Python

"""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
def test_restricted_charge_halves_the_budget() -> None:
"""SRD "Charge": a creature able to take only a standard action (here a
disabled one at 0 hp) can still charge, but only up to its speed (1x,
6 cells at 30 ft) instead of double speed — a target 6 cells away stays
chargeable, one 10 cells away is not."""
a = make_state(make_combatant("a"), "players", (0, 0))
near = make_state(make_combatant("near"), "monsters", (0, 6))
engine = make_engine([a, near], make_grid(12))
a.hp = 0 # disabled -> staggered -> restricted charge
result = engine.can_charge(a, near)
assert isinstance(result, ChargeResult)
assert result.weapon is not None # end cell 5 steps away <= 1x budget
a2 = make_state(make_combatant("a2"), "players", (0, 0))
far = make_state(make_combatant("far"), "monsters", (0, 10))
engine2 = make_engine([a2, far], make_grid(12))
a2.hp = 0
res2 = engine2.can_charge(a2, far)
assert isinstance(res2, ChargeResult)
assert res2.weapon is None # 9 steps to the closest attack cell needs 2x
a2.hp = 20 # standing again: full 2x charge is legal
res3 = engine2.can_charge(a2, far)
assert isinstance(res3, ChargeResult)
assert res3.weapon is not None
# --- 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]