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:
@@ -15,12 +15,12 @@
|
|||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "shaken",
|
"condition_name": "bane",
|
||||||
"duration_rounds": 10,
|
"duration_rounds": 10,
|
||||||
"save_negates": true
|
"save_negates": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "SRD spells/bane.md: enemies within a 50-ft burst centered on the caster take a -1 penalty on attack rolls and a -1 penalty on saving throws against fear effects; Will negates. The -1 penalty is approximated with the shaken condition (-2 on attacks and saves), matching the existing Doom approximation. Duration 1 min/level approximated as 10 rounds."
|
"description": "SRD spells/bane.md: enemies within a 50-ft burst centered on the caster take a -1 penalty on attack rolls and a -1 penalty on saving throws against fear effects; Will negates. Modeled with the dedicated bane condition (untyped -1 on attacks, -1 on saves conditioned on the fear descriptor) - the previous shaken (-2) approximation overcounted the penalty. Duration 1 min/level approximated as 10 rounds."
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "bleeding",
|
"condition_name": "bleeding",
|
||||||
"duration_rounds": 1,
|
"duration_rounds": 1,
|
||||||
"save_negates": true
|
"save_negates": true,
|
||||||
|
"requires_dying": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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)."
|
"description": "SRD spells/bleed.md: you target one living creature that has -1 or fewer hit points; it begins dying, taking 1 point of damage per round; Will negates. Gated on the target's hit points (requires_dying): a creature at 0 hp or above is not a valid target and the spell has no effect. 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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,14 @@
|
|||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "blurred",
|
"condition_name": "blurred",
|
||||||
"duration_rounds": 10,
|
"duration_rounds": 10,
|
||||||
"save_negates": false
|
"duration_rounds_per_level": 10,
|
||||||
|
"save_negates": false,
|
||||||
|
"images": {
|
||||||
|
"dice": "1d4",
|
||||||
|
"per_levels": 3,
|
||||||
|
"max": 8
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Illusory doubles (1d4 + 1 per 3 caster levels, max 8) occupy your space, making attacks risk striking a figment instead. Duration 1 min/level approximated as 10 rounds. Simplification: the image-destruction mechanic is approximated as the blurred condition (20% miss chance); the real spell can deflect attacks entirely until the images are used up."
|
"description": "SRD spells/mirror-image.md: 1d4 illusory doubles plus one per three caster levels (maximum eight total) inhabit your space. The image count is rolled at cast time and folded into the engine's concealment machinery as a dynamic miss chance of images/(images+1) on the blurred carrier condition (an attack that would hit strikes a figment instead). Duration 1 min./level scaled as 10 rounds per caster level. Simplification: the figment-destruction track (a hit or a near miss by 5 or less destroys one image; area and no-attack-roll effects ignore them) is not modeled - the miss chance persists for the whole duration."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
from itertools import pairwise
|
from itertools import pairwise
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Literal
|
from typing import TYPE_CHECKING, Literal
|
||||||
@@ -6815,6 +6815,10 @@ class CombatEngine:
|
|||||||
spell_name: str,
|
spell_name: str,
|
||||||
caster_level: int = 0,
|
caster_level: int = 0,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
# SRD spells/bleed.md targets "a living creature that has -1 or fewer
|
||||||
|
# hit points" - a healthy target is simply not eligible for the effect.
|
||||||
|
if effect.requires_dying and target.hp >= 0:
|
||||||
|
return "no effect (target not dying)"
|
||||||
duration = effect.duration_rounds
|
duration = effect.duration_rounds
|
||||||
if effect.duration_rounds_per_level > 0:
|
if effect.duration_rounds_per_level > 0:
|
||||||
duration = max(1, caster_level * effect.duration_rounds_per_level)
|
duration = max(1, caster_level * effect.duration_rounds_per_level)
|
||||||
@@ -6822,7 +6826,7 @@ class CombatEngine:
|
|||||||
duration = min(duration, effect.max_duration_rounds)
|
duration = min(duration, effect.max_duration_rounds)
|
||||||
if saved and effect.save_negates:
|
if saved and effect.save_negates:
|
||||||
# PF1e partial save (SRD spells/fear.md): a successful save does
|
# PF1e partial save (SRD spells/fear.md): a successful save does
|
||||||
# not always spare the target — it may impose a weaker condition
|
# not always spare the target - it may impose a weaker condition
|
||||||
# (Fear: shaken for 1 round instead of panicked).
|
# (Fear: shaken for 1 round instead of panicked).
|
||||||
if effect.save_partial is not None:
|
if effect.save_partial is not None:
|
||||||
name, partial_duration = effect.save_partial
|
name, partial_duration = effect.save_partial
|
||||||
@@ -6831,10 +6835,51 @@ class CombatEngine:
|
|||||||
return ""
|
return ""
|
||||||
return f"{name} ({partial_duration}r) (partial save)"
|
return f"{name} ({partial_duration}r) (partial save)"
|
||||||
return f"{effect.condition_name} negated"
|
return f"{effect.condition_name} negated"
|
||||||
|
if effect.images is not None:
|
||||||
|
return self._impose_mirror_images(
|
||||||
|
target, effect, duration, spell_name, caster_level
|
||||||
|
)
|
||||||
return self._impose_condition(
|
return self._impose_condition(
|
||||||
target, effect.condition_name, duration, spell_name
|
target, effect.condition_name, duration, spell_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _impose_mirror_images(
|
||||||
|
self,
|
||||||
|
target: CombatantState,
|
||||||
|
effect: ConditionEffect,
|
||||||
|
duration_rounds: int,
|
||||||
|
spell_name: str,
|
||||||
|
caster_level: int,
|
||||||
|
) -> str:
|
||||||
|
"""Impose Mirror Image's concealment with a cast-time image roll.
|
||||||
|
|
||||||
|
PF1e (SRD spells/mirror-image.md): "1d4 images plus one image per
|
||||||
|
three caster levels (maximum eight images total)". The engine has no
|
||||||
|
figment-destruction track, so the image count is folded into the
|
||||||
|
existing concealment machinery as a dynamic miss chance on the
|
||||||
|
condition instance: an attack that would hit strikes a figment with
|
||||||
|
probability images / (images + 1).
|
||||||
|
"""
|
||||||
|
spec = effect.images
|
||||||
|
if spec is None:
|
||||||
|
return ""
|
||||||
|
base = CONDITIONS_BY_NAME.get(effect.condition_name)
|
||||||
|
if base is None:
|
||||||
|
return ""
|
||||||
|
rolled = spec.dice.roll(self._rng)
|
||||||
|
images = min(spec.max_images, rolled + caster_level // spec.per_levels)
|
||||||
|
if images <= 0:
|
||||||
|
return "0 images"
|
||||||
|
miss_pct = 100 * images // (images + 1)
|
||||||
|
target.active_conditions.append(
|
||||||
|
ActiveCondition(
|
||||||
|
condition=replace(base, miss_chance=miss_pct),
|
||||||
|
rounds_left=duration_rounds,
|
||||||
|
source=f"spell: {spell_name}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return f"{effect.condition_name} {miss_pct}% ({images} images, {duration_rounds}r)"
|
||||||
|
|
||||||
def _impose_condition(
|
def _impose_condition(
|
||||||
self,
|
self,
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
@@ -7187,6 +7232,8 @@ class CombatEngine:
|
|||||||
save_partial=partial,
|
save_partial=partial,
|
||||||
duration_rounds_per_level=per_level,
|
duration_rounds_per_level=per_level,
|
||||||
max_duration_rounds=max_rounds,
|
max_duration_rounds=max_rounds,
|
||||||
|
images=effect.images,
|
||||||
|
requires_dying=effect.requires_dying,
|
||||||
)
|
)
|
||||||
if isinstance(effect, BuffEffect):
|
if isinstance(effect, BuffEffect):
|
||||||
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ COWERING = Condition(
|
|||||||
cant_act=True,
|
cant_act=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Bane (SRD spells/bane.md): "Each affected creature takes a -1 penalty on
|
||||||
|
# attack rolls and a -1 penalty on saving throws against fear effects." The
|
||||||
|
# save penalties are conditioned on the "fear" descriptor so they apply only
|
||||||
|
# to saves against fear effects (the engine's descriptor machinery, same
|
||||||
|
# convention as inspire courage's conditioned morale).
|
||||||
|
BANE = Condition(
|
||||||
|
name="bane",
|
||||||
|
modifiers=(
|
||||||
|
StatModifier(target="attack", value=-1),
|
||||||
|
StatModifier(target="fort", value=-1, condition="fear"),
|
||||||
|
StatModifier(target="ref", value=-1, condition="fear"),
|
||||||
|
StatModifier(target="will", value=-1, condition="fear"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ── Physical conditions ─────────────────────────────────────────────────────
|
# ── Physical conditions ─────────────────────────────────────────────────────
|
||||||
# Sickened: -2 attack, weapon damage, saving throws, skill/ability checks (CRB)
|
# Sickened: -2 attack, weapon damage, saving throws, skill/ability checks (CRB)
|
||||||
SICKENED = Condition(
|
SICKENED = Condition(
|
||||||
@@ -341,6 +356,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = {
|
|||||||
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED,
|
||||||
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
|
NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING,
|
||||||
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
|
ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED,
|
||||||
HELPLESS, FASCINATED, INVISIBLE, BLEEDING, CONFUSED,
|
HELPLESS, FASCINATED, INVISIBLE, BLEEDING, CONFUSED, BANE,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,26 @@ class DamageEffect:
|
|||||||
return DiceExpr(count=count, sides=sides, bonus=bonus)
|
return DiceExpr(count=count, sides=sides, bonus=bonus)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImageCountScaling:
|
||||||
|
"""Roll-based illusory-double count (PF1e Mirror Image).
|
||||||
|
|
||||||
|
SRD spells/mirror-image.md: "1d4 images plus one image per three caster
|
||||||
|
levels (maximum eight images total)". The dice are rolled at cast time;
|
||||||
|
the caster-level bonus is ``caster_level // per_levels`` additional
|
||||||
|
images, capped so the TOTAL stays at ``max_images``. The engine converts
|
||||||
|
the count into a concealment miss chance of ``images / (images + 1)``
|
||||||
|
(an attack that would hit strikes a figment instead).
|
||||||
|
|
||||||
|
- dice: the rolled base count (1d4)
|
||||||
|
- per_levels: caster levels required per additional image
|
||||||
|
- max_images: cap on the total image count
|
||||||
|
"""
|
||||||
|
dice: DiceExpr
|
||||||
|
per_levels: int
|
||||||
|
max_images: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ConditionEffect:
|
class ConditionEffect:
|
||||||
"""Transient condition (PF1e: ray of enfeeblement, hold person, doom...).
|
"""Transient condition (PF1e: ray of enfeeblement, hold person, doom...).
|
||||||
@@ -185,6 +205,13 @@ class ConditionEffect:
|
|||||||
round/level", doom "1 min./level" — replacing the fixed
|
round/level", doom "1 min./level" — replacing the fixed
|
||||||
``duration_rounds`` when > 0; ``max_duration_rounds`` caps it (0 = no
|
``duration_rounds`` when > 0; ``max_duration_rounds`` caps it (0 = no
|
||||||
cap, SRD vanish "maximum 5 rounds").
|
cap, SRD vanish "maximum 5 rounds").
|
||||||
|
|
||||||
|
``images`` (Mirror Image) replaces the plain condition imposition with a
|
||||||
|
cast-time roll: the rolled image count becomes a dynamic miss chance on
|
||||||
|
the condition instance (``images / (images + 1)``), reusing the engine's
|
||||||
|
concealment machinery. ``requires_dying`` gates the effect on the SRD
|
||||||
|
targeting eligibility of the Bleed cantrip ("one living creature that has
|
||||||
|
-1 or fewer hit points"): a target at 0 hp or above is unaffected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
condition_name: str
|
condition_name: str
|
||||||
@@ -196,6 +223,8 @@ class ConditionEffect:
|
|||||||
save_partial: tuple[str, int] | None = None
|
save_partial: tuple[str, int] | None = None
|
||||||
duration_rounds_per_level: int = 0
|
duration_rounds_per_level: int = 0
|
||||||
max_duration_rounds: int = 0
|
max_duration_rounds: int = 0
|
||||||
|
images: ImageCountScaling | None = None
|
||||||
|
requires_dying: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -351,7 +380,9 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
|
|||||||
return 400 + 40 * caster_level
|
return 400 + 40 * caster_level
|
||||||
|
|
||||||
|
|
||||||
_FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering"})
|
# Conditions that carry the fear descriptor (SRD spells/bane.md is [fear],
|
||||||
|
# so a bane-condition effect tags its save with the fear descriptor too).
|
||||||
|
_FEAR_CONDITIONS = frozenset({"frightened", "panicked", "shaken", "cowering", "bane"})
|
||||||
|
|
||||||
|
|
||||||
def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]:
|
def save_descriptors_for_effects(effects: tuple[SpellEffect, ...]) -> frozenset[str]:
|
||||||
@@ -587,6 +618,20 @@ def _parse_save_partial(value: Json) -> tuple[str, int]:
|
|||||||
return (_as_str(data["condition_name"]), duration)
|
return (_as_str(data["condition_name"]), duration)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_images(value: Json) -> ImageCountScaling:
|
||||||
|
data = _as_dict(value)
|
||||||
|
dice = parse_dice(_as_str(data["dice"]))
|
||||||
|
per_levels = _as_int(data.get("per_levels", 3))
|
||||||
|
if per_levels < 1:
|
||||||
|
msg = f"images per_levels must be >= 1, got {per_levels}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
max_images = _as_int(data["max"])
|
||||||
|
if max_images < 1:
|
||||||
|
msg = f"images max must be >= 1, got {max_images}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return ImageCountScaling(dice=dice, per_levels=per_levels, max_images=max_images)
|
||||||
|
|
||||||
|
|
||||||
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
|
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
|
||||||
formula = parse_dice(_as_str(effect["formula"]))
|
formula = parse_dice(_as_str(effect["formula"]))
|
||||||
scaling_raw = effect.get("scaling")
|
scaling_raw = effect.get("scaling")
|
||||||
@@ -630,6 +675,8 @@ def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
|||||||
if max_rounds < 0:
|
if max_rounds < 0:
|
||||||
msg = f"max_duration_rounds must be >= 0, got {max_rounds}"
|
msg = f"max_duration_rounds must be >= 0, got {max_rounds}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
images_raw = effect.get("images")
|
||||||
|
images = None if images_raw is None else _parse_images(images_raw)
|
||||||
return ConditionEffect(
|
return ConditionEffect(
|
||||||
condition_name=_as_str(effect["condition_name"]),
|
condition_name=_as_str(effect["condition_name"]),
|
||||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||||
@@ -637,6 +684,8 @@ def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
|||||||
save_partial=save_partial,
|
save_partial=save_partial,
|
||||||
duration_rounds_per_level=per_level,
|
duration_rounds_per_level=per_level,
|
||||||
max_duration_rounds=max_rounds,
|
max_duration_rounds=max_rounds,
|
||||||
|
images=images,
|
||||||
|
requires_dying=bool(effect.get("requires_dying", False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+66
-22
@@ -418,36 +418,80 @@ class TestAreaSavesPerSpellSaveType:
|
|||||||
|
|
||||||
|
|
||||||
class TestBleedCantrip:
|
class TestBleedCantrip:
|
||||||
"""Given: the Bleed cantrip (SRD spells/bleed.md: Will negates; the target
|
"""Given: the Bleed cantrip (SRD spells/bleed.md: "you target a living
|
||||||
takes 1 point of bleed damage per round)
|
creature that has -1 or fewer hit points"; Will negates)
|
||||||
When: the target fails / succeeds its Will save
|
When: cast on a healthy target / applied on a dying target
|
||||||
Then: failed — bleeding condition, 1 hp lost at the start of its turn;
|
Then: healthy - the requires_dying gate blocks the condition entirely;
|
||||||
saved — no condition, no bleed damage."""
|
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()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
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, c, t = _make_engine(caster, target, [2])
|
||||||
engine._cast_spell(c, t, "Bleed")
|
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 not any(ac.condition.name == "bleeding" for ac in t.active_conditions)
|
||||||
assert t.bleed_damage == 0
|
assert t.bleed_damage == 0
|
||||||
assert t.hp == 30
|
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:
|
class TestTargetedSpellSaveNegates:
|
||||||
"""Given: a targeted spell with a save (half_on_save=False)
|
"""Given: a targeted spell with a save (half_on_save=False)
|
||||||
@@ -778,7 +822,7 @@ class TestBaneEnemiesOnly:
|
|||||||
engine._cast_spell(
|
engine._cast_spell(
|
||||||
engine._states[0], enemy, "Bane",
|
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 ally.active_conditions
|
||||||
assert not engine._states[0].active_conditions
|
assert not engine._states[0].active_conditions
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user