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:
@@ -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
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user