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
+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,