feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move)
This commit is contained in:
+108
-13
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user