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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 21:15:14 +02:00
parent 1b5cac234e
commit fa01ed74c5
3 changed files with 83 additions and 16 deletions
+54
View File
@@ -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]