Files

273 lines
9.9 KiB
Python

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