fix(rules): stabilize is a StabilizeEffect that auto-stabilizes a dying creature
This commit is contained in:
@@ -7,6 +7,10 @@
|
|||||||
"harmless": true,
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [
|
||||||
"description": "SRD spells/stabilize.md: a cantrip - you target one living creature with -1 or fewer hit points and it is automatically stabilized, taking no further hp loss until it takes damage again. Will negates (harmless). Deferred: the dying/stabilization track (stabilized_hp, set only by _process_dying and healing) is not exposed to the spell-effect system, and the pre-cast gate rejects inactive (hp < 0) targets, so no spell can currently reach a dying creature; a zero-heal HealEffect hack would additionally stop bleeding, which the RAW stabilize does not do. No combat effect encoded."
|
{
|
||||||
|
"type": "stabilize"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "SRD spells/stabilize.md: a cantrip - you target one living creature with -1 or fewer hit points and it is automatically stabilized, taking no further hp loss until it takes damage again. Will negates (harmless)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ from pf1e_simulator.spells import (
|
|||||||
RecurringDamageSpec,
|
RecurringDamageSpec,
|
||||||
SaveType,
|
SaveType,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
|
StabilizeEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
TempHpEffect,
|
TempHpEffect,
|
||||||
close_range_ft,
|
close_range_ft,
|
||||||
@@ -469,10 +470,22 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]
|
|||||||
return " ".join(parts)
|
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(
|
def _non_damage_effect_summary(
|
||||||
effect: (
|
effect: (
|
||||||
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
||||||
| OneShotEffect | TempHpEffect
|
| OneShotEffect | TempHpEffect | StabilizeEffect
|
||||||
)
|
)
|
||||||
) -> str:
|
) -> str:
|
||||||
if isinstance(effect, ConditionEffect):
|
if isinstance(effect, ConditionEffect):
|
||||||
@@ -483,12 +496,8 @@ def _non_damage_effect_summary(
|
|||||||
return f"area {effect.radius_ft}ft"
|
return f"area {effect.radius_ft}ft"
|
||||||
if isinstance(effect, SummonEffect):
|
if isinstance(effect, SummonEffect):
|
||||||
return f"summon {effect.creature}"
|
return f"summon {effect.creature}"
|
||||||
if isinstance(effect, (OneShotEffect, TempHpEffect)):
|
if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)):
|
||||||
return (
|
return _simple_effect_summary(effect)
|
||||||
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)"
|
|
||||||
)
|
|
||||||
return f"buff {effect.duration_rounds}r"
|
return f"buff {effect.duration_rounds}r"
|
||||||
|
|
||||||
|
|
||||||
@@ -6904,7 +6913,12 @@ class CombatEngine:
|
|||||||
caster, spell, spell_name, center_pos, has_area=has_area
|
caster, spell, spell_name, center_pos, has_area=has_area
|
||||||
)
|
)
|
||||||
return not point_ok
|
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
|
return True
|
||||||
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
dist_ft = self.creature_distance(caster, target) * _SQUARE_FT
|
||||||
if dist_ft > spell_range_ft(spell, caster.combatant.level):
|
if dist_ft > spell_range_ft(spell, caster.combatant.level):
|
||||||
@@ -7155,7 +7169,7 @@ class CombatEngine:
|
|||||||
self,
|
self,
|
||||||
effect: (
|
effect: (
|
||||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||||
| PenaltyEffect | OneShotEffect | TempHpEffect
|
| PenaltyEffect | OneShotEffect | TempHpEffect | StabilizeEffect
|
||||||
),
|
),
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
caster_live: _LiveStats,
|
caster_live: _LiveStats,
|
||||||
@@ -7193,8 +7207,8 @@ class CombatEngine:
|
|||||||
effect, target, caster=caster, saved=saved,
|
effect, target, caster=caster, saved=saved,
|
||||||
empower=empower, maximize=maximize, spell_name=spell_name,
|
empower=empower, maximize=maximize, spell_name=spell_name,
|
||||||
)
|
)
|
||||||
if isinstance(effect, (OneShotEffect, TempHpEffect)):
|
if isinstance(effect, (OneShotEffect, TempHpEffect, StabilizeEffect)):
|
||||||
return self._apply_grant_effect(
|
return self._apply_simple_effect(
|
||||||
effect, target, saved=saved, spell_name=spell_name,
|
effect, target, saved=saved, spell_name=spell_name,
|
||||||
)
|
)
|
||||||
return self._apply_buff_effect(
|
return self._apply_buff_effect(
|
||||||
@@ -7202,9 +7216,9 @@ class CombatEngine:
|
|||||||
caster_level=caster.combatant.level,
|
caster_level=caster.combatant.level,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _apply_grant_effect(
|
def _apply_simple_effect(
|
||||||
self,
|
self,
|
||||||
effect: OneShotEffect | TempHpEffect,
|
effect: OneShotEffect | TempHpEffect | StabilizeEffect,
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
*,
|
*,
|
||||||
saved: bool,
|
saved: bool,
|
||||||
@@ -7214,7 +7228,28 @@ class CombatEngine:
|
|||||||
return self._apply_one_shot_effect(
|
return self._apply_one_shot_effect(
|
||||||
effect, target, saved=saved, spell_name=spell_name,
|
effect, target, saved=saved, spell_name=spell_name,
|
||||||
)
|
)
|
||||||
|
if isinstance(effect, TempHpEffect):
|
||||||
return self._apply_temp_hp_effect(effect, target, saved=saved)
|
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(
|
def _apply_one_shot_effect(
|
||||||
self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str
|
self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str
|
||||||
|
|||||||
@@ -303,6 +303,22 @@ class TempHpEffect:
|
|||||||
duration_rounds: int
|
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)
|
@dataclass(frozen=True)
|
||||||
class PenaltyEffect:
|
class PenaltyEffect:
|
||||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||||
@@ -385,6 +401,7 @@ class SummonEffect:
|
|||||||
SpellEffect = (
|
SpellEffect = (
|
||||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||||
| PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect
|
| 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)
|
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:
|
def _parse_penalty_effect(effect: dict[str, Json]) -> PenaltyEffect:
|
||||||
flat_raw = effect.get("scaling_flat")
|
flat_raw = effect.get("scaling_flat")
|
||||||
scaling_flat = None if flat_raw is None else _parse_flat_scaling(flat_raw)
|
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,
|
"buff": _parse_buff_effect,
|
||||||
"one_shot": _parse_one_shot_effect,
|
"one_shot": _parse_one_shot_effect,
|
||||||
"temp_hp": _parse_temp_hp_effect,
|
"temp_hp": _parse_temp_hp_effect,
|
||||||
|
"stabilize": _parse_stabilize_effect,
|
||||||
"penalty": _parse_penalty_effect,
|
"penalty": _parse_penalty_effect,
|
||||||
"area": _parse_area_effect,
|
"area": _parse_area_effect,
|
||||||
"summon": _parse_summon_effect,
|
"summon": _parse_summon_effect,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from pf1e_simulator.spells import (
|
|||||||
PenaltyEffect,
|
PenaltyEffect,
|
||||||
SpellEffect,
|
SpellEffect,
|
||||||
SpellSpec,
|
SpellSpec,
|
||||||
|
StabilizeEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
TempHpEffect,
|
TempHpEffect,
|
||||||
load_spell,
|
load_spell,
|
||||||
@@ -84,6 +85,7 @@ _EFFECT_TYPES = (
|
|||||||
PenaltyEffect,
|
PenaltyEffect,
|
||||||
AreaEffect,
|
AreaEffect,
|
||||||
SummonEffect,
|
SummonEffect,
|
||||||
|
StabilizeEffect,
|
||||||
TempHpEffect,
|
TempHpEffect,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""Tests for the Stabilize cantrip (SRD spells/stabilize.md).
|
||||||
|
|
||||||
|
Given: the Stabilize cantrip encoding an automatic stabilization of a dying
|
||||||
|
creature as a StabilizeEffect
|
||||||
|
When: casting on a dying ally / a conscious creature / a dead creature / a
|
||||||
|
hostile target / the default policy choosing a spell
|
||||||
|
Then: a dying same-side target is stabilized without a save roll (harmless,
|
||||||
|
willing target) and stops losing hp until new damage below the stabilized hp
|
||||||
|
resumes dying; conscious and dead targets are unaffected; a hostile target
|
||||||
|
rolls its Will save (success negates); and default_policy never proposes the
|
||||||
|
cantrip (no spell slot, level-0 skipped).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pf1e_simulator.combat import (
|
||||||
|
CombatantState,
|
||||||
|
CombatEngine,
|
||||||
|
_choose_spell_action,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.dice import parse_dice
|
||||||
|
from pf1e_simulator.grid import Grid
|
||||||
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
|
from pf1e_simulator.models import (
|
||||||
|
AbilityScores,
|
||||||
|
ACProfile,
|
||||||
|
AttackSpec,
|
||||||
|
Combatant,
|
||||||
|
DamageComponent,
|
||||||
|
Saves,
|
||||||
|
)
|
||||||
|
from pf1e_simulator.rng import ScriptedRng
|
||||||
|
from pf1e_simulator.spells import StabilizeEffect, load_spell_registry
|
||||||
|
|
||||||
|
_SPELLS_DIR = Path(__file__).resolve().parent.parent / "data" / "spells"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_combatant(
|
||||||
|
cid: str, *, casting: bool = False, spells: list[str] | None = None
|
||||||
|
) -> Combatant:
|
||||||
|
attack = AttackSpec(
|
||||||
|
id=f"{cid}-w",
|
||||||
|
name="club",
|
||||||
|
kind="melee",
|
||||||
|
attack_bonus=3,
|
||||||
|
damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])],
|
||||||
|
)
|
||||||
|
return Combatant(
|
||||||
|
id=cid,
|
||||||
|
name=cid,
|
||||||
|
level=4,
|
||||||
|
size="Medium",
|
||||||
|
abilities=AbilityScores(
|
||||||
|
str_score=10, dex_score=10, con_score=10,
|
||||||
|
int_score=18, wis_score=10, cha_score=10,
|
||||||
|
),
|
||||||
|
hp_max=30,
|
||||||
|
ac=ACProfile(total=12, touch=12, flat_footed=12),
|
||||||
|
bab=2,
|
||||||
|
initiative_mod=0,
|
||||||
|
speed_land_ft=30,
|
||||||
|
saves=Saves(fort=2, ref=2, will=2),
|
||||||
|
attacks=[attack],
|
||||||
|
casting_ability="int" if casting else "wis",
|
||||||
|
spell_slots={1: 3} if casting else {},
|
||||||
|
spells=spells or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine(
|
||||||
|
queue: list[int],
|
||||||
|
*,
|
||||||
|
sides: dict[str, str] | None = None,
|
||||||
|
positions: dict[str, tuple[int, int]] | None = None,
|
||||||
|
combatants: list[Combatant] | None = None,
|
||||||
|
) -> CombatEngine:
|
||||||
|
roster = combatants or [_make_combatant("hero")]
|
||||||
|
side_map = sides or {c.id: "players" for c in roster}
|
||||||
|
pos_map = positions or {c.id: (i, 0) for i, c in enumerate(roster)}
|
||||||
|
states = [
|
||||||
|
CombatantState(
|
||||||
|
combatant=c, side=side_map[c.id], pos=pos_map[c.id], hp=c.hp_max
|
||||||
|
)
|
||||||
|
for c in roster
|
||||||
|
]
|
||||||
|
legend = {".": TerrainType(type="floor", move_cost=1)}
|
||||||
|
spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
|
||||||
|
grid = Grid.from_spec(spec)
|
||||||
|
reg = load_spell_registry(_SPELLS_DIR)
|
||||||
|
return CombatEngine(ScriptedRng(queue), grid, states, spell_registry=reg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStabilizeLoading:
|
||||||
|
"""Given: data/spells/stabilize.json
|
||||||
|
When: load_spell_registry is called
|
||||||
|
Then: Stabilize is a StabilizeEffect cantrip (conjuration, close, Will
|
||||||
|
negates (harmless))."""
|
||||||
|
|
||||||
|
def test_stabilize_is_a_stabilize_effect(self) -> None:
|
||||||
|
reg = load_spell_registry(_SPELLS_DIR)
|
||||||
|
spell = reg["Stabilize"]
|
||||||
|
assert len(spell.effects) == 1
|
||||||
|
effect = spell.effects[0]
|
||||||
|
assert isinstance(effect, StabilizeEffect)
|
||||||
|
assert spell.level == 0
|
||||||
|
assert spell.school == "conjuration"
|
||||||
|
assert spell.range == "close"
|
||||||
|
assert spell.save == "will"
|
||||||
|
assert spell.harmless is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestStabilizeCasting:
|
||||||
|
"""Given: a caster casting Stabilize
|
||||||
|
When: the target is dying / conscious / dead / hostile
|
||||||
|
Then: a dying same-side target is stabilized without a save roll
|
||||||
|
(harmless, willing target) and stops losing hp; conscious and dead
|
||||||
|
targets are unaffected; a hostile target rolls its Will save."""
|
||||||
|
|
||||||
|
def test_stabilizes_dying_ally_without_save_roll(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "players"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
assert t.stabilized_hp == -5
|
||||||
|
# Saving Throw Will negates (harmless): a willing same-side target
|
||||||
|
# forgoes the save, so the cast consumes no d20 from the queue.
|
||||||
|
assert len(engine._rng._queue) == 1
|
||||||
|
|
||||||
|
def test_stabilized_creature_stops_losing_hp(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "players"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
engine._process_dying(t)
|
||||||
|
# Stabilized: no Constitution check, no further hp loss.
|
||||||
|
assert t.hp == -5
|
||||||
|
assert t.stabilized_hp == -5
|
||||||
|
assert len(engine._rng._queue) == 1
|
||||||
|
|
||||||
|
def test_new_damage_below_stabilized_hp_resumes_dying(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "players"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
t.hp = -6
|
||||||
|
engine._process_dying(t)
|
||||||
|
# New damage below the stabilized hp resumes dying: the marker is
|
||||||
|
# cleared and the Constitution check is rolled (d20=3 + CON(0) - 6
|
||||||
|
# = -3 < DC 10 -> fails, loses 1 hp).
|
||||||
|
assert t.stabilized_hp is None
|
||||||
|
assert t.hp == -7
|
||||||
|
assert len(engine._rng._queue) == 0
|
||||||
|
|
||||||
|
def test_no_effect_on_conscious_creature(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "players"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = 5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
assert t.stabilized_hp is None
|
||||||
|
assert t.hp == 5
|
||||||
|
|
||||||
|
def test_no_effect_on_dead_creature(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "players"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -12 # below -CON (con 10): dead
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
assert t.stabilized_hp is None
|
||||||
|
|
||||||
|
def test_hostile_target_save_failure_stabilizes(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "monsters"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
# d20=3 + will 2 = 5 < DC 14 (10 + level 0 + int 4): save fails, the
|
||||||
|
# dying target stabilizes.
|
||||||
|
assert t.stabilized_hp == -5
|
||||||
|
assert len(engine._rng._queue) == 0
|
||||||
|
|
||||||
|
def test_hostile_target_save_success_negates(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True)
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[20],
|
||||||
|
sides={"caster": "players", "target": "monsters"},
|
||||||
|
positions={"caster": (0, 0), "target": (1, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
t.hp = -5
|
||||||
|
engine._cast_spell(c, t, "Stabilize")
|
||||||
|
# d20=20 + will 2 = 22 >= DC 14: save succeeds, the spell is negated.
|
||||||
|
assert t.stabilized_hp is None
|
||||||
|
assert len(engine._rng._queue) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestStabilizePolicy:
|
||||||
|
"""Given: the default spell selection
|
||||||
|
When: a caster knows Stabilize (cantrip)
|
||||||
|
Then: _choose_spell_action never proposes it (no spell slot, level-0
|
||||||
|
skipped, and StabilizeEffect is neither a heal nor a buff)."""
|
||||||
|
|
||||||
|
def test_choose_spell_action_never_proposes_stabilize(self) -> None:
|
||||||
|
caster = _make_combatant("caster", casting=True, spells=["Stabilize"])
|
||||||
|
target = _make_combatant("target")
|
||||||
|
engine = _make_engine(
|
||||||
|
[3],
|
||||||
|
sides={"caster": "players", "target": "monsters"},
|
||||||
|
positions={"caster": (0, 0), "target": (5, 0)},
|
||||||
|
combatants=[caster, target],
|
||||||
|
)
|
||||||
|
c, t = engine.states
|
||||||
|
assert _choose_spell_action(engine, c, t) is None
|
||||||
Reference in New Issue
Block a user