From 94521394efff6db857564e99a7bc45b45f315c4e Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): add 5-foot step (free action, no AoO, mutual exclusion with move) --- README.md | 25 +++++--- src/pf1e_simulator/combat.py | 83 +++++++++++++++++++----- tests/test_combat.py | 121 +++++++++++++++++++++++++++++++---- 3 files changed, 190 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0db30b7..7aed2b1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f9375e6..2dad3c5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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), diff --git a/tests/test_combat.py b/tests/test_combat.py index 801dfcc..91adc34 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", + )