diff --git a/README.md b/README.md index cd7b5da..0c8c80b 100644 --- a/README.md +++ b/README.md @@ -987,7 +987,11 @@ Règles modélisées : Saignement (`bleeding`, V5b) : une créature qui saigne subit `bleed_damage` points de dégâts au **début de son tour** (avant politique et flags) ; tout effet restaurant des points de vie (sort de soin, channel energy) stoppe le - saignement (`bleed_damage = 0`) et retire la condition. Simplification + saignement (`bleed_damage = 0`) et retire la condition ; l'expiration de la + dernière condition `bleeding` inflige son **dernier tick** puis remet + `bleed_damage = 0` (conformance D2 — SRD « Bleed » : le cantrip, sauvegarde + de **Volonté** annule, pose la condition `bleeding` avec `bleed_damage = 1`, + donc 1 point de dégâts par round). Simplification documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur n'a pas d'action Soin). Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit diff --git a/data/spells/bleed.json b/data/spells/bleed.json index ec8c4a0..c0dcb91 100644 --- a/data/spells/bleed.json +++ b/data/spells/bleed.json @@ -3,7 +3,7 @@ "level": 0, "school": "necromancy", "range": "close", - "save": "fort", + "save": "will", "base_dc": 10, "cast_time": "standard", "effects": [ @@ -14,5 +14,5 @@ "save_negates": true } ], - "description": "A cantrip that causes a wound to bleed: the target takes 1 point of damage per round until healed. Fortitude negates. Modeled as the bleeding condition for 1 round." + "description": "SRD spells/bleed.md: a living creature at -1 or fewer hit points begins dying, taking 1 point of damage per round; Will negates. Modeled as the bleeding condition for 1 round with bleed_damage = 1 (the engine's bleed tick deals it at the start of the target's turn)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 77c90d4..6d17ca5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1732,17 +1732,31 @@ class CombatEngine: return speed_mult def _decrement_durations(self, state: CombatantState) -> None: - """Decrement duration rounds for buffs and conditions; remove expired ones.""" + """Decrement duration rounds for buffs and conditions; remove expired ones. + + A bleeding condition's last round ends with its bleed tick: the DoT + lives on ``bleed_damage`` (CRB Conditions — bleeding), so the final + damage is applied as the last condition instance expires and the + field is cleared (mirrors ``_stop_bleeding`` on healing). + """ state.active_buffs[:] = [ ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source) for b in state.active_buffs if b.rounds_left > 1 ] - state.active_conditions[:] = [ + kept_conditions = [ ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source) for c in state.active_conditions if c.rounds_left > 1 ] + bleeding_expired = ( + any(ac.condition.name == "bleeding" for ac in state.active_conditions) + and not any(ac.condition.name == "bleeding" for ac in kept_conditions) + ) + state.active_conditions[:] = kept_conditions + if bleeding_expired and state.bleed_damage > 0: + self._apply_bleed_damage(state) + state.bleed_damage = 0 def _turn_gate(self, state: CombatantState) -> bool | None: """Start-of-turn effects: bleed tick, confused d% roll, cant_act flag. @@ -5571,6 +5585,11 @@ class CombatEngine: source=f"spell: {spell_name}", ) ) + if effect.condition_name == "bleeding": + # The bleeding condition's damage-over-time is engine-driven + # (CombatantState.bleed_damage, CRB Conditions — bleeding); the + # SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round. + target.bleed_damage = max(target.bleed_damage, 1) return f"{effect.condition_name} ({effect.duration_rounds}r)" def _apply_heal_effect( diff --git a/tests/test_spell_registry_batch1.py b/tests/test_spell_registry_batch1.py index 02b3c6f..f04ce50 100644 --- a/tests/test_spell_registry_batch1.py +++ b/tests/test_spell_registry_batch1.py @@ -42,7 +42,7 @@ _BATCH1: dict[str, tuple[str, int, str, str, str]] = { "Bane": ("bane", 1, "enchantment", "medium", "will"), "Bear's Endurance": ("bears_endurance", 2, "transmutation", "touch", "fort"), "Bladed Dash": ("bladed_dash", 1, "transmutation", "close", "none"), - "Bleed": ("bleed", 0, "necromancy", "close", "fort"), + "Bleed": ("bleed", 0, "necromancy", "close", "will"), "Bless": ("bless", 1, "enchantment", "medium", "none"), "Blindness/Deafness": ("blindness_deafness", 2, "necromancy", "medium", "will"), "Charm Person": ("charm_person", 1, "enchantment", "medium", "will"), diff --git a/tests/test_spells.py b/tests/test_spells.py index 2acc74c..c6f3391 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -396,6 +396,38 @@ class TestAreaSavesPerSpellSaveType: assert not any(ac.condition.name == "stunned" for ac in t.active_conditions) +class TestBleedCantrip: + """Given: the Bleed cantrip (SRD spells/bleed.md: Will negates; the target + takes 1 point of bleed damage per round) + When: the target fails / succeeds its Will save + Then: failed — bleeding condition, 1 hp lost at the start of its turn; + saved — no condition, no bleed damage.""" + + def test_failed_save_deals_one_bleed_damage_per_round(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=30) + # Will save d20=2 (+2) vs DC 14 -> fail. + engine, c, t = _make_engine(caster, target, [2]) + engine._cast_spell(c, t, "Bleed") + assert any(ac.condition.name == "bleeding" for ac in t.active_conditions) + assert t.bleed_damage == 1 + # The target's next turn start: the 1-round condition expires and its + # final bleed tick lands (1 hp). + engine._decrement_durations(t) + assert t.hp == 30 - 1 + assert t.bleed_damage == 0 + + def test_successful_save_does_not_bleed(self) -> None: + caster = _make_caster() + target = _make_target(hp_max=30) + # Will save natural 20 -> auto-success (SRD: Will negates). + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Bleed") + assert not any(ac.condition.name == "bleeding" for ac in t.active_conditions) + assert t.bleed_damage == 0 + assert t.hp == 30 + + class TestTargetedSpellSaveNegates: """Given: a targeted spell with a save (half_on_save=False) When: the target succeeds / fails its save