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
+49 -2
View File
@@ -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
+16 -1
View File
@@ -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,
]
}
+50 -1
View File
@@ -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)),
)