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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user