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:
@@ -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 /
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user