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
+15 -10
View File
@@ -269,12 +269,10 @@ Règles modélisées :
- Économie d'action : un tour normal donne une action standard + une action de - Économie d'action : un tour normal donne une action standard + une action de
mouvement (ou une action à round complet), plus swift/free/immediate. La mouvement (ou une action à round complet), plus swift/free/immediate. La
politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ; politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ;
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est `default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)`
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra, si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
immédiates (hors-tour, consomment le prochain swift) ne sont pas encore (hors-tour, consomment le prochain swift) ne sont pas encore modélisées.
modélisées. `default_policy` choisit la charge quand l'ennemi est hors de
portée mais joignable par une ligne droite (voir ci-dessous).
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
@@ -306,7 +304,7 @@ Règles modélisées :
porte une AoO avant chaque tir. Une AoO par combattant par round (piste porte une AoO avant chaque tir. Une AoO par combattant par round (piste
`_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées `_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de (PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
placement (5 ft, à venir) évite les AoO de mouvement ; la retraite ne placement (5 ft) ne provoque jamais d'AoO (voir ci-dessous) ; la retraite ne
protège que la case de départ (voir ci-dessous). protège que la case de départ (voir ci-dessous).
- Charge : action à round complet. Le combattant se déplace en ligne droite - Charge : action à round complet. Le combattant se déplace en ligne droite
(Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut (Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut
@@ -322,6 +320,12 @@ Règles modélisées :
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via le mouvement). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée). une politique personnalisée).
- Pas de placement (5-foot step) : action libre qui déplace le combattant d'une
case vers l'ennemi le plus proche sans provoquer d'AoO. Exclusivité mutuelle
avec tout autre mouvement (move, charge, retraite) dans le même tour —
tracked via `moved_this_turn`, réinitialisé au début du tour. `default_policy`
choisit `(5foot_step, full_attack)` quand un seul pas suffit à entrer en
portée.
- Distance : pénalité cumulative de 2 par incrément de portée complet au-delà - Distance : pénalité cumulative de 2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ; du premier, appliquée au jet d'attaque et à la confirmation de critique ;
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
@@ -333,7 +337,7 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états. - Sorts, jets de sauvegarde, conditions et états.
- Manœuvres de combat. - Manœuvres de combat.
- Effets mécaniques de hauteur/élévation. - Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, pas de placement (5 ft) - Tailles Large+ (2×2), allonge > 5 ft
et actions immédiates hors-tour. et actions immédiates hors-tour.
## Architecture ## Architecture
@@ -368,7 +372,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) : La gate de validation complète (tests + lint + types) :
```bash ```bash
uv run pytest -q # 216 tests uv run pytest -q # 219 tests
uv run ruff check src tests uv run ruff check src tests
uv run basedpyright src # mode strict uv run basedpyright src # mode strict
``` ```
@@ -383,7 +387,8 @@ uv run basedpyright src # mode strict
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à
outrance, attaques d'opportunité, charge et retraite sont déjà modélisés. outrance, attaques d'opportunité, charge, retraite et pas de placement sont
déjà modélisés.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites - **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement. en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et - **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+67 -16
View File
@@ -5,13 +5,13 @@ Phase 0 documented deviations from PF1e (conventions):
(or one full-round action), plus swift/free/immediate. The policy returns an (or one full-round action), plus swift/free/immediate. The policy returns an
ordered sequence of `Action` per turn; the engine executes them in order, ordered sequence of `Action` per turn; the engine executes them in order,
stopping early if the actor drops or one side is wiped. `default_policy` stopping early if the actor drops or one side is wiped. `default_policy`
returns `(move, attack)` when the nearest enemy is out of reach (move at returns `(full_attack,)` when already in range, `(charge,)` when a
full speed along the Dijkstra path, then attack if a weapon is usable) and straight-line charge path exists, `(5foot_step, full_attack)` when a single
`(attack,)` when already in range. The move action may replace the standard step brings the target into range, or `(move, attack)` otherwise (move at
action (e.g. a double move) but the default policy does not need this. full speed along the Dijkstra path, then single attack). Immediate actions
Immediate actions (off-turn, consume next swift) are deferred; flanking, (off-turn, consume next swift) are deferred; flanking, full attack, charge,
full attack, charge, and withdraw are modeled; attacks of opportunity are withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
resolved inline (see below). inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action (down or dead) mid-routine. BAB iterative attacks (full-round action
@@ -44,8 +44,10 @@ Phase 0 documented deviations from PF1e (conventions):
threatening enemy gets one AoO before each ranged swing. One AoO per threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start); combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step (deferred) avoids AoOs from movement; the withdraw action 5-foot step never provokes AoOs (it is not a move action); the withdraw
protects only the starting square (see below). action protects only the starting square (see below). A 5-foot step and
any other movement (move, charge, withdraw) are mutually exclusive within
the same turn (``moved_this_turn`` flag, cleared in ``_take_turn``).
- Charge: a full-round action that moves in a straight line (Bresenham) up to - Charge: a full-round action that moves in a straight line (Bresenham) up to
2x speed toward the closest square from which the charger can melee the 2x speed toward the closest square from which the charger can melee the
target, then makes a single melee attack at +2. The charger takes -2 AC target, then makes a single melee attack at +2. The charger takes -2 AC
@@ -60,6 +62,12 @@ Phase 0 documented deviations from PF1e (conventions):
move). The path is a greedy ascent of the Dijkstra cost field from the move). The path is a greedy ascent of the Dijkstra cost field from the
threat. ``default_policy`` does not choose withdraw (it is only available threat. ``default_policy`` does not choose withdraw (it is only available
via a custom policy). via a custom policy).
- 5-foot step: a free action that moves one square toward the nearest enemy
without provoking AoOs. Mutually exclusive with any other movement (move,
charge, withdraw) in the same turn — tracked via ``moved_this_turn``,
cleared at the start of each turn in ``_take_turn``. ``default_policy``
uses ``(5foot_step, full_attack)`` when a single step brings the target
into weapon range.
- Ranged: cumulative -2 per full range increment beyond the first, up to 10 - Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls. range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished. Thrown weapons (5 increments max) are not distinguished.
@@ -149,6 +157,7 @@ class CombatantState:
pos: Pos pos: Pos
hp: int hp: int
ac_penalty: int = 0 ac_penalty: int = 0
moved_this_turn: bool = False
@property @property
def active(self) -> bool: def active(self) -> bool:
@@ -186,14 +195,15 @@ class Action:
(single attack, highest BAB only — no iteratives), ``move`` is a move (single attack, highest BAB only — no iteratives), ``move`` is a move
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge`` action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
is a special full-round action (2x speed, straight line, +2 attack, -2 is a special full-round action (2x speed, straight line, +2 attack, -2
AC), ``swift`` and ``free`` are minor actions, ``immediate`` is an AC), ``5foot_step`` is a free action (one square, no AoO, mutually
off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
policy-returned sequence per turn. actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
no-op. The engine executes a policy-returned sequence per turn.
""" """
kind: Literal[ kind: Literal[
"attack", "move", "wait", "swift", "free", "immediate", "full_round", "attack", "move", "wait", "swift", "free", "immediate", "full_round",
"full_attack", "charge", "withdraw", "full_attack", "charge", "withdraw", "5foot_step",
] ]
target_id: str | None = None target_id: str | None = None
@@ -241,6 +251,7 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool: def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over.""" """Execute one combatant's full turn; return True if the battle is over."""
state.ac_penalty = 0 state.ac_penalty = 0
state.moved_this_turn = False
actions = self._policy(self, state) actions = self._policy(self, state)
before = len(self._transcript) before = len(self._transcript)
for action in actions: for action in actions:
@@ -463,6 +474,17 @@ class CombatEngine:
return True return True
return False return False
def can_step5_to_attack(self, state: CombatantState, target: CombatantState) -> bool:
"""True if a 5-foot step toward ``target`` would put ``state`` in weapon range."""
path = self._move_path(state, target)
if not path:
return False
orig = state.pos
state.pos = path[0]
weapon = self.weapon_for(state, target)
state.pos = orig
return weapon is not None
def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None: def can_charge(self, state: CombatantState, target: CombatantState) -> AttackSpec | None:
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None. """Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
@@ -591,6 +613,7 @@ class CombatEngine:
attacker.pos = step attacker.pos = step
actual_path.append(step) actual_path.append(step)
if actual_path: if actual_path:
attacker.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log( self._log(
f"round {self._current_round} {attacker.combatant.id}: charge {coords}" f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
@@ -611,6 +634,10 @@ class CombatEngine:
self._execute_charge(state, target) self._execute_charge(state, target)
elif action.kind == "withdraw": elif action.kind == "withdraw":
self._withdraw(state, target) self._withdraw(state, target)
elif action.kind == "5foot_step":
if target is None:
return
self._step5(state, target)
elif action.kind == "move": elif action.kind == "move":
if target is None: if target is None:
return return
@@ -732,7 +759,23 @@ class CombatEngine:
): ):
return return
def _step5(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
return
start = state.pos
state.pos = path[0]
state.moved_this_turn = True
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"5ft step ({start[0]},{start[1]})->({path[0][0]},{path[0][1]})"
)
def _move(self, state: CombatantState, target: CombatantState) -> None: def _move(self, state: CombatantState, target: CombatantState) -> None:
if state.moved_this_turn:
return
path = self._move_path(state, target) path = self._move_path(state, target)
if not path: if not path:
return return
@@ -750,6 +793,7 @@ class CombatEngine:
state.pos = step state.pos = step
actual_path.append(step) actual_path.append(step)
if actual_path: if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(f"round {self._current_round} {state.combatant.id}: move {coords}") self._log(f"round {self._current_round} {state.combatant.id}: move {coords}")
@@ -869,6 +913,7 @@ class CombatEngine:
state.pos = step state.pos = step
actual_path.append(step) actual_path.append(step)
if actual_path: if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path)) coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}" f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
@@ -882,13 +927,14 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Standard-attack the nearest enemy in range; charge if reachable; otherwise approach. """Full-attack the nearest enemy; charge, 5ft-step, or approach if out of range.
Decision order: Decision order:
1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed). 1. Full-attack if a weapon is usable against the nearest enemy this turn (no move needed).
2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC). 2. Charge if a straight-line charge path exists (2x speed, +2 attack, -2 AC).
3. Move toward the target then single attack (standard + move economy). 3. 5-foot step then full-attack if a single step brings the target into weapon range.
4. Wait if nothing is possible. 4. Move toward the target then single attack (standard + move economy).
5. Wait if nothing is possible.
""" """
target = engine.nearest_enemy(state) target = engine.nearest_enemy(state)
if target is None: if target is None:
@@ -898,6 +944,11 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
charge_weapon = engine.can_charge(state, target) charge_weapon = engine.can_charge(state, target)
if charge_weapon is not None: if charge_weapon is not None:
return (Action(kind="charge", target_id=target.combatant.id),) return (Action(kind="charge", target_id=target.combatant.id),)
if engine.can_step5_to_attack(state, target):
return (
Action(kind="5foot_step", target_id=target.combatant.id),
Action(kind="full_attack", target_id=target.combatant.id),
)
return ( return (
Action(kind="move", target_id=target.combatant.id), Action(kind="move", target_id=target.combatant.id),
Action(kind="attack", target_id=target.combatant.id), Action(kind="attack", target_id=target.combatant.id),
+108 -13
View File
@@ -14,6 +14,7 @@ from pf1e_simulator.combat import (
CombatantStats, CombatantStats,
CombatEngine, CombatEngine,
CombatResult, CombatResult,
default_policy,
) )
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid 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: 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 = { legend = {
".": TerrainType(type="floor", move_cost=1), ".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True), "#": 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 == ( assert result.transcript == (
"initiative: archer d20=10+0=10", "initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9", "initiative: target d20=9+0=9",
"round 1 archer: move (0,1)->(1,2)->(0,3)", "round 1 archer: 5ft step (0,1)->(1,2)",
"round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS", "round 1 archer: short bow vs target d20=5+4=9 AC 13 -> MISS",
"round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", "round 1 target: wait",
"round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS", "round 2 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)",
"round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS", "round 2 target: wait",
"round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)", "round 3 archer: short bow vs target d20=10+4=14 AC 13 -> HIT 3 damage (3->0)",
"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", "round 3 archer: target down",
"battle over: players win in 3 rounds", "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: 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)} legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend) spec = MapSpec(name="range-beyond", terrain=("." * 24,), legend=legend)
grid = Grid.from_spec(spec) grid = Grid.from_spec(spec)
@@ -669,8 +667,8 @@ def test_ranged_weapon_unusable_beyond_maximum_range() -> None:
assert result.transcript == ( assert result.transcript == (
"initiative: archer d20=10+0=10", "initiative: archer d20=10+0=10",
"initiative: target d20=9+0=9", "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: 5ft step (0,0)->(0,1)",
"round 1 archer: short bow vs target d20=8+4-14=-2 AC 13 -> MISS", "round 1 archer: short bow vs target d20=8+4-18=-6 AC 13 -> MISS",
"round 1 target: wait", "round 1 target: wait",
"battle over: draw after 1 rounds", "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", "round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS",
"battle over: draw after 1 rounds", "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",
)