From 22d76e967733a7006927b8a135e8344b3c14b953 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 5 Sep 2026 00:16:17 +0200 Subject: [PATCH] fix(combat): channel energy targets living vs undead --- README.md | 10 ++++-- data/monsters/lich.json | 1 + src/pf1e_simulator/combat.py | 60 ++++++++++++++++++++----------- src/pf1e_simulator/models.py | 1 + tests/test_class_abilities.py | 67 +++++++++++++++++++++++++++++++++++ tests/test_determinism.py | 4 +-- tests/test_runner_spells.py | 8 +++-- 7 files changed, 124 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ac2c065..e1bd949 100644 --- a/README.md +++ b/README.md @@ -381,11 +381,15 @@ Règles modélisées : toute transparence (noms décoratifs / de race) — aucun n'est silencieusement ignoré (`tests/test_feats.py`). - Capacités de classe émises par `default_policy`, chacune conditionnée à une ressource - `> 0` dépensée dans la méthode d'exécution : `channel_energy` (oni, allié < 50 % ou - ennemis groupés à 30 ft), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` + `> 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é), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` (tammara, action bonus +1d6 à ≤ 30 ft), `inspire_courage` + `spell_combat` (misty) — couvertes par `tests/test_class_abilities.py`. -- Le Liche (`data/monsters/lich.json`) : aura de terreur (30 ft, Volonté DD 22 ou +- Le Liche (`data/monsters/lich.json`) : `creature_type: "undead"` (sensible à l'énergie + positive canalisée), aura de terreur (30 ft, Volonté DD 22 ou `shaken` 1 round, au début de son tour) et contact paralysant (au toucher, `paralysé` 1d4+1 rounds), RD 15/magie+contondant ; son JSON porte un dictionnaire `spell_slots` sans clés dupliquées. diff --git a/data/monsters/lich.json b/data/monsters/lich.json index 09bdc80..5623725 100644 --- a/data/monsters/lich.json +++ b/data/monsters/lich.json @@ -2,6 +2,7 @@ "name": "Lich", "level": 11, "size": "Medium", + "creature_type": "undead", "abilities": { "str_score": 12, "dex_score": 14, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 3773305..5bf0203 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -295,6 +295,7 @@ class Action: hex_name: str | None = None # "evil_eye", "slumber", "cackle" 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 @dataclass(frozen=True) @@ -1156,7 +1157,9 @@ class CombatEngine: """Dispatch a class-ability action by kind, reading its carrier field.""" kind = action.kind if kind == "channel_energy": - self._execute_channel_energy(state, target) + self._execute_channel_energy( + state, target, action.channel_mode or "heal" + ) elif kind == "hex": self._execute_hex(state, target, action.hex_name) elif kind == "deed": @@ -1393,9 +1396,18 @@ class CombatEngine: ) def _execute_channel_energy( - self, state: CombatantState, target: CombatantState | None + self, + state: CombatantState, + target: CombatantState | None, + channel_mode: Literal["heal", "harm"], ) -> None: - """Execute channel energy (burst 30ft, 2d6 heal/damage, spends channel energy).""" + """Execute channel energy (positive energy, burst 30ft, 2d6). + + 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 + (PF1e Channel Energy: positive energy heals living creatures and + damages undead creatures within 30 feet). + """ if not state.spend_resource("channel_energy", 1): self._log( f"round {self._current_round} {state.combatant.id}: " @@ -1407,8 +1419,7 @@ class CombatEngine: # Channel energy affects all creatures within 30ft (6 cells) radius_cells = 30 // _SQUARE_FT # 6 cells - center = state.pos - affected = self._creatures_in_range(center, radius_cells) + affected = self._creatures_in_range(state.pos, radius_cells) if not affected: self._log( @@ -1417,22 +1428,21 @@ class CombatEngine: ) return - # Determine if healing or harming based on target relationship - is_harm = target.side != state.side + wanted_type = "living" if channel_mode == "heal" else "undead" + hit = [s for s in affected if s.combatant.creature_type == wanted_type] # 2d6 damage/healing dmg = 1 + self._rng.roll_die(11) # 2d6 - for affected_state in affected: - is_ally = affected_state.side == state.side - if is_ally and not is_harm: + for affected_state in hit: + if channel_mode == "heal": self._channel_heal(state, affected_state, dmg) - elif not is_ally and is_harm: + else: self._channel_damage(state, affected_state, dmg) self._log( f"round {self._current_round} {state.combatant.id}: " - f"channel energy ({'harm' if is_harm else 'heal'}) {dmg} -> {len(affected)} targets" + f"channel energy ({channel_mode}) {dmg} -> {len(hit)} targets" ) def _channel_heal( @@ -2524,8 +2534,15 @@ def default_policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, return (Action(kind="withdraw", target_id=target.combatant.id),) # Channel energy (oni): standard action that heal/harms in a 30ft burst. - if _emit_channel_energy(engine, state): - return (Action(kind="channel_energy", target_id=target.combatant.id),) + channel_mode = _emit_channel_energy(engine, state) + if channel_mode is not None: + return ( + Action( + kind="channel_energy", + target_id=target.combatant.id, + channel_mode=channel_mode, + ), + ) # Hex (nairda): standard action when the target is within close range. if _emit_hex(engine, state, target): @@ -2558,21 +2575,24 @@ _GROUPED_ENEMY_COUNT = 2 _HEX_RANGE_FT = 25 -def _emit_channel_energy(engine: CombatEngine, state: CombatantState) -> bool: - """True when oni should channel energy: resource available and a trigger is met.""" +def _emit_channel_energy( + engine: CombatEngine, state: CombatantState +) -> Literal["heal", "harm"] | None: + """Channel mode when oni should channel (heal living / harm undead).""" if state.resources_left.get("channel_energy", 0) <= 0: - return False + return None if _find_ally_needing_heal(engine, state) is not None: - return True + return "heal" radius_cells = 30 // _SQUARE_FT - grouped_enemies = [ + grouped_undead_enemies = [ s for s in engine.states if s.side != state.side and s.active + and s.combatant.creature_type == "undead" and engine.grid.distance(state.pos, s.pos) <= radius_cells ] - return len(grouped_enemies) >= _GROUPED_ENEMY_COUNT + return "harm" if len(grouped_undead_enemies) >= _GROUPED_ENEMY_COUNT else None def _emit_hex( diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 74a4ad9..c5438ed 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -267,6 +267,7 @@ class Combatant(_FrozenModel): name: str level: int = Field(ge=1) size: str = "Medium" + creature_type: Literal["living", "undead", "construct"] = "living" abilities: AbilityScores hp_max: int = Field(ge=1) ac: ACProfile diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index d8f745c..fb8d509 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -7,6 +7,8 @@ tammara (deeds), misty (spell_combat + inspire_courage). Each class has an from __future__ import annotations +from typing import Literal + from pf1e_simulator.combat import Action, CombatantState, CombatEngine, default_policy from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid @@ -29,6 +31,7 @@ def _combatant( resources: dict[str, int] | None = None, spells: list[str] | None = None, hp_max: int = 20, + creature_type: Literal["living", "undead", "construct"] = "living", ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", @@ -55,6 +58,7 @@ def _combatant( attacks=[attack], resources=resources or {}, spells=spells or [], + creature_type=creature_type, ) @@ -134,6 +138,69 @@ def test_oni_channel_energy_spends_resource() -> None: assert me.resources_left["channel_energy"] == 5 +def test_oni_channel_harm_does_not_damage_living_enemy() -> None: + """Regression: positive energy must not damage living creatures (reported bug: + oni harmed a Giant Belostomatid — living vermin). Harm mode only hits undead.""" + oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) + vermin = _combatant("belostomatid", hp_max=30) + engine, me, enemy = _engine(oni, [vermin], rng_queue=[11]) + + action = Action( + kind="channel_energy", target_id=vermin.id, channel_mode="harm" + ) + engine._execute(me, action) + + assert me.hp == 30 # living caster unaffected by harm mode + assert enemy[0].hp == 30 # living vermin unaffected by harm mode + + +def test_oni_channel_harm_damages_undead() -> None: + """Harm mode damages undead creatures in the burst regardless of side.""" + 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]) + + action = Action(kind="channel_energy", target_id=lich.id, channel_mode="harm") + engine._execute(me, action) + + assert enemy[0].hp == 100 - (1 + 11) # 2d6 = 1 + roll_die(11) + + +def test_oni_channel_heal_heals_living_enemies_in_range() -> None: + """Heal mode heals all living creatures in the burst — including enemies + (Selective Channeling, which would exclude them, is not modeled).""" + oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) + goblin = _combatant("goblin", hp_max=6) + engine, me, enemy = _engine(oni, [goblin], rng_queue=[5]) + enemy[0].hp = 2 + + action = Action( + kind="channel_energy", target_id=me.combatant.id, channel_mode="heal" + ) + engine._execute(me, action) + + assert enemy[0].hp == min(2 + (1 + 5), 6) # healed, capped at hp_max + + +def test_oni_emits_channel_harm_only_for_grouped_undead() -> None: + """Channel harm is emitted only when at least 2 undead enemies are grouped at 30ft + — living enemies are never harmed by positive energy.""" + oni = _combatant("oni", resources={"channel_energy": 6}, hp_max=30) + lich1 = _combatant("lich1", hp_max=100, creature_type="undead") + + engine, me, _ = _engine(oni, [lich1]) + assert not any(a.kind == "channel_energy" for a in default_policy(engine, me)) + + lich2 = _combatant("lich2", hp_max=100, creature_type="undead") + engine, me, _ = _engine(oni, [lich1, lich2]) + actions = default_policy(engine, me) + + assert any( + a.kind == "channel_energy" and a.channel_mode == "harm" + for a in actions + ) + + # --- nairda: hex --- diff --git a/tests/test_determinism.py b/tests/test_determinism.py index b0c85ae..cb2bd68 100644 --- a/tests/test_determinism.py +++ b/tests/test_determinism.py @@ -35,9 +35,9 @@ def _make_grid() -> Grid: def _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: - """Emit a channel_energy on an alpha, otherwise the default policy.""" + """Emit a channel_energy (heal mode) on an alpha, otherwise the default policy.""" if state.combatant.id == "oni-a": - return (Action(kind="channel_energy", target_id="oni-b"),) + return (Action(kind="channel_energy", target_id="oni-b", channel_mode="heal"),) return default_policy(_engine, state) diff --git a/tests/test_runner_spells.py b/tests/test_runner_spells.py index 069800a..03d0b66 100644 --- a/tests/test_runner_spells.py +++ b/tests/test_runner_spells.py @@ -37,15 +37,19 @@ def test_monte_carlo_caster_pc_casts_spell() -> None: """oni (cleric) casts against data/spells/ once it drops below 50% HP. oni's known spells overlap the registry's heal spells (Cure Light/Moderate - Wounds); with enough damage taken it heals via cast_spell. The Monte Carlo + Wounds); with enough damage taken it heals via cast_spell. Channel energy is + stripped from the resource pool: it now heals living creatures in the burst, + so below 50% HP oni self-heals by channeling and never reaches the spell + branch — this test stays a pure spell-wiring check. The Monte Carlo loop mirrors run_encounter: build fresh states and one CombatEngine per run with the registry from SPELLS_DIR. """ + oni = load_combatant(ONI).model_copy(update={"resources": {}}) spec = EncounterSpec( map=load_map(MAP), round_cap=100, sides=( - Side(name="players", combatants=(load_combatant(ONI),), zone="A"), + Side(name="players", combatants=(oni,), zone="A"), Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"), ), )