fix(rules): virtue is 1 temporary hit point for 1 minute

This commit is contained in:
2026-09-20 10:32:05 +02:00
parent 1238c40e43
commit a8a65b18cd
5 changed files with 387 additions and 18 deletions
+8 -2
View File
@@ -6,6 +6,12 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"effects": [], "effects": [
"description": "SRD spells/virtue.md: a cantrip - the subject gains 1 temporary hit point for 1 minute. Spell resistance yes (harmless). Deferred: CombatantState has no temporary-hit-point track (temp HP must absorb damage before real hp, not affect the dying/dead thresholds, and expire on duration - a cross-cutting change to every damage site), so no mechanical effect is applied." {
"type": "temp_hp",
"amount": 1,
"duration_rounds": 10
}
],
"description": "SRD spells/virtue.md: a cantrip - the subject gains 1 temporary hit point for 1 minute. Saving Throw none, spell resistance yes (harmless). Modeled as a TempHpEffect (amount 1, 10 rounds): temporary hit points absorb damage before real hp (CRB 'Temporary Hit Points'), expire when the granting effect's duration ends, and do not stack (the creature keeps the higher amount, ties keep the longer remaining duration). default_policy never casts it: cantrips have no spell slot, and _find_buff_spell skips level-0 spells, so Virtue cannot preempt a leveled buff (Divine Favor) in the known-spell order."
} }
+74 -15
View File
@@ -151,6 +151,7 @@ from pf1e_simulator.spells import (
SaveType, SaveType,
SpellEffect, SpellEffect,
SummonEffect, SummonEffect,
TempHpEffect,
close_range_ft, close_range_ft,
save_descriptors_for_effects, save_descriptors_for_effects,
spell_range_ft, spell_range_ft,
@@ -469,7 +470,10 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]
def _non_damage_effect_summary( def _non_damage_effect_summary(
effect: ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect effect: (
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
| OneShotEffect | TempHpEffect
)
) -> str: ) -> str:
if isinstance(effect, ConditionEffect): if isinstance(effect, ConditionEffect):
return f"{effect.condition_name} {effect.duration_rounds}r" return f"{effect.condition_name} {effect.duration_rounds}r"
@@ -479,8 +483,12 @@ 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): if isinstance(effect, (OneShotEffect, TempHpEffect)):
return f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}" 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)"
)
return f"buff {effect.duration_rounds}r" return f"buff {effect.duration_rounds}r"
@@ -788,6 +796,11 @@ class CombatantState:
# One-shot typed bonuses (SRD spells/guidance.md) discharged by the first # One-shot typed bonuses (SRD spells/guidance.md) discharged by the first
# roll of their track and expiring with their duration. # roll of their track and expiring with their duration.
one_shot_bonuses: list[ActiveOneShot] = field(default_factory=list) one_shot_bonuses: list[ActiveOneShot] = field(default_factory=list)
# Temporary hit points (SRD spells/virtue.md): absorb damage before real
# hp (CRB "Temporary Hit Points") and expire when the granting effect's
# duration ends (temp_hp_rounds_left, decremented like buffs).
temp_hp: int = 0
temp_hp_rounds_left: int = 0
resources_left: dict[str, int] = field(default_factory=dict) resources_left: dict[str, int] = field(default_factory=dict)
spell_slots_left: dict[int, int] = field(default_factory=dict) spell_slots_left: dict[int, int] = field(default_factory=dict)
moved_this_turn: bool = False moved_this_turn: bool = False
@@ -1682,6 +1695,8 @@ class CombatEngine:
} }
for shot in state.one_shot_bonuses for shot in state.one_shot_bonuses
), ),
"temp_hp": state.temp_hp,
"temp_hp_rounds_left": state.temp_hp_rounds_left,
"speed_land_ft": state.combatant.speed_land_ft, "speed_land_ft": state.combatant.speed_land_ft,
"speed_fly_ft": state.combatant.speed_fly_ft, "speed_fly_ft": state.combatant.speed_fly_ft,
"speed_effective_ft": _speed_ft(state), "speed_effective_ft": _speed_ft(state),
@@ -2021,7 +2036,7 @@ class CombatEngine:
# strenuous) action costs 1 hp — the creature slips to -1 and # strenuous) action costs 1 hp — the creature slips to -1 and
# is dying. Simplification: if the action itself healed the # is dying. Simplification: if the action itself healed the
# creature above 0 hp the cost is not applied. # creature above 0 hp the cost is not applied.
state.hp -= 1 self._lose_hp(state, 1)
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)" f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
@@ -2255,6 +2270,10 @@ class CombatEngine:
for o in state.one_shot_bonuses for o in state.one_shot_bonuses
if o.rounds_left > 1 if o.rounds_left > 1
] ]
if state.temp_hp_rounds_left > 0:
state.temp_hp_rounds_left -= 1
if state.temp_hp_rounds_left == 0:
state.temp_hp = 0
bleeding_expired = ( bleeding_expired = (
any(ac.condition.name == "bleeding" for ac in state.active_conditions) any(ac.condition.name == "bleeding" for ac in state.active_conditions)
and not any(ac.condition.name == "bleeding" for ac in kept_conditions) and not any(ac.condition.name == "bleeding" for ac in kept_conditions)
@@ -2285,12 +2304,19 @@ class CombatEngine:
return self._winner_side() is not None return self._winner_side() is not None
return None return None
def _lose_hp(self, state: CombatantState, amount: int) -> None:
"""Apply damage: temp HP absorbs first (CRB "Temporary Hit Points"),
then real hp. Callers keep booking the full amount to stats."""
absorbed = min(state.temp_hp, amount)
state.temp_hp -= absorbed
state.hp -= amount - absorbed
def _apply_bleed_damage(self, state: CombatantState) -> None: def _apply_bleed_damage(self, state: CombatantState) -> None:
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB).""" """PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
if state.bleed_damage <= 0: if state.bleed_damage <= 0:
return return
old_hp = state.hp old_hp = state.hp
state.hp -= state.bleed_damage self._lose_hp(state, state.bleed_damage)
self._stats[state.combatant.id].damage_taken += state.bleed_damage self._stats[state.combatant.id].damage_taken += state.bleed_damage
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -2310,7 +2336,7 @@ class CombatEngine:
raw = entry.formula.roll(self._rng) raw = entry.formula.roll(self._rng)
dmg = self._apply_energy_resistance(raw, entry.types, state) dmg = self._apply_energy_resistance(raw, entry.types, state)
old_hp = state.hp old_hp = state.hp
state.hp -= dmg self._lose_hp(state, dmg)
live.damage_taken += dmg live.damage_taken += dmg
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -2354,7 +2380,7 @@ class CombatEngine:
"""Confused 51-75: 1d8 + Str modifier damage to self with item in hand.""" """Confused 51-75: 1d8 + Str modifier damage to self with item in hand."""
dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod
old_hp = state.hp old_hp = state.hp
state.hp -= dmg self._lose_hp(state, dmg)
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})" f"confused: hits self for {dmg} (hp {old_hp}->{state.hp})"
@@ -3137,7 +3163,7 @@ class CombatEngine:
dist_ft=dist_ft, denies_dex=denies_dex, dist_ft=dist_ft, denies_dex=denies_dex,
) )
if not concealment_miss: if not concealment_miss:
target.hp -= damage self._lose_hp(target, damage)
damage = self._consume_up_close_and_deadly( damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit attacker, target, weapon, dist_ft, damage, hit=hit
) )
@@ -3444,7 +3470,7 @@ class CombatEngine:
): ):
bonus = self._rng.roll_die(6) bonus = self._rng.roll_die(6)
damage += bonus damage += bonus
target.hp -= bonus self._lose_hp(target, bonus)
attacker.deed_up_close_and_deadly = False attacker.deed_up_close_and_deadly = False
return damage return damage
@@ -6026,7 +6052,7 @@ class CombatEngine:
save = self.resolve_save(affected_state, "will", dc) save = self.resolve_save(affected_state, "will", dc)
dealt = dmg // 2 if save.success else dmg dealt = dmg // 2 if save.success else dmg
hp_before = affected_state.hp hp_before = affected_state.hp
affected_state.hp -= dealt self._lose_hp(affected_state, dealt)
self._stats[state.combatant.id].damage_dealt += dealt self._stats[state.combatant.id].damage_dealt += dealt
self._stats[affected_state.combatant.id].damage_taken += dealt self._stats[affected_state.combatant.id].damage_taken += dealt
self._log( self._log(
@@ -7129,7 +7155,7 @@ class CombatEngine:
self, self,
effect: ( effect: (
DamageEffect | ConditionEffect | HealEffect | BuffEffect DamageEffect | ConditionEffect | HealEffect | BuffEffect
| PenaltyEffect | OneShotEffect | PenaltyEffect | OneShotEffect | TempHpEffect
), ),
target: CombatantState, target: CombatantState,
caster_live: _LiveStats, caster_live: _LiveStats,
@@ -7167,8 +7193,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): if isinstance(effect, (OneShotEffect, TempHpEffect)):
return self._apply_one_shot_effect( return self._apply_grant_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(
@@ -7176,6 +7202,20 @@ class CombatEngine:
caster_level=caster.combatant.level, caster_level=caster.combatant.level,
) )
def _apply_grant_effect(
self,
effect: OneShotEffect | TempHpEffect,
target: CombatantState,
*,
saved: bool,
spell_name: str,
) -> str:
if isinstance(effect, OneShotEffect):
return self._apply_one_shot_effect(
effect, target, saved=saved, spell_name=spell_name,
)
return self._apply_temp_hp_effect(effect, target, saved=saved)
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
) -> str: ) -> str:
@@ -7201,6 +7241,25 @@ class CombatEngine:
f"({effect.duration_rounds}r)" f"({effect.duration_rounds}r)"
) )
def _apply_temp_hp_effect(
self, effect: TempHpEffect, target: CombatantState, *, saved: bool
) -> str:
"""Grant temporary hit points (SRD spells/virtue.md).
Temp HP do not stack (CRB "Temporary Hit Points"): the creature keeps
the higher amount, ties keep the longer remaining duration.
"""
if saved:
return "temp hp negated"
if target.temp_hp > effect.amount or (
target.temp_hp == effect.amount
and target.temp_hp_rounds_left >= effect.duration_rounds
):
return f"temp hp kept ({target.temp_hp})"
target.temp_hp = effect.amount
target.temp_hp_rounds_left = effect.duration_rounds
return f"temp hp +{effect.amount} ({effect.duration_rounds}r)"
def _apply_damage_effect( def _apply_damage_effect(
self, self,
effect: DamageEffect, effect: DamageEffect,
@@ -7306,7 +7365,7 @@ class CombatEngine:
if empower: if empower:
dmg = int(dmg * 1.5) dmg = int(dmg * 1.5)
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target) dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
target.hp -= dmg self._lose_hp(target, dmg)
return dmg return dmg
def _attach_recurring_damage( def _attach_recurring_damage(
@@ -7474,7 +7533,7 @@ class CombatEngine:
# DR, and it does not stop bleeding or stabilize. Bitter reduces # DR, and it does not stop bleeding or stabilize. Bitter reduces
# healing, not this damage. # healing, not this damage.
dmg = heal // 2 if saved else heal dmg = heal // 2 if saved else heal
target.hp -= dmg self._lose_hp(target, dmg)
if caster_live is not None: if caster_live is not None:
caster_live.damage_dealt += dmg caster_live.damage_dealt += dmg
if target_live is not None: if target_live is not None:
+31 -1
View File
@@ -286,6 +286,23 @@ class OneShotEffect:
duration_rounds: int duration_rounds: int
@dataclass(frozen=True)
class TempHpEffect:
"""PF1e temporary hit points (SRD spells/virtue.md: "granting the subject
1 temporary hit point").
Temporary hit points absorb damage before real hit points (CRB "Temporary
Hit Points": "damage is applied to temporary hit points first") and expire
when the granting effect's duration ends. They do not stack: a creature
with temp HP that receives more keeps the higher amount (CRB: "it must
decide to keep the old ones or the new ones" — the engine keeps the
higher, ties keep the longer remaining duration).
"""
amount: int
duration_rounds: int
@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).
@@ -367,7 +384,7 @@ class SummonEffect:
SpellEffect = ( SpellEffect = (
DamageEffect | ConditionEffect | HealEffect | BuffEffect DamageEffect | ConditionEffect | HealEffect | BuffEffect
| PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | PenaltyEffect | AreaEffect | SummonEffect | OneShotEffect | TempHpEffect
) )
@@ -772,6 +789,18 @@ def _parse_one_shot_effect(effect: dict[str, Json]) -> OneShotEffect:
) )
def _parse_temp_hp_effect(effect: dict[str, Json]) -> TempHpEffect:
amount = _as_int(effect["amount"])
if amount < 1:
msg = f"temp_hp amount must be >= 1, got {amount}"
raise ValueError(msg)
duration = _as_int(effect["duration_rounds"])
if duration < 1:
msg = f"temp_hp duration_rounds must be >= 1, got {duration}"
raise ValueError(msg)
return TempHpEffect(amount=amount, duration_rounds=duration)
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)
@@ -825,6 +854,7 @@ _EFFECT_PARSERS: dict[str, Callable[[dict[str, Json]], SpellEffect]] = {
"heal": _parse_heal_effect, "heal": _parse_heal_effect,
"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,
"penalty": _parse_penalty_effect, "penalty": _parse_penalty_effect,
"area": _parse_area_effect, "area": _parse_area_effect,
"summon": _parse_summon_effect, "summon": _parse_summon_effect,
+2
View File
@@ -28,6 +28,7 @@ from pf1e_simulator.spells import (
SpellEffect, SpellEffect,
SpellSpec, SpellSpec,
SummonEffect, SummonEffect,
TempHpEffect,
load_spell, load_spell,
load_spell_registry, load_spell_registry,
) )
@@ -83,6 +84,7 @@ _EFFECT_TYPES = (
PenaltyEffect, PenaltyEffect,
AreaEffect, AreaEffect,
SummonEffect, SummonEffect,
TempHpEffect,
) )
+272
View File
@@ -0,0 +1,272 @@
"""Tests for the Virtue cantrip temp HP (SRD spells/virtue.md).
Given: the Virtue cantrip encoding 1 temporary hit point for 1 minute
(10 rounds) as a TempHpEffect
When: casting on a willing ally / taking damage / ticking durations / the
default policy choosing a buff spell
Then: the temp HP is applied without a save roll on a willing target, damage
is absorbed by temp HP before real hp (CRB "Temporary Hit Points"), temp HP
does not stack (the higher amount wins, ties keep the longer remaining
duration), it expires with its duration, and default_policy never proposes
the cantrip (no spell slot, level-0 skipped by _find_buff_spell).
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
CombatantState,
CombatEngine,
_choose_spell_action,
_find_buff_spell,
)
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 TempHpEffect, 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 TestVirtueLoading:
"""Given: data/spells/virtue.json
When: load_spell_registry is called
Then: Virtue is a TempHpEffect granting 1 temp HP for 10 rounds."""
def test_virtue_is_a_temp_hp_effect(self) -> None:
reg = load_spell_registry(_SPELLS_DIR)
spell = reg["Virtue"]
assert len(spell.effects) == 1
effect = spell.effects[0]
assert isinstance(effect, TempHpEffect)
assert effect.amount == 1
assert effect.duration_rounds == 10
class TestVirtueCasting:
"""Given: a caster casting Virtue on a willing ally
When: _cast_spell is called
Then: 1 temp HP is applied without a save roll (harmless, willing target)
and damage is absorbed by temp HP before real hp."""
def test_applies_temp_hp_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
engine._cast_spell(c, t, "Virtue")
assert t.temp_hp == 1
assert t.temp_hp_rounds_left == 10
# Saving Throw none (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_temp_hp_absorbs_damage_first(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
engine._cast_spell(c, t, "Virtue")
t.bleed_damage = 2
engine._apply_bleed_damage(t)
# 2 bleed damage: 1 absorbed by temp HP, 1 real hp lost.
assert t.temp_hp == 0
assert t.hp == t.combatant.hp_max - 1
def test_temp_hp_keeps_disabled_creature_from_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
engine._cast_spell(c, t, "Virtue")
t.hp = 0
t.bleed_damage = 1
engine._apply_bleed_damage(t)
# Temp HP absorbs the hit: real hp stays 0 (disabled), not -1 (dying).
assert t.hp == 0
assert t.temp_hp == 0
def test_temp_hp_does_not_stack(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
engine._cast_spell(c, t, "Virtue")
engine._cast_spell(c, t, "Virtue")
# Equal amounts: the tie keeps the longer remaining duration (both
# fresh here, so the existing 10 rounds are kept).
assert t.temp_hp == 1
assert t.temp_hp_rounds_left == 10
def test_temp_hp_tie_keeps_longer_remaining_duration(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
engine._cast_spell(c, t, "Virtue")
for _ in range(3):
engine._decrement_durations(t)
assert t.temp_hp_rounds_left == 7
engine._cast_spell(c, t, "Virtue")
# Equal amounts, existing 7 rounds left vs fresh 10: the fresh casting
# wins the tie (longer remaining duration).
assert t.temp_hp == 1
assert t.temp_hp_rounds_left == 10
def test_temp_hp_expires_after_ten_rounds(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
engine._cast_spell(c, t, "Virtue")
assert t.temp_hp == 1
for _ in range(10):
engine._decrement_durations(t)
assert t.temp_hp == 0
assert t.temp_hp_rounds_left == 0
class TestVirtuePolicy:
"""Given: the default buff-spell selection
When: a caster knows Virtue (cantrip) and Divine Favor (level 1)
Then: _find_buff_spell skips the level-0 cantrip and returns the leveled
buff, and _choose_spell_action proposes Divine Favor, never Virtue."""
def test_find_buff_spell_skips_level0_cantrip(self) -> None:
caster = _make_combatant(
"caster", casting=True, spells=["Virtue", "Divine Favor"]
)
engine = _make_engine([3], combatants=[caster])
c = engine.states[0]
assert _find_buff_spell(engine, c.combatant.spells) == "Divine Favor"
def test_find_buff_spell_returns_none_without_leveled_buff(self) -> None:
caster = _make_combatant("caster", casting=True, spells=["Virtue"])
engine = _make_engine([3], combatants=[caster])
c = engine.states[0]
assert _find_buff_spell(engine, c.combatant.spells) is None
def test_choose_spell_action_proposes_divine_favor(self) -> None:
caster = _make_combatant(
"caster", casting=True, spells=["Virtue", "Divine Favor"]
)
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
actions = _choose_spell_action(engine, c, t)
assert actions is not None
assert actions[0].kind == "cast_spell"
assert actions[0].spell_name == "Divine Favor"
assert actions[0].target_id == c.combatant.id
def test_choose_spell_action_never_proposes_virtue(self) -> None:
caster = _make_combatant("caster", casting=True, spells=["Virtue"])
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