diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6e1a2f1..19a0639 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2707,9 +2707,11 @@ class CombatEngine: PF1e (CRB): a prone defender takes -4 AC against melee attacks but gains +4 AC against ranged attacks; a helpless defender (paralyzed, asleep, unconscious, helpless) has an effective Dexterity score of 0, - so its whole Dex modifier gives way to the Dex-0 penalty (-5) — - flat-footed AC already drops a positive Dex bonus, a native Dex - penalty must still be replaced.""" + so its whole Dex modifier gives way to the Dex-0 penalty (-5). The + flat-footed base already excludes ALL Dex (bonus and penalty alike), + so the mod is exactly -5 there; on the touch base the stored Dex + modifier is stripped at the base (see resolve_attack) before this + -5 is added.""" mods = self._stat_modifiers(target, "ac") if self._has_condition_named(target, "prone"): mods.append( @@ -2718,8 +2720,7 @@ class CombatEngine: ) ) if self._is_helpless(target): - dex_penalty = min(0, target.combatant.abilities.dex_mod) - mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD - dex_penalty)) + mods.append(StatModifier(target="ac", value=_DEX_ZERO_MOD)) return mods def resolve_attack( @@ -2804,9 +2805,14 @@ class CombatEngine: ): base_ac = full_ac_base elif denies_dex: - base_ac = ( - target.combatant.ac.touch - max(0, target.combatant.abilities.dex_mod) - ) + # A flat-footed defender loses only a POSITIVE Dex bonus (a + # Dex penalty stays on touch AC); a HELPLESS defender has an + # effective Dex score of 0, so its whole stored Dex modifier + # is stripped here and the exact -5 Dex-0 penalty is added by + # _attack_ac_mods. + dex_mod = target.combatant.abilities.dex_mod + strip = dex_mod if self._is_helpless(target) else max(0, dex_mod) + base_ac = target.combatant.ac.touch - strip else: base_ac = target.combatant.ac.touch else: diff --git a/tests/test_conformance_p1.py b/tests/test_conformance_p1.py index 9db8154..e1c93cc 100644 --- a/tests/test_conformance_p1.py +++ b/tests/test_conformance_p1.py @@ -328,8 +328,9 @@ def test_stunned_is_not_helpless() -> None: def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None: - # A Dex 8 (-1) helpless target: the -1 penalty gives way to the Dex-0 -5 - # (adjustment -5 - (-1) = -4 on top of the flat-footed base). + # A Dex 8 (-1) helpless target: the flat-footed base already excludes + # ALL Dex (bonus and penalty alike), so effective Dex 0 adds exactly -5. + # The old formula added -5 - (-1) = -4, double-counting the Dex penalty. eng = _engine( [_combatant("atk"), _combatant("def", ac=14, dex=8)], [(0, 0), (0, 1)], @@ -337,7 +338,68 @@ def test_helpless_dex_zero_replaces_a_native_dex_penalty() -> None: ) _add_condition(eng._states[1], PARALYZED) result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) - assert result.ac == 10 # 14 - 4 + assert result.ac == 9 # 14 - 5 + + +def _touch(cid: str) -> AttackSpec: + return AttackSpec( + id=f"{cid}-touch", + name="touch", + kind="touch", + attack_bonus=5, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["cold"])], + ) + + +def test_helpless_positive_dex_melee_ac_is_flat_footed_minus5() -> None: + # Dex 16 (+3): flat-footed base drops the +3 already; Dex-0 adds -5. + eng = _engine( + [_combatant("atk"), _combatant("def", ac=16, dex=16)], + [(0, 0), (0, 1)], + [10, 4], + ) + _add_condition(eng._states[1], PARALYZED) + result = eng.resolve_attack(eng._states[0], eng._states[1], _melee("atk")) + assert result.ac == 11 # 16 - 5 + + +def test_helpless_positive_dex_touch_ac_strips_dex_then_minus5() -> None: + # Dex 16 (+3): touch base 16 includes the +3; effective Dex 0 strips it + # (16 - 3) and adds -5 -> 8. + eng = _engine( + [_combatant("atk"), _combatant("def", ac=16, dex=16)], + [(0, 0), (0, 1)], + [10, 4], + ) + _add_condition(eng._states[1], PARALYZED) + result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) + assert result.ac == 8 + + +def test_helpless_negative_dex_touch_ac_strips_penalty_then_minus5() -> None: + # Dex 8 (-1): touch base 14 includes the -1; effective Dex 0 replaces it + # (14 + 1) and adds -5 -> 10. + eng = _engine( + [_combatant("atk"), _combatant("def", ac=14, dex=8)], + [(0, 0), (0, 1)], + [10, 4], + ) + _add_condition(eng._states[1], PARALYZED) + result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) + assert result.ac == 10 + + +def test_flat_footed_negative_dex_touch_keeps_penalty_without_minus5() -> None: + # Control: a merely flat-footed (NOT helpless) Dex 8 target loses only a + # positive Dex bonus — the -1 stays on touch AC and no Dex-0 -5 applies. + eng = _engine( + [_combatant("atk"), _combatant("def", ac=14, dex=8)], + [(0, 0), (0, 1)], + [10, 4], + ) + _add_condition(eng._states[1], CONDITIONS_BY_NAME["flat_footed"]) + result = eng.resolve_attack(eng._states[0], eng._states[1], _touch("atk")) + assert result.ac == 14 # ── B6: nauseated — single move action only, no casting ──────────────────────