From c96b36d3f93d6fe8a7cd42744db6b79d98d8c971 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 12:32:45 +0200 Subject: [PATCH] fix(spells): apply a shaken effect when a fear save succeeds ConditionEffect gains save_partial (JSON 'on_success': condition_name + duration_rounds). On a successful save that would otherwise negate, _apply_condition_effect now imposes the weaker condition (SRD fear.md: panicked on a failed Will save, shaken for 1 round on a success). Extended _metamagic_transform_sub to preserve/double it; extracted _impose_condition helper. Updated fear.json, added failed/successful-save and loader tests in test_spells.py, and the README. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 25 +++++++++++++++---------- data/spells/fear.json | 5 +++-- src/pf1e_simulator/combat.py | 33 +++++++++++++++++++++++++++++---- src/pf1e_simulator/spells.py | 16 ++++++++++++++++ tests/test_spells.py | 32 +++++++++++++++++++++++++++++--- 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 37ff933..c4b5b96 100644 --- a/README.md +++ b/README.md @@ -1253,7 +1253,14 @@ Règles modélisées : annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de dégâts peut déclarer `save_negates: false` (conformance D1 — SRD « Sound Burst » : « Fortitude partial » n'annule que l'étourdissement, - jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts. + jamais les dés) : la sauvegarde réussie ne réduit alors pas ces dégâts. + **Sauvegardes partielles (S2d)** : un `ConditionEffect` peut déclarer + `"on_success": {"condition_name": "shaken", "duration_rounds": 1}` (champ + `save_partial`) — sur une sauvegarde **réussie**, au lieu d'annuler + totalement l'effet, la cible subit la condition affaiblie indiquée (SRD + Fear : paniqué à l'échec, `shaken` 1 round au succès). Sans `on_success`, + le comportement reste « sauvegarde annule ». L'Extend Spell double aussi la + durée de la condition partielle. - Dons : les 8 PJ portent 55 noms de dons uniques ; le chargeur résout chacun d'eux soit vers un effet réel (Power Attack actif, Weapon Focus ciblé par arme, Toughness lié au niveau, dons passifs via `_stat_modifiers`), soit vers un « no-op » tracé en @@ -1431,16 +1438,14 @@ plutôt que bricolées) : minuscules sur une même case) ne sont pas modélisées ; seule la substitution Dextérité/Force du CMB (conformance L1) tient compte de leur taille. -Limites de sorts (conformance D6-D9, documentées plutôt que bricolées — le -moteur n'a pas encore de sauvegarde partielle) : +Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : -- **Fear — sauvegarde totale** (SRD spells/fear.md : « each - living creature in the area [...] becomes panicked unless it succeeds on a - Will save [...]. If the Will save succeeds, the creature is shaken for 1 - round » ; cône de 30 ft — modélisé, S1a/S1c) : la condition appliquée est - bien `panicked` (conformance D8), mais le moteur n'a pas de sauvegarde - partielle pour les conditions — un succès annule totalement l'effet au lieu - de poser `shaken` 1 round ; la durée « 1 round/niveau » est fixée à 4 rounds. +- **Fear — durée figée** (SRD spells/fear.md : « each living creature in the + area [...] becomes panicked unless it succeeds on a Will save [...]. If the + Will save succeeds, the creature is shaken for 1 round » ; cône de 30 ft — + modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic + à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus + bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds. - **Résistance aux sorts appliquée à tout sort ciblé** : le moteur oppose la SR du combattant à chaque sort qui le cible, sans drapeau par sort ; les rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid diff --git a/data/spells/fear.json b/data/spells/fear.json index 566260c..49cfc68 100644 --- a/data/spells/fear.json +++ b/data/spells/fear.json @@ -17,10 +17,11 @@ "type": "condition", "condition_name": "panicked", "duration_rounds": 4, - "save_negates": true + "save_negates": true, + "on_success": { "condition_name": "shaken", "duration_rounds": 1 } } ] } ], - "description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save (a successful save makes it shaken for 1 round instead). Modeled as panicked for 4 rounds on a failed save; the shaken-on-success partial needs partial-save support (documented limitation). The SRD range is 30 ft. - modeled as the closest category (close)." + "description": "SRD spells/fear.md: each living creature in the 30-ft. cone becomes panicked unless it succeeds on a Will save; a creature that succeeds on the save is instead shaken for 1 round (partial save). Modeled as panicked for 4 rounds on a failed save, shaken for 1 round on a successful save. The SRD range is 30 ft. - modeled as the closest category (close)." } diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 79e4b15..0b0ac00 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -6010,23 +6010,44 @@ class CombatEngine: spell_name: str, ) -> str: if saved and effect.save_negates: + # PF1e partial save (SRD spells/fear.md): a successful save does + # not always spare the target — it may impose a weaker condition + # (Fear: shaken for 1 round instead of panicked). + if effect.save_partial is not None: + name, duration = effect.save_partial + applied = self._impose_condition(target, name, duration, spell_name) + if not applied: + return "" + return f"{name} ({duration}r) (partial save)" return f"{effect.condition_name} negated" - cond = CONDITIONS_BY_NAME.get(effect.condition_name) + return self._impose_condition( + target, effect.condition_name, effect.duration_rounds, spell_name + ) + + def _impose_condition( + self, + target: CombatantState, + condition_name: str, + duration_rounds: int, + spell_name: str, + ) -> str: + """Append a timed condition to the target; handle the bleeding DoT.""" + cond = CONDITIONS_BY_NAME.get(condition_name) if cond is None: return "" target.active_conditions.append( ActiveCondition( condition=cond, - rounds_left=effect.duration_rounds, + rounds_left=duration_rounds, source=f"spell: {spell_name}", ) ) - if effect.condition_name == "bleeding": + if condition_name == "bleeding": # The bleeding condition's damage-over-time is engine-driven # (CombatantState.bleed_damage, CRB Conditions — bleeding); the # SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round. target.bleed_damage = max(target.bleed_damage, 1) - return f"{effect.condition_name} ({effect.duration_rounds}r)" + return f"{condition_name} ({duration_rounds}r)" def _apply_heal_effect( self, @@ -6222,10 +6243,14 @@ class CombatEngine: return effect if isinstance(effect, ConditionEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds + partial = effect.save_partial + if partial is not None and has_extend: + partial = (partial[0], partial[1] * 2) return ConditionEffect( condition_name=effect.condition_name, duration_rounds=duration, save_negates=effect.save_negates, + save_partial=partial, ) if isinstance(effect, BuffEffect): duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 9df6ce8..eaffa00 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -173,6 +173,10 @@ class ConditionEffect: condition_name: str duration_rounds: int save_negates: bool = True + # PF1e partial save (SRD spells/fear.md): on a SUCCESSFUL save the target + # is not spared outright but suffers a weaker condition instead. Encoded as + # (condition_name, duration_rounds); None = a successful save fully negates. + save_partial: tuple[str, int] | None = None @dataclass(frozen=True) @@ -468,6 +472,15 @@ def _parse_recurring(value: Json) -> RecurringDamageSpec: ) +def _parse_save_partial(value: Json) -> tuple[str, int]: + data = _as_dict(value) + duration = _as_int(data["duration_rounds"]) + if duration < 1: + msg = f"on_success duration_rounds must be >= 1, got {duration}" + raise ValueError(msg) + return (_as_str(data["condition_name"]), duration) + + def _parse_effect(data: Json) -> SpellEffect: effect = _as_dict(data) effect_type = _as_str(effect["type"]) @@ -502,10 +515,13 @@ def _parse_effect(data: Json) -> SpellEffect: 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": return HealEffect( diff --git a/tests/test_spells.py b/tests/test_spells.py index aa39f98..9e10fd7 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -172,6 +172,18 @@ class TestSpellLoading: assert isinstance(eff, ConditionEffect) assert eff.condition_name == "paralyzed" assert eff.save_negates is True + assert eff.save_partial is None + + def test_fear_condition_has_save_partial(self) -> None: + # PF1e (SRD spells/fear.md): a successful Will save imposes shaken 1 + # round instead of panicked — the partial-save spec. + spell = load_spell(_SPELLS_DIR / "fear.json") + area = spell.effects[0] + assert isinstance(area, AreaEffect) + eff = area.effects[0] + assert isinstance(eff, ConditionEffect) + assert eff.condition_name == "panicked" + assert eff.save_partial == ("shaken", 1) def test_cure_light_has_heal_effect(self) -> None: spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json") @@ -534,9 +546,9 @@ class TestHoldPerson: class TestFear: """Given: a caster casting Fear (SRD spells/fear.md: panicked unless the - Will save succeeds) - When: target fails save - Then: panicked condition is applied.""" + Will save succeeds; a successful save makes the creature shaken 1 round) + When: target fails / succeeds the save + Then: panicked is applied / shaken (partial save) is applied instead.""" def test_failed_save_applies_panicked(self) -> None: caster = _make_caster() @@ -544,6 +556,20 @@ class TestFear: engine, c, t = _make_engine(caster, target, [2]) engine._cast_spell(c, t, "Fear") assert any(ac.condition.name == "panicked" for ac in t.active_conditions) + assert not any(ac.condition.name == "shaken" for ac in t.active_conditions) + + def test_successful_save_applies_shaken(self) -> None: + """Given: the target succeeds its Will save (natural 20) + When: Fear resolves + Then: it is shaken for 1 round (partial save), not panicked.""" + caster = _make_caster() + target = _make_target() + engine, c, t = _make_engine(caster, target, [20]) + engine._cast_spell(c, t, "Fear") + shaken = [ac for ac in t.active_conditions if ac.condition.name == "shaken"] + assert len(shaken) == 1 + assert shaken[0].rounds_left == 1 + assert not any(ac.condition.name == "panicked" for ac in t.active_conditions) class TestCureLightWounds: