fix(rules): virtue is 1 temporary hit point for 1 minute
This commit is contained in:
@@ -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