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
+12 -3
View File
@@ -398,7 +398,10 @@ Règles modélisées :
`maneuver`, `channel_energy`, `hex`, `delay`, `ready`, `maneuver`, `channel_energy`, `hex`, `delay`, `ready`,
`total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que `total_defense`, `aid_another` ; `cast_spell` occupe le compartiment que
lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`, lui donne son **temps d'incantation** (champ `cast_time` de `SpellSpec`,
voir « Temps d'incantation » plus bas ; standard par défaut) ; voir « Temps d'incantation » plus bas ; standard par défaut) ; exception
par action (conformance D5 — APG « Cackle ») : `hex` avec
`hex_name="cackle"` occupe le compartiment **libre** (résolu par
`_effective_slot`, le kind `hex` restant standard pour evil_eye/slumber) ;
**mouvement** = `move`, `stand_up`, **mouvement** = `move`, `stand_up`,
`retrieve_weapon` (sortir une arme est une action de mouvement), `retrieve_weapon` (sortir une arme est une action de mouvement),
`repair_weapon` ; **swift** = `swift`, `deed`, `repair_weapon` ; **swift** = `swift`, `deed`,
@@ -1102,8 +1105,14 @@ Règles modélisées :
égal au niveau de la sorcière** (Volonté annule ; simplification documentée : égal au niveau de la sorcière** (Volonté annule ; simplification documentée :
la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée, la fin RAW du sortilège quand la cible subit des dégâts n'est pas modélisée,
le moteur n'a pas de crochet de réveil) ; le moteur n'a pas de crochet de réveil) ;
`cackle` prolonge d'un round les sortilèges déjà `cackle` (APG, conformance D5) est une **action libre sans coût de
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à ressource** qui prolonge d'un round tous les sortilèges evil-eye (et
fortune/misfortune — non implémentés, seule extension modélisée : les
buffs evil_eye) portés par **toutes les créatures à 30 ft** de la
sorcière, sur une liste instantanée pour ne pas s'étendre à l'infini.
Simplification documentée : le slot « hex » reste standard pour
evil_eye/slumber ; les autres hexes à prolonger ne sont pas implémentés.
Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
zone effectue la sauvegarde listée par le sort (Volonté / Réflexes / zone effectue la sauvegarde listée par le sort (Volonté / Réflexes /
Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès Constitution — le sort primaire réutilise son jet déjà effectué) ; un succès
+41 -20
View File
@@ -4733,7 +4733,14 @@ class CombatEngine:
def _execute_hex( def _execute_hex(
self, state: CombatantState, target: CombatantState | None, hex_name: str | None self, state: CombatantState, target: CombatantState | None, hex_name: str | None
) -> 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): if not state.spend_resource("hexes", 1):
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: hex failed (no resources)" f"round {self._current_round} {state.combatant.id}: hex failed (no resources)"
@@ -4741,7 +4748,7 @@ class CombatEngine:
return return
if target is None or not target.active: if target is None or not target.active:
return return
if hex_name not in ("evil_eye", "slumber", "cackle"): if hex_name not in ("evil_eye", "slumber"):
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}" f"round {self._current_round} {state.combatant.id}: unknown hex {hex_name}"
) )
@@ -4751,10 +4758,8 @@ class CombatEngine:
if hex_name == "evil_eye": if hex_name == "evil_eye":
self._hex_evil_eye(state, target, hex_dc) self._hex_evil_eye(state, target, hex_dc)
elif hex_name == "slumber": else:
self._hex_slumber(state, target, hex_dc) self._hex_slumber(state, target, hex_dc)
elif hex_name == "cackle":
self._hex_cackle(state, target)
_EVIL_EYE_ROUNDS = 3 _EVIL_EYE_ROUNDS = 3
_EVIL_EYE_SAVED_ROUNDS = 1 _EVIL_EYE_SAVED_ROUNDS = 1
@@ -4807,29 +4812,42 @@ class CombatEngine:
f"{target.combatant.id} -> asleep ({rounds}r)" f"{target.combatant.id} -> asleep ({rounds}r)"
) )
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None: _CACKLE_RANGE_FT = 30
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
extended = False def _hex_cackle(self, state: CombatantState) -> None:
for ac in list(target.active_conditions): """Cackle (APG): a free action that extends the duration of every
if ac.source.startswith("hex:"): evil eye, fortune or misfortune hex on all creatures within 30 ft by
target.active_conditions.remove(ac) 1 round. Fortune/misfortune are not implemented, so the extension
target.active_conditions.append( covers evil_eye buffs; each buff list is rebuilt in a single pass so
ActiveCondition( an extended hex is never re-extended."""
condition=ac.condition, extended = 0
rounds_left=ac.rounds_left + 1, for other in self._states:
source=ac.source, 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,
) )
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")
) )
extended = True
if extended: if extended:
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: cackle extends " f"round {self._current_round} {state.combatant.id}: cackle extends "
f"hex on {target.combatant.id}" f"{extended} evil eye hex(es)"
) )
else: else:
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: cackle - no hex " f"round {self._current_round} {state.combatant.id}: cackle - no hex "
f"to extend on {target.combatant.id}" f"to extend"
) )
def _execute_deed( def _execute_deed(
@@ -6607,9 +6625,12 @@ def _cast_time_slot(cast_time: CastTime) -> _ActionEconomySlot:
def _effective_slot(action: Action, cast_time: CastTimeResolver) -> _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": if action.kind == "cast_spell":
return _cast_time_slot(cast_time(action)) return _cast_time_slot(cast_time(action))
if action.kind == "hex" and action.hex_name == "cackle":
return "free"
return _action_economy_slot(action.kind) return _action_economy_slot(action.kind)
+73 -42
View File
@@ -1,19 +1,24 @@
"""Tests for the cackle hex: bound the extension of hex conditions (Task 9). """Tests for the cackle hex (APG): free action, no resource cost, extends
every evil-eye hex on all creatures within 30 ft by exactly 1 round.
Cackle must extend exactly one +1 round per pre-existing hex condition, in a The pre-fix code spent a hex use, acted on a single target, and extended
single pass, without re-extending the replacements it appends. The pre-fix code every ``hex:``-sourced condition (including slumber, which RAW cackle does
mutated ``target.active_conditions`` (remove/append) while iterating it, which not touch). Cackle now rebuilds each in-range creature's buff list in a
grew the list unboundedly and looped forever. single pass, so an extended hex is never re-extended and the call always
terminates (the pre-Task-9 code mutated the list while iterating it).
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from pf1e_simulator.combat import (
Action,
from pf1e_simulator.combat import CombatantState, CombatEngine CombatantState,
from pf1e_simulator.conditions import CONDITIONS_BY_NAME CombatEngine,
_default_cast_time,
_effective_slot,
)
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition from pf1e_simulator.effects import ActiveBuff, StatModifier
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import ( from pf1e_simulator.models import (
@@ -26,11 +31,9 @@ from pf1e_simulator.models import (
) )
from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.rng import ScriptedRng
if TYPE_CHECKING: _HEX_SOURCE = "hex: evil_eye by caster"
from collections.abc import Collection _OTHER_SOURCE = "hex: evil_eye by caster-2"
_UNRELATED_SOURCE = "spell: bull's strength"
_HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2")
_ROUNDS_BEFORE = (2, 1)
def _make_witch(cid: str = "caster") -> Combatant: def _make_witch(cid: str = "caster") -> Combatant:
@@ -90,7 +93,7 @@ def _make_target(cid: str = "target") -> Combatant:
) )
def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]: def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]:
c = CombatantState( c = CombatantState(
combatant=_make_witch(), side="players", pos=(0, 0), hp=20, combatant=_make_witch(), side="players", pos=(0, 0), hp=20,
resources_left={"hexes": 3}, resources_left={"hexes": 3},
@@ -98,42 +101,70 @@ def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState]:
t = CombatantState( t = CombatantState(
combatant=_make_target(), side="monsters", pos=(3, 0), hp=30, combatant=_make_target(), side="monsters", pos=(3, 0), hp=30,
) )
far = CombatantState(
combatant=_make_target("far"), side="monsters", pos=(10, 0), hp=30,
)
legend = {".": TerrainType(type="floor", move_cost=1)} legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend)
engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t]) engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t, far])
return engine, c, t return engine, c, t, far
def _seed_hex_conditions(target: CombatantState) -> dict[str, int]: def _evil_eye(rounds_left: int, source: str = _HEX_SOURCE) -> ActiveBuff:
target.active_conditions = [ return ActiveBuff(
ActiveCondition( modifiers=(StatModifier(target="attack", value=-2),),
condition=CONDITIONS_BY_NAME["shaken"], rounds_left=rounds_left,
rounds_left=rounds,
source=source, source=source,
) )
for source, rounds in zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True)
]
return dict(zip(_HEX_SOURCES, _ROUNDS_BEFORE, strict=True))
def _rounds_by_source(conditions: Collection[ActiveCondition]) -> dict[str, int]: def _rounds_by_source(buffs: list[ActiveBuff]) -> dict[str, int]:
return {ac.source: ac.rounds_left for ac in conditions} return {b.source: b.rounds_left for b in buffs}
class TestCackleExtendsHexOnce: class TestCackleExtendsEvilEyeOnce:
"""Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes, """Given: a target carrying 2 pre-existing evil-eye buffs (2 Evil Eyes,
rounds_left 2 and 1) rounds_left 2 and 1) and an unrelated buff, plus a creature beyond 30 ft
carrying its own evil eye
When: the witch cackles When: the witch cackles
Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left Then: EXACTLY 2 extensions of +1 round happen in range — each evil eye's
rises by 1, the total hex-condition count stays 2 (no growth), and the rounds_left rises by 1, the unrelated buff is untouched, the out-of-range
call terminates (the pre-fix loop never did).""" creature is not extended, the buff count stays stable (no growth), and
the call terminates."""
def test_exactly_two_hex_extensions_of_one_round(self) -> None: def test_exactly_two_evil_eye_extensions_of_one_round(self) -> None:
engine, c, t = _make_engine() engine, c, t, far = _make_engine()
expected = _seed_hex_conditions(t) t.active_buffs = [_evil_eye(2), _evil_eye(1, _OTHER_SOURCE),
ActiveBuff(modifiers=(), rounds_left=5, source=_UNRELATED_SOURCE)]
far.active_buffs = [_evil_eye(3, "hex: evil_eye by other-witch")]
engine._hex_cackle(c, t) engine._hex_cackle(c)
after = _rounds_by_source(t.active_conditions) after = _rounds_by_source(t.active_buffs)
assert len(after) == 2 assert len(after) == 3
assert after == {source: rounds + 1 for source, rounds in expected.items()} assert after[_HEX_SOURCE] == 3
assert after[_OTHER_SOURCE] == 2
assert after[_UNRELATED_SOURCE] == 5
assert _rounds_by_source(far.active_buffs)["hex: evil_eye by other-witch"] == 3
class TestCackleIsFreeAndCostless:
"""Given: the cackle hex action
When: budgeted and executed
Then: it occupies the FREE slot (not the standard one), does not spend a
hex use, and works without a resolved target."""
def test_cackle_slot_is_free_but_evil_eye_stays_standard(self) -> None:
cackle = Action(kind="hex", hex_name="cackle")
evil_eye = Action(kind="hex", target_id="target", hex_name="evil_eye")
assert _effective_slot(cackle, _default_cast_time) == "free"
assert _effective_slot(evil_eye, _default_cast_time) == "standard"
def test_cackle_spends_no_hex_resource(self) -> None:
engine, c, t, _far = _make_engine()
t.active_buffs = [_evil_eye(1)]
engine._execute(c, Action(kind="hex", hex_name="cackle"))
assert c.resources_left["hexes"] == 3
assert _rounds_by_source(t.active_buffs)[_HEX_SOURCE] == 2