diff --git a/data/spells/bane.json b/data/spells/bane.json index 3d5978b..4fac823 100644 --- a/data/spells/bane.json +++ b/data/spells/bane.json @@ -15,12 +15,12 @@ "effects": [ { "type": "condition", - "condition_name": "shaken", + "condition_name": "bane", "duration_rounds": 10, "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." } \ No newline at end of file diff --git a/data/spells/bleed.json b/data/spells/bleed.json index c0dcb91..8fc9dc7 100644 --- a/data/spells/bleed.json +++ b/data/spells/bleed.json @@ -11,8 +11,9 @@ "type": "condition", "condition_name": "bleeding", "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)." } diff --git a/data/spells/mirror_image.json b/data/spells/mirror_image.json index 162e14e..c2a15af 100644 --- a/data/spells/mirror_image.json +++ b/data/spells/mirror_image.json @@ -11,8 +11,14 @@ "type": "condition", "condition_name": "blurred", "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." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ac62c59..4ca64fa 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -117,7 +117,7 @@ Phase 0 documented deviations from PF1e (conventions): from __future__ import annotations from collections.abc import Callable, Iterable -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from itertools import pairwise from pathlib import Path from typing import TYPE_CHECKING, Literal @@ -6815,6 +6815,10 @@ class CombatEngine: spell_name: str, caster_level: int = 0, ) -> 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 if effect.duration_rounds_per_level > 0: duration = max(1, caster_level * effect.duration_rounds_per_level) @@ -6822,7 +6826,7 @@ class CombatEngine: duration = min(duration, effect.max_duration_rounds) if saved and effect.save_negates: # 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). if effect.save_partial is not None: name, partial_duration = effect.save_partial @@ -6831,10 +6835,51 @@ class CombatEngine: return "" return f"{name} ({partial_duration}r) (partial save)" 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( 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( self, target: CombatantState, @@ -7187,6 +7232,8 @@ class CombatEngine: save_partial=partial, duration_rounds_per_level=per_level, max_duration_rounds=max_rounds, + images=effect.images, + requires_dying=effect.requires_dying, ) if isinstance(effect, BuffEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds diff --git a/src/pf1e_simulator/conditions.py b/src/pf1e_simulator/conditions.py index fe41adc..49a37bf 100644 --- a/src/pf1e_simulator/conditions.py +++ b/src/pf1e_simulator/conditions.py @@ -85,6 +85,21 @@ COWERING = Condition( 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 ───────────────────────────────────────────────────── # Sickened: -2 attack, weapon damage, saving throws, skill/ability checks (CRB) SICKENED = Condition( @@ -341,6 +356,6 @@ CONDITIONS_BY_NAME: dict[str, Condition] = { BLINDED, DEAFENED, FLAT_FOOTED, PRONE, STUNNED, PARALYZED, NAUSEATED, DAZED, STAGGERED, FRIGHTENED, PANICKED, COWERING, ASLEEP, GRAPPLED, PINNED, BLURRED, DISPLACED, - HELPLESS, FASCINATED, INVISIBLE, BLEEDING, CONFUSED, + HELPLESS, FASCINATED, INVISIBLE, BLEEDING, CONFUSED, BANE, ] } diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index d3cd9cc..1d1411d 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -176,6 +176,26 @@ class DamageEffect: 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) class ConditionEffect: """Transient condition (PF1e: ray of enfeeblement, hold person, doom...). @@ -185,6 +205,13 @@ class ConditionEffect: round/level", doom "1 min./level" — replacing the fixed ``duration_rounds`` when > 0; ``max_duration_rounds`` caps it (0 = no 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 @@ -196,6 +223,8 @@ class ConditionEffect: save_partial: tuple[str, int] | None = None duration_rounds_per_level: int = 0 max_duration_rounds: int = 0 + images: ImageCountScaling | None = None + requires_dying: bool = False @dataclass(frozen=True) @@ -351,7 +380,9 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int: 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]: @@ -587,6 +618,20 @@ def _parse_save_partial(value: Json) -> tuple[str, int]: 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: formula = parse_dice(_as_str(effect["formula"])) scaling_raw = effect.get("scaling") @@ -630,6 +675,8 @@ def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect: if max_rounds < 0: msg = f"max_duration_rounds must be >= 0, got {max_rounds}" raise ValueError(msg) + images_raw = effect.get("images") + images = None if images_raw is None else _parse_images(images_raw) return ConditionEffect( condition_name=_as_str(effect["condition_name"]), duration_rounds=_as_int(effect["duration_rounds"]), @@ -637,6 +684,8 @@ def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect: save_partial=save_partial, duration_rounds_per_level=per_level, max_duration_rounds=max_rounds, + images=images, + requires_dying=bool(effect.get("requires_dying", False)), ) diff --git a/tests/test_spells.py b/tests/test_spells.py index ad3a9f2..b47c516 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -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