fix(rules): stabilize is a StabilizeEffect that auto-stabilizes a dying creature
This commit is contained in:
@@ -150,6 +150,7 @@ from pf1e_simulator.spells import (
|
||||
RecurringDamageSpec,
|
||||
SaveType,
|
||||
SpellEffect,
|
||||
StabilizeEffect,
|
||||
SummonEffect,
|
||||
TempHpEffect,
|
||||
close_range_ft,
|
||||
@@ -469,10 +470,22 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _simple_effect_summary(
|
||||
effect: OneShotEffect | TempHpEffect | StabilizeEffect,
|
||||
) -> str:
|
||||
if isinstance(effect, OneShotEffect):
|
||||
return (
|
||||
f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}"
|
||||
)
|
||||
if isinstance(effect, TempHpEffect):
|
||||
return f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
|
||||
return "stabilize"
|
||||
|
||||
|
||||
def _non_damage_effect_summary(
|
||||
effect: (
|
||||
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
||||
| OneShotEffect | TempHpEffect
|
||||
| OneShotEffect | TempHpEffect | StabilizeEffect
|
||||
)
|
||||
) -> str:
|
||||
if isinstance(effect, ConditionEffect):
|
||||
@@ -483,12 +496,8 @@ def _non_damage_effect_summary(
|
||||
return f"area {effect.radius_ft}ft"
|
||||
if isinstance(effect, SummonEffect):
|
||||
return f"summon {effect.creature}"
|
||||
if isinstance(effect, (OneShotEffect, TempHpEffect)):
|
||||
return (
|
||||
f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}"
|
||||
if isinstance(effect, OneShotEffect)
|
||||
else f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
|
||||
)
|
||||
if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)):
|
||||
return _simple_effect_summary(effect)
|
||||
return f"buff {effect.duration_rounds}r"
|
||||
|
||||
|
||||
@@ -6904,7 +6913,12 @@ class CombatEngine:
|
||||
caster, spell, spell_name, center_pos, has_area=has_area
|
||||
)
|
||||
return not point_ok
|
||||
if not target.active:
|
||||
if not target.active and not any(
|
||||
isinstance(e, StabilizeEffect) for e in spell.effects
|
||||
):
|
||||
# Stabilize (SRD spells/stabilize.md) targets a living creature
|
||||
# at -1 or fewer hit points — an inactive state every other spell
|
||||
# cannot reach, so it alone bypasses the active-target gate.
|
||||
return True
|
||||
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
||||
if dist_ft > spell_range_ft(spell, caster.combatant.level):
|
||||
@@ -7155,7 +7169,7 @@ class CombatEngine:
|
||||
self,
|
||||
effect: (
|
||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
| PenaltyEffect | OneShotEffect | TempHpEffect
|
||||
| PenaltyEffect | OneShotEffect | TempHpEffect | StabilizeEffect
|
||||
),
|
||||
target: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
@@ -7193,8 +7207,8 @@ class CombatEngine:
|
||||
effect, target, caster=caster, saved=saved,
|
||||
empower=empower, maximize=maximize, spell_name=spell_name,
|
||||
)
|
||||
if isinstance(effect, (OneShotEffect, TempHpEffect)):
|
||||
return self._apply_grant_effect(
|
||||
if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)):
|
||||
return self._apply_simple_effect(
|
||||
effect, target, saved=saved, spell_name=spell_name,
|
||||
)
|
||||
return self._apply_buff_effect(
|
||||
@@ -7202,9 +7216,9 @@ class CombatEngine:
|
||||
caster_level=caster.combatant.level,
|
||||
)
|
||||
|
||||
def _apply_grant_effect(
|
||||
def _apply_simple_effect(
|
||||
self,
|
||||
effect: OneShotEffect | TempHpEffect,
|
||||
effect: OneShotEffect | TempHpEffect | StabilizeEffect,
|
||||
target: CombatantState,
|
||||
*,
|
||||
saved: bool,
|
||||
@@ -7214,7 +7228,28 @@ class CombatEngine:
|
||||
return self._apply_one_shot_effect(
|
||||
effect, target, saved=saved, spell_name=spell_name,
|
||||
)
|
||||
return self._apply_temp_hp_effect(effect, target, saved=saved)
|
||||
if isinstance(effect, TempHpEffect):
|
||||
return self._apply_temp_hp_effect(effect, target, saved=saved)
|
||||
return self._apply_stabilize_effect(target, saved=saved)
|
||||
|
||||
def _apply_stabilize_effect(
|
||||
self, target: CombatantState, *, saved: bool
|
||||
) -> str:
|
||||
"""Stabilize a dying creature (SRD spells/stabilize.md).
|
||||
|
||||
The spell targets a living creature at -1 or fewer hit points and
|
||||
automatically stabilizes it: stabilized_hp is set to the target's
|
||||
current hp, the same marker a successful Constitution check writes in
|
||||
_process_dying, so the per-round hp loss stops. New damage below that
|
||||
hp clears the marker and the creature resumes dying normally; a
|
||||
creature at hp >= 0 or dead is unaffected.
|
||||
"""
|
||||
if saved:
|
||||
return "stabilize negated"
|
||||
if target.dead or target.hp >= 0:
|
||||
return "stabilize no effect"
|
||||
target.stabilized_hp = target.hp
|
||||
return f"stabilized (hp {target.hp})"
|
||||
|
||||
def _apply_one_shot_effect(
|
||||
self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str
|
||||
|
||||
@@ -303,6 +303,22 @@ class TempHpEffect:
|
||||
duration_rounds: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StabilizeEffect:
|
||||
"""PF1e stabilize (SRD spells/stabilize.md: "you target a living creature
|
||||
that has -1 or fewer hit points. That creature is automatically stabilized
|
||||
and does not lose any further hit points").
|
||||
|
||||
The engine's dying track already carries the stabilization marker
|
||||
(``CombatantState.stabilized_hp``, written by a successful Constitution
|
||||
check in ``_process_dying``); the spell writes the same marker at the
|
||||
target's current hp. The marker stops the per-round hp loss while
|
||||
``hp >= stabilized_hp`` and is cleared by new damage below it, so the
|
||||
creature "continues dying normally" (SRD) after being hurt again. A
|
||||
creature at hp >= 0 or dead is unaffected.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PenaltyEffect:
|
||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||
@@ -385,6 +401,7 @@ class SummonEffect:
|
||||
SpellEffect = (
|
||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
| PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect
|
||||
| StabilizeEffect
|
||||
)
|
||||
|
||||
|
||||
@@ -801,6 +818,13 @@ def _parse_temp_hp_effect(effect: dict[str, Json]) -> TempHpEffect:
|
||||
return TempHpEffect(amount=amount, duration_rounds=duration)
|
||||
|
||||
|
||||
def _parse_stabilize_effect(effect: dict[str, Json]) -> StabilizeEffect:
|
||||
if effect.keys() - {"type"}:
|
||||
msg = f"stabilize effect takes no fields, got {sorted(effect.keys() - {'type'})}"
|
||||
raise ValueError(msg)
|
||||
return StabilizeEffect()
|
||||
|
||||
|
||||
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
||||
@@ -855,6 +879,7 @@ _EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
|
||||
"buff": _parse_buff_effect,
|
||||
"one_shot": _parse_one_shot_effect,
|
||||
"temp_hp": _parse_temp_hp_effect,
|
||||
"stabilize": _parse_stabilize_effect,
|
||||
"penalty": _parse_penalty_effect,
|
||||
"area": _parse_area_effect,
|
||||
"summon": _parse_summon_effect,
|
||||
|
||||
Reference in New Issue
Block a user