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
+6 -1
View File
@@ -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
+8
View File
@@ -1398,6 +1398,14 @@ class CombatEngine:
def _take_turn(self, state: CombatantState) -> bool:
"""Execute one combatant's full turn; return True if the battle is over."""
# 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:
+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]