"""V5b conditions: bleeding damage-over-time and confused random action. Canonical PF1e (Core Rulebook, Conditions): a bleeding creature takes the listed bleed damage at the beginning of its turn, stopped by any spell that cures hit point damage (the DC 15 Heal check is not modeled — no Heal action). A confused subject rolls d% at the beginning of each turn: 01-25 act normally, 26-50 babble incoherently, 51-75 deal 1d8 + Str modifier to self, 76-100 attack the nearest creature (simplification: the "attack the attacker" clause is reduced to "nearest creature"). """ from __future__ import annotations from pf1e_simulator.conditions import ( BLEEDING, CONDITIONS_BY_NAME, CONFUSED, ) from pf1e_simulator.dice import parse_dice from pf1e_simulator.models import ( AbilityScores, ACProfile, Combatant, Saves, ) from pf1e_simulator.spells import HealEffect from tests.test_accuracy_rules import ( _add_condition, _combatant, _engine, _melee, ) from tests.test_observation_enriched import _snapshot # ── Bleeding ───────────────────────────────────────────────────────────────── def test_bleed_damage_applied_at_turn_start() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4], ) a, b = eng._states a.bleed_damage = 3 eng._take_turn(a) # Bleed lands before the policy's full attack (d20 15+5 hits AC 10, 1d6=4). assert a.hp == 97 assert b.hp == 96 assert any("a: bleed 3 (hp 100->97)" in line for line in eng._transcript) bleed_idx = next(i for i, ln in enumerate(eng._transcript) if "bleed" in ln) attack_idx = next(i for i, ln in enumerate(eng._transcript) if "vs b" in ln) assert bleed_idx < attack_idx def test_bleed_applies_even_when_cannot_act() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) a, _b = eng._states _add_condition(a, "stunned") a.bleed_damage = 2 over = eng._take_turn(a) # Empty RNG queue: bleed and the cant_act forfeit consume no dice. assert a.hp == 98 assert over is False assert any("a: bleed 2 (hp 100->98)" in line for line in eng._transcript) assert any("a: wait (cannot act)" in line for line in eng._transcript) def test_bleed_to_death_ends_battle() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) a, _b = eng._states a.hp = -8 # dying, con 10 -> death threshold min(-10, -10) = -10 a.bleed_damage = 5 over = eng._take_turn(a) assert a.hp == -13 assert a.dead is True assert over is True # b is the only active side assert any("a: bleed 5 (hp -8->-13)" in line for line in eng._transcript) def test_bleed_tick_records_damage_taken() -> None: """Attrition (README "dégâts subis"): bleed is damage - the tick lands in damage_taken with no attributed dealer (no attack source).""" eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) a, _b = eng._states a.bleed_damage = 2 eng._apply_bleed_damage(a) assert a.hp == 98 assert eng._stats["a"].damage_taken == 2 assert eng._stats["a"].damage_dealt == 0 assert eng._stats["b"].damage_taken == 0 def test_heal_spell_clears_bleed() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 7)], ["players", "monsters"], [5], ) a, _b = eng._states a.hp = 50 a.bleed_damage = 4 _add_condition(a, "bleeding") a.conditions.append(BLEEDING) result = eng._apply_heal_effect( HealEffect(formula=parse_dice("1d8")), a, caster_level=1, empower=False, maximize=False, saved=False, ) assert result == "+5 HP" assert a.hp == 55 assert a.bleed_damage == 0 assert not any(ac.condition.name == "bleeding" for ac in a.active_conditions) assert not any(cond.name == "bleeding" for cond in a.conditions) def test_channel_energy_clears_bleed() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [], ) caster, target = eng._states target.hp = 40 target.bleed_damage = 6 _add_condition(target, "bleeding") eng._channel_heal(caster, target, 10) assert target.hp == 50 assert target.bleed_damage == 0 assert not any(ac.condition.name == "bleeding" for ac in target.active_conditions) # ── Confused ───────────────────────────────────────────────────────────────── def _strong_combatant(cid: str) -> Combatant: """A combatant with Str 16 (+3 modifier) for the confused self-harm roll.""" return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=16, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=100, ac=ACProfile(total=10, touch=10, flat_footed=10), bab=1, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=0, ref=0, will=0), attacks=[_melee(cid)], ) def test_confused_acts_normally_on_01_25() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [10, 15, 4], ) a, b = eng._states _add_condition(a, "confused") eng._take_turn(a) # d100 10 -> act normally: the policy's full attack runs (15+5 hits, 4 dmg). assert b.hp == 96 assert a.hp == 100 assert any("vs b" in line and "HIT" in line for line in eng._transcript) assert not any("confused:" in line for line in eng._transcript) def test_confused_babbles_on_26_50() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [30], ) a, b = eng._states _add_condition(a, "confused") over = eng._take_turn(a) # Only the d100 consumed: no attack roll, no damage anywhere. assert over is False assert a.hp == 100 assert b.hp == 100 assert any("a: confused: babbles" in line for line in eng._transcript) assert not any("vs" in line for line in eng._transcript) def test_confused_hits_self_on_51_75() -> None: eng = _engine( [_strong_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [60, 5], ) a, b = eng._states _add_condition(a, "confused") over = eng._take_turn(a) # 1d8 (5) + Str mod (+3) = 8 damage to self, no attack roll consumed. assert over is False assert a.hp == 92 assert b.hp == 100 assert any( "a: confused: hits self for 8 (hp 100->92)" in line for line in eng._transcript ) def test_confused_self_damage_knocks_out_and_ends_battle() -> None: eng = _engine( [_strong_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [60, 8], ) a, _b = eng._states a.hp = 2 _add_condition(a, "confused") over = eng._take_turn(a) # 1d8 (8) + Str mod (+3) = 11 -> hp -9: dying (threshold -10), not dead; # only b's side is active, so the winner check ends the battle cleanly. assert a.hp == -9 assert a.dead is False assert a.active is False assert over is True def test_confused_attacks_nearest_creature_friend_or_foe_on_76_100() -> None: eng = _engine( [_combatant("a"), _combatant("b"), _combatant("c")], [(0, 0), (0, 3), (0, 1)], ["players", "monsters", "players"], [90, 15, 4], ) a, b, c = eng._states _add_condition(a, "confused") over = eng._take_turn(a) # Nearest creature is the ALLY c (1 cell) not the enemy b (3 cells): # d20 15+5 hits AC 10 for 4 damage. assert over is False assert c.hp == 96 assert b.hp == 100 assert a.hp == 100 assert any("a: confused: attacks c" in line for line in eng._transcript) assert any("vs c" in line and "HIT" in line for line in eng._transcript) assert not any("vs b" in line for line in eng._transcript) def test_confused_attack_nearest_nothing_in_reach() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 7)], ["players", "monsters"], [90], ) a, b = eng._states _add_condition(a, "confused") over = eng._take_turn(a) # Only the d100 consumed: the nearest creature is out of melee reach. assert over is False assert a.hp == 100 assert b.hp == 100 assert any( "a: confused: attacks nearest but nothing in reach" in line for line in eng._transcript ) assert not any("vs" in line for line in eng._transcript) def test_confused_ignored_without_condition() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4], ) a, b = eng._states eng._take_turn(a) # No confused condition: no d% roll is consumed (queue starts at the d20). assert b.hp == 96 assert not any("confused" in line for line in eng._transcript) # ── Registration + snapshot exposure ───────────────────────────────────────── def test_new_conditions_registered() -> None: assert CONDITIONS_BY_NAME["bleeding"] is BLEEDING assert CONDITIONS_BY_NAME["confused"] is CONFUSED assert BLEEDING.modifiers == () assert BLEEDING.cant_act is False assert BLEEDING.denies_dex_to_ac is False assert BLEEDING.miss_chance == 0 assert CONFUSED.modifiers == () assert CONFUSED.cant_act is False assert CONFUSED.miss_chance == 0 def test_snapshot_exposes_bleed_damage() -> None: eng = _engine( [_combatant("a"), _combatant("b")], [(0, 0), (0, 3)], ["players", "monsters"], [], ) a, _b = eng._states snap = _snapshot(eng, a) mine = next(c for c in snap["combatants"] if c["id"] == "a") assert mine["bleed_damage"] == 0 a.bleed_damage = 7 _add_condition(a, "bleeding") _add_condition(a, "confused") snap = _snapshot(eng, a) mine = next(c for c in snap["combatants"] if c["id"] == "a") assert mine["bleed_damage"] == 7 names = {name for name, _rounds in mine["conditions"]} assert {"bleeding", "confused"} <= names defs = dict(snap["condition_definitions"]) assert len(defs) == len(CONDITIONS_BY_NAME) assert defs["bleeding"]["flags"] == [] assert defs["confused"]["flags"] == ["cant_aoo"]