fix(spells): make the bleed spell bleed

PF1e (SRD spells/bleed.md): "Saving Throw Will negates" and the target takes 1 point of damage per round. Two divergences: the JSON declared a Fortitude save, and the bleeding condition never set the engine's bleed_damage field, so the spell was a no-op. Bleed now saves Will; _apply_condition_effect sets bleed_damage = 1 when a spell applies bleeding, and _decrement_durations lands the final tick as the last bleeding condition expires before clearing the field (heal-path clearing already existed in _stop_bleeding). README updated.

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-12 12:46:53 +02:00
parent 8c7e7075ad
commit 910ed1dc7d
5 changed files with 61 additions and 6 deletions
+5 -1
View File
@@ -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
+2 -2
View File
@@ -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)."
}
+21 -2
View File
@@ -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(
+1 -1
View File
@@ -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"),
+32
View File
@@ -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