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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-12 13:21:26 +02:00
parent 84bcf004c3
commit d152d42af9
4 changed files with 29 additions and 12 deletions
+6 -2
View File
@@ -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 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 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 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 à cases) ; la pénalité est **totalement évitée (0)** si la cible ou l'adversaire
≥ 10 ft (2 cases) de l'attaquant. Le don « Precise Shot » annule la pénalité. 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. 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 - 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 terrain — partiel +2, standard +4 (ou `cover: true`), amélioré +8 ; le
+15 -6
View File
@@ -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 # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons
_POINT_BLANK_RANGE_FT = 30 _POINT_BLANK_RANGE_FT = 30
_FIRING_MELEE_PENALTY = -4 # PF1e: ranged attack into melee _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) = the -4 penalty is avoided
_FIRING_MELEE_SAFE_FT = 10 # PF1e: 2 cells (10 ft) from attacker = reduced penalty
_SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice) _SNEAK_ATTACK_FEATURE = "Sneak Attack" # PF1e: rogue class feature (precision dice)
_PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty _PRECISE_SHOT_FEATURE = "Precise Shot" # PF1e: feat negating firing-into-melee penalty
_IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range _IMPROVED_CRITICAL_FEATURE = "Improved Critical" # PF1e: feat doubling the threat range
@@ -2645,8 +2644,18 @@ class CombatEngine:
self, attacker: CombatantState, target: CombatantState self, attacker: CombatantState, target: CombatantState
) -> int: ) -> int:
"""PF1e firing into melee: -4 on a ranged attack whose target is """PF1e firing into melee: -4 on a ranged attack whose target is
engaged in melee, reduced to -2 when the target or every threatening engaged in melee, avoided entirely (0) when the target or every
opponent stands 10+ ft from the attacker. Precise Shot removes it. 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 The target is engaged when an active enemy of the target (not the
attacker itself) threatens the target's occupied cells with a melee attacker itself) threatens the target's occupied cells with a melee
@@ -2665,11 +2674,11 @@ class CombatEngine:
if not threats: if not threats:
return 0 return 0
if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT: if self.creature_distance(attacker, target) * _SQUARE_FT >= _FIRING_MELEE_SAFE_FT:
return _FIRING_MELEE_REDUCED_PENALTY return 0
for threat in threats: for threat in threats:
dist = self.creature_distance(attacker, threat) * _SQUARE_FT dist = self.creature_distance(attacker, threat) * _SQUARE_FT
if dist >= _FIRING_MELEE_SAFE_FT: if dist >= _FIRING_MELEE_SAFE_FT:
return _FIRING_MELEE_REDUCED_PENALTY return 0
return _FIRING_MELEE_PENALTY return _FIRING_MELEE_PENALTY
def _engaged_in_melee(self, state: CombatantState) -> bool: def _engaged_in_melee(self, state: CombatantState) -> bool:
+7 -3
View File
@@ -337,7 +337,11 @@ def test_firing_into_melee_minus4() -> None:
assert t.hp == 100 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),)) archer = _combatant("archer", ac=99, attacks=(_bow("archer", attack_bonus=8),))
tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),)) tgt = _combatant("tgt", ac=15, attacks=(_bow("tgt"),))
guard = _combatant("guard", ac=99) 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 a, t, _g = eng._states
result = eng.resolve_attack(a, t, a.combatant.attacks[0]) result = eng.resolve_attack(a, t, a.combatant.attacks[0])
assert result.total == 15 assert result.total == 17
assert result.melee_penalty == -2 assert result.melee_penalty == 0
assert result.hit is True assert result.hit is True
assert result.damage == 3 assert result.damage == 3
+1 -1
View File
@@ -816,7 +816,7 @@ def test_ranged_attack_ignores_flanking() -> None:
"initiative: aly d20=9+0=9", "initiative: aly d20=9+0=9",
"initiative: tgt d20=8+0=8", "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 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 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", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",