fix(rules): condition durations scale with caster level (hold person, doom, touch of fatigue, vanish)

ConditionEffect gains duration_rounds_per_level and max_duration_rounds
(mirroring PenaltyEffect), applied as caster_level * per_level rounds capped
at max: hold person 1 rd/lvl, doom 1 min/lvl, touch of fatigue 1 rd/lvl,
vanish 1 rd/lvl (max 5). Extend Spell doubles both the per-level rate and
the cap. Tests cover scaling and the vanish cap.
This commit is contained in:
2026-09-17 23:26:45 +02:00
parent a144423522
commit 0c3f5fd907
7 changed files with 92 additions and 11 deletions
+26 -5
View File
@@ -6518,7 +6518,10 @@ class CombatEngine:
spell_name=spell_name,
)
if isinstance(effect, ConditionEffect):
return self._apply_condition_effect(effect, target, saved=saved, spell_name=spell_name)
return self._apply_condition_effect(
effect, target, saved=saved, spell_name=spell_name,
caster_level=caster.combatant.level,
)
if isinstance(effect, HealEffect):
return self._apply_heal_effect(
effect, target, caster_level=caster.combatant.level,
@@ -6669,20 +6672,26 @@ class CombatEngine:
*,
saved: bool,
spell_name: str,
caster_level: int = 0,
) -> str:
duration = effect.duration_rounds
if effect.duration_rounds_per_level > 0:
duration = max(1, caster_level * effect.duration_rounds_per_level)
if effect.max_duration_rounds > 0:
duration = min(duration, effect.max_duration_rounds)
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)
name, partial_duration = effect.save_partial
applied = self._impose_condition(target, name, partial_duration, spell_name)
if not applied:
return ""
return f"{name} ({duration}r) (partial save)"
return f"{name} ({partial_duration}r) (partial save)"
return f"{effect.condition_name} negated"
return self._impose_condition(
target, effect.condition_name, effect.duration_rounds, spell_name
target, effect.condition_name, duration, spell_name
)
def _impose_condition(
@@ -6998,6 +7007,16 @@ class CombatEngine:
return effect
if isinstance(effect, ConditionEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
per_level = (
effect.duration_rounds_per_level * 2
if has_extend
else effect.duration_rounds_per_level
)
max_rounds = (
effect.max_duration_rounds * 2
if has_extend
else effect.max_duration_rounds
)
partial = effect.save_partial
if partial is not None and has_extend:
partial = (partial[0], partial[1] * 2)
@@ -7006,6 +7025,8 @@ class CombatEngine:
duration_rounds=duration,
save_negates=effect.save_negates,
save_partial=partial,
duration_rounds_per_level=per_level,
max_duration_rounds=max_rounds,
)
if isinstance(effect, BuffEffect):
duration = effect.duration_rounds * 2 if has_extend else effect.duration_rounds
+21
View File
@@ -176,6 +176,15 @@ class DamageEffect:
@dataclass(frozen=True)
class ConditionEffect:
"""Transient condition (PF1e: ray of enfeeblement, hold person, doom...).
``duration_rounds_per_level`` (mirroring ``PenaltyEffect``'s field of the
same name) makes the duration caster-level-scaled — SRD hold person "1
round/level", doom "1 min./level" — replacing the fixed
``duration_rounds`` when > 0; ``max_duration_rounds`` caps it (0 = no
cap, SRD vanish "maximum 5 rounds").
"""
condition_name: str
duration_rounds: int
save_negates: bool = True
@@ -183,6 +192,8 @@ class ConditionEffect:
# 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
duration_rounds_per_level: int = 0
max_duration_rounds: int = 0
@dataclass(frozen=True)
@@ -602,11 +613,21 @@ def _parse_damage_effect(effect: dict[str, Json]) -> DamageEffect:
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)
per_level = _as_int(effect.get("duration_rounds_per_level", 0))
if per_level < 0:
msg = f"duration_rounds_per_level must be >= 0, got {per_level}"
raise ValueError(msg)
max_rounds = _as_int(effect.get("max_duration_rounds", 0))
if max_rounds < 0:
msg = f"max_duration_rounds must be >= 0, got {max_rounds}"
raise ValueError(msg)
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,
duration_rounds_per_level=per_level,
max_duration_rounds=max_rounds,
)