"""Tests for the cackle hex: bound the extension of hex conditions (Task 9). Cackle must extend exactly one +1 round per pre-existing hex condition, in a single pass, without re-extending the replacements it appends. The pre-fix code mutated ``target.active_conditions`` (remove/append) while iterating it, which grew the list unboundedly and looped forever. """ from __future__ import annotations from typing import TYPE_CHECKING from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.conditions import CONDITIONS_BY_NAME from pf1e_simulator.dice import parse_dice from pf1e_simulator.effects import ActiveCondition 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 if TYPE_CHECKING: from collections.abc import Collection _HEX_SOURCES = ("hex: evil_eye by caster", "hex: evil_eye by caster-2") _ROUNDS_BEFORE = (2, 1) 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]: 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, ) legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) engine = CombatEngine(ScriptedRng([]), Grid.from_spec(spec), [c, t]) return engine, c, t def _seed_hex_conditions(target: CombatantState) -> dict[str, int]: target.active_conditions = [ ActiveCondition( condition=CONDITIONS_BY_NAME["shaken"], rounds_left=rounds, 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]: return {ac.source: ac.rounds_left for ac in conditions} class TestCackleExtendsHexOnce: """Given: a target carrying 2 pre-existing hex conditions (2 Evil Eyes, rounds_left 2 and 1) When: the witch cackles Then: EXACTLY 2 extensions of +1 round happen — each hex's rounds_left rises by 1, the total hex-condition count stays 2 (no growth), and the call terminates (the pre-fix loop never did).""" def test_exactly_two_hex_extensions_of_one_round(self) -> None: engine, c, t = _make_engine() expected = _seed_hex_conditions(t) engine._hex_cackle(c, t) after = _rounds_by_source(t.active_conditions) assert len(after) == 2 assert after == {source: rounds + 1 for source, rounds in expected.items()}