From a6366edd334b6719c0f8a2f7379f708f71525cc6 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:47 +0200 Subject: [PATCH] feat(combat): channel energy and hexes --- src/pf1e_simulator/combat.py | 132 +++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 1505058..4c32340 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -898,6 +898,10 @@ class CombatEngine: self._execute_cast_spell(state, target, action.spell_name) elif action.kind == "maneuver": self._execute_maneuver(state, target, action.maneuver) + elif action.kind == "channel_energy": + self._execute_channel_energy(state, target) + elif action.kind == "hex": + self._execute_hex(state, target, action.hex_name) def _execute_cast_spell( self, state: CombatantState, @@ -1012,6 +1016,134 @@ class CombatEngine: else: self._log(f"round {self._current_round} {state.combatant.id}: escape vs {target.combatant.id} d20={roll}+{escape_bonus}={total} CMD {target_cmd} -> FAILED") + def _execute_channel_energy(self, state: CombatantState, target: CombatantState | None) -> None: + """Execute channel energy (burst 30ft, 2d6 heal/damage, consumes channel_energy resource).""" + if not state.spend_resource("channel_energy", 1): + self._log(f"round {self._current_round} {state.combatant.id}: channel energy failed (no resources)") + return + if target is None or not target.active: + return + + # Channel energy affects all creatures within 30ft (6 cells) + radius_cells = 30 // _SQUARE_FT # 6 cells + center = state.pos + affected: list[CombatantState] = [] + for state_other in self._states: + if not state_other.active: + continue + dist = self._grid.distance(center, state_other.pos) + if dist <= radius_cells: + affected.append(state_other) + + if not affected: + self._log(f"round {self._current_round} {state.combatant.id}: channel energy - no targets in range") + return + + # Determine if healing or harming based on target relationship + is_harm = target is not None and target.side != state.side + + # 2d6 damage/healing + import random + dmg = random.randint(2, 12) # 2d6 + + for affected_state in affected: + is_ally = affected_state.side == state.side + if is_ally and not is_harm: + # Heal allies + old_hp = affected_state.hp + affected_state.hp = min(affected_state.hp + dmg, affected_state.combatant.hp_max) + actual = affected_state.hp - old_hp + self._stats[state.combatant.id].damage_dealt += actual + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy heals {affected_state.combatant.id} +{actual} HP" + ) + elif not is_ally and is_harm: + # Damage enemies + affected_state.hp -= dmg + self._stats[state.combatant.id].damage_dealt += dmg + self._stats[affected_state.combatant.id].damage_taken += dmg + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy damages {affected_state.combatant.id} {dmg} ({affected_state.hp_before if hasattr(affected_state, 'hp_before') else '?'}->{affected_state.hp})" + ) + if affected_state.dead: + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"{affected_state.combatant.id} dead" + ) + else: + # Neutral (enemies when healing, allies when harming) + pass + + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"channel energy ({'harm' if is_harm else 'heal'}) {dmg} -> {len(affected)} targets" + ) + + def _execute_hex(self, state: CombatantState, target: CombatantState | None, hex_name: str | None) -> None: + """Execute a witch hex (evil_eye, slumber, cackle).""" + if not state.spend_resource("hexes", 1): + self._log(f"round {self._current_round} {state.combatant.id}: hex failed (no resources)") + return + if target is None or not target.active: + return + if hex_name not in ("evil_eye", "slumber", "cackle"): + self._log(f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}") + return + + if hex_name == "evil_eye": + # Evil Eye: shaken (-2 AC or saves), 1 round, Will negates + save_result = self.resolve_save(target, "will", 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2) + if save_result.success: + self._log(f"round {self._current_round} {state.combatant.id}: evil eye on {target.combatant.id} -> SAVED") + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["shaken"], + rounds_left=1, + source=f"hex: evil_eye by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["shaken"]) + self._log(f"round {self._current_round} {state.combatant.id}: evil eye on {target.combatant.id} -> shaken (1r)") + + elif hex_name == "slumber": + # Slumber: asleep/disabled if Will fails + save_result = self.resolve_save(target, "will", 10 + state.combatant.level // 2 + (state.combatant.abilities.cha_score - 10) // 2) + if save_result.success: + self._log(f"round {self._current_round} {state.combatant.id}: slumber on {target.combatant.id} -> SAVED") + return + target.active_conditions.append( + ActiveCondition( + condition=CONDITIONS_BY_NAME["asleep"], + rounds_left=1, + source=f"hex: slumber by {state.combatant.id}", + ) + ) + target.conditions.append(CONDITIONS_BY_NAME["asleep"]) + self._log(f"round {self._current_round} {state.combatant.id}: slumber on {target.combatant.id} -> asleep (1r)") + + elif hex_name == "cackle": + # Cackle: prolong one hex by 1 round + # For simplicity, extend all hex conditions on target by 1 round + extended = False + for ac in target.active_conditions: + if ac.source.startswith("hex:"): + # Can't modify frozen dataclass, need to recreate + target.active_conditions.remove(ac) + target.active_conditions.append( + ActiveCondition( + condition=ac.condition, + rounds_left=ac.rounds_left + 1, + source=ac.source, + ) + ) + extended = True + if extended: + self._log(f"round {self._current_round} {state.combatant.id}: cackle extends hex on {target.combatant.id}") + else: + self._log(f"round {self._current_round} {state.combatant.id}: cackle - no hex to extend on {target.combatant.id}") def _execute_attack( self, state: CombatantState, target: CombatantState | None, kind: str ) -> None: