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:
@@ -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 :
|
||||
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
|
||||
cible n'est pas pris en compte — seul `creature_type` compte, `selective_channeling` non
|
||||
modélisé ; les créatures blessées obtiennent un jet de Volonté DD `10 +
|
||||
cible n'est pas pris en compte — seul `creature_type` compte ; **Selective
|
||||
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
|
||||
— 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
|
||||
@@ -1761,10 +1773,11 @@ Règles modélisées :
|
||||
suffisent). Le couvert par paire est exposé via la clé `cover_bonus`
|
||||
(calcul identique à `resolve_attack`, couvert mou des créatures inclus).
|
||||
Les API de décision exposées (items L1-L8)
|
||||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||||
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
|
||||
B7) et `cast_defensively` (incantation défensive, B24) sur l'action
|
||||
`cast_spell` ;
|
||||
s'y retrouvent : `direction`/`trick_condition`/`weapon_id` sur `Action`,
|
||||
ainsi que `metamagic` (dons de métamagie choisis pour cette incantation,
|
||||
B7) et `cast_defensively` (incantation défensive, B24) sur l'action
|
||||
`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`.
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
|
||||
_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
|
||||
_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
|
||||
@@ -1114,6 +1117,12 @@ class Action:
|
||||
deed_name: str | None = None # "up_close_and_deadly", "quick_clear"
|
||||
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
|
||||
# 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
|
||||
trick_condition: TrickCondition | None = None # dirty trick condition choice
|
||||
# weapon id for attack / full_attack / retrieve_weapon / repair_weapon
|
||||
@@ -4698,7 +4707,8 @@ class CombatEngine:
|
||||
kind = action.kind
|
||||
if kind == "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":
|
||||
self._execute_hex(state, target, action.hex_name)
|
||||
@@ -5752,6 +5762,8 @@ class CombatEngine:
|
||||
state: CombatantState,
|
||||
target: CombatantState | None,
|
||||
channel_mode: Literal["heal", "harm"],
|
||||
*,
|
||||
exclude: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""Execute channel energy (positive energy, burst 30ft, 2d6).
|
||||
|
||||
@@ -5760,7 +5772,8 @@ class CombatEngine:
|
||||
(PF1e Channel Energy: positive energy heals living creatures and
|
||||
damages undead creatures within 30 feet). Damaged creatures get a
|
||||
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):
|
||||
self._log(
|
||||
@@ -5784,6 +5797,7 @@ class CombatEngine:
|
||||
|
||||
wanted_type = "living" if channel_mode == "heal" else "undead"
|
||||
hit = [s for s in affected if s.combatant.creature_type == wanted_type]
|
||||
hit = self._selective_channel(state, hit, exclude)
|
||||
|
||||
# 2d6 damage/healing
|
||||
dmg = 1 + self._rng.roll_die(11) # 2d6
|
||||
@@ -5808,6 +5822,45 @@ class CombatEngine:
|
||||
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(
|
||||
self, state: CombatantState, affected_state: CombatantState, dmg: int
|
||||
) -> None:
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user