From fa01ed74c52a80791a6683125e7f718b1f19a6db Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 21:15:14 +0200 Subject: [PATCH] fix(rules): stop delay from double-processing the turn start A freshly-delayed combatant is re-inserted into the round order and _take_turn runs a second time in the same round. That second pass re-ran _decrement_durations and _turn_gate, double-ticking bleed, double-decrementing buff/condition durations and re-firing the fear aura. The start-of-turn block is now skipped when delayed_this_round is already set at entry (cleared at round start, set only by a delay action); the delayed turn still gets its policy call, budget and execution. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 7 ++++- src/pf1e_simulator/combat.py | 38 +++++++++++++++---------- tests/test_delay_action.py | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b34bd6e..9782e42 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,12 @@ Règles modélisées : cible − 0,5 (ou au minimum des totaux − 0,5 sans cible) et **persiste aux rounds suivants** ; le reste du tour est perdu, un second retard dans le même round est rejeté (`already_delayed`), et le drapeau se réinitialise au début - de chaque round. `default_policy` ne choisit pas le retard (disponible via + de chaque round. Le tour réinséré plus tard dans le même round **ne rejoue + pas le début de tour** : la décrément des durées (buffs/conditions), les + dégâts continus (saignement), l'effet de terreur et les réinitialisations de + drapeaux par tour ne s'appliquent qu'une fois par round (le tour retardé garde + néanmoins son appel de politique, son budget et son exécution normaux). + `default_policy` ne choisit pas le retard (disponible via une politique personnalisée). - Préparation (`Action(kind="ready", ready_trigger=...)`, action standard) : le combattant se prépare à agir quand un événement déclencheur survient. Le diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 52e6f83..25da034 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1398,21 +1398,29 @@ class CombatEngine: def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" - self._decrement_durations(state) - gate = self._turn_gate(state) - if gate is not None: - # A start-of-turn effect (bleed, confusion, cant_act) forfeited - # the turn; the value is the battle-over flag. - state.last_action_results = [] - return gate - self._apply_fear_aura(state) - state.moved_this_turn = False - state.power_attack = False - state.defensive_ac_bonus = 0 - state.fight_defensively = False - state.aid_attack_bonus = 0 - state.aid_ac_bonus = 0 - state.running = False + # A freshly-delayed combatant is re-inserted into the round order and + # ``_take_turn`` runs a second time in the SAME round. The start-of-turn + # block below (duration decrement, the bleed/confusion/cant_act gate, + # the fear aura and the per-turn flag resets) must run only ONCE per + # round, so it is skipped when ``delayed_this_round`` is already set at + # entry (it was cleared at round start and only a delay action sets it). + # The delayed turn still gets its policy call, budget and execution. + if not state.delayed_this_round: + self._decrement_durations(state) + gate = self._turn_gate(state) + if gate is not None: + # A start-of-turn effect (bleed, confusion, cant_act) forfeited + # the turn; the value is the battle-over flag. + state.last_action_results = [] + return gate + self._apply_fear_aura(state) + state.moved_this_turn = False + state.power_attack = False + state.defensive_ac_bonus = 0 + state.fight_defensively = False + state.aid_attack_bonus = 0 + state.aid_ac_bonus = 0 + state.running = False original = self._policy(self, state) results: list[ActionResult] = [] actions = self._filter_turn_actions(state, original, results) diff --git a/tests/test_delay_action.py b/tests/test_delay_action.py index fc9100e..d79a217 100644 --- a/tests/test_delay_action.py +++ b/tests/test_delay_action.py @@ -17,6 +17,7 @@ from pf1e_simulator.combat import ( CombatEngine, ) from pf1e_simulator.dice import parse_dice +from pf1e_simulator.effects import ActiveBuff, StatModifier from pf1e_simulator.models import ( AbilityScores, ACProfile, @@ -272,3 +273,56 @@ def test_delay_flag_resets_next_round() -> None: assert eng._initiative_keys["hero"] == (2.0, 2, 0) hero = eng._states[0] assert hero.delayed_this_round is True + + +# ── (f) a delayed turn does not double-process the start of the round ───────── + + +def _delay_bleed_engine() -> CombatEngine: + """hero (mod +2 → 7, delays then acts) vs v1 (mod +1 → 5) vs v2 (mod 0 → 3). + + hero is bleeding and carries a 2-round buff; the initiative rolls are the + only d20s the engine may consume (a repeated dying/confusion roll or a + second bleed tick that drops hero below 0 would raise on an empty queue). + """ + hero = make_state(_combatant("hero", initiative_mod=2), "players", (0, 0)) + v1 = make_state(_combatant("v1", initiative_mod=1), "monsters", (0, 3)) + v2 = make_state(_combatant("v2"), "monsters", (0, 5)) + eng = CombatEngine( + ScriptedRng([7, 5, 3]), make_grid(), [hero, v1, v2], + policy=_counting_policy( + lambda cid, n: (Action(kind="delay"),) if (cid, n) == ("hero", 0) else _WAIT, + [], + ), + round_cap=1, + ) + hero.bleed_damage = 1 + hero.active_buffs.append( + ActiveBuff(modifiers=(StatModifier(target="ac", value=2, bonus_type="insight"),), + rounds_left=2, source="test") + ) + return eng + + +def _bleed_ticks(eng: CombatEngine, cid: str) -> int: + return sum(1 for ln in eng._transcript if f"{cid}: bleed" in ln) + + +def test_delay_does_not_double_bleed_tick() -> None: + # Given: a bleeding combatant that delays; When: it acts later the same + # round; Then: bleed ticks exactly once (the re-inserted turn skips the + # start-of-turn gate). + eng = _delay_bleed_engine() + eng.run() + assert _bleed_ticks(eng, "hero") == 1 + + +def test_delay_does_not_double_decrement_buff_duration() -> None: + # Given: a delayed combatant carrying a 2-round buff; When: the round runs + # (hero acts twice: the delay turn and the re-inserted turn); Then: the + # buff duration decrements once (2 -> 1), not twice. + eng = _delay_bleed_engine() + hero = eng._states[0] + eng.run() + assert [b.rounds_left for b in hero.active_buffs] == [1] +