From c23fc366e4c195e5c49cb9052e0c88c92916e9e4 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 18:58:03 +0200 Subject: [PATCH] fix(combat): _find_ally_needing_heal uses hp*2 < hp_max (strictly below half) - the floored hp < hp_max//2 was off by one for odd hp_max (5 -> threshold 2, so 2/5 hp counted healthy); + threshold regression test --- src/pf1e_simulator/combat.py | 9 +++++++-- tests/test_class_abilities.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 92c2646..8af6ccd 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -8311,9 +8311,14 @@ def _find_heal_spell(engine: CombatEngine, spells_known: list[str]) -> str | Non def _find_ally_needing_heal(engine: CombatEngine, caster: CombatantState) -> CombatantState | None: - """Find an ally below 50% HP.""" + """Find an ally below half HP. + + ``hp * 2 < hp_max`` is strictly-below-half for every hp_max; the old + ``hp < hp_max // 2`` floored the threshold (hp_max 5 -> 2), so a level-1 + character at 2/5 hp counted as healthy. + """ for ally_state in engine.states: - below_half = ally_state.hp < ally_state.combatant.hp_max // 2 + below_half = ally_state.hp * 2 < ally_state.combatant.hp_max if ally_state.side == caster.side and ally_state.active and below_half: return ally_state return None diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index f7ea339..bec977f 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -127,6 +127,23 @@ def test_oni_emits_channel_energy_when_ally_hurt() -> None: assert any(a.kind == "channel_energy" for a in actions) +def test_oni_channel_heal_triggers_below_half_of_odd_hp_max() -> None: + """Strictly-below-half threshold: hp_max 5 at hp 2 satisfies 2*2 < 5 but + failed the floored old check hp < hp_max // 2 (2 < 2), leaving a 40%-HP + ally untreated.""" + oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) + goblin = _combatant("goblin", hp_max=6) + engine, me, _ = _engine(oni, [goblin]) + hurt_ally = _state(_combatant("halfling", hp_max=5), "players", (5, 5), hp=2) + engine.states.append(hurt_ally) + + actions = default_policy(engine, me) + + assert any( + a.kind == "channel_energy" and a.channel_mode == "heal" for a in actions + ) + + def test_oni_silent_when_channel_energy_exhausted() -> None: oni = _combatant("oni", resources={"channel_energy": 0}, hp_max=30) goblin = _combatant("goblin", hp_max=6)