fix(rules): correct the evil eye hex

PF1e (APG Evil Eye): the target takes -2 on ONE chosen option (AC, ability checks, attack rolls, saving throws or skill checks) for 3 rounds, and "A Will save reduces this to just 1 round" — it never negates. _hex_evil_eye applied shaken (-2 attack AND saves) for 1 round with the save negating entirely. It now adds a targeted -2 attack ActiveBuff for 3 rounds, or 1 round on a successful Will save. Simplification documented in README: the option choice is not modeled (attack rolls are the engine's target). The hex-DC behavioral test now reads the -2 attack (3r) transcript line.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 12:49:48 +02:00
parent 910ed1dc7d
commit f9c369380d
4 changed files with 61 additions and 16 deletions
+5 -1
View File
@@ -1094,7 +1094,11 @@ Règles modélisées :
- Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat - Combat Reflexes : 1 AoO par combattant par round par défaut ; avec le don Combat
Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1). Reflexes, `1 + mod de Dextérité` AoO par round (`_aoo_allowance`, plancher 1).
- Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence` - Sortilèges du sorcier (witch hex) : le DD vaut `10 + niveau//2 + mod d'Intelligence`
(evil_eye, slumber, cackle) ; `cackle` prolonge d'un round les sortilèges déjà (evil_eye, slumber, cackle) ; `evil_eye` (APG, conformance D3) inflige −2 à
**une seule option** — l'option choisie est modélisée par les jets d'attaque —
pendant **3 rounds** ; une sauvegarde de Volonté réussie ne l'annule pas mais
**réduit la durée à 1 round** (« A Will save reduces this to just 1 round ») ;
`cackle` prolonge d'un round les sortilèges déjà
actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à actifs, sur une liste instantanée pour ne pas s'étendre à l'infini. Les sorts à
zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la zone (AoE) utilisent le DD d'incantation du jeteur et chaque créature de la
zone effectue la sauvegarde listée par le sort (Volonté / Réflexes / zone effectue la sauvegarde listée par le sort (Volonté / Réflexes /
+13 -12
View File
@@ -4756,27 +4756,28 @@ class CombatEngine:
elif hex_name == "cackle": elif hex_name == "cackle":
self._hex_cackle(state, target) self._hex_cackle(state, target)
_EVIL_EYE_ROUNDS = 3
_EVIL_EYE_SAVED_ROUNDS = 1
def _hex_evil_eye( def _hex_evil_eye(
self, state: CombatantState, target: CombatantState, hex_dc: int self, state: CombatantState, target: CombatantState, hex_dc: int
) -> None: ) -> None:
"""Evil Eye: shaken (-2 AC or saves), 1 round, Will negates.""" """Evil Eye (APG): -2 on one chosen option for 3 rounds; a Will save
reduces it to 1 round (never negates). Simplification: the chosen
option is attack rolls — the engine's other hex targets are not
selectable per cast."""
save_result = self.resolve_save(target, "will", hex_dc) save_result = self.resolve_save(target, "will", hex_dc)
if save_result.success: rounds = self._EVIL_EYE_SAVED_ROUNDS if save_result.success else self._EVIL_EYE_ROUNDS
self._log( target.active_buffs.append(
f"round {self._current_round} {state.combatant.id}: evil eye on " ActiveBuff(
f"{target.combatant.id} -> SAVED" modifiers=(StatModifier(target="attack", value=-2),),
) rounds_left=rounds,
return
target.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["shaken"],
rounds_left=1,
source=f"hex: evil_eye by {state.combatant.id}", source=f"hex: evil_eye by {state.combatant.id}",
) )
) )
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: evil eye on " f"round {self._current_round} {state.combatant.id}: evil eye on "
f"{target.combatant.id} -> shaken (1r)" f"{target.combatant.id} -> -2 attack ({rounds}r)"
) )
def _hex_slumber( def _hex_slumber(
+39
View File
@@ -255,6 +255,45 @@ def test_nairda_hex_spends_resource() -> None:
assert me.resources_left["hexes"] == 4 assert me.resources_left["hexes"] == 4
def test_evil_eye_failed_save_deals_minus_two_attack_three_rounds() -> None:
"""APG Evil Eye: -2 on one chosen option, 3 rounds; Will does not negate.
Given: a target that fails the Will save (d20 2 +4 < DC 14)
When: the witch hexes it
Then: a -2 attack modifier is applied for 3 rounds (no shaken condition)."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[2])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
)
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
assert len(hex_buffs) == 1
assert hex_buffs[0].rounds_left == 3
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
assert not any(ac.condition.name == "shaken" for ac in enemy[0].active_conditions)
def test_evil_eye_saved_deals_minus_two_attack_one_round() -> None:
"""APG Evil Eye: "A Will save reduces this to just 1 round" — the penalty
still lands. Given: a target that saves (natural 20)
When: the witch hexes it
Then: the -2 attack modifier is applied for exactly 1 round."""
nairda = _combatant("nairda", resources={"hexes": 5})
goblin = _combatant("goblin", hp_max=6)
engine, me, enemy = _engine(nairda, [goblin], rng_queue=[20])
engine._execute(
me, Action(kind="hex", target_id=enemy[0].combatant.id, hex_name="evil_eye")
)
hex_buffs = [b for b in enemy[0].active_buffs if b.source.startswith("hex: evil_eye")]
assert len(hex_buffs) == 1
assert hex_buffs[0].rounds_left == 1
assert hex_buffs[0].modifiers == (StatModifier(target="attack", value=-2),)
# --- tammara: deeds --- # --- tammara: deeds ---
+4 -3
View File
@@ -110,8 +110,9 @@ class TestHexDcUsesInt:
Then: DC = 10 + 4//2 + (21-10)//2 = 17. Then: DC = 10 + 4//2 + (21-10)//2 = 17.
Behavioral test: target has Will +2. d20=13 -> total 15. Behavioral test: target has Will +2. d20=13 -> total 15.
DC 17: 15 < 17 -> fail -> shaken applied. DC 17: 15 < 17 -> fail -> the -2 attack hex lands for its full 3 rounds
DC 12 (wrong, uses CHA): 15 >= 12 -> success -> no effect. (APG Evil Eye: a save only reduces the duration to 1 round).
DC 12 (wrong, uses CHA): 15 >= 12 -> "saved" -> 1 round.
""" """
def test_hex_dc_uses_int_not_cha(self) -> None: def test_hex_dc_uses_int_not_cha(self) -> None:
@@ -119,7 +120,7 @@ class TestHexDcUsesInt:
target = _make_target(hp_max=100) target = _make_target(hp_max=100)
engine, c, t = _make_engine(caster, target, [13]) engine, c, t = _make_engine(caster, target, [13])
engine._execute_hex(c, t, "evil_eye") engine._execute_hex(c, t, "evil_eye")
assert any("shaken" in line for line in engine._transcript) assert any("-2 attack (3r)" in line for line in engine._transcript)
class TestAoEDcEqualsCasterSpellDc: class TestAoEDcEqualsCasterSpellDc: