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