fix(rules): virtue is 1 temporary hit point for 1 minute
This commit is contained in:
@@ -6,6 +6,12 @@
|
||||
"save": "none",
|
||||
"base_dc": 10,
|
||||
"cast_time": "standard",
|
||||
"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."
|
||||
"effects": [
|
||||
{
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ from pf1e_simulator.spells import (
|
||||
SaveType,
|
||||
SpellEffect,
|
||||
SummonEffect,
|
||||
TempHpEffect,
|
||||
close_range_ft,
|
||||
save_descriptors_for_effects,
|
||||
spell_range_ft,
|
||||
@@ -469,7 +470,10 @@ def _damage_effect_summary(effect: DamageEffect | PenaltyEffect, tags: list[str]
|
||||
|
||||
|
||||
def _non_damage_effect_summary(
|
||||
effect: ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect | OneShotEffect
|
||||
effect: (
|
||||
ConditionEffect | HealEffect | BuffEffect | AreaEffect | SummonEffect
|
||||
| OneShotEffect | TempHpEffect
|
||||
)
|
||||
) -> str:
|
||||
if isinstance(effect, ConditionEffect):
|
||||
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"
|
||||
if isinstance(effect, SummonEffect):
|
||||
return f"summon {effect.creature}"
|
||||
if isinstance(effect, OneShotEffect):
|
||||
return f"one-shot +{effect.value} {effect.bonus_type} on {effect.track}"
|
||||
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)"
|
||||
)
|
||||
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
|
||||
# roll of their track and expiring with their duration.
|
||||
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)
|
||||
spell_slots_left: dict[int, int] = field(default_factory=dict)
|
||||
moved_this_turn: bool = False
|
||||
@@ -1682,6 +1695,8 @@ class CombatEngine:
|
||||
}
|
||||
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_fly_ft": state.combatant.speed_fly_ft,
|
||||
"speed_effective_ft": _speed_ft(state),
|
||||
@@ -2021,7 +2036,7 @@ class CombatEngine:
|
||||
# strenuous) action costs 1 hp — the creature slips to -1 and
|
||||
# is dying. Simplification: if the action itself healed the
|
||||
# creature above 0 hp the cost is not applied.
|
||||
state.hp -= 1
|
||||
self._lose_hp(state, 1)
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
f"disabled: strenuous action costs 1 hp (hp 0 -> -1, dying)"
|
||||
@@ -2255,6 +2270,10 @@ class CombatEngine:
|
||||
for o in state.one_shot_bonuses
|
||||
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 = (
|
||||
any(ac.condition.name == "bleeding" for ac in state.active_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 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:
|
||||
"""PF1e bleeding: take ``bleed_damage`` at the beginning of the turn (CRB)."""
|
||||
if state.bleed_damage <= 0:
|
||||
return
|
||||
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._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
@@ -2310,7 +2336,7 @@ class CombatEngine:
|
||||
raw = entry.formula.roll(self._rng)
|
||||
dmg = self._apply_energy_resistance(raw, entry.types, state)
|
||||
old_hp = state.hp
|
||||
state.hp -= dmg
|
||||
self._lose_hp(state, dmg)
|
||||
live.damage_taken += dmg
|
||||
self._log(
|
||||
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."""
|
||||
dmg = self._rng.roll_die(8) + state.combatant.abilities.str_mod
|
||||
old_hp = state.hp
|
||||
state.hp -= dmg
|
||||
self._lose_hp(state, dmg)
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
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,
|
||||
)
|
||||
if not concealment_miss:
|
||||
target.hp -= damage
|
||||
self._lose_hp(target, damage)
|
||||
damage = self._consume_up_close_and_deadly(
|
||||
attacker, target, weapon, dist_ft, damage, hit=hit
|
||||
)
|
||||
@@ -3444,7 +3470,7 @@ class CombatEngine:
|
||||
):
|
||||
bonus = self._rng.roll_die(6)
|
||||
damage += bonus
|
||||
target.hp -= bonus
|
||||
self._lose_hp(target, bonus)
|
||||
attacker.deed_up_close_and_deadly = False
|
||||
return damage
|
||||
|
||||
@@ -6026,7 +6052,7 @@ class CombatEngine:
|
||||
save = self.resolve_save(affected_state, "will", dc)
|
||||
dealt = dmg // 2 if save.success else dmg
|
||||
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[affected_state.combatant.id].damage_taken += dealt
|
||||
self._log(
|
||||
@@ -7129,7 +7155,7 @@ class CombatEngine:
|
||||
self,
|
||||
effect: (
|
||||
DamageEffect | ConditionEffect | HealEffect | BuffEffect
|
||||
| PenaltyEffect | OneShotEffect
|
||||
| PenaltyEffect | OneShotEffect | TempHpEffect
|
||||
),
|
||||
target: CombatantState,
|
||||
caster_live: _LiveStats,
|
||||
@@ -7167,8 +7193,8 @@ class CombatEngine:
|
||||
effect, target, caster=caster, saved=saved,
|
||||
empower=empower, maximize=maximize, spell_name=spell_name,
|
||||
)
|
||||
if isinstance(effect, OneShotEffect):
|
||||
return self._apply_one_shot_effect(
|
||||
if isinstance(effect, (OneShotEffect, TempHpEffect)):
|
||||
return self._apply_grant_effect(
|
||||
effect, target, saved=saved, spell_name=spell_name,
|
||||
)
|
||||
return self._apply_buff_effect(
|
||||
@@ -7176,6 +7202,20 @@ class CombatEngine:
|
||||
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(
|
||||
self, effect: OneShotEffect, target: CombatantState, *, saved: bool, spell_name: str
|
||||
) -> str:
|
||||
@@ -7201,6 +7241,25 @@ class CombatEngine:
|
||||
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(
|
||||
self,
|
||||
effect: DamageEffect,
|
||||
@@ -7306,7 +7365,7 @@ class CombatEngine:
|
||||
if empower:
|
||||
dmg = int(dmg * 1.5)
|
||||
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
|
||||
target.hp -= dmg
|
||||
self._lose_hp(target, dmg)
|
||||
return dmg
|
||||
|
||||
def _attach_recurring_damage(
|
||||
@@ -7474,7 +7533,7 @@ class CombatEngine:
|
||||
# DR, and it does not stop bleeding or stabilize. Bitter reduces
|
||||
# healing, not this damage.
|
||||
dmg = heal // 2 if saved else heal
|
||||
target.hp -= dmg
|
||||
self._lose_hp(target, dmg)
|
||||
if caster_live is not None:
|
||||
caster_live.damage_dealt += dmg
|
||||
if target_live is not None:
|
||||
|
||||
@@ -286,6 +286,23 @@ class OneShotEffect:
|
||||
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)
|
||||
class PenaltyEffect:
|
||||
"""PF1e rolled ability penalty (SRD Ray of Enfeeblement).
|
||||
@@ -367,7 +384,7 @@ class SummonEffect:
|
||||
|
||||
SpellEffect = (
|
||||
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:
|
||||
flat_raw = effect.get("scaling_flat")
|
||||
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,
|
||||
"buff": _parse_buff_effect,
|
||||
"one_shot": _parse_one_shot_effect,
|
||||
"temp_hp": _parse_temp_hp_effect,
|
||||
"penalty": _parse_penalty_effect,
|
||||
"area": _parse_area_effect,
|
||||
"summon": _parse_summon_effect,
|
||||
|
||||
@@ -28,6 +28,7 @@ from pf1e_simulator.spells import (
|
||||
SpellEffect,
|
||||
SpellSpec,
|
||||
SummonEffect,
|
||||
TempHpEffect,
|
||||
load_spell,
|
||||
load_spell_registry,
|
||||
)
|
||||
@@ -83,6 +84,7 @@ _EFFECT_TYPES = (
|
||||
PenaltyEffect,
|
||||
AreaEffect,
|
||||
SummonEffect,
|
||||
TempHpEffect,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user