"""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. The pre-fix code spent a hex use, acted on a single target, and extended every ``hex:``-sourced condition (including slumber, which RAW cackle does not touch). Cackle now rebuilds each in-range creature's buff list in a 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 pf1e_simulator.combat import ( Action, CombatantState, CombatEngine, _default_cast_time, _effective_slot, ) from pf1e_simulator.dice import parse_dice from pf1e_simulator.effects import ActiveBuff, StatModifier from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng _HEX_SOURCE = "hex: evil_eye by caster" _OTHER_SOURCE = "hex: evil_eye by caster-2" _UNRELATED_SOURCE = "spell: bull's strength" def _make_witch(cid: str = "caster") -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="dagger", kind="melee", attack_bonus=2, damage=[DamageComponent(formula=parse_dice("1d4"), types=["piercing"])], ) return Combatant( id=cid, name=cid, level=5, size="Medium", abilities=AbilityScores( str_score=10, dex_score=14, con_score=12, int_score=18, wis_score=12, cha_score=10, ), hp_max=20, ac=ACProfile(total=12, touch=12, flat_footed=10), bab=2, initiative_mod=2, speed_land_ft=30, saves=Saves(fort=3, ref=4, will=4), attacks=[attack], casting_ability="int", spell_slots={1: 4}, resources={"hexes": 3}, ) def _make_target(cid: str = "target") -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="club", kind="melee", attack_bonus=3, damage=[DamageComponent(formula=parse_dice("1d6"), types=["bludgeoning"])], ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=30, ac=ACProfile(total=10, touch=10, flat_footed=10), bab=1, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=2, ref=2, will=2), attacks=[attack], ) def _make_engine() -> tuple[CombatEngine, CombatantState, CombatantState, CombatantState]: c = CombatantState( combatant=_make_witch(), side="players", pos=(0, 0), hp=20, resources_left={"hexes": 3}, ) t = CombatantState( 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)} spec = MapSpec(name="test", terrain=tuple(["." * 12] * 12), legend=legend) engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t, far]) return engine, c, t, far def _evil_eye(rounds_left: int, source: str = _HEX_SOURCE) -> ActiveBuff: return ActiveBuff( modifiers=(StatModifier(target="attack", value=-2),), rounds_left=rounds_left, source=source, ) def _rounds_by_source(buffs: list[ActiveBuff]) -> dict[str, int]: return {b.source: b.rounds_left for b in buffs} class TestCackleExtendsEvilEyeOnce: """Given: a target carrying 2 pre-existing evil-eye buffs (2 Evil Eyes, rounds_left 2 and 1) and an unrelated buff, plus a creature beyond 30 ft carrying its own evil eye When: the witch cackles Then: EXACTLY 2 extensions of +1 round happen in range — each evil eye's rounds_left rises by 1, the unrelated buff is untouched, the out-of-range creature is not extended, the buff count stays stable (no growth), and the call terminates.""" def test_exactly_two_evil_eye_extensions_of_one_round(self) -> None: engine, c, t, far = _make_engine() 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) after = _rounds_by_source(t.active_buffs) assert len(after) == 3 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