fix(rules): natural weapons get no BAB iteratives, secondary attacks at -5

PF1e (CRB "Natural Attacks"):
- Natural weapons (natural=true) get exactly one swing per full attack,
  regardless of BAB (no BAB iteratives for natural attacks).
- Secondary natural attacks (secondary=true) attack at BAB -5; the
  Multiattack feat reduces this to -2.
- Manifested in combat.py (_full_attack_weapon, _resolve_swing),
  models.py (AttackSpec.natural/secondary), 6 data JSONs, 4 new tests,
  and README documentation.

1461 passed, ruff clean, basedpyright 0 errors.
This commit is contained in:
2026-09-16 12:25:30 +02:00
parent 59caf67909
commit e5691bdb6f
10 changed files with 739 additions and 508 deletions
+12
View File
@@ -6699,6 +6699,12 @@ class CombatEngine:
target_live = self._stats[target.combatant.id]
hp_before = target.hp
override = bonus_override
if weapon.secondary:
# PF1e (CRB "Natural Attacks"): secondary natural attacks are at
# base attack bonus -5; the Multiattack feat reduces this to -2.
penalty = 2 if self._has_feat(attacker, "Multiattack") else 5
base = weapon.attack_bonus if override is None else override
override = base - penalty
if attack_penalty:
base = weapon.attack_bonus if override is None else override
override = base + attack_penalty
@@ -6829,6 +6835,12 @@ class CombatEngine:
if weapon.count > 1:
self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
return
if weapon.natural:
# PF1e (CRB "Natural Attacks"): "You do not receive additional
# natural attacks for a high base attack bonus." One swing per
# natural weapon, at its own attack bonus.
self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
return
n = min(
_MAX_ITERATIVES,
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
+8 -2
View File
@@ -242,14 +242,18 @@ class AttackSpec(_FrozenModel):
increment and against full AC beyond it. `two_handed` marks a weapon swung
in two hands (two-handed weapon, one-handed weapon used two hands, or a
primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
with such a weapon (CRB "Power Attack"). `reach_weapon` marks a weapon
with the reach weapon quality (CRB "Big and Little Creatures in Combat"):
it doubles the wielder's NATURAL reach but cannot strike at the wielder's
natural reach or less — the usable zone is a RING (dead zone = natural
reach). Without the flag a melee attack follows the creature's natural
reach, which is a DISK: a Large creature's natural attack (its JSON
`reach_ft` IS its natural reach, 10 ft) still strikes and threatens
adjacent squares.
adjacent squares. `natural` marks a natural weapon (bite, claw, etc.):
PF1e (CRB "Natural Attacks") grants no BAB iteratives for natural
weapons. `secondary` marks a secondary natural attack (tail, wing, etc.):
the engine applies -5 to the attack bonus (reduced to -2 with the
Multiattack feat).
"""
id: str
@@ -266,6 +270,8 @@ class AttackSpec(_FrozenModel):
count: int = Field(default=1, ge=1)
firearm: bool = False
two_handed: bool = False
natural: bool = False
secondary: bool = False
class DamageReduction(_FrozenModel):