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:
@@ -1253,7 +1253,14 @@ Règles modélisées :
|
|||||||
annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de
|
annule » (Sleep, Color Spray, Web) sauvent donc aussi. Une composante de
|
||||||
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
|
||||||
|
|||||||
@@ -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)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user