From 83bbcae952bb327e8e99f89d552c2c648e503110 Mon Sep 17 00:00:00 2001 From: ctan Date: Thu, 17 Sep 2026 21:28:00 +0200 Subject: [PATCH] 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. --- data/spells/ray_of_enfeeblement.json | 14 +- src/pf1e_simulator/combat.py | 70 +++++++- src/pf1e_simulator/spells.py | 245 ++++++++++++++++++--------- tests/test_spell_registry_batch3.py | 2 + 4 files changed, 240 insertions(+), 91 deletions(-) diff --git a/data/spells/ray_of_enfeeblement.json b/data/spells/ray_of_enfeeblement.json index ed0663a..e8a85ab 100644 --- a/data/spells/ray_of_enfeeblement.json +++ b/data/spells/ray_of_enfeeblement.json @@ -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." +} \ No newline at end of file diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d57b4be..fdee901 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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, diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index b811914..5043371 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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: diff --git a/tests/test_spell_registry_batch3.py b/tests/test_spell_registry_batch3.py index a5bfd64..8220b1f 100644 --- a/tests/test_spell_registry_batch3.py +++ b/tests/test_spell_registry_batch3.py @@ -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, )