"""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