From e0bf4843949578a6e095935bad574c5090ae808a Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 21:24:48 +0200 Subject: [PATCH] fix(rules): firing into melee exemption measured target-to-nearest-ally, not attacker-to-target (SRD Shooting into a Melee) --- src/pf1e_simulator/combat.py | 35 ++++++++++++++++++++++------------- tests/test_accuracy_rules.py | 34 ++++++++++++++++++++++++++++++---- tests/test_combat.py | 2 +- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 76ec879..6e1a2f1 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3056,18 +3056,22 @@ class CombatEngine: self, attacker: CombatantState, target: CombatantState ) -> int: """PF1e firing into melee: -4 on a ranged attack whose target is - engaged in melee, avoided entirely (0) 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 stands 10+ ft + from every friendly character of 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). + you can avoid the -4 penalty" — the exemption is measured from the + TARGET to the attacker's nearest friendly character (a creature on + the attacker's side other than the attacker), NOT from the attacker's + own position; an archer 30 ft away still takes the -4 when the target + is locked in melee with one of the archer's allies at 5 ft. 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 @@ -3085,12 +3089,17 @@ class CombatEngine: ] if not threats: return 0 - if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT: + allies = [ + s + for s in self._states + if s is not attacker and s.active and s.side == attacker.side + ] + if all( + self.creature_distance(target, ally) * _SQUARE_FT + >= _FIRING_MELEE_SAFE_FT + for ally in allies + ): return 0 - for threat in threats: - dist = self.creature_distance(attacker, threat) * _SQUARE_FT - if dist >= _FIRING_MELEE_SAFE_FT: - 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 b67d51b..eb9fadd 100644 --- a/tests/test_accuracy_rules.py +++ b/tests/test_accuracy_rules.py @@ -338,11 +338,13 @@ def test_firing_into_melee_minus4() -> None: assert t.hp == 100 -def test_firing_into_melee_avoided_when_target_10ft_away() -> None: +def test_firing_into_melee_not_avoided_when_ally_engaged_5ft_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). + # can avoid the -4 penalty" — the exemption is measured from the TARGET to + # the attacker's nearest friendly character, not from the attacker. The + # archer is 10 ft from the target, but the target is engaged at 5 ft by + # the archer's own ally (the guard) -> the -4 still applies. archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),)) tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) guard = _combatant("guard", ac=99) @@ -354,8 +356,32 @@ def test_firing_into_melee_avoided_when_target_10ft_away() -> None: ) a, t, _g = eng._states result = eng.resolve_attack(a, t, a.combatant.attacks[0]) - assert result.total == 17 + assert result.melee_penalty == -4 + assert result.total == 13 + assert result.hit is False + assert t.hp == 100 + + +def test_firing_into_melee_avoided_when_nearest_ally_20ft_away() -> None: + # The target is engaged (a third-party melee threat stands next to it), + # but the attacker's nearest friendly character is 20 ft from the target + # -> the -4 is avoided even though the archer itself is only 5 ft away. + # The OLD (buggy) rule measured attacker->target/attacker->threat and + # would wrongly apply the -4 here. + archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),)) + tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) + ally = _combatant("ally", ac=99) + raider = _combatant("raider") # neither the archer's side nor the target's + eng = _engine( + [archer, tgt, ally, raider], + [(0, 0), (0, 1), (0, 5), (1, 2)], + ["players", "monsters", "players", "gnolls"], + [10, 3], + ) + a, t, _al, _r = eng._states + result = eng.resolve_attack(a, t, a.combatant.attacks[0]) assert result.melee_penalty == 0 + assert result.total == 18 assert result.hit is True assert result.damage == 3 diff --git a/tests/test_combat.py b/tests/test_combat.py index 7950e9d..0c7d6a8 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=14 AC 15 -> MISS", + "round 1 arc: short bow vs tgt d20=12+2-4(melee)=10 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",