fix(rules): allow a Will save to halve channeled energy damage

PF1e CRB (Channel Energy): creatures damaged by channeled energy get a Will
save (DC 10 + half the channeler's level + her Charisma modifier) for half
damage (floor). Harm mode only; heal mode unchanged. Charisma is used RAW
even for casters whose spellcasting ability differs (channeling is a
Charisma class feature).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 20:24:51 +02:00
parent 6f784d3369
commit e321c67779
3 changed files with 47 additions and 12 deletions
+3 -1
View File
@@ -949,7 +949,9 @@ Règles modélisées :
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_channeling` non
modélisé), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` modélisé ; 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 (tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque
résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ; résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ;
sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas),
+25 -9
View File
@@ -4195,7 +4195,9 @@ class CombatEngine:
Heal mode heals all living creatures in the burst; harm mode damages Heal mode heals all living creatures in the burst; harm mode damages
all undead creatures in the burst — the target's side does not matter all undead creatures in the burst — the target's side does not matter
(PF1e Channel Energy: positive energy heals living creatures and (PF1e Channel Energy: positive energy heals living creatures and
damages undead creatures within 30 feet). 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).
""" """
if not state.spend_resource("channel_energy", 1): if not state.spend_resource("channel_energy", 1):
self._log( self._log(
@@ -4223,11 +4225,20 @@ class CombatEngine:
# 2d6 damage/healing # 2d6 damage/healing
dmg = 1 + self._rng.roll_die(11) # 2d6 dmg = 1 + self._rng.roll_die(11) # 2d6
# PF1e (CRB, Channel Energy): creatures damaged by the channel get a
# Will save (DC 10 + half the channeler's level + her Charisma
# modifier) for half damage. Charisma is used RAW even when the
# caster's spellcasting ability is another one (channeling is a
# Charisma class feature). Heal mode grants no save.
channel_dc = 10 + state.combatant.level // 2 + (
(state.combatant.abilities.cha_score - 10) // 2
)
for affected_state in hit: for affected_state in hit:
if channel_mode == "heal": if channel_mode == "heal":
self._channel_heal(state, affected_state, dmg) self._channel_heal(state, affected_state, dmg)
else: else:
self._channel_damage(state, affected_state, dmg) self._channel_damage(state, affected_state, dmg, channel_dc)
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
@@ -4248,15 +4259,20 @@ class CombatEngine:
) )
def _channel_damage( def _channel_damage(
self, state: CombatantState, affected_state: CombatantState, dmg: int self, state: CombatantState, affected_state: CombatantState, dmg: int, dc: int
) -> None: ) -> None:
"""Apply channel energy damage: Will save (DC from the channeler) for half."""
save = self.resolve_save(affected_state, "will", dc)
dealt = dmg // 2 if save.success else dmg
hp_before = affected_state.hp hp_before = affected_state.hp
affected_state.hp -= dmg affected_state.hp -= dealt
self._stats[state.combatant.id].damage_dealt += dmg self._stats[state.combatant.id].damage_dealt += dealt
self._stats[affected_state.combatant.id].damage_taken += dmg self._stats[affected_state.combatant.id].damage_taken += dealt
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: channel energy "
f"channel energy damages {affected_state.combatant.id} {dmg} " f"damages {affected_state.combatant.id} {dealt} "
f"(Will DC{dc} d20={save.roll}+{save.total - save.roll}={save.total} "
f"{'SAVED half' if save.success else 'FAILED full'}) "
f"({hp_before}->{affected_state.hp})" f"({hp_before}->{affected_state.hp})"
) )
if affected_state.dead: if affected_state.dead:
@@ -4269,7 +4285,7 @@ class CombatEngine:
kind="damage_taken", kind="damage_taken",
actor=state, actor=state,
target=affected_state, target=affected_state,
damage=dmg, damage=dealt,
) )
) )
+19 -2
View File
@@ -155,15 +155,32 @@ def test_oni_channel_harm_does_not_damage_living_enemy() -> None:
def test_oni_channel_harm_damages_undead() -> None: def test_oni_channel_harm_damages_undead() -> None:
"""Harm mode damages undead creatures in the burst regardless of side.""" """Harm mode damages undead creatures in the burst regardless of side;
a failed Will save takes full damage (CRB Channel Energy)."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
lich = _combatant("lich", hp_max=100, creature_type="undead") lich = _combatant("lich", hp_max=100, creature_type="undead")
engine, me, enemy = _engine(oni, [lich], rng_queue=[11]) engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 5])
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm") action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
engine._execute(me, action) engine._execute(me, action)
assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11) assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11)
# Will save fails: d20 5 + will 4 = 9 < DC 14 (10 + lvl//2 2 + cha 2)
assert any("Will DC14" in line and "FAILED full" in line for line in engine._transcript)
def test_oni_channel_harm_will_save_halves_damage() -> None:
"""CRB Channel Energy: an undead that passes the Will save takes half
damage (floor)."""
oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30)
lich = _combatant("lich", hp_max=100, creature_type="undead")
engine, me, enemy = _engine(oni, [lich], rng_queue=[11, 20])
action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm")
engine._execute(me, action)
assert enemy[0].hp == 100 - (1 + 11) // 2 # 2d6 = 12, saved for half = 6
assert any("Will DC14" in line and "SAVED half" in line for line in engine._transcript)
def test_oni_channel_heal_heals_living_enemies_in_range() -> None: def test_oni_channel_heal_heals_living_enemies_in_range() -> None: