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
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 ;
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
immédiates (hors-tour, consomment le prochain swift) ne sont pas encore
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).
`default_policy` renvoie `(full_attack,)` si l'ennemi est en portée, `(charge,)`
si une ligne droite existe, `(5foot_step, full_attack)` si un seul pas
suffit à entrer en portée, ou `(move, attack)` sinon. Les actions immédiates
(hors-tour, consomment le prochain swift) ne sont pas encore modélisées.
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
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
@@ -306,7 +304,7 @@ Règles modélisées :
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
(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).
- 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
@@ -322,6 +320,12 @@ Règles modélisées :
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
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à
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
@@ -333,7 +337,7 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états.
- Manœuvres de combat.
- 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.
## Architecture
@@ -368,7 +372,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 216 tests
uv run pytest -q # 219 tests
uv run ruff check src tests
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
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
en politiques, balayage de matrices de positionnement.
- **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
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`
returns `(move, attack)` when the nearest enemy is out of reach (move at
full speed along the Dijkstra path, then attack if a weapon is usable) and
`(attack,)` when already in range. The move action may replace the standard
action (e.g. a double move) but the default policy does not need this.
Immediate actions (off-turn, consume next swift) are deferred; flanking,
full attack, charge, and withdraw are modeled; attacks of opportunity are
resolved inline (see below).
returns `(full_attack,)` when already in range, `(charge,)` when a
straight-line charge path exists, `(5foot_step, full_attack)` when a single
step brings the target into range, or `(move, attack)` otherwise (move at
full speed along the Dijkstra path, then single attack). Immediate actions
(off-turn, consume next swift) are deferred; flanking, full attack, charge,
withdraw, and 5-foot step are modeled; attacks of opportunity are resolved
inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(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
combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step (deferred) avoids AoOs from movement; the withdraw action
protects only the starting square (see below).
5-foot step never provokes AoOs (it is not a move action); the withdraw
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
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
@@ -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
threat. ``default_policy`` does not choose withdraw (it is only available
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
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
@@ -149,6 +157,7 @@ class CombatantState:
pos: Pos
hp: int
ac_penalty: int = 0
moved_this_turn: bool = False
@property
def active(self) -> bool:
@@ -186,14 +195,15 @@ class Action:
(single attack, highest BAB only — no iteratives), ``move`` is a move
action, ``full_attack`` is a full-round action (BAB iteratives), ``charge``
is a special full-round action (2x speed, straight line, +2 attack, -2
AC), ``swift`` and ``free`` are minor actions, ``immediate`` is an
off-turn reaction (deferred). ``wait`` is a no-op. The engine executes a
policy-returned sequence per turn.
AC), ``5foot_step`` is a free action (one square, no AoO, mutually
exclusive with move/charge/withdraw), ``swift`` and ``free`` are minor
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
no-op. The engine executes a policy-returned sequence per turn.
"""
kind: Literal[
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
"full_attack", "charge", "withdraw",
"full_attack", "charge", "withdraw", "5foot_step",
]
target_id: str | None = None
@@ -241,6 +251,7 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
state.ac_penalty = 0
state.moved_this_turn = False
actions = self._policy(self, state)
before = len(self._transcript)
for action in actions:
@@ -463,6 +474,17 @@ class CombatEngine:
return True
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:
"""Return a melee weapon if ``state`` can charge ``target`` this turn, else None.
@@ -591,6 +613,7 @@ class CombatEngine:
attacker.pos = step
actual_path.append(step)
if actual_path:
attacker.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {attacker.combatant.id}: charge {coords}"
@@ -611,6 +634,10 @@ class CombatEngine:
self._execute_charge(state, target)
elif action.kind == "withdraw":
self._withdraw(state, target)
elif action.kind == "5foot_step":
if target is None:
return
self._step5(state, target)
elif action.kind == "move":
if target is None:
return
@@ -732,7 +759,23 @@ class CombatEngine:
):
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:
if state.moved_this_turn:
return
path = self._move_path(state, target)
if not path:
return
@@ -750,6 +793,7 @@ class CombatEngine:
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
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}")
@@ -869,6 +913,7 @@ class CombatEngine:
state.pos = step
actual_path.append(step)
if actual_path:
state.moved_this_turn = True
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
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, ...]:
"""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:
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).
3. Move toward the target then single attack (standard + move economy).
4. Wait if nothing is possible.
3. 5-foot step then full-attack if a single step brings the target into weapon range.
4. Move toward the target then single attack (standard + move economy).
5. Wait if nothing is possible.
"""
target = engine.nearest_enemy(state)
if target is None:
@@ -898,6 +944,11 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action,
charge_weapon = engine.can_charge(state, target)
if charge_weapon is not None:
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 (
Action(kind="move", 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,
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",
)