fix(rules): correct the cackle hex

PF1e (APG Cackle): "(Su) Free Action; ... extends the duration of all evil eye, fortune or misfortune hexes on all creatures within 30 feet by 1 round." The engine spent a hex use, acted only on the resolved target, and extended every hex-sourced condition (including slumber, which RAW cackle does not touch). Cackle is now dispatched before the resource/target gate (no cost), occupies the FREE budget slot via _effective_slot (the hex kind stays standard for evil_eye/slumber), and rebuilds the evil_eye buff list of every creature within 30 ft in a single bounded pass. Fortune/misfortune are not implemented, so evil_eye is the only extendable family member — documented in README. test_cackle.py rewritten for the new semantics (extension, range, free slot, no cost).

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-12 12:58:32 +02:00
parent 68699ad6c7
commit b0fe553782
3 changed files with 134 additions and 73 deletions
+42 -21
View File
@@ -4733,7 +4733,14 @@ class CombatEngine:
def _execute_hex(
self, state: CombatantState, target: CombatantState | None, hex_name: str | None
) -> None:
"""Execute a witch hex (evil_eye, slumber, cackle)."""
"""Execute a witch hex (evil_eye, slumber, cackle).
APG Cackle is a free action with no hex resource cost, so it is
dispatched before the resource/target gate; evil_eye and slumber are
standard actions that each spend one use."""
if hex_name == "cackle":
self._hex_cackle(state)
return
if not state.spend_resource("hexes", 1):
self._log(
f"round {self._current_round} {state.combatant.id}: hex failed (no resources)"
@@ -4741,7 +4748,7 @@ class CombatEngine:
return
if target is None or not target.active:
return
if hex_name not in ("evil_eye", "slumber", "cackle"):
if hex_name not in ("evil_eye", "slumber"):
self._log(
f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}"
)
@@ -4751,10 +4758,8 @@ class CombatEngine:
if hex_name == "evil_eye":
self._hex_evil_eye(state, target, hex_dc)
elif hex_name == "slumber":
else:
self._hex_slumber(state, target, hex_dc)
elif hex_name == "cackle":
self._hex_cackle(state, target)
_EVIL_EYE_ROUNDS = 3
_EVIL_EYE_SAVED_ROUNDS = 1
@@ -4807,29 +4812,42 @@ class CombatEngine:
f"{target.combatant.id} -> asleep ({rounds}r)"
)
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
extended = False
for ac in list(target.active_conditions):
if ac.source.startswith("hex:"):
target.active_conditions.remove(ac)
target.active_conditions.append(
ActiveCondition(
condition=ac.condition,
rounds_left=ac.rounds_left + 1,
source=ac.source,
)
_CACKLE_RANGE_FT = 30
def _hex_cackle(self, state: CombatantState) -> None:
"""Cackle (APG): a free action that extends the duration of every
evil eye, fortune or misfortune hex on all creatures within 30 ft by
1 round. Fortune/misfortune are not implemented, so the extension
covers evil_eye buffs; each buff list is rebuilt in a single pass so
an extended hex is never re-extended."""
extended = 0
for other in self._states:
if not other.active:
continue
if self.creature_distance(state, other) * _SQUARE_FT > self._CACKLE_RANGE_FT:
continue
other.active_buffs[:] = [
ActiveBuff(
modifiers=buff.modifiers,
rounds_left=buff.rounds_left + 1,
source=buff.source,
)
extended = True
if buff.source.startswith("hex: evil_eye")
else buff
for buff in other.active_buffs
]
extended += sum(
1 for buff in other.active_buffs if buff.source.startswith("hex: evil_eye")
)
if extended:
self._log(
f"round {self._current_round} {state.combatant.id}: cackle extends "
f"hex on {target.combatant.id}"
f"{extended} evil eye hex(es)"
)
else:
self._log(
f"round {self._current_round} {state.combatant.id}: cackle - no hex "
f"to extend on {target.combatant.id}"
f"to extend"
)
def _execute_deed(
@@ -6607,9 +6625,12 @@ def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _ActionEconomySlot:
"""The budget slot an action consumes, resolving cast_spell by cast time."""
"""The budget slot an action consumes, resolving cast_spell by cast time
and the cackle hex by its APG free-action cost."""
if action.kind == "cast_spell":
return _cast_time_slot(cast_time(action))
if action.kind == "hex" and action.hex_name == "cackle":
return "free"
return _action_economy_slot(action.kind)