diff --git a/README.md b/README.md index 0c8c80b..7027e4f 100644 --- a/README.md +++ b/README.md @@ -1094,7 +1094,11 @@ Règles modélisées : - 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). - 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 à 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 / diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6d17ca5..692399b 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4756,27 +4756,28 @@ class CombatEngine: elif hex_name == "cackle": self._hex_cackle(state, target) + _EVIL_EYE_ROUNDS = 3 + _EVIL_EYE_SAVED_ROUNDS = 1 + def _hex_evil_eye( self, state: CombatantState, target: CombatantState, hex_dc: int ) -> 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) - if save_result.success: - self._log( - f"round {self._current_round} {state.combatant.id}: evil eye on " - f"{target.combatant.id} -> SAVED" - ) - return - target.active_conditions.append( - ActiveCondition( - condition=CONDITIONS_BY_NAME["shaken"], - rounds_left=1, + rounds = self._EVIL_EYE_SAVED_ROUNDS if save_result.success else self._EVIL_EYE_ROUNDS + target.active_buffs.append( + ActiveBuff( + modifiers=(StatModifier(target="attack", value=-2),), + rounds_left=rounds, source=f"hex: evil_eye by {state.combatant.id}", ) ) self._log( 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( diff --git a/tests/test_class_abilities.py b/tests/test_class_abilities.py index e3d3ced..eeb2246 100644 --- a/tests/test_class_abilities.py +++ b/tests/test_class_abilities.py @@ -255,6 +255,45 @@ def test_nairda_hex_spends_resource() -> None: 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 --- diff --git a/tests/test_hex_aoe_dr.py b/tests/test_hex_aoe_dr.py index a7cb5d0..94f6388 100644 --- a/tests/test_hex_aoe_dr.py +++ b/tests/test_hex_aoe_dr.py @@ -110,8 +110,9 @@ class TestHexDcUsesInt: Then: DC = 10 + 4//2 + (21-10)//2 = 17. Behavioral test: target has Will +2. d20=13 -> total 15. - DC 17: 15 < 17 -> fail -> shaken applied. - DC 12 (wrong, uses CHA): 15 >= 12 -> success -> no effect. + DC 17: 15 < 17 -> fail -> the -2 attack hex lands for its full 3 rounds + (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: @@ -119,7 +120,7 @@ class TestHexDcUsesInt: target = _make_target(hp_max=100) engine, c, t = _make_engine(caster, target, [13]) 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: