136 lines
6.2 KiB
Python
136 lines
6.2 KiB
Python
"""Selective Channeling: the channeler may exclude targets from her channel.
|
|
|
|
PF1e (SRD Feats > Selective Channeling, feats/selective-channeling.md):
|
|
"When you channel energy, you can choose a number of targets in the area up
|
|
to your Charisma modifier. These targets are not affected by your channeled
|
|
energy. Normal: All targets in a 30-foot burst are affected when you channel
|
|
energy. You can only choose whether or not you are affected." The engine
|
|
honors ``Action(kind="channel_energy", channel_exclude=(ids...))`` only with
|
|
the feat, capped at the Charisma modifier; without the feat the list is
|
|
ignored (all affected, the engine's default). default_policy never excludes
|
|
(available via a custom policy).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from pf1e_simulator.abilities import AbilitySpec
|
|
from pf1e_simulator.combat import Action, CombatEngine, default_policy
|
|
from pf1e_simulator.rng import ScriptedRng
|
|
from tests.test_class_abilities import _combatant, _grid, _state
|
|
|
|
if TYPE_CHECKING:
|
|
from pf1e_simulator.models import Combatant
|
|
|
|
_SELECTIVE = AbilitySpec(name="Selective Channeling", category="feat",
|
|
source_detail="test")
|
|
|
|
|
|
def _feat(channeler: Combatant) -> Combatant:
|
|
return channeler.model_copy(update={"features": [_SELECTIVE]})
|
|
|
|
|
|
def _engine(states: list, queue: list[int]) -> CombatEngine:
|
|
return CombatEngine(ScriptedRng(queue), _grid(), states)
|
|
|
|
|
|
def test_heal_mode_excludes_a_living_enemy_with_the_feat() -> None:
|
|
"""Given: a channeler with the feat, a wounded ally and a wounded LIVING
|
|
enemy inside the 30-ft burst (positive energy heals every living
|
|
creature, side included)
|
|
When: she channels heal excluding the enemy
|
|
Then: the ally and the channeler heal, the excluded enemy does not — the
|
|
observable payoff of the feat in the current data (deny the heal to the
|
|
enemy)."""
|
|
caster = _state(_feat(_combatant("oni", resources={"channel_energy": 6})),
|
|
"players", (5, 5), hp=15)
|
|
ally = _state(_combatant("ally"), "players", (5, 6), hp=10)
|
|
foe = _state(_combatant("foe"), "monsters", (5, 7), hp=10)
|
|
engine = _engine([caster, ally, foe], [5]) # 2d6 = 1+5 = 6
|
|
engine._current_round = 1
|
|
engine._execute_channel_energy(caster, ally, "heal", exclude=("foe",))
|
|
assert ally.hp == 16
|
|
assert caster.hp == 20 # 15 + 6 capped at hp_max 20
|
|
assert foe.hp == 10 # excluded: not healed
|
|
assert any("selective channeling excludes foe" in line
|
|
for line in engine._transcript)
|
|
|
|
|
|
def test_heal_mode_without_the_feat_heals_the_enemy_anyway() -> None:
|
|
"""Given: the same geometry, a channeler WITHOUT the feat
|
|
When: her policy still sends an exclusion list
|
|
Then: the exclusion is ignored (SRD Normal: "All targets in a 30-foot
|
|
burst are affected") and the living enemy is healed."""
|
|
caster = _state(_combatant("oni", resources={"channel_energy": 6}),
|
|
"players", (5, 5), hp=20)
|
|
ally = _state(_combatant("ally"), "players", (5, 6), hp=10)
|
|
foe = _state(_combatant("foe"), "monsters", (5, 7), hp=10)
|
|
engine = _engine([caster, ally, foe], [5])
|
|
engine._current_round = 1
|
|
engine._execute_channel_energy(caster, ally, "heal", exclude=("foe",))
|
|
assert foe.hp == 16
|
|
assert any("selective channeling ignored (no feat)" in line
|
|
for line in engine._transcript)
|
|
|
|
|
|
def test_exclusions_are_capped_at_the_charisma_modifier() -> None:
|
|
"""Given: a Cha 14 channeler (modifier +2, so at most 2 excluded) with
|
|
three wounded living enemies in the burst
|
|
When: the policy names three exclusions
|
|
Then: only the first two are dropped; the third is healed (deterministic
|
|
truncation)."""
|
|
caster = _state(_feat(_combatant("oni", resources={"channel_energy": 6})),
|
|
"players", (5, 5), hp=20)
|
|
foes = [
|
|
_state(_combatant(f"foe{i}"), "monsters", (5, 6 + i), hp=10)
|
|
for i in range(3)
|
|
]
|
|
engine = _engine([caster, *foes], [5]) # one shared 2d6 = 6
|
|
engine._current_round = 1
|
|
engine._execute_channel_energy(
|
|
caster, foes[0], "heal", exclude=("foe0", "foe1", "foe2")
|
|
)
|
|
assert foes[0].hp == 10 # excluded
|
|
assert foes[1].hp == 10 # excluded
|
|
assert foes[2].hp == 16 # over the +2 cap: healed like any living target
|
|
|
|
|
|
def test_harm_mode_can_spare_an_undead_ally() -> None:
|
|
"""Given: a channeler with the feat, an UNDEAD ally and an undead enemy
|
|
in the burst (harm mode damages every undead, side included)
|
|
When: she channels harm excluding the ally
|
|
Then: only the enemy rolls its Will save and takes damage — the ally is
|
|
untouched (no save die consumed: the queue holds the 2d6 and the single
|
|
enemy save)."""
|
|
caster = _state(_feat(_combatant("oni", resources={"channel_energy": 6})),
|
|
"players", (5, 5), hp=20)
|
|
pet = _state(_combatant("pet", creature_type="undead"), "players", (5, 6),
|
|
hp=20)
|
|
foe = _state(_combatant("skeleton", creature_type="undead"), "monsters",
|
|
(5, 7), hp=20)
|
|
engine = _engine([caster, pet, foe], [5, 1]) # 2d6 = 6; foe Will d20=1 fails
|
|
engine._current_round = 1
|
|
engine._execute_channel_energy(caster, foe, "harm", exclude=("pet",))
|
|
assert pet.hp == 20 # spared
|
|
assert foe.hp == 20 - 6 # failed save: full damage
|
|
assert any("selective channeling excludes pet" in line
|
|
for line in engine._transcript)
|
|
|
|
|
|
def test_default_policy_never_excludes() -> None:
|
|
"""Given: the shipped default policy
|
|
When: it emits a channel_energy action
|
|
Then: channel_exclude is empty (the feat stays opt-in via a custom
|
|
policy, like weapon_id/destination/metamagic)."""
|
|
caster = _state(_feat(_combatant("oni", resources={"channel_energy": 6})),
|
|
"players", (5, 5), hp=20)
|
|
ally = _state(_combatant("ally"), "players", (5, 6), hp=5)
|
|
foe = _state(_combatant("foe"), "monsters", (5, 8), hp=6)
|
|
engine = _engine([caster, ally, foe], [10, 10])
|
|
actions = default_policy(engine, caster)
|
|
channels = [a for a in actions if a.kind == "channel_energy"]
|
|
assert channels, "the ally is below half hp: default_policy should channel"
|
|
assert all(a.channel_exclude == () for a in channels)
|
|
assert isinstance(channels[0], Action)
|