diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index cf1adf6..69bfb32 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1396,7 +1396,7 @@ class CombatEngine: 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 target.active_conditions: + for ac in list(target.active_conditions): if ac.source.startswith("hex:"): target.active_conditions.remove(ac) target.active_conditions.append( diff --git a/tests/test_cackle.py b/tests/test_cackle.py new file mode 100644 index 0000000..e5b7549 --- /dev/null +++ b/tests/test_cackle.py @@ -0,0 +1,139 @@ +"""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()}