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