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
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
+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
_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:
+7 -3
View File
@@ -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
+1 -1
View File
@@ -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",