From a2825e73ccdf60156493a4535eda7713146877a8 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 23:35:07 +0200 Subject: [PATCH] fix(rules): apply the power attack penalty to melee touch attacks PF1e (CRB Power Attack): "take a -1 penalty on all melee attack rolls ... The bonus damage does not apply to touch attacks." A melee touch (kind "touch", no range increment) is a melee attack, so _power_attack_amt now rides its attack and confirmation rolls; the +2X/+3X damage bonus stays gated on kind == "melee". Ranged attacks and rays are unaffected. The test that pinned the old melee-weapon-only simplification was rewritten to lock the corrected behavior; README and abilities.py updated. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 11 ++++++++--- src/pf1e_simulator/abilities.py | 5 +++-- src/pf1e_simulator/combat.py | 12 ++++++++++-- tests/test_conformance_p2.py | 13 +++++++++---- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bf59dbc..50104e6 100644 --- a/README.md +++ b/README.md @@ -743,8 +743,11 @@ Règles modélisées : ex. le contact paralysant du liche) n'en fait rien. Conformance P2 : le contact de mêlée (touch sans incrément) est une **attaque de mêlée** — il bénéficie du flanquement, de la hauteur (+1), du +4 contre une cible inerme - et peut porter une attaque sournoise ; Power Attack reste réservé aux armes - de mêlée (le contact n'en bénéficie pas). + et peut porter une attaque sournoise ; Power Attack (conformance A6 — CRB : + « a –1 penalty on all melee attack rolls… The bonus damage does not apply to + touch attacks ») s'applique à **la touche** du contact de mêlée (pénalité −X + au jet d'attaque et de confirmation) mais son bonus de dégâts reste réservé + aux armes de mêlée (le contact ne gagne pas le +2X/+3X). - Jets de sauvegarde : `resolve_save(state, save_type, dc)` — fort/ref/will. 1 naturel = échec automatique, 20 naturel = succès automatique. Les modificateurs d'effets (`StatModifier` avec `target` correspondant) sont @@ -1205,7 +1208,9 @@ la lecture mais non appliquée dans la résolution. - `abilities.py` — dons et capacités : `AbilitySpec` (dataclass avec catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X - dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le + dégâts, X = BAB//4 + 1 — la pénalité −X vise toutes les attaques de mêlée + (contact de mêlée inclus, conformance A6), le bonus de dégâts uniquement les + armes de mêlée —, activé comme action libre via le kind `activate_power_attack` qui pose `state.power_attack`). Conformance B16 (CRB « Power Attack ») : le bonus de dégâts passe à **+3X (×1,5)** pour une arme marquée `two_handed: true` dans le JSON de l'attaque (arme à deux diff --git a/src/pf1e_simulator/abilities.py b/src/pf1e_simulator/abilities.py index 0679f27..7cc7a2c 100644 --- a/src/pf1e_simulator/abilities.py +++ b/src/pf1e_simulator/abilities.py @@ -139,8 +139,9 @@ DODGE = AbilitySpec( ), ) -# Power Attack: active feat — declare at turn start, -X atk / +2X dmg on all -# melee attacks this turn, where X = BAB//4 + 1 (min 1). No passive modifiers; +# Power Attack: active feat — declare at turn start, -X atk on all melee +# attack rolls (melee touch included) and +2X dmg on melee WEAPON damage only +# (never touch), where X = BAB//4 + 1 (min 1). No passive modifiers; # the engine checks for this feat and the ``power_attack`` flag on state. POWER_ATTACK = AbilitySpec( name="Power Attack", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index ad9d558..9811a15 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -2283,7 +2283,12 @@ class CombatEngine: if self._is_melee_attack(weapon) and self._is_helpless(target): atk_mods.append(StatModifier(target="attack", value=_HELPLESS_MELEE_BONUS)) attack_mod = resolve_modifiers(atk_mods) - pa_amt = self._power_attack_amt(attacker) if weapon.kind == "melee" else 0 + # PF1e (CRB Power Attack): the -X penalty rides ALL melee attack rolls + # — a melee touch (no range increment) is a melee attack. The damage + # bonus it buys stays weapon-only (see _resolve_hit_damage). + pa_amt = ( + self._power_attack_amt(attacker) if self._is_melee_attack(weapon) else 0 + ) fd_penalty = ( _FIGHT_DEFENSIVELY_ATTACK_PENALTY if attacker.fight_defensively else 0 ) @@ -2445,7 +2450,10 @@ class CombatEngine: # weapon used in two hands, or a primary natural with 1.5x Strength # (modeled via the weapon's `two_handed` flag). The off-hand / # secondary-natural halving (-50%, +1X) is NOT modeled: two-weapon - # fighting is not in the engine (documented simplification). + # fighting is not in the engine (documented simplification). The + # bonus damage never applies to touch attacks (CRB), which is why + # this gate is kind == "melee" while the -X penalty also rides melee + # touch rolls. if weapon.kind == "melee": damage += pa_amt * (3 if weapon.two_handed else 2) if crit: diff --git a/tests/test_conformance_p2.py b/tests/test_conformance_p2.py index 3e397bb..77738f9 100644 --- a/tests/test_conformance_p2.py +++ b/tests/test_conformance_p2.py @@ -6,7 +6,9 @@ B8 Flanking: the flanking ally must actually THREATEN the target (a reach weapon's 5-ft dead zone does not threaten) and must not be unable to act. B9 Melee touch (kind "touch", no range increment) is a melee attack: it gains flanking, high ground, the vs-helpless +4 and can sneak attack; Power - Attack stays melee-only. + Attack's -X penalty rides its attack rolls while the bonus damage never + applies to touch (A6 refinement — the original "melee-only feat" reading + was itself a deviation). B10 Blinded: NO flat -2 on attack rolls (the modeled 50% total-concealment miss is the RAW penalty); the -4 Dex-skill penalty rides the dex_skills target (B22). @@ -233,7 +235,7 @@ def test_melee_touch_can_sneak_attack() -> None: assert any("sneak attack" in line for line in eng._transcript) -def test_power_attack_still_excludes_melee_touch() -> None: +def test_power_attack_penalizes_melee_touch_attack_roll() -> None: atk = _combatant( "atk", bab=5, attacks=(_touch("atk"),), features=("Power Attack",) ) @@ -242,8 +244,11 @@ def test_power_attack_still_excludes_melee_touch() -> None: a = eng._states[0] a.power_attack = True result = eng.resolve_attack(a, eng._states[1], _touch("atk")) - # Power Attack is melee-weapon-only: the touch swing keeps its full bonus. - assert result.total == 10 + 10 + # PF1e (CRB Power Attack): the -X penalty rides ALL melee attack rolls and + # a melee touch IS a melee attack (X = 5//4 + 1 = 2); the bonus damage + # never applies to touch attacks — 1d6 = 2, no +2X. + assert result.total == 10 + 10 - 2 + assert result.damage == 2 # ── B10: blinded has no flat attack penalty ──────────────────────────────────