From d152d42af92217b8e76ff03146f3bd4601d58909 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 12 Sep 2026 13:21:26 +0200 Subject: [PATCH] fix(rules): avoid the firing-into-melee penalty at 10 feet SRD 'Attack - Shooting or Throwing into a Melee': a target at least 10 ft from the nearest friendly character lets you avoid the -4 penalty entirely (0, not -2). The -2 is reserved for targets two size categories larger than the engaged friendlies (0 at three+), which is not modeled (documented). Golden transcript unaffected (shot still misses). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- README.md | 8 ++++++-- src/pf1e_simulator/combat.py | 21 +++++++++++++++------ tests/test_accuracy_rules.py | 10 +++++++--- tests/test_combat.py | 2 +- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 40eb6ad..4c12fcf 100644 --- a/README.md +++ b/README.md @@ -937,8 +937,12 @@ Règles modélisées : ou `touch` avec incrément) contre une cible engagée dans une mêlée subit −4 au jet d'attaque ET au jet de confirmation (la cible est « engagée » quand un ennemi actif autre que l'attaquant, doté d'une arme de mêlée, menace ses - cases) ; la pénalité tombe à −2 si la cible ou l'adversaire menaçant est à - ≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. + cases) ; la pénalité est **totalement évitée (0)** si la cible ou l'adversaire + menaçant est à ≥ 10 ft (2 cases) de l'attaquant (SRD « Attack — Shooting or + Throwing into a Melee » : « you can avoid the −4 penalty »). Simplification + documentée : la réduction RAW à −2 quand la cible est de deux catégories de + taille au-dessus des personnages amis (et 0 à trois ou plus) n'est pas + modélisée. Le don « Precise Shot » annule la pénalité. Les attaques de mêlée ne sont pas concernées. - Couvert : les trois niveaux PF1e sont modélisés via `cover_bonus` sur le terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f1bb7ff..d9ba4bc 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -177,8 +177,7 @@ _UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 _FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee -_FIRING_MELEE_REDUCED_PENALTY = -2 # PF1e: -2 when the melee is 10+ ft away -_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty +_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) = the -4 penalty is avoided _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty _IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range @@ -2645,8 +2644,18 @@ class CombatEngine: self, attacker: CombatantState, target: CombatantState ) -> int: """PF1e firing into melee: -4 on a ranged attack whose target is - engaged in melee, reduced to -2 when the target or every threatening - opponent stands 10+ ft from the attacker. Precise Shot removes it. + engaged in melee, avoided entirely (0) when the target or every + threatening opponent stands 10+ ft from the attacker. Precise Shot + removes it. + + PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your + target (or the part of your target you're aiming at, if it's a big + target) is at least 10 feet away from the nearest friendly character, + you can avoid the -4 penalty" — the 10+ ft clause is therefore 0, not + -2. The -2 applies only when the target is two size categories larger + than the friendly characters it is engaged with (and 0 at three or + more): size-category reduction is NOT modeled (documented + simplification). The target is engaged when an active enemy of the target (not the attacker itself) threatens the target's occupied cells with a melee @@ -2665,11 +2674,11 @@ class CombatEngine: if not threats: return 0 if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT: - return _FIRING_MELEE_REDUCED_PENALTY + return 0 for threat in threats: dist = self.creature_distance(attacker, threat) * _SQUARE_FT if dist >= _FIRING_MELEE_SAFE_FT: - return _FIRING_MELEE_REDUCED_PENALTY + return 0 return _FIRING_MELEE_PENALTY def _engaged_in_melee(self, state: CombatantState) -> bool: diff --git a/tests/test_accuracy_rules.py b/tests/test_accuracy_rules.py index 47c17a7..c13fe53 100644 --- a/tests/test_accuracy_rules.py +++ b/tests/test_accuracy_rules.py @@ -337,7 +337,11 @@ def test_firing_into_melee_minus4() -> None: assert t.hp == 100 -def test_firing_into_melee_minus2_when_target_10ft_away() -> None: +def test_firing_into_melee_avoided_when_target_10ft_away() -> None: + # PF1e (SRD "Attack" — Shooting or Throwing into a Melee): "If your target + # [...] is at least 10 feet away from the nearest friendly character, you + # can avoid the -4 penalty" — the 10+ ft clause is 0, not -2 (the -2 is + # reserved for targets two size categories larger, not modeled). archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),)) tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) guard = _combatant("guard", ac=99) @@ -349,8 +353,8 @@ def test_firing_into_melee_minus2_when_target_10ft_away() -> None: ) a, t, _g = eng._states result = eng.resolve_attack(a, t, a.combatant.attacks[0]) - assert result.total == 15 - assert result.melee_penalty == -2 + assert result.total == 17 + assert result.melee_penalty == 0 assert result.hit is True assert result.damage == 3 diff --git a/tests/test_combat.py b/tests/test_combat.py index a49953a..3538077 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -816,7 +816,7 @@ def test_ranged_attack_ignores_flanking() -> None: "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", "round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", - "round 1 arc: short bow vs tgt d20=12+2-2(melee)=12 AC 15 -> MISS", + "round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS", "battle over: draw after 1 rounds",