diff --git a/README.md b/README.md index b4863c1..23003ed 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index a290821..4408750 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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: diff --git a/tests/test_selective_channeling.py b/tests/test_selective_channeling.py new file mode 100644 index 0000000..a64e121 --- /dev/null +++ b/tests/test_selective_channeling.py @@ -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)