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`
|
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
|
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
|
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
|
documentée : le jet de compétence Soin DD 15 n'est pas modélisé (le moteur
|
||||||
n'a pas d'action Soin).
|
n'a pas d'action Soin).
|
||||||
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
|
Confus (`confused`, V5b) : au début de chaque tour, jet d% — 01-25 agit
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"level": 0,
|
"level": 0,
|
||||||
"school": "necromancy",
|
"school": "necromancy",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "fort",
|
"save": "will",
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"save_negates": true
|
"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
|
return speed_mult
|
||||||
|
|
||||||
def _decrement_durations(self, state: CombatantState) -> None:
|
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[:] = [
|
state.active_buffs[:] = [
|
||||||
ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source)
|
ActiveBuff(modifiers=b.modifiers, rounds_left=b.rounds_left - 1, source=b.source)
|
||||||
for b in state.active_buffs
|
for b in state.active_buffs
|
||||||
if b.rounds_left > 1
|
if b.rounds_left > 1
|
||||||
]
|
]
|
||||||
state.active_conditions[:] = [
|
kept_conditions = [
|
||||||
ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source)
|
ActiveCondition(condition=c.condition, rounds_left=c.rounds_left - 1, source=c.source)
|
||||||
for c in state.active_conditions
|
for c in state.active_conditions
|
||||||
if c.rounds_left > 1
|
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:
|
def _turn_gate(self, state: CombatantState) -> bool | None:
|
||||||
"""Start-of-turn effects: bleed tick, confused d% roll, cant_act flag.
|
"""Start-of-turn effects: bleed tick, confused d% roll, cant_act flag.
|
||||||
@@ -5571,6 +5585,11 @@ class CombatEngine:
|
|||||||
source=f"spell: {spell_name}",
|
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)"
|
return f"{effect.condition_name} ({effect.duration_rounds}r)"
|
||||||
|
|
||||||
def _apply_heal_effect(
|
def _apply_heal_effect(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _BATCH1: dict[str, tuple[str, int, str, str, str]] = {
|
|||||||
"Bane": ("bane", 1, "enchantment", "medium", "will"),
|
"Bane": ("bane", 1, "enchantment", "medium", "will"),
|
||||||
"Bear's Endurance": ("bears_endurance", 2, "transmutation", "touch", "fort"),
|
"Bear's Endurance": ("bears_endurance", 2, "transmutation", "touch", "fort"),
|
||||||
"Bladed Dash": ("bladed_dash", 1, "transmutation", "close", "none"),
|
"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"),
|
"Bless": ("bless", 1, "enchantment", "medium", "none"),
|
||||||
"Blindness/Deafness": ("blindness_deafness", 2, "necromancy", "medium", "will"),
|
"Blindness/Deafness": ("blindness_deafness", 2, "necromancy", "medium", "will"),
|
||||||
"Charm Person": ("charm_person", 1, "enchantment", "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)
|
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:
|
class TestTargetedSpellSaveNegates:
|
||||||
"""Given: a targeted spell with a save (half_on_save=False)
|
"""Given: a targeted spell with a save (half_on_save=False)
|
||||||
When: the target succeeds / fails its save
|
When: the target succeeds / fails its save
|
||||||
|
|||||||
Reference in New Issue
Block a user