feat(combat): add attacks of opportunity (movement + ranged-in-melee triggers)
This commit is contained in:
+130
-3
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
from pf1e_simulator.combat import (
|
||||
Action,
|
||||
CombatantState,
|
||||
CombatantStats,
|
||||
CombatEngine,
|
||||
@@ -536,7 +537,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60)
|
||||
target = make_combatant("target", speed=0)
|
||||
states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))]
|
||||
rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1])
|
||||
rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1])
|
||||
engine = CombatEngine(rng, grid, states, round_cap=4)
|
||||
result = engine.run()
|
||||
assert result.winner == "players"
|
||||
@@ -544,10 +545,13 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None:
|
||||
"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 3 archer: target down",
|
||||
"battle over: players win in 3 rounds",
|
||||
@@ -775,7 +779,7 @@ def test_no_flanking_without_opposite_ally() -> None:
|
||||
|
||||
|
||||
def test_ranged_attack_ignores_flanking() -> None:
|
||||
"""Ranged attacks get no flanking bonus, and ranged allies don't threaten."""
|
||||
"""Ranged attacks get no flanking bonus; ranged in melee provokes AoO."""
|
||||
arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0)
|
||||
aly = make_combatant("aly", attack_bonus=0, speed=0)
|
||||
tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0)
|
||||
@@ -784,12 +788,13 @@ def test_ranged_attack_ignores_flanking() -> None:
|
||||
make_state(aly, "players", (5, 4)),
|
||||
make_state(tgt, "monsters", (4, 4)),
|
||||
]
|
||||
engine = make_engine([10, 9, 8, 12, 10, 5], states, round_cap=1)
|
||||
engine = make_engine([10, 9, 8, 15, 3, 12, 10, 5], states, round_cap=1)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: arc d20=10+0=10",
|
||||
"initiative: aly d20=9+0=9",
|
||||
"initiative: tgt d20=8+0=8",
|
||||
"round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)",
|
||||
"round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS",
|
||||
"round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS",
|
||||
"round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
|
||||
@@ -949,3 +954,125 @@ def test_full_attack_low_bab_single_swing() -> None:
|
||||
"round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Attacks of opportunity (CRB: movement + ranged-in-melee, 1/round) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
|
||||
"""Test policy: always move toward the 'dest' combatant."""
|
||||
return (Action(kind="move", target_id="dest"),)
|
||||
|
||||
|
||||
def test_movement_provokes_aoo() -> None:
|
||||
"""Moving through a threatened square provokes an AoO; movement continues after."""
|
||||
runner = make_combatant("runner", hp=10, ac=12, speed=30)
|
||||
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
|
||||
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||
states = [
|
||||
make_state(runner, "players", (4, 1)),
|
||||
make_state(guard, "monsters", (3, 1)),
|
||||
make_state(dest, "monsters", (4, 5)),
|
||||
]
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 8, 15, 3, 5]),
|
||||
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||
)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: runner d20=10+0=10",
|
||||
"initiative: guard d20=9+0=9",
|
||||
"initiative: dest d20=8+0=8",
|
||||
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (10->7)",
|
||||
"round 1 runner: move (4,1)->(3,2)->(3,3)->(3,4)",
|
||||
"round 1 guard: wait",
|
||||
"round 1 dest: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_aoo_drops_mover() -> None:
|
||||
"""If an AoO drops the mover, movement stops and the mover's side loses."""
|
||||
runner = make_combatant("runner", hp=3, ac=12, speed=30)
|
||||
guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0)
|
||||
dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0)
|
||||
states = [
|
||||
make_state(runner, "players", (4, 1)),
|
||||
make_state(guard, "monsters", (3, 1)),
|
||||
make_state(dest, "monsters", (4, 5)),
|
||||
]
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 8, 15, 3]),
|
||||
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||
)
|
||||
result = engine.run()
|
||||
assert result.winner == "monsters"
|
||||
assert result.transcript == (
|
||||
"initiative: runner d20=10+0=10",
|
||||
"initiative: guard d20=9+0=9",
|
||||
"initiative: dest d20=8+0=8",
|
||||
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)",
|
||||
"round 1 guard: runner down",
|
||||
"battle over: monsters win in 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_aoo_limit_one_per_round() -> None:
|
||||
"""A combatant can only make one AoO per round, even if multiple squares are threatened."""
|
||||
runner = make_combatant("runner", hp=20, ac=12, speed=30)
|
||||
guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
||||
states = [
|
||||
make_state(runner, "players", (0, 0)),
|
||||
make_state(guard, "monsters", (2, 2)),
|
||||
make_state(dest, "monsters", (4, 4)),
|
||||
]
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 8, 15, 3]),
|
||||
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||
)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: runner d20=10+0=10",
|
||||
"initiative: guard d20=9+0=9",
|
||||
"initiative: dest d20=8+0=8",
|
||||
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)",
|
||||
"round 1 guard: wait",
|
||||
"round 1 dest: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
|
||||
def test_two_guards_each_one_aoo() -> None:
|
||||
"""Two enemies threatening the same square each get their own AoO (1/round each)."""
|
||||
runner = make_combatant("runner", hp=20, ac=12, speed=30)
|
||||
g1 = make_combatant("g1", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||
g2 = make_combatant("g2", hp=20, ac=15, attack_bonus=5, speed=0)
|
||||
dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0)
|
||||
states = [
|
||||
make_state(runner, "players", (4, 1)),
|
||||
make_state(g1, "monsters", (3, 1)),
|
||||
make_state(g2, "monsters", (3, 2)),
|
||||
make_state(dest, "monsters", (4, 6)),
|
||||
]
|
||||
engine = CombatEngine(
|
||||
ScriptedRng([10, 9, 8, 7, 15, 3, 15, 3]),
|
||||
make_grid(), states, round_cap=1, policy=_move_to_dest,
|
||||
)
|
||||
result = engine.run()
|
||||
assert result.transcript == (
|
||||
"initiative: runner d20=10+0=10",
|
||||
"initiative: g1 d20=9+0=9",
|
||||
"initiative: g2 d20=8+0=8",
|
||||
"initiative: dest d20=7+0=7",
|
||||
"round 1 g1: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
|
||||
"round 1 g2: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (17->14)",
|
||||
"round 1 runner: move (4,1)->(4,2)->(3,3)->(3,4)->(3,5)",
|
||||
"round 1 g1: wait",
|
||||
"round 1 g2: wait",
|
||||
"round 1 dest: wait",
|
||||
"battle over: draw after 1 rounds",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user