feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 5b8735f001
commit 94521394ef
3 changed files with 190 additions and 39 deletions
+108 -13
View File
@@ -14,6 +14,7 @@ from pf1e_simulator.combat import (
CombatantStats,
CombatEngine,
CombatResult,
default_policy,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
@@ -527,7 +528,7 @@ def test_melee_attack_gets_cover_bonus_across_wall_corner() -> None:
def test_ranged_no_line_of_effect_moves_around_wall() -> None:
"""An archer without line of effect moves around the wall at full speed, then shoots."""
"""An archer without line of effect 5ft-steps around the wall, then shoots from safety."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
@@ -544,15 +545,12 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9",
"round 1 archer: move (0,1)->(1,2)->(0,3)",
"round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS",
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS",
"round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS",
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)",
"round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS",
"round 3 target: AoO vs archer d20=2+2=4 AC 13 -> MISS",
"round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)",
"round 1 archer: 5ft step (0,1)->(1,2)",
"round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
"round 1 target: wait",
"round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 target: wait",
"round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)",
"round 3 archer: target down",
"battle over: players win in 3 rounds",
)
@@ -656,7 +654,7 @@ def test_ranged_weapon_usable_at_maximum_range() -> None:
def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
"""Beyond 10 range increments the weapon is dropped and the policy moves."""
"""Beyond 10 range increments the weapon is dropped and the policy 5ft-steps into range."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend)
grid = Grid.from_spec(spec)
@@ -669,8 +667,8 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
assert result.transcript == (
"initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9",
"round 1 archer: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)->(0,6)",
"round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS",
"round 1 archer: 5ft step (0,0)->(0,1)",
"round 1 archer: short bow vs target d20=8+4-18=-6 AC 13 -> MISS",
"round 1 target: wait",
"battle over: draw after 1 rounds",
)
@@ -1236,3 +1234,100 @@ def test_move_attack_single_swing() -> None:
"round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS",
"battle over: draw after 1 rounds",
)
# --------------------------------------------------------------------------- #
# 5-foot step (free action, no AoO, mutually exclusive with move) #
# --------------------------------------------------------------------------- #
def _step5_orc2(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "gob":
return (
Action(kind="5foot_step", target_id="orc2"),
Action(kind="full_attack", target_id="orc2"),
)
return default_policy(engine, state)
def test_5ft_step_no_aoo() -> None:
"""A 5-foot step leaves a threatened square without provoking any AoO."""
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
orc1 = make_combatant("orc1", hp=20, ac=15, attack_bonus=2, speed=0)
orc2 = make_combatant("orc2", hp=20, ac=15, attack_bonus=2, speed=0)
states = [
make_state(gob, "players", (1, 0)),
make_state(orc1, "monsters", (0, 0)),
make_state(orc2, "monsters", (1, 2)),
]
engine = CombatEngine(
ScriptedRng([20, 1, 1, 10, 3, 15, 2]),
make_grid(), states, round_cap=1, policy=_step5_orc2,
)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=20+10=30",
"initiative: orc2 d20=1+0=1",
"initiative: orc1 d20=1+0=1",
"round 1 gob: 5ft step (1,0)->(0,1)",
"round 1 gob: short sword vs orc2 d20=10+2=12 AC 15 -> MISS",
"round 1 orc2: short sword vs gob d20=3+2=5 AC 15 -> MISS",
"round 1 orc1: short sword vs gob d20=15+2=17 AC 15 -> HIT 2 damage (20->18)",
"battle over: draw after 1 rounds",
)
def _move_then_step5(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "gob":
return (
Action(kind="move", target_id="orc"),
Action(kind="5foot_step", target_id="orc"),
)
return default_policy(engine, state)
def test_move_blocks_5ft_step() -> None:
"""A move action sets moved_this_turn, blocking any 5-foot step afterward."""
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))]
engine = CombatEngine(
ScriptedRng([20, 1, 10, 3]),
make_grid(), states, round_cap=1, policy=_move_then_step5,
)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=20+10=30",
"initiative: orc d20=1+0=1",
"round 1 gob: move (0,0)->(0,1)->(0,2)->(0,3)->(0,4)->(0,5)",
"round 1 orc: short sword vs gob d20=10+2=12 AC 15 -> MISS",
"battle over: draw after 1 rounds",
)
def _step5_then_move(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
if state.combatant.id == "gob":
return (
Action(kind="5foot_step", target_id="orc"),
Action(kind="move", target_id="orc"),
)
return default_policy(engine, state)
def test_5ft_step_blocks_move() -> None:
"""A 5-foot step sets moved_this_turn, blocking any move action afterward."""
gob = make_combatant("gob", hp=20, ac=15, attack_bonus=2, speed=30, initiative_mod=10)
orc = make_combatant("orc", hp=20, ac=15, attack_bonus=2, speed=0)
states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (0, 6))]
engine = CombatEngine(
ScriptedRng([20, 1]),
make_grid(), states, round_cap=1, policy=_step5_then_move,
)
result = engine.run()
assert result.transcript == (
"initiative: gob d20=20+10=30",
"initiative: orc d20=1+0=1",
"round 1 gob: 5ft step (0,0)->(0,1)",
"round 1 orc: wait",
"battle over: draw after 1 rounds",
)