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:
2026-09-17 21:28:00 +02:00
parent 611952cbf7
commit 83bbcae952
4 changed files with 240 additions and 91 deletions
+8 -6
View File
@@ -9,11 +9,13 @@
"cast_time": "standard",
"effects": [
{
"type": "condition",
"condition_name": "fatigued",
"duration_rounds": 4,
"save_negates": true
"type": "penalty",
"formula": "1d6",
"scaling_flat": { "amount": 1, "per_levels": 2, "max": 5 },
"targets": ["attack", "damage"],
"duration_rounds_per_level": 1,
"half_on_save": true
}
],
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability damage, so the Strength penalty is modeled as the fatigued condition (attack/damage penalties, no charge) for 4 rounds (1 round/level at caster level 4); Fortitude halves is approximated as Fortitude negates."
}
"description": "A coruscating ray strikes the target (ranged touch attack), dealing 1d6 points of Strength damage +1 per two caster levels (maximum 1d6+5); a successful Fortitude save halves the damage. Simplification: the engine has no ability-score track, so the Strength penalty is modeled as negative untyped modifiers on the attack and damage rolls (the two combat rolls Strength feeds) for 1 round per caster level; the SRD 'cannot drop below 1' floor and the non-stacking clause are not modeled."
}
+67 -3
View File
@@ -135,6 +135,7 @@ from pf1e_simulator.spells import (
ConditionEffect,
DamageEffect,
HealEffect,
PenaltyEffect,
RecurringDamageSpec,
SaveType,
SpellEffect,
@@ -434,8 +435,9 @@ def _burst_center(
def _spell_effect_summary(effect: SpellEffect) -> str:
"""Short serializable summary of one spell effect (never the description)."""
if isinstance(effect, DamageEffect):
parts = [dice_to_str(effect.formula), *effect.types]
if isinstance(effect, (DamageEffect, PenaltyEffect)):
tags = effect.types if isinstance(effect, DamageEffect) else effect.targets
parts = [dice_to_str(effect.formula), *tags]
if effect.half_on_save:
parts.append("half on save")
return " ".join(parts)
@@ -494,6 +496,8 @@ def _spell_half_on_save(spell: SpellSpec) -> bool:
for effect in spell.effects:
if isinstance(effect, DamageEffect) and effect.half_on_save:
return True
if isinstance(effect, PenaltyEffect) and effect.half_on_save:
return True
if isinstance(effect, AreaEffect) and effect.save_for_half:
return True
return False
@@ -6424,7 +6428,7 @@ class CombatEngine:
def _apply_targeted_spell_effect(
self,
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect,
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect,
target: CombatantState,
caster_live: _LiveStats,
target_live: _LiveStats,
@@ -6452,6 +6456,11 @@ class CombatEngine:
effect, target, caster_level=caster.combatant.level,
empower=empower, maximize=maximize, saved=saved,
)
if isinstance(effect, PenaltyEffect):
return self._apply_penalty_effect(
effect, target, caster=caster, saved=saved,
empower=empower, maximize=maximize, spell_name=spell_name,
)
return self._apply_buff_effect(effect, target, spell_name=spell_name)
def _apply_damage_effect(
@@ -6680,6 +6689,48 @@ class CombatEngine:
)
return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)"
def _apply_penalty_effect(
self,
effect: PenaltyEffect,
target: CombatantState,
*,
caster: CombatantState,
saved: bool,
empower: bool,
maximize: bool,
spell_name: str,
) -> str:
"""Apply a rolled ability penalty (SRD Ray of Enfeeblement).
The penalty is rolled (maximized/empowered like spell damage), halved
on a successful save, and applied as negative untyped StatModifiers on
the listed targets for ``duration_rounds_per_level`` rounds per caster
level. A penalty of 0 or less has no effect.
"""
formula = effect.effective_formula(caster.combatant.level)
if maximize:
penalty = formula.max_value()
else:
penalty = formula.roll(self._rng)
if empower:
penalty = int(penalty * 1.5)
if saved and effect.half_on_save:
penalty //= 2
if penalty <= 0:
return "no penalty"
duration = max(1, caster.combatant.level * effect.duration_rounds_per_level)
target.active_buffs.append(
ActiveBuff(
modifiers=tuple(
StatModifier(target=t, value=-penalty, bonus_type="")
for t in effect.targets
),
rounds_left=duration,
source=f"spell: {spell_name}",
)
)
return f"-{penalty} {'/'.join(effect.targets)} ({duration}r)"
def _apply_area_effect(
self,
effect: AreaEffect,
@@ -6862,6 +6913,19 @@ class CombatEngine:
if isinstance(effect, BuffEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
if isinstance(effect, PenaltyEffect):
duration = (
effect.duration_rounds_per_level * 2
if has_extend
else effect.duration_rounds_per_level
)
return PenaltyEffect(
formula=effect.formula,
scaling_flat=effect.scaling_flat,
targets=effect.targets,
duration_rounds_per_level=duration,
half_on_save=effect.half_on_save,
)
if isinstance(effect, AreaEffect):
return AreaEffect(
shape=effect.shape,
+163 -82
View File
@@ -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:
+2
View File
@@ -24,6 +24,7 @@ from pf1e_simulator.spells import (
ConditionEffect,
DamageEffect,
HealEffect,
PenaltyEffect,
SpellEffect,
SpellSpec,
SummonEffect,
@@ -79,6 +80,7 @@ _EFFECT_TYPES = (
ConditionEffect,
HealEffect,
BuffEffect,
PenaltyEffect,
AreaEffect,
SummonEffect,
)