diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a5cf42a..28b45a6 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -753,10 +753,14 @@ class CombatantState: # Aid another & run (not in the frozen data model): aid bonuses granted to # this combatant by an ally's aid another — aid_attack_bonus is consumed by # its next attack roll, aid_ac_bonus applies against the next attack that - # targets it. Both (and the running flag, which denies the Dex bonus to AC - # until the runner's next turn) reset at the start of the combatant's turn. + # targets it. PF1e (SRD "Aid Another"): the +2 lasts "as long as that + # attack comes before the beginning of your next turn" — YOUR = the AIDER, + # so each grant is tracked on the aider (target id, mode) and expires at + # the start of the aider's next turn. The running flag denies the Dex + # bonus to AC until the runner's next turn. aid_attack_bonus: int = 0 aid_ac_bonus: int = 0 + aid_grants: list[tuple[str, str]] = field(default_factory=list) running: bool = False # Bleeding (V5b, not in the frozen data model): damage taken at the start # of each turn; any effect that restores hit points clears it. @@ -1780,8 +1784,7 @@ class CombatEngine: state.defensive_ac_bonus = 0 state.fight_defensively = False state.total_defense = False - state.aid_attack_bonus = 0 - state.aid_ac_bonus = 0 + self._expire_aid_grants(state) state.running = False state.swift_used_this_turn = False original = self._policy(self, state) @@ -4221,6 +4224,7 @@ class CombatEngine: target.aid_ac_bonus += _AID_ANOTHER_BONUS else: target.aid_attack_bonus += _AID_ANOTHER_BONUS + state.aid_grants.append((target.combatant.id, mode)) self._log( f"round {self._current_round} {state.combatant.id}: " f"aid another {target.combatant.id} ({mode}) " @@ -4229,6 +4233,29 @@ class CombatEngine: ) return ActionResult(kind=kind, ok=True, reason=_REASON_OK) + def _expire_aid_grants(self, aider: CombatantState) -> None: + """Remove this aider's unspent aid bonuses from its beneficiaries. + + PF1e (SRD "Aid Another"): the +2 lasts "as long as that attack comes + before the beginning of your next turn" — your = the AIDER's turn, so + expiry is tracked on the aider. Each grant subtracts its +2 from the + beneficiary's pool (floor 0 — a bonus already consumed by an attack + is untouched). Simplification: an aider removed before its next turn + never expires its grants (the engine has no death hook for them). + """ + for target_id, mode in aider.aid_grants: + for state in self._states: + if state.combatant.id != target_id: + continue + if mode == "ac": + state.aid_ac_bonus = max(0, state.aid_ac_bonus - _AID_ANOTHER_BONUS) + else: + state.aid_attack_bonus = max( + 0, state.aid_attack_bonus - _AID_ANOTHER_BONUS + ) + break + aider.aid_grants = [] + def _execute_run( self, state: CombatantState, target: CombatantState | None, action: Action ) -> ActionResult: diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index 90b9f7e..2804d23 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -293,6 +293,32 @@ def test_failed_aid_grants_nothing() -> None: assert hero.hp == 97 +# ── (c2) the aid bonus expires at the AIDER's next turn, not the beneficiary's ─ + +def test_aid_bonus_survives_beneficiary_turn_and_expires_at_aider_turn() -> None: + # SRD "Aid Another": the +2 lasts "as long as that attack comes before the + # beginning of your next turn" — your = the AIDER's. The ally aids the + # hero (ac) in round 1; the hero's own round-2 turn start must NOT clear + # the bonus, the ally's round-2 turn start must. + seen: dict[str, int] = {} + + def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + cid = state.combatant.id + if cid == "hero" and engine._current_round == 2: + seen["hero_turn"] = state.aid_ac_bonus + if cid == "foe" and engine._current_round == 2: + seen["foe_turn"] = engine._states[0].aid_ac_bonus + if cid == "ally" and engine._current_round == 1: + return (Action(kind="aid_another", target_id="hero", aid_mode="ac"),) + return _WAIT + + # Rolls: 3 initiatives (10,10,10); aid d20=10+5=15 >= 10 success. + eng = _trio(policy, [10, 10, 10, 10], round_cap=2) + eng.run() + assert seen["hero_turn"] == 2 # survived the beneficiary's turn start + assert seen["foe_turn"] == 0 # expired at the aider's turn start + + # ── (d) run moves up to 4x speed (destination and target), sets running ──────