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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user