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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-13 12:32:45 +02:00
parent a59ebee37a
commit c96b36d3f9
5 changed files with 92 additions and 19 deletions
+14 -9
View File
@@ -1254,6 +1254,13 @@ Règles modélisées :
dégâts peut déclarer `save_negates: false` (conformance D1 — SRD dégâts peut déclarer `save_negates: false` (conformance D1 — SRD
« Sound Burst » : « Fortitude partial » n'annule que l'étourdissement, « 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 - 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 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 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 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. 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 Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
moteur n'a pas encore de sauvegarde partielle) :
- **Fear — sauvegarde totale** (SRD spells/fear.md : « each - **Fear — durée figée** (SRD spells/fear.md : « each living creature in the
living creature in the area [...] becomes panicked unless it succeeds on a area [...] becomes panicked unless it succeeds on a Will save [...]. If the
Will save [...]. If the Will save succeeds, the creature is shaken for 1 Will save succeeds, the creature is shaken for 1 round » ; cône de 30 ft —
round » ; cône de 30 ft — modélisé, S1a/S1c) : la condition appliquée est modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic
bien `panicked` (conformance D8), mais le moteur n'a pas de sauvegarde à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus
partielle pour les conditions — un succès annule totalement l'effet au lieu bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds.
de poser `shaken` 1 round ; la durée « 1 round/niveau » est fixée à 4 rounds.
- **Résistance aux sorts appliquée à tout sort ciblé** : le moteur oppose la - **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 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 rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid
+3 -2
View File
@@ -17,10 +17,11 @@
"type": "condition", "type": "condition",
"condition_name": "panicked", "condition_name": "panicked",
"duration_rounds": 4, "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)."
} }
+29 -4
View File
@@ -6010,23 +6010,44 @@ class CombatEngine:
spell_name: str, spell_name: str,
) -> str: ) -> str:
if saved and effect.save_negates: 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" 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: if cond is None:
return "" return ""
target.active_conditions.append( target.active_conditions.append(
ActiveCondition( ActiveCondition(
condition=cond, condition=cond,
rounds_left=effect.duration_rounds, rounds_left=duration_rounds,
source=f"spell: {spell_name}", source=f"spell: {spell_name}",
) )
) )
if effect.condition_name == "bleeding": if condition_name == "bleeding":
# The bleeding condition's damage-over-time is engine-driven # The bleeding condition's damage-over-time is engine-driven
# (CombatantState.bleed_damage, CRB Conditions — bleeding); the # (CombatantState.bleed_damage, CRB Conditions — bleeding); the
# SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round. # SRD Bleed cantrip (spells/bleed.md) deals 1 bleed damage/round.
target.bleed_damage = max(target.bleed_damage, 1) 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( def _apply_heal_effect(
self, self,
@@ -6222,10 +6243,14 @@ class CombatEngine:
return effect return effect
if isinstance(effect, ConditionEffect): if isinstance(effect, ConditionEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds 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( return ConditionEffect(
condition_name=effect.condition_name, condition_name=effect.condition_name,
duration_rounds=duration, duration_rounds=duration,
save_negates=effect.save_negates, save_negates=effect.save_negates,
save_partial=partial,
) )
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
+16
View File
@@ -173,6 +173,10 @@ class ConditionEffect:
condition_name: str condition_name: str
duration_rounds: int duration_rounds: int
save_negates: bool = True 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) @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: def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data) effect = _as_dict(data)
effect_type = _as_str(effect["type"]) effect_type = _as_str(effect["type"])
@@ -502,10 +515,13 @@ def _parse_effect(data: Json) -> SpellEffect:
recurring=recurring, recurring=recurring,
) )
if effect_type == "condition": 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( return ConditionEffect(
condition_name=_as_str(effect["condition_name"]), condition_name=_as_str(effect["condition_name"]),
duration_rounds=_as_int(effect["duration_rounds"]), duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(effect.get("save_negates", True)), save_negates=bool(effect.get("save_negates", True)),
save_partial=save_partial,
) )
if effect_type == "heal": if effect_type == "heal":
return HealEffect( return HealEffect(
+29 -3
View File
@@ -172,6 +172,18 @@ class TestSpellLoading:
assert isinstance(eff, ConditionEffect) assert isinstance(eff, ConditionEffect)
assert eff.condition_name == "paralyzed" assert eff.condition_name == "paralyzed"
assert eff.save_negates is True 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: def test_cure_light_has_heal_effect(self) -> None:
spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json") spell = load_spell(_SPELLS_DIR / "cure_light_wounds.json")
@@ -534,9 +546,9 @@ class TestHoldPerson:
class TestFear: class TestFear:
"""Given: a caster casting Fear (SRD spells/fear.md: panicked unless the """Given: a caster casting Fear (SRD spells/fear.md: panicked unless the
Will save succeeds) Will save succeeds; a successful save makes the creature shaken 1 round)
When: target fails save When: target fails / succeeds the save
Then: panicked condition is applied.""" Then: panicked is applied / shaken (partial save) is applied instead."""
def test_failed_save_applies_panicked(self) -> None: def test_failed_save_applies_panicked(self) -> None:
caster = _make_caster() caster = _make_caster()
@@ -544,6 +556,20 @@ class TestFear:
engine, c, t = _make_engine(caster, target, [2]) engine, c, t = _make_engine(caster, target, [2])
engine._cast_spell(c, t, "Fear") engine._cast_spell(c, t, "Fear")
assert any(ac.condition.name == "panicked" for ac in t.active_conditions) 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: class TestCureLightWounds: