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
+29 -4
View File
@@ -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
+16
View File
@@ -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(