fix(combat): bound cackle hex extension
This commit is contained in:
@@ -1396,7 +1396,7 @@ class CombatEngine:
|
|||||||
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
|
def _hex_cackle(self, state: CombatantState, target: CombatantState) -> None:
|
||||||
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
|
"""Cackle: prolong one hex by 1 round (extends all hex conditions)."""
|
||||||
extended = False
|
extended = False
|
||||||
for ac in target.active_conditions:
|
for ac in list(target.active_conditions):
|
||||||
if ac.source.startswith("hex:"):
|
if ac.source.startswith("hex:"):
|
||||||
target.active_conditions.remove(ac)
|
target.active_conditions.remove(ac)
|
||||||
target.active_conditions.append(
|
target.active_conditions.append(
|
||||||
|
|||||||
@@ -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()}
|
||||||
Reference in New Issue
Block a user