feat(combat): channel energy and hexes

This commit is contained in:
2026-09-04 22:55:47 +02:00
parent 77ffad8e2f
commit a6366edd33
+132
View File
@@ -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: