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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user