diff --git a/data/spells/doom.json b/data/spells/doom.json index 0c4e42b..599aa1c 100644 --- a/data/spells/doom.json +++ b/data/spells/doom.json @@ -11,8 +11,9 @@ "type": "condition", "condition_name": "shaken", "duration_rounds": 10, + "duration_rounds_per_level": 10, "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)." } diff --git a/data/spells/hold_person.json b/data/spells/hold_person.json index 95d1242..7fe91fc 100644 --- a/data/spells/hold_person.json +++ b/data/spells/hold_person.json @@ -10,9 +10,10 @@ { "type": "condition", "condition_name": "paralyzed", - "duration_rounds": 3, + "duration_rounds": 1, + "duration_rounds_per_level": 1, "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." } diff --git a/data/spells/touch_of_fatigue.json b/data/spells/touch_of_fatigue.json index e27fd5b..9a14641 100644 --- a/data/spells/touch_of_fatigue.json +++ b/data/spells/touch_of_fatigue.json @@ -10,9 +10,10 @@ { "type": "condition", "condition_name": "fatigued", - "duration_rounds": 4, + "duration_rounds": 1, + "duration_rounds_per_level": 1, "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." } diff --git a/data/spells/vanish.json b/data/spells/vanish.json index 9c89869..ffa1517 100644 --- a/data/spells/vanish.json +++ b/data/spells/vanish.json @@ -10,7 +10,9 @@ { "type": "condition", "condition_name": "invisible", - "duration_rounds": 5, + "duration_rounds": 1, + "duration_rounds_per_level": 1, + "max_duration_rounds": 5, "save_negates": false } ], diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 5ad1520..7e0a5e4 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 6da654c..bbb93f3 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -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, ) diff --git a/tests/test_spells.py b/tests/test_spells.py index 391c5f7..ad3a9f2 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -551,6 +551,40 @@ class TestHoldPerson: 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: """Given: a caster casting Fear (SRD spells/fear.md: panicked unless the Will save succeeds; a successful save makes the creature shaken 1 round)