fix(rules): mirror image rolled image count with dynamic images/(images+1) concealment miss chance, bleed cantrip gated on the SRD dying-target eligibility, bane -1 fear-scoped penalty via a dedicated bane condition (SRD spells/mirror-image.md, spells/bleed.md, spells/bane.md)

This commit is contained in:
2026-09-19 13:22:19 +02:00
parent d9b590ebce
commit 56310b191a
7 changed files with 194 additions and 32 deletions
+66 -22
View File
@@ -418,36 +418,80 @@ class TestAreaSavesPerSpellSaveType:
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."""
"""Given: the Bleed cantrip (SRD spells/bleed.md: "you target a living
creature that has -1 or fewer hit points"; Will negates)
When: cast on a healthy target / applied on a dying target
Then: healthy - the requires_dying gate blocks the condition entirely;
dying - bleeding condition with 1 bleed damage."""
def test_failed_save_deals_one_bleed_damage_per_round(self) -> None:
def test_healthy_target_is_not_a_valid_bleed_target(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
# Will save d20=2 (+2) vs DC 14 -> fail.
# Will save d20=2 (+2) vs DC 14 -> fail; the hp gate still blocks.
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
def test_dying_target_begins_bleeding(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [])
t.hp = -3 # below 0 hp: the SRD-only valid target for this cantrip
spell = engine._spell_registry["Bleed"]
effect = spell.effects[0]
assert isinstance(effect, ConditionEffect)
result = engine._apply_condition_effect(
effect, t, saved=False, spell_name="Bleed",
)
assert "bleeding" in result
assert any(ac.condition.name == "bleeding" for ac in t.active_conditions)
assert t.bleed_damage == 1
def test_successful_save_negates_the_bleed(self) -> None:
caster = _make_caster()
target = _make_target(hp_max=30)
engine, c, t = _make_engine(caster, target, [20])
t.hp = -3
spell = engine._spell_registry["Bleed"]
effect = spell.effects[0]
assert isinstance(effect, ConditionEffect)
result = engine._apply_condition_effect(
effect, t, saved=True, spell_name="Bleed",
)
assert "negated" in result
assert not t.active_conditions
assert t.bleed_damage == 0
class TestMirrorImages:
"""Given: Mirror Image (SRD spells/mirror-image.md: "1d4 images plus one
image per three caster levels (maximum eight images total)")
When: cast at level 5 with a rolled 1d4 of 3 / at level 30 with a 4
Then: 4 images -> 80% miss (images/(images+1)), 10 rounds per level;
the count caps at 8 images -> 88% miss."""
def test_image_count_scales_with_level(self) -> None:
caster = _make_caster(level=5)
target = _make_target()
# 1d4 images roll = 3 -> 3 + 5//3 = 4 images -> 4/(4+1) = 80% miss.
engine, c, t = _make_engine(caster, target, [3])
engine._cast_spell(c, c, "Mirror Image")
images = [ac for ac in c.active_conditions if ac.condition.name == "blurred"]
assert len(images) == 1
assert images[0].condition.miss_chance == 80
assert images[0].rounds_left == 50 # 1 min./level = 10 rounds x level 5
def test_image_count_caps_at_eight(self) -> None:
caster = _make_caster(level=30)
target = _make_target()
# 1d4 = 4 -> 4 + 30//3 = 14, capped at 8 -> 8/9 = 88% miss.
engine, c, t = _make_engine(caster, target, [4])
engine._cast_spell(c, c, "Mirror Image")
images = [ac for ac in c.active_conditions if ac.condition.name == "blurred"]
assert images[0].condition.miss_chance == 88
class TestTargetedSpellSaveNegates:
"""Given: a targeted spell with a save (half_on_save=False)
@@ -778,7 +822,7 @@ class TestBaneEnemiesOnly:
engine._cast_spell(
engine._states[0], enemy, "Bane",
)
assert any(c.condition.name == "shaken" for c in enemy.active_conditions)
assert any(c.condition.name == "bane" for c in enemy.active_conditions)
assert not ally.active_conditions
assert not engine._states[0].active_conditions