From 25149c64bacaabbc73c0a3632e4dc811f114351c Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 14:35:42 +0200 Subject: [PATCH] fix(rules): allow a free weapon draw with a move at BAB +1 SRD 'Move Actions': 'If you have a base attack bonus of +1 or higher, you may draw a weapon as a free action combined with a regular move.' _filter_turn_actions computes free_draw (BAB >= 1 AND a move action in the turn sequence) and threads it through _effective_slot/_action_economy_filter/_staggered_filter: retrieve_weapon then occupies the free slot instead of the move slot. Static classification stays 'move' (repair_weapon and no-move draws unchanged). Filter-level and engine-level (BAB +2 vs BAB 0) tests added. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 12 +++-- src/pf1e_simulator/combat.py | 39 ++++++++++++++--- tests/test_conformance_action_economy.py | 27 ++++++++++++ tests/test_retrieve_weapon.py | 56 ++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 93b46e4..9552e9f 100644 --- a/README.md +++ b/README.md @@ -402,9 +402,15 @@ Règles modélisées : par action (conformance D5 — APG « Cackle ») : `hex` avec `hex_name="cackle"` occupe le compartiment **libre** (résolu par `_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ; - **mouvement** = `move`, `stand_up`, - `retrieve_weapon` (sortir une arme est une action de mouvement), - `repair_weapon` ; **swift** = `swift`, `deed`, + **mouvement** = `move`, `stand_up`, + `retrieve_weapon` (sortir une arme est une action de mouvement), + `repair_weapon` ; exception conformance L7 (SRD « Move Actions ») : « If + you have a base attack bonus of +1 or higher, you may draw a weapon as a + free action combined with a regular move » — quand la séquence du tour + contient une action `move` ET que le combattant a un BAB ≥ +1, + `retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw` + résolu par `_effective_slot`) et n'entame pas le budget mouvement ; + `repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ; **swift** = `swift`, `deed`, `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, `full_round`, `spell_combat` (sort + attaque à outrance dans le même round), `run` (round complet en RAW — il consacre le standard ET le mouvement, tout diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a62aa52..a6cd86e 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1625,7 +1625,16 @@ class CombatEngine: for _ in range(len(actions) - len(schedulable)) ) actions = schedulable - kept = _action_economy_filter(actions, cast_time=self._cast_time_of) + # PF1e (SRD "Move Actions"): "If you have a base attack bonus of +1 or + # higher, you may draw a weapon as a free action combined with a + # regular move." The draw rides the turn's move action instead of + # consuming the move slot. + free_draw = state.combatant.bab >= 1 and any( + a.kind == "move" for a in actions + ) + kept = _action_economy_filter( + actions, cast_time=self._cast_time_of, free_draw=free_draw + ) results.extend( ActionResult( kind=kind, ok=False, reason=_REASON_DOWNGRADED_ACTION_ECONOMY @@ -1642,7 +1651,9 @@ class CombatEngine: actions = kept state.immediate_used = False if self._is_staggered_limited(state): - kept = _staggered_filter(actions, cast_time=self._cast_time_of) + kept = _staggered_filter( + actions, cast_time=self._cast_time_of, free_draw=free_draw + ) results.extend( ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_STAGGERED) for kind in _dropped_kinds(actions, kept) @@ -6790,13 +6801,24 @@ def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot: raise ValueError(msg) -def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _ActionEconomySlot: +def _effective_slot( + action: Action, cast_time: CastTimeResolver, *, free_draw: bool = False +) -> _ActionEconomySlot: """The budget slot an action consumes, resolving cast_spell by cast time - and the cackle hex by its APG free-action cost.""" + and the cackle hex by its APG free-action cost. + + ``free_draw`` (SRD "Move Actions": "If you have a base attack bonus of +1 + or higher, you may draw a weapon as a free action combined with a regular + move") makes retrieve_weapon occupy the free slot instead of the move + slot — the caller sets it only when the turn's sequence carries a move + action and the actor's BAB is at least +1. + """ if action.kind == "cast_spell": return _cast_time_slot(cast_time(action)) if action.kind == "hex" and action.hex_name == "cackle": return "free" + if action.kind == "retrieve_weapon" and free_draw: + return "free" return _action_economy_slot(action.kind) @@ -6831,6 +6853,7 @@ def _staggered_filter( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, + free_draw: bool = False, ) -> tuple[Action, ...]: """Staggered: one move OR one standard action, plus every swift and free. @@ -6859,7 +6882,7 @@ def _staggered_filter( kept.append(Action(kind="attack", target_id=action.target_id)) slot_used = True continue - slot = _effective_slot(action, cast_time) + slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "full_round": if action.kind == "withdraw" and not slot_used: # Restricted Withdraw: standard action, move up to speed. @@ -6950,6 +6973,7 @@ def _action_economy_filter( actions: tuple[Action, ...], *, cast_time: CastTimeResolver = _default_cast_time, + free_draw: bool = False, ) -> tuple[Action, ...]: """Degrade a policy sequence to the legal PF1e action economy. @@ -6964,7 +6988,8 @@ def _action_economy_filter( move action converts the standard slot (so (move, stand_up) and (move, move) are legal). The kept sequence is therefore the earliest order-stable legal prefix-subsequence; every skipped action is - deterministic. + deterministic. ``free_draw`` makes retrieve_weapon a free action (SRD + "Move Actions", BAB +1 combined with a move — see _effective_slot). """ kept: list[Action] = [] spent: dict[_ActionEconomySlot, bool] = { @@ -6975,7 +7000,7 @@ def _action_economy_filter( "free": False, } for action in actions: - slot = _effective_slot(action, cast_time) + slot = _effective_slot(action, cast_time, free_draw=free_draw) if slot == "free": kept.append(action) continue diff --git a/tests/test_conformance_action_economy.py b/tests/test_conformance_action_economy.py index 013fcd9..238991b 100644 --- a/tests/test_conformance_action_economy.py +++ b/tests/test_conformance_action_economy.py @@ -362,6 +362,33 @@ def test_staggered_filter_keeps_withdraw_as_restricted_standard() -> None: assert _staggered_filter((_foe("withdraw"), _foe("attack"))) == (_foe("withdraw"),) +def test_free_draw_with_a_move_at_bab_plus1() -> None: + # SRD "Move Actions": "If you have a base attack bonus of +1 or higher, + # you may draw a weapon as a free action combined with a regular move." + # With the engine's free_draw flag set (BAB >= 1 AND a move in sequence), + # retrieve_weapon does not consume the move slot — the standard stays + # available for the attack. + actions = (_foe("move"), _foe("retrieve_weapon"), _foe("attack")) + assert _action_economy_filter(actions, free_draw=True) == actions + # Without the free draw the retrieve spends the move slot, converts the + # standard slot (move-slot conversion) and the attack is surplus. + kept_no_free = _action_economy_filter(actions, free_draw=False) + assert tuple(a.kind for a in kept_no_free) == ("move", "retrieve_weapon") + # A second standard after a free draw is surplus: the move slot was + # taken by the move, the standard by the first attack. + kept = _action_economy_filter( + (_foe("move"), _foe("retrieve_weapon"), _foe("attack"), _foe("attack")), + free_draw=True, + ) + assert tuple(a.kind for a in kept) == ("move", "retrieve_weapon", "attack") + # Without a move in the sequence the engine never sets free_draw; the + # retrieve then spends the move slot itself and the attack is surplus. + kept_no_move = _action_economy_filter( + (_foe("retrieve_weapon"), _foe("attack")), free_draw=False + ) + assert tuple(a.kind for a in kept_no_move) == ("retrieve_weapon", "attack") + + def test_nauseated_after_budget_keeps_single_move() -> None: # (move, attack, move): the budget drops the second move; the nauseated # filter then keeps only the first move and drops the standard attack. diff --git a/tests/test_retrieve_weapon.py b/tests/test_retrieve_weapon.py index 07b9843..489b340 100644 --- a/tests/test_retrieve_weapon.py +++ b/tests/test_retrieve_weapon.py @@ -159,3 +159,59 @@ def _run_with_retrieve() -> tuple[str, ...]: def test_determinism_same_seed_same_transcript() -> None: assert _run_with_retrieve() == _run_with_retrieve() + + +# --- Test 6: free draw combined with a move at BAB +1 ------------------------ + + +def _move_retrieve_attack_run(bab: int) -> CombatEngine: + a_spec = make_combatant("a", attack_bonus=bab) + b_spec = make_combatant("b") + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 4)) + a.weapon_stolen.add("a-w") + + def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + if state.combatant.id == "a": + return ( + Action(kind="move", target_id="b"), + Action(kind="retrieve_weapon", weapon_id="a-w"), + Action(kind="attack", target_id="b"), + ) + return (Action(kind="wait"),) + + engine = CombatEngine( + ScriptedRng([10, 5, 10, 3]), make_grid(), [a, b], policy=policy, round_cap=1 + ) + engine.run() + return engine + + +def test_bab_plus1_draws_for_free_and_still_attacks() -> None: + # SRD "Move Actions": "If you have a base attack bonus of +1 or higher, + # you may draw a weapon as a free action combined with a regular move." + # Given: a BAB +2 combatant whose turn sequence is (move, retrieve, attack) + # When: the turn runs + # Then: all three actions execute — the draw rides the move for free and + # the standard attack still happens. + engine = _move_retrieve_attack_run(bab=2) + a = engine._states[0] + assert not a.weapon_stolen + kinds = [(r.kind, r.ok) for r in a.last_action_results] + assert ("move", True) in kinds + assert ("retrieve_weapon", True) in kinds + assert ("attack", True) in kinds + assert any(" vs b " in line for line in engine._transcript) + + +def test_bab_zero_draw_still_costs_the_standard_slot() -> None: + # Given: the same sequence but BAB 0 — the draw is a move action, which + # converts the standard slot, leaving nothing for the attack. + # Then: the attack is downgraded out. + engine = _move_retrieve_attack_run(bab=0) + a = engine._states[0] + assert not a.weapon_stolen + assert any( + r.kind == "attack" and r.reason == "downgraded:action_economy" + for r in a.last_action_results + )