fix(rules): selective channeling modeled as a policy-chosen exclusion - Action(kind=channel_energy) gains channel_exclude, honored only with the Selective Channeling feat and capped at the Charisma modifier (SRD feats/selective-channeling.md: 'choose a number of targets in the area up to your Charisma modifier... not affected by your channeled energy'); without the feat the list is ignored and logged (SRD Normal: all targets affected); observable in current data because heal mode also heals living ENEMIES in the burst (deny them the heal) and harm mode can spare an undead ally; default_policy never excludes (opt-in, like weapon_id/destination/metamagic); golden transcripts unchanged; +5 tests

This commit is contained in:
2026-09-19 22:49:40 +02:00
parent 9f0d86273a
commit 5fe2b72339
3 changed files with 209 additions and 8 deletions
+16 -3
View File
@@ -1591,8 +1591,20 @@ Règles modélisées :
`> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive : `> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, énergie positive :
si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au si un allié est < 50 % de ses PV → soigne tous les êtres vivants à 30 ft ; sinon, si au
moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la moins 2 morts-vivants ennemis sont groupés à 30 ft → les blesse en 2d6 ; le camp de la
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non cible n'est pas pris en compte — seul `creature_type` compte ; **Selective
modélisé ; les créatures blessées obtiennent un jet de Volonté DD `10 + Channeling** modélisé (SRD `feats/selective-channeling.md` : « you can
choose a number of targets in the area up to your Charisma modifier. These
targets are not affected by your channeled energy ») via
`Action(kind="channel_energy", channel_exclude=(ids…))` — honoré
uniquement avec le don, plafonné au modificateur de Charisme (troncature
déterministe de la liste excédentaire), sinon ignoré et journalisé
(`selective channeling ignored (no feat)` — SRD Normal : « All targets in a
30-foot burst are affected ») ; `default_policy` n'exclut jamais (opt-in
via politique personnalisée, comme `weapon_id`/`destination`/métamagie) ;
effet observable dans les données actuelles : le mode soin soigne aussi les
**ennemis vivants** dans le burst (positive energy heals every living
creature), le don permet de leur refuser le soin, et le mode blessure peut
épargner un allié mort-vivant ; les créatures blessées obtiennent un jet de Volonté DD `10 +
demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts
— CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` — CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly`
(tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque (tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
@@ -1764,7 +1776,8 @@ Règles modélisées :
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`, s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation, ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
B7) et `cast_defensively` (incantation défensive, B24) sur l'action B7) et `cast_defensively` (incantation défensive, B24) sur l'action
`cast_spell` ; `cast_spell`, et `channel_exclude` (ids exclus par Selective Channeling,
plafonné au mod de Charisme, don requis) sur l'action `channel_energy` ;
`can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`. `can_charge` → `ChargeResult`, kinds `retrieve_weapon`/`repair_weapon`.
Le kind `activate_power_attack` (action libre) active le don Power Attack Le kind `activate_power_attack` (action libre) active le don Power Attack
(`state.power_attack = True`) si le combattant possède le don ; sans le don, (`state.power_attack = True`) si le combattant possède le don ; sans le don,
+55 -2
View File
@@ -191,6 +191,9 @@ _FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) = the -4 penalty is avoided _FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) = the -4 penalty is avoided
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
# PF1e: feat letting a channeler exclude up to her Cha modifier targets from
# her own channel energy (SRD feats/selective-channeling.md).
_SELECTIVE_CHANNELING_FEAT = "Selective Channeling"
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range _IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
_CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20) _CRIT_RANGE_BASE = 21 # threat range top is 21 - min natural die (20 -> 20-20)
_DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check _DYING_DC = 10 # PF1e: DC of the per-round stabilization Constitution check
@@ -1114,6 +1117,12 @@ class Action:
deed_name: str | None = None # "up_close_and_deadly", "quick_clear" deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel" class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel"
channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use channel_mode: Literal["heal", "harm"] | None = None # channel energy polarity for this use
# PF1e (SRD Feats > Selective Channeling): "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."
# Ids the channeler's policy excludes from THIS channel; honored only
# with the feat, capped at the Charisma modifier (excess entries drop).
channel_exclude: tuple[str, ...] = ()
direction: tuple[int, int] | None = None # steering for directional maneuvers direction: tuple[int, int] | None = None # steering for directional maneuvers
trick_condition: TrickCondition | None = None # dirty trick condition choice trick_condition: TrickCondition | None = None # dirty trick condition choice
# weapon id for attack / full_attack / retrieve_weapon / repair_weapon # weapon id for attack / full_attack / retrieve_weapon / repair_weapon
@@ -4698,7 +4707,8 @@ class CombatEngine:
kind = action.kind kind = action.kind
if kind == "channel_energy": if kind == "channel_energy":
self._execute_channel_energy( self._execute_channel_energy(
state, target, action.channel_mode or "heal" state, target, action.channel_mode or "heal",
exclude=action.channel_exclude,
) )
elif kind == "hex": elif kind == "hex":
self._execute_hex(state, target, action.hex_name) self._execute_hex(state, target, action.hex_name)
@@ -5752,6 +5762,8 @@ class CombatEngine:
state: CombatantState, state: CombatantState,
target: CombatantState | None, target: CombatantState | None,
channel_mode: Literal["heal", "harm"], channel_mode: Literal["heal", "harm"],
*,
exclude: tuple[str, ...] = (),
) -> None: ) -> None:
"""Execute channel energy (positive energy, burst 30ft, 2d6). """Execute channel energy (positive energy, burst 30ft, 2d6).
@@ -5760,7 +5772,8 @@ class CombatEngine:
(PF1e Channel Energy: positive energy heals living creatures and (PF1e Channel Energy: positive energy heals living creatures and
damages undead creatures within 30 feet). Damaged creatures get a damages undead creatures within 30 feet). Damaged creatures get a
Will save (DC 10 + half the channeler's level + her Cha modifier) Will save (DC 10 + half the channeler's level + her Cha modifier)
for half damage (CRB Channel Energy). for half damage (CRB Channel Energy). ``exclude`` is the Selective
Channeling payload (see ``_selective_channel``).
""" """
if not state.spend_resource("channel_energy", 1): if not state.spend_resource("channel_energy", 1):
self._log( self._log(
@@ -5784,6 +5797,7 @@ class CombatEngine:
wanted_type = "living" if channel_mode == "heal" else "undead" wanted_type = "living" if channel_mode == "heal" else "undead"
hit = [s for s in affected if s.combatant.creature_type == wanted_type] hit = [s for s in affected if s.combatant.creature_type == wanted_type]
hit = self._selective_channel(state, hit, exclude)
# 2d6 damage/healing # 2d6 damage/healing
dmg = 1 + self._rng.roll_die(11) # 2d6 dmg = 1 + self._rng.roll_die(11) # 2d6
@@ -5808,6 +5822,45 @@ class CombatEngine:
f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets" f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets"
) )
def _selective_channel(
self,
state: CombatantState,
hit: list[CombatantState],
exclude: tuple[str, ...],
) -> list[CombatantState]:
"""Apply the Selective Channeling feat to a resolved channel target list.
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." Without the feat the exclusion list is ignored
(the engine's default is "all targets affected, channeler included");
with it, the first ``Cha modifier`` ids are dropped from the affected
set (deterministic truncation of an over-long list). The policy
chooses the ids: default_policy never excludes (available via a
custom policy, like the other opt-in payloads).
"""
if not exclude:
return hit
if not self._has_feat(state, _SELECTIVE_CHANNELING_FEAT):
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"selective channeling ignored (no feat)"
)
return hit
cha_mod = (state.combatant.abilities.cha_score - 10) // 2
allowed = set(exclude[:max(0, cha_mod)])
kept = [s for s in hit if s.combatant.id not in allowed]
dropped = [s.combatant.id for s in hit if s.combatant.id in allowed]
if dropped:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"selective channeling excludes {', '.join(dropped)}"
)
return kept
def _channel_heal( def _channel_heal(
self, state: CombatantState, affected_state: CombatantState, dmg: int self, state: CombatantState, affected_state: CombatantState, dmg: int
) -> None: ) -> None:
+135
View File
@@ -0,0 +1,135 @@
"""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)