feat(combat): add withdraw action (double move with AoO protection on first square)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 7543c55ed4
commit 5b1957152c
3 changed files with 172 additions and 14 deletions
+57
View File
@@ -1145,3 +1145,60 @@ def test_charge_too_close_full_attacks_instead() -> None:
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
"battle over: draw after 1 rounds",
)
# --------------------------------------------------------------------------- #
# Withdraw (CRB: double move, first square protected, subsequent provoke) #
# --------------------------------------------------------------------------- #
def _withdraw_from_foe(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
return (Action(kind="withdraw", target_id="foe"),)
def test_withdraw_protects_first_square() -> None:
"""Withdraw moves 2x speed away; the starting square does not provoke AoO."""
flee = make_combatant("flee", hp=20, ac=15, attack_bonus=0, speed=30)
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
states = [make_state(flee, "players", (4, 1)), make_state(foe, "monsters", (3, 1))]
engine = CombatEngine(
ScriptedRng([10, 9, 15, 3]),
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
)
result = engine.run()
assert result.transcript == (
"initiative: flee d20=10+0=10",
"initiative: foe d20=9+0=9",
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,0)",
"round 1 foe: wait",
"battle over: draw after 1 rounds",
)
def test_withdraw_provokes_after_first_square() -> None:
"""Subsequent squares during withdraw provoke AoOs from other enemies."""
flee = make_combatant("flee", hp=20, ac=12, attack_bonus=0, speed=30)
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
foe2 = make_combatant("foe2", hp=20, ac=15, attack_bonus=5, speed=0)
states = [
make_state(flee, "players", (4, 1)),
make_state(foe, "monsters", (3, 1)),
make_state(foe2, "monsters", (6, 1)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
)
result = engine.run()
assert result.transcript == (
"initiative: flee d20=10+0=10",
"initiative: foe d20=9+0=9",
"initiative: foe2 d20=8+0=8",
"round 1 foe2: AoO vs flee d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,1)",
"round 1 foe: wait",
"round 1 foe2: wait",
"battle over: draw after 1 rounds",
)