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:
@@ -11,8 +11,9 @@
|
|||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "shaken",
|
"condition_name": "shaken",
|
||||||
"duration_rounds": 10,
|
"duration_rounds": 10,
|
||||||
|
"duration_rounds_per_level": 10,
|
||||||
"save_negates": true
|
"save_negates": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "SRD spells/doom.md: the subject is filled with horrible dread and becomes shaken (-2 on attacks and saves). Saving Throw Will negates - there is no partial success (the earlier '-1 on a successful save' wording was a D&D 3.5 rule that does not exist in PF1e). Duration 1 min/level approximated as 10 rounds."
|
"description": "SRD spells/doom.md: the subject is filled with horrible dread and becomes shaken (-2 on attacks and saves). Saving Throw Will negates - there is no partial success (the earlier '-1 on a successful save' wording was a D&D 3.5 rule that does not exist in PF1e). Duration 1 min/level (10 rounds per level)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
{
|
{
|
||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "paralyzed",
|
"condition_name": "paralyzed",
|
||||||
"duration_rounds": 3,
|
"duration_rounds": 1,
|
||||||
|
"duration_rounds_per_level": 1,
|
||||||
"save_negates": true
|
"save_negates": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "The subject becomes paralyzed and freezes in place. Will negates."
|
"description": "The subject becomes paralyzed and freezes in place. Will negates. Duration 1 round/level."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
{
|
{
|
||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "fatigued",
|
"condition_name": "fatigued",
|
||||||
"duration_rounds": 4,
|
"duration_rounds": 1,
|
||||||
|
"duration_rounds_per_level": 1,
|
||||||
"save_negates": true
|
"save_negates": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "A cantrip: a melee touch attack channels negative energy, making the target fatigued for 1 round/level (modeled as 4 rounds at caster level 4; the effect ends with the duration rather than by long rest). No effect on an already fatigued creature or on creatures immune to fatigue. Fortitude negates."
|
"description": "A cantrip: a melee touch attack channels negative energy, making the target fatigued for 1 round/level (the effect ends with the duration rather than by long rest). No effect on an already fatigued creature or on creatures immune to fatigue. Fortitude negates."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
{
|
{
|
||||||
"type": "condition",
|
"type": "condition",
|
||||||
"condition_name": "invisible",
|
"condition_name": "invisible",
|
||||||
"duration_rounds": 5,
|
"duration_rounds": 1,
|
||||||
|
"duration_rounds_per_level": 1,
|
||||||
|
"max_duration_rounds": 5,
|
||||||
"save_negates": false
|
"save_negates": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6518,7 +6518,10 @@ class CombatEngine:
|
|||||||
spell_name=spell_name,
|
spell_name=spell_name,
|
||||||
)
|
)
|
||||||
if isinstance(effect, ConditionEffect):
|
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):
|
if isinstance(effect, HealEffect):
|
||||||
return self._apply_heal_effect(
|
return self._apply_heal_effect(
|
||||||
effect, target, caster_level=caster.combatant.level,
|
effect, target, caster_level=caster.combatant.level,
|
||||||
@@ -6669,20 +6672,26 @@ class CombatEngine:
|
|||||||
*,
|
*,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
spell_name: str,
|
spell_name: str,
|
||||||
|
caster_level: int = 0,
|
||||||
) -> str:
|
) -> 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:
|
if saved and effect.save_negates:
|
||||||
# PF1e partial save (SRD spells/fear.md): a successful save does
|
# PF1e partial save (SRD spells/fear.md): a successful save does
|
||||||
# not always spare the target — it may impose a weaker condition
|
# not always spare the target — it may impose a weaker condition
|
||||||
# (Fear: shaken for 1 round instead of panicked).
|
# (Fear: shaken for 1 round instead of panicked).
|
||||||
if effect.save_partial is not None:
|
if effect.save_partial is not None:
|
||||||
name, duration = effect.save_partial
|
name, partial_duration = effect.save_partial
|
||||||
applied = self._impose_condition(target, name, duration, spell_name)
|
applied = self._impose_condition(target, name, partial_duration, spell_name)
|
||||||
if not applied:
|
if not applied:
|
||||||
return ""
|
return ""
|
||||||
return f"{name} ({duration}r) (partial save)"
|
return f"{name} ({partial_duration}r) (partial save)"
|
||||||
return f"{effect.condition_name} negated"
|
return f"{effect.condition_name} negated"
|
||||||
return self._impose_condition(
|
return self._impose_condition(
|
||||||
target, effect.condition_name, effect.duration_rounds, spell_name
|
target, effect.condition_name, duration, spell_name
|
||||||
)
|
)
|
||||||
|
|
||||||
def _impose_condition(
|
def _impose_condition(
|
||||||
@@ -6998,6 +7007,16 @@ 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
|
||||||
|
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
|
partial = effect.save_partial
|
||||||
if partial is not None and has_extend:
|
if partial is not None and has_extend:
|
||||||
partial = (partial[0], partial[1] * 2)
|
partial = (partial[0], partial[1] * 2)
|
||||||
@@ -7006,6 +7025,8 @@ class CombatEngine:
|
|||||||
duration_rounds=duration,
|
duration_rounds=duration,
|
||||||
save_negates=effect.save_negates,
|
save_negates=effect.save_negates,
|
||||||
save_partial=partial,
|
save_partial=partial,
|
||||||
|
duration_rounds_per_level=per_level,
|
||||||
|
max_duration_rounds=max_rounds,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ class DamageEffect:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ConditionEffect:
|
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
|
condition_name: str
|
||||||
duration_rounds: int
|
duration_rounds: int
|
||||||
save_negates: bool = True
|
save_negates: bool = True
|
||||||
@@ -183,6 +192,8 @@ class ConditionEffect:
|
|||||||
# is not spared outright but suffers a weaker condition instead. Encoded as
|
# is not spared outright but suffers a weaker condition instead. Encoded as
|
||||||
# (condition_name, duration_rounds); None = a successful save fully negates.
|
# (condition_name, duration_rounds); None = a successful save fully negates.
|
||||||
save_partial: tuple[str, int] | None = None
|
save_partial: tuple[str, int] | None = None
|
||||||
|
duration_rounds_per_level: int = 0
|
||||||
|
max_duration_rounds: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
def _parse_condition_effect(effect: dict[str, Json]) -> ConditionEffect:
|
||||||
partial_raw = effect.get("on_success")
|
partial_raw = effect.get("on_success")
|
||||||
save_partial = None if partial_raw is None else _parse_save_partial(partial_raw)
|
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(
|
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,
|
save_partial=save_partial,
|
||||||
|
duration_rounds_per_level=per_level,
|
||||||
|
max_duration_rounds=max_rounds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -551,6 +551,40 @@ class TestHoldPerson:
|
|||||||
assert any("negated" in line for line in engine._transcript)
|
assert any("negated" in line for line in engine._transcript)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConditionDurationScaling:
|
||||||
|
"""Given: a ConditionEffect with duration_rounds_per_level (SRD hold person
|
||||||
|
"1 round/level", doom "1 min./level", vanish "1 round/level (maximum 5)")
|
||||||
|
When: the spell is cast
|
||||||
|
Then: the condition lasts caster_level * per_level rounds, capped at max."""
|
||||||
|
|
||||||
|
def test_hold_person_scales_with_caster_level(self) -> None:
|
||||||
|
caster = _make_caster(level=5)
|
||||||
|
target = _make_target()
|
||||||
|
engine, c, t = _make_engine(caster, target, [2])
|
||||||
|
engine._cast_spell(c, t, "Hold Person")
|
||||||
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "paralyzed")
|
||||||
|
assert ac.rounds_left == 5 # 1 round/level
|
||||||
|
|
||||||
|
def test_vanish_caps_at_maximum(self) -> None:
|
||||||
|
caster = _make_caster(level=8)
|
||||||
|
target = _make_target()
|
||||||
|
# Vanish is range touch: the target must be adjacent (5 ft).
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
caster, target, [2], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, t, "Vanish")
|
||||||
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "invisible")
|
||||||
|
assert ac.rounds_left == 5 # 1 round/level, maximum 5
|
||||||
|
|
||||||
|
def test_doom_scales_to_minutes(self) -> None:
|
||||||
|
caster = _make_caster(level=5)
|
||||||
|
target = _make_target()
|
||||||
|
engine, c, t = _make_engine(caster, target, [2])
|
||||||
|
engine._cast_spell(c, t, "Doom")
|
||||||
|
ac = next(ac for ac in t.active_conditions if ac.condition.name == "shaken")
|
||||||
|
assert ac.rounds_left == 50 # 1 min/level = 10 rounds/level
|
||||||
|
|
||||||
|
|
||||||
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; a successful save makes the creature shaken 1 round)
|
Will save succeeds; a successful save makes the creature shaken 1 round)
|
||||||
|
|||||||
Reference in New Issue
Block a user