diff --git a/README.md b/README.md index 69ea52d..0db30b7 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 215 tests +uv run pytest -q # 216 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d44f4eb..f9375e6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -183,11 +183,12 @@ class Action: """What a policy wants a combatant to do this turn. Kinds map to PF1e action types: ``attack`` is a standard action - (single attack), ``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. + (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. """ kind: Literal[ @@ -625,7 +626,9 @@ class CombatEngine: weapon = self.weapon_for(state, target) if weapon is None: return - if kind == "full_attack": + if kind == "attack": + self._single_attack(state, target, weapon) + elif kind == "full_attack": self._full_attack(state, target, weapon) else: self._attack(state, target, weapon) @@ -698,6 +701,15 @@ class CombatEngine: if self._resolve_swing(attacker, target, weapon, label=label): return + def _single_attack( + self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec + ) -> None: + if not attacker.active: + return + if self._check_provoked_aoo(attacker, weapon): + return + self._resolve_swing(attacker, target, weapon, label=weapon.name) + def _full_attack( self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: @@ -870,12 +882,12 @@ Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Full-attack the nearest enemy in range; charge if reachable; otherwise approach. + """Standard-attack the nearest enemy in range; charge if reachable; otherwise approach. Decision order: - 1. Full-attack if a weapon is usable against the nearest enemy this turn. + 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 full-attack (standard + move economy). + 3. Move toward the target then single attack (standard + move economy). 4. Wait if nothing is possible. """ target = engine.nearest_enemy(state) @@ -888,5 +900,5 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, return (Action(kind="charge", target_id=target.combatant.id),) return ( Action(kind="move", target_id=target.combatant.id), - Action(kind="full_attack", 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 3a6f6f2..801dfcc 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -1202,3 +1202,37 @@ def test_withdraw_provokes_after_first_square() -> None: "round 1 foe2: wait", "battle over: draw after 1 rounds", ) + + +# --------------------------------------------------------------------------- # +# Standard attack (single swing, no iteratives — PF1e standard action) # +# --------------------------------------------------------------------------- # + + +def _move_then_attack( + engine: CombatEngine, state: CombatantState +) -> tuple[Action, ...]: + return ( + Action(kind="move", target_id="orc"), + Action(kind="attack", target_id="orc"), + ) + + +def test_move_attack_single_swing() -> None: + """move+attack gives ONE attack at highest BAB; move+full_attack gives iteratives.""" + gob = make_combatant("gob", hp=20, ac=15, attack_bonus=6, speed=30, initiative_mod=10) + orc = make_combatant("orc", hp=20, ac=15, attack_bonus=1, speed=0) + states = [make_state(gob, "players", (0, 0)), make_state(orc, "monsters", (3, 0))] + engine = CombatEngine( + ScriptedRng([20, 1, 10, 15, 3]), + make_grid(), states, round_cap=1, policy=_move_then_attack, + ) + result = engine.run() + assert result.transcript == ( + "initiative: gob d20=20+10=30", + "initiative: orc d20=1+0=1", + "round 1 gob: move (0,0)->(1,0)->(2,0)", + "round 1 gob: short sword vs orc d20=10+6=16 AC 15 -> HIT 15 damage (20->5)", + "round 1 orc: short sword vs orc d20=3+1=4 AC 15 -> MISS", + "battle over: draw after 1 rounds", + )