feat(combat): channel energy and hexes
This commit is contained in:
@@ -898,6 +898,10 @@ class CombatEngine:
|
|||||||
self._execute_cast_spell(state, target, action.spell_name)
|
self._execute_cast_spell(state, target, action.spell_name)
|
||||||
elif action.kind == "maneuver":
|
elif action.kind == "maneuver":
|
||||||
self._execute_maneuver(state, target, action.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(
|
def _execute_cast_spell(
|
||||||
self,
|
self,
|
||||||
state: CombatantState,
|
state: CombatantState,
|
||||||
@@ -1012,6 +1016,134 @@ class CombatEngine:
|
|||||||
else:
|
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")
|
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(
|
def _execute_attack(
|
||||||
self, state: CombatantState, target: CombatantState | None, kind: str
|
self, state: CombatantState, target: CombatantState | None, kind: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user