b0fe553782
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>
171 lines
5.7 KiB
Python
171 lines
5.7 KiB
Python
"""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
|