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",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
{
|
{
|
||||||
"type": "condition",
|
"type": "penalty",
|
||||||
"condition_name": "fatigued",
|
"formula": "1d6",
|
||||||
"duration_rounds": 4,
|
"scaling_flat": { "amount": 1, "per_levels": 2, "max": 5 },
|
||||||
"save_negates": true
|
"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,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
|
PenaltyEffect,
|
||||||
RecurringDamageSpec,
|
RecurringDamageSpec,
|
||||||
SaveType,
|
SaveType,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
@@ -434,8 +435,9 @@ def _burst_center(
|
|||||||
|
|
||||||
def _spell_effect_summary(effect: SpellEffect) -> str:
|
def _spell_effect_summary(effect: SpellEffect) -> str:
|
||||||
"""Short serializable summary of one spell effect (never the description)."""
|
"""Short serializable summary of one spell effect (never the description)."""
|
||||||
if isinstance(effect, DamageEffect):
|
if isinstance(effect, (DamageEffect, PenaltyEffect)):
|
||||||
parts = [dice_to_str(effect.formula), *effect.types]
|
tags = effect.types if isinstance(effect, DamageEffect) else effect.targets
|
||||||
|
parts = [dice_to_str(effect.formula), *tags]
|
||||||
if effect.half_on_save:
|
if effect.half_on_save:
|
||||||
parts.append("half on save")
|
parts.append("half on save")
|
||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
@@ -494,6 +496,8 @@ def _spell_half_on_save(spell: SpellSpec) -> bool:
|
|||||||
for effect in spell.effects:
|
for effect in spell.effects:
|
||||||
if isinstance(effect, DamageEffect) and effect.half_on_save:
|
if isinstance(effect, DamageEffect) and effect.half_on_save:
|
||||||
return True
|
return True
|
||||||
|
if isinstance(effect, PenaltyEffect) and effect.half_on_save:
|
||||||
|
return True
|
||||||
if isinstance(effect, AreaEffect) and effect.save_for_half:
|
if isinstance(effect, AreaEffect) and effect.save_for_half:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -6424,7 +6428,7 @@ class CombatEngine:
|
|||||||
|
|
||||||
def _apply_targeted_spell_effect(
|
def _apply_targeted_spell_effect(
|
||||||
self,
|
self,
|
||||||
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect,
|
effect: DamageEffect | ConditionEffect | HealEffect | BuffEffect | PenaltyEffect,
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
caster_live: _LiveStats,
|
caster_live: _LiveStats,
|
||||||
target_live: _LiveStats,
|
target_live: _LiveStats,
|
||||||
@@ -6452,6 +6456,11 @@ class CombatEngine:
|
|||||||
effect, target, caster_level=caster.combatant.level,
|
effect, target, caster_level=caster.combatant.level,
|
||||||
empower=empower, maximize=maximize, saved=saved,
|
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)
|
return self._apply_buff_effect(effect, target, spell_name=spell_name)
|
||||||
|
|
||||||
def _apply_damage_effect(
|
def _apply_damage_effect(
|
||||||
@@ -6680,6 +6689,48 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return f"buff {len(effect.modifiers)} mods ({effect.duration_rounds}r)"
|
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(
|
def _apply_area_effect(
|
||||||
self,
|
self,
|
||||||
effect: AreaEffect,
|
effect: AreaEffect,
|
||||||
@@ -6862,6 +6913,19 @@ class CombatEngine:
|
|||||||
if isinstance(effect, BuffEffect):
|
if isinstance(effect, BuffEffect):
|
||||||
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
|
||||||
return BuffEffect(modifiers=effect.modifiers, duration_rounds=duration)
|
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):
|
if isinstance(effect, AreaEffect):
|
||||||
return AreaEffect(
|
return AreaEffect(
|
||||||
shape=effect.shape,
|
shape=effect.shape,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ from pf1e_simulator.dice import DiceExpr, dice_to_str, parse_dice
|
|||||||
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
|
from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from pf1e_simulator.loaders.foundry import Json
|
from pf1e_simulator.loaders.foundry import Json
|
||||||
|
|
||||||
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
||||||
@@ -215,6 +217,36 @@ class BuffEffect:
|
|||||||
duration_rounds: int
|
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)
|
@dataclass(frozen=True)
|
||||||
class AreaEffect:
|
class AreaEffect:
|
||||||
"""Area of effect spell (burst / cone / line).
|
"""Area of effect spell (burst / cone / line).
|
||||||
@@ -250,7 +282,10 @@ class SummonEffect:
|
|||||||
duration_rounds: int
|
duration_rounds: int
|
||||||
|
|
||||||
|
|
||||||
SpellEffect = DamageEffect | ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
SpellEffect = (
|
||||||
|
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||||
|
| PenaltyEffect | AreaEffect | SummonEffect
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -406,16 +441,21 @@ def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
|
|||||||
return value in get_args(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]:
|
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
|
||||||
return value in get_args(BonusType)
|
return value in get_args(BonusType)
|
||||||
|
|
||||||
|
|
||||||
def _parse_modifier(value: Json) -> StatModifier:
|
def _parse_modifier(value: Json) -> StatModifier:
|
||||||
m = _as_dict(value)
|
m = _as_dict(value)
|
||||||
target = _as_str(m["target"])
|
target = _parse_stat_target(m["target"])
|
||||||
if not _is_stat_target(target):
|
|
||||||
msg = f"unknown stat target: {target!r}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
bonus = _as_str(m.get("bonus_type", ""))
|
bonus = _as_str(m.get("bonus_type", ""))
|
||||||
if not _is_bonus_type(bonus):
|
if not _is_bonus_type(bonus):
|
||||||
msg = f"unknown bonus type: {bonus!r}"
|
msg = f"unknown bonus type: {bonus!r}"
|
||||||
@@ -518,10 +558,7 @@ def _parse_save_partial(value: Json) -> tuple[str, int]:
|
|||||||
return (_as_str(data["condition_name"]), duration)
|
return (_as_str(data["condition_name"]), duration)
|
||||||
|
|
||||||
|
|
||||||
def _parse_effect(data: Json) -> SpellEffect:
|
def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
|
||||||
effect = _as_dict(data)
|
|
||||||
effect_type = _as_str(effect["type"])
|
|
||||||
if effect_type == "damage":
|
|
||||||
formula = parse_dice(_as_str(effect["formula"]))
|
formula = parse_dice(_as_str(effect["formula"]))
|
||||||
scaling_raw = effect.get("scaling")
|
scaling_raw = effect.get("scaling")
|
||||||
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
|
scaling = None if scaling_raw is None else _parse_die_scaling(scaling_raw)
|
||||||
@@ -551,7 +588,9 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
projectiles=projectiles,
|
projectiles=projectiles,
|
||||||
recurring=recurring,
|
recurring=recurring,
|
||||||
)
|
)
|
||||||
if effect_type == "condition":
|
|
||||||
|
|
||||||
|
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
||||||
partial_raw = effect.get("on_success")
|
partial_raw = effect.get("on_success")
|
||||||
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
||||||
return ConditionEffect(
|
return ConditionEffect(
|
||||||
@@ -560,19 +599,40 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
save_negates=bool(effect.get("save_negates", True)),
|
save_negates=bool(effect.get("save_negates", True)),
|
||||||
save_partial=save_partial,
|
save_partial=save_partial,
|
||||||
)
|
)
|
||||||
if effect_type == "heal":
|
|
||||||
|
|
||||||
|
def _parse_heal_effect(effect: dict[str, Json]) -> HealEffect:
|
||||||
flat_raw = effect.get("scaling_flat")
|
flat_raw = effect.get("scaling_flat")
|
||||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||||
return HealEffect(
|
return HealEffect(
|
||||||
formula=parse_dice(_as_str(effect["formula"])),
|
formula=parse_dice(_as_str(effect["formula"])),
|
||||||
scaling_flat=scaling_flat,
|
scaling_flat=scaling_flat,
|
||||||
)
|
)
|
||||||
if effect_type == "buff":
|
|
||||||
|
|
||||||
|
def _parse_buff_effect(effect: dict[str, Json]) -> BuffEffect:
|
||||||
return BuffEffect(
|
return BuffEffect(
|
||||||
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
|
||||||
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
duration_rounds=_as_int(effect.get("duration_rounds", 0)),
|
||||||
)
|
)
|
||||||
if effect_type == "area":
|
|
||||||
|
|
||||||
|
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"))
|
shape_raw = _as_str(effect.get("shape", "burst"))
|
||||||
if not _is_area_shape(shape_raw):
|
if not _is_area_shape(shape_raw):
|
||||||
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
|
msg = f"unknown area shape: {shape_raw!r} (expected one of {get_args(AreaShape)})"
|
||||||
@@ -591,13 +651,34 @@ def _parse_effect(data: Json) -> SpellEffect:
|
|||||||
save_for_half=bool(effect.get("save_for_half", True)),
|
save_for_half=bool(effect.get("save_for_half", True)),
|
||||||
targets=targets_raw,
|
targets=targets_raw,
|
||||||
)
|
)
|
||||||
if effect_type == "summon":
|
|
||||||
|
|
||||||
|
def _parse_summon_effect(effect: dict[str, Json]) -> SummonEffect:
|
||||||
return SummonEffect(
|
return SummonEffect(
|
||||||
creature=_as_str(effect["creature"]),
|
creature=_as_str(effect["creature"]),
|
||||||
duration_rounds=_as_int(effect["duration_rounds"]),
|
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"])
|
||||||
|
parser = _EFFECT_PARSERS.get(effect_type)
|
||||||
|
if parser is None:
|
||||||
msg = f"Unknown effect type: {effect_type}"
|
msg = f"Unknown effect type: {effect_type}"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
return parser(effect)
|
||||||
|
|
||||||
|
|
||||||
def load_spell(path: str | Path) -> SpellSpec:
|
def load_spell(path: str | Path) -> SpellSpec:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from pf1e_simulator.spells import (
|
|||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
DamageEffect,
|
DamageEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
|
PenaltyEffect,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
SpellSpec,
|
SpellSpec,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
@@ -79,6 +80,7 @@ _EFFECT_TYPES = (
|
|||||||
ConditionEffect,
|
ConditionEffect,
|
||||||
HealEffect,
|
HealEffect,
|
||||||
BuffEffect,
|
BuffEffect,
|
||||||
|
PenaltyEffect,
|
||||||
AreaEffect,
|
AreaEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user