fix(rules): ray of enfeeblement applies a rolled Strength penalty (Fort half) instead of fatigued
SRD ray-of-enfeeblement.md: 1d6 + 1 per 2 caster levels (max 1d6+5) Strength penalty, Fortitude half, 1 round/level. Adds a PenaltyEffect type (rolled formula, scaling, half on save, level-scaled duration) applied as negative untyped modifiers on the rolls Strength feeds (attack, damage), since the engine has no ability-score track. Formerly modeled as a fatigued condition with Fort negates.
This commit is contained in:
+163
-82
@@ -23,6 +23,8 @@ from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice
|
||||
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from pf1e_simulator.loaders.foundry import Json
|
||||
|
||||
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
||||
@@ -215,6 +217,36 @@ class BuffEffect:
|
||||
duration_rounds: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PenaltyEffect:
|
||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||
|
||||
The engine has no ability-score track, so the Strength penalty is
|
||||
modeled on the two combat rolls Strength feeds — attack and damage
|
||||
rolls — as negative untyped StatModifiers for the spell's duration.
|
||||
"""
|
||||
formula: DiceExpr
|
||||
scaling_flat: DamageFlatScaling | None = None
|
||||
targets: tuple[StatTarget, ...] = ()
|
||||
duration_rounds_per_level: int = 1
|
||||
half_on_save: bool = True
|
||||
|
||||
def effective_formula(self, caster_level: int) -> DiceExpr:
|
||||
"""The penalty formula resolved for a caster of ``caster_level``.
|
||||
|
||||
PF1e (CRB, Magic — Caster Level): the flat bonus grows with the
|
||||
caster and stops at the stated cap — bonus = min(max, (level //
|
||||
per_levels) * amount), mirroring ``DamageEffect.effective_formula``.
|
||||
"""
|
||||
bonus = self.formula.bonus
|
||||
if self.scaling_flat is not None:
|
||||
bonus += min(
|
||||
self.scaling_flat.max_amount,
|
||||
(caster_level // self.scaling_flat.per_levels) * self.scaling_flat.amount,
|
||||
)
|
||||
return DiceExpr(count=self.formula.count, sides=self.formula.sides, bonus=bonus)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AreaEffect:
|
||||
"""Area of effect spell (burst / cone / line).
|
||||
@@ -250,7 +282,10 @@ class SummonEffect:
|
||||
duration_rounds: int
|
||||
|
||||
|
||||
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
||||
SpellEffect = (
|
||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
| PenaltyEffect | AreaEffect | SummonEffect
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -406,16 +441,21 @@ def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
|
||||
return value in get_args(StatTarget)
|
||||
|
||||
|
||||
def _parse_stat_target(value: Json) -> StatTarget:
|
||||
target = _as_str(value)
|
||||
if not _is_stat_target(target):
|
||||
msg = f"unknown stat target: {target!r}"
|
||||
raise ValueError(msg)
|
||||
return target
|
||||
|
||||
|
||||
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
|
||||
return value in get_args(BonusType)
|
||||
|
||||
|
||||
def _parse_modifier(value: Json) -> StatModifier:
|
||||
m = _as_dict(value)
|
||||
target = _as_str(m["target"])
|
||||
if not _is_stat_target(target):
|
||||
msg = f"unknown stat target: {target!r}"
|
||||
raise ValueError(msg)
|
||||
target = _parse_stat_target(m["target"])
|
||||
bonus = _as_str(m.get("bonus_type", ""))
|
||||
if not _is_bonus_type(bonus):
|
||||
msg = f"unknown bonus type: {bonus!r}"
|
||||
@@ -518,86 +558,127 @@ def _parse_save_partial(value: Json) -> tuple[str, int]:
|
||||
return (_as_str(data["condition_name"]), duration)
|
||||
|
||||
|
||||
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
|
||||
formula = parse_dice(_as_str(effect["formula"]))
|
||||
scaling_raw = effect.get("scaling")
|
||||
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
|
||||
if (
|
||||
scaling is not None
|
||||
and formula.count > 0
|
||||
and scaling.dice.sides != formula.sides
|
||||
):
|
||||
msg = (
|
||||
"damage scaling dice must match the base formula die type: "
|
||||
f"{dice_to_str(scaling.dice)} vs {dice_to_str(formula)}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
proj_raw = effect.get("projectiles")
|
||||
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
|
||||
rec_raw = effect.get("recurring")
|
||||
recurring = None if rec_raw is None else _parse_recurring(rec_raw)
|
||||
return DamageEffect(
|
||||
formula=formula,
|
||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
||||
half_on_save=bool(effect.get("half_on_save", False)),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
scaling=scaling,
|
||||
scaling_flat=scaling_flat,
|
||||
projectiles=projectiles,
|
||||
recurring=recurring,
|
||||
)
|
||||
|
||||
|
||||
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
||||
partial_raw = effect.get("on_success")
|
||||
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
||||
return ConditionEffect(
|
||||
condition_name=_as_str(effect["condition_name"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
save_partial=save_partial,
|
||||
)
|
||||
|
||||
|
||||
def _parse_heal_effect(effect: dict[str, Json]) -> HealEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
return HealEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
scaling_flat=scaling_flat,
|
||||
)
|
||||
|
||||
|
||||
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
|
||||
return BuffEffect(
|
||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||
)
|
||||
|
||||
|
||||
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
targets: tuple[StatTarget, ...] = tuple(
|
||||
_parse_stat_target(t) for t in _as_list(effect["targets"])
|
||||
)
|
||||
return PenaltyEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
scaling_flat=scaling_flat,
|
||||
targets=targets,
|
||||
duration_rounds_per_level=_as_int(effect.get("duration_rounds_per_level", 1)),
|
||||
half_on_save=bool(effect.get("half_on_save", True)),
|
||||
)
|
||||
|
||||
|
||||
def _parse_area_effect(effect: dict[str, Json]) -> AreaEffect:
|
||||
shape_raw = _as_str(effect.get("shape", "burst"))
|
||||
if not _is_area_shape(shape_raw):
|
||||
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
|
||||
raise ValueError(msg)
|
||||
targets_raw = _as_str(effect.get("targets", "all"))
|
||||
if not _is_area_targets(targets_raw):
|
||||
msg = (
|
||||
f"unknown area targets: {targets_raw!r} "
|
||||
f"(expected one of {get_args(AreaTargets)})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return AreaEffect(
|
||||
shape=shape_raw,
|
||||
radius_ft=_as_int(effect.get("radius_ft", 20)),
|
||||
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||
save_for_half=bool(effect.get("save_for_half", True)),
|
||||
targets=targets_raw,
|
||||
)
|
||||
|
||||
|
||||
def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect:
|
||||
return SummonEffect(
|
||||
creature=_as_str(effect["creature"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
)
|
||||
|
||||
|
||||
_EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
|
||||
"damage": _parse_damage_effect,
|
||||
"condition": _parse_condition_effect,
|
||||
"heal": _parse_heal_effect,
|
||||
"buff": _parse_buff_effect,
|
||||
"penalty": _parse_penalty_effect,
|
||||
"area": _parse_area_effect,
|
||||
"summon": _parse_summon_effect,
|
||||
}
|
||||
|
||||
|
||||
def _parse_effect(data: Json) -> SpellEffect:
|
||||
effect = _as_dict(data)
|
||||
effect_type = _as_str(effect["type"])
|
||||
if effect_type == "damage":
|
||||
formula = parse_dice(_as_str(effect["formula"]))
|
||||
scaling_raw = effect.get("scaling")
|
||||
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
|
||||
if (
|
||||
scaling is not None
|
||||
and formula.count > 0
|
||||
and scaling.dice.sides != formula.sides
|
||||
):
|
||||
msg = (
|
||||
"damage scaling dice must match the base formula die type: "
|
||||
f"{dice_to_str(scaling.dice)} vs {dice_to_str(formula)}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
proj_raw = effect.get("projectiles")
|
||||
projectiles = None if proj_raw is None else _parse_projectiles(proj_raw)
|
||||
rec_raw = effect.get("recurring")
|
||||
recurring = None if rec_raw is None else _parse_recurring(rec_raw)
|
||||
return DamageEffect(
|
||||
formula=formula,
|
||||
types=tuple(_as_str(t) for t in _as_list(effect["types"])),
|
||||
half_on_save=bool(effect.get("half_on_save", False)),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
scaling=scaling,
|
||||
scaling_flat=scaling_flat,
|
||||
projectiles=projectiles,
|
||||
recurring=recurring,
|
||||
)
|
||||
if effect_type == "condition":
|
||||
partial_raw = effect.get("on_success")
|
||||
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
||||
return ConditionEffect(
|
||||
condition_name=_as_str(effect["condition_name"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
save_negates=bool(effect.get("save_negates", True)),
|
||||
save_partial=save_partial,
|
||||
)
|
||||
if effect_type == "heal":
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
return HealEffect(
|
||||
formula=parse_dice(_as_str(effect["formula"])),
|
||||
scaling_flat=scaling_flat,
|
||||
)
|
||||
if effect_type == "buff":
|
||||
return BuffEffect(
|
||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||
)
|
||||
if effect_type == "area":
|
||||
shape_raw = _as_str(effect.get("shape", "burst"))
|
||||
if not _is_area_shape(shape_raw):
|
||||
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
|
||||
raise ValueError(msg)
|
||||
targets_raw = _as_str(effect.get("targets", "all"))
|
||||
if not _is_area_targets(targets_raw):
|
||||
msg = (
|
||||
f"unknown area targets: {targets_raw!r} "
|
||||
f"(expected one of {get_args(AreaTargets)})"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return AreaEffect(
|
||||
shape=shape_raw,
|
||||
radius_ft=_as_int(effect.get("radius_ft", 20)),
|
||||
effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
|
||||
save_for_half=bool(effect.get("save_for_half", True)),
|
||||
targets=targets_raw,
|
||||
)
|
||||
if effect_type == "summon":
|
||||
return SummonEffect(
|
||||
creature=_as_str(effect["creature"]),
|
||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
||||
)
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
parser = _EFFECT_PARSERS.get(effect_type)
|
||||
if parser is None:
|
||||
msg = f"Unknown effect type: {effect_type}"
|
||||
raise ValueError(msg)
|
||||
return parser(effect)
|
||||
|
||||
|
||||
def load_spell(path: str | Path) -> SpellSpec:
|
||||
|
||||
Reference in New Issue
Block a user