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