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
+10 -3
View File
@@ -647,9 +647,16 @@ Règles modélisées :
résout `count` balayages indépendants dans la même action ; les balayages résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des rafale. Attaque à outrance (action à round complet) : à BAB +6/+11/+16, des
attaques supplémentaires à -5/-10/-15 ; les armes naturelles (`count` > 1) attaques supplémentaires à -5/-10/-15 pour les armes manufacturées. Les
ne gagnent pas d'itératifs. Plusieurs armes naturelles en une seule attaque armes naturelles (`natural: true` dans le JSON — morsures, griffes, sabots,
à outrance ne sont pas modélisées. talons…) ne gagnent **jamais** d'itératifs : une attaque à outrance avec une
arme naturelle résout **un seul** balayage quel que soit le BAB (CRB
« Natural Attacks » : « You do not receive additional natural attacks for a
high base attack bonus »). Une attaque naturelle marquée `secondary: true`
(les attaques secondaires du bestiaire) frappe à **BAB −5** ; le don
Multiattack réduit cette pénalité à **−2** (CRB « Natural Attacks » /
« Multiattack »). Plusieurs armes naturelles en une seule attaque à outrance
ne sont pas modélisées.
Choix de l'arme : `Action(kind="attack", weapon_id=...)` et Choix de l'arme : `Action(kind="attack", weapon_id=...)` et
`Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée `Action(kind="full_attack", weapon_id=...)` frappent avec l'arme désignée
(l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au (l'arc plutôt que l'épée, l'arme principale plutôt que la secondaire) au
+5 -2
View File
@@ -33,14 +33,17 @@
"damage": [ "damage": [
{ {
"formula": "1d6+1", "formula": "1d6+1",
"types": ["piercing"] "types": [
"piercing"
]
} }
], ],
"damage_bonus": 1, "damage_bonus": 1,
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
} }
], ],
"source": "summon monster II", "source": "summon monster II",
+30 -6
View File
@@ -15,30 +15,54 @@
"cha_score": 2 "cha_score": 2
}, },
"hp_max": 30, "hp_max": 30,
"ac": { "total": 15, "touch": 10, "flat_footed": 14 }, "ac": {
"total": 15,
"touch": 10,
"flat_footed": 14
},
"bab": 3, "bab": 3,
"initiative_mod": 1, "initiative_mod": 1,
"speed_land_ft": 20, "speed_land_ft": 20,
"saves": { "fort": 7, "ref": 2, "will": 1 }, "saves": {
"fort": 7,
"ref": 2,
"will": 1
},
"attacks": [ "attacks": [
{ {
"name": "bite", "name": "bite",
"kind": "melee", "kind": "melee",
"attack_bonus": 4, "attack_bonus": 4,
"damage": [{ "formula": "1d6", "types": ["piercing"] }], "damage": [
{
"formula": "1d6",
"types": [
"piercing"
]
}
],
"damage_bonus": 2, "damage_bonus": 2,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 10 "reach_ft": 10,
"natural": true
}, },
{ {
"name": "claw", "name": "claw",
"kind": "melee", "kind": "melee",
"attack_bonus": 4, "attack_bonus": 4,
"damage": [{ "formula": "1d4", "types": ["slashing"] }], "damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 2, "damage_bonus": 2,
"crit_mult": 2, "crit_mult": 2,
"count": 2, "count": 2,
"reach_ft": 10 "reach_ft": 10,
"natural": true
} }
], ],
"cmb": 6, "cmb": 6,
+10 -4
View File
@@ -34,14 +34,17 @@
"damage": [ "damage": [
{ {
"formula": "1d4", "formula": "1d4",
"types": ["piercing"] "types": [
"piercing"
]
} }
], ],
"damage_bonus": 0, "damage_bonus": 0,
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
}, },
{ {
"id": "harvie:claw", "id": "harvie:claw",
@@ -51,14 +54,17 @@
"damage": [ "damage": [
{ {
"formula": "1d3", "formula": "1d3",
"types": ["slashing"] "types": [
"slashing"
]
} }
], ],
"damage_bonus": 0, "damage_bonus": 0,
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
} }
], ],
"source": "fiches_personnages/harvie_sheet.json", "source": "fiches_personnages/harvie_sheet.json",
+5 -2
View File
@@ -34,14 +34,17 @@
"damage": [ "damage": [
{ {
"formula": "1d4", "formula": "1d4",
"types": ["piercing"] "types": [
"piercing"
]
} }
], ],
"damage_bonus": 1, "damage_bonus": 1,
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
} }
], ],
"source": "fiches_personnages/ierlieth_sheet.json", "source": "fiches_personnages/ierlieth_sheet.json",
+4 -2
View File
@@ -86,7 +86,8 @@
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
}, },
{ {
"name": "Bite (attaque naturelle)", "name": "Bite (attaque naturelle)",
@@ -104,7 +105,8 @@
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
}, },
{ {
"name": "Unarmed Strike", "name": "Unarmed Strike",
+2 -1
View File
@@ -87,7 +87,8 @@
"crit_range": 20, "crit_range": 20,
"crit_mult": 2, "crit_mult": 2,
"reach_ft": 5, "reach_ft": 5,
"count": 1 "count": 1,
"natural": true
} }
], ],
"source": "fiches_personnages/oni_sheet.json", "source": "fiches_personnages/oni_sheet.json",
+12
View File
@@ -6699,6 +6699,12 @@ class CombatEngine:
target_live = self._stats[target.combatant.id] target_live = self._stats[target.combatant.id]
hp_before = target.hp hp_before = target.hp
override = bonus_override 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: if attack_penalty:
base = weapon.attack_bonus if override is None else override base = weapon.attack_bonus if override is None else override
override = base + attack_penalty override = base + attack_penalty
@@ -6829,6 +6835,12 @@ class CombatEngine:
if weapon.count > 1: if weapon.count > 1:
self._attack(attacker, target, weapon, attack_penalty=attack_penalty) self._attack(attacker, target, weapon, attack_penalty=attack_penalty)
return 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( n = min(
_MAX_ITERATIVES, _MAX_ITERATIVES,
1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY), 1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY),
+7 -1
View File
@@ -249,7 +249,11 @@ class AttackSpec(_FrozenModel):
reach). Without the flag a melee attack follows the creature's 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, which is a DISK: a Large creature's natural attack (its JSON
`reach_ft` IS its natural reach, 10 ft) still strikes and threatens `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 id: str
@@ -266,6 +270,8 @@ class AttackSpec(_FrozenModel):
count: int = Field(default=1, ge=1) count: int = Field(default=1, ge=1)
firearm: bool = False firearm: bool = False
two_handed: bool = False two_handed: bool = False
natural: bool = False
secondary: bool = False
class DamageReduction(_FrozenModel): class DamageReduction(_FrozenModel):
+167
View File
@@ -0,0 +1,167 @@
"""Natural weapon attack rules (CRB "Natural Attacks").
Given an attacker with natural weapons and high BAB
When a full attack is resolved
Then natural weapons make exactly one swing each (no BAB iteratives)
Given a secondary natural attack
When a swing is resolved
Then the attack bonus is reduced by 5
Given a secondary natural attack and the attacker has the Multiattack feat
When a swing is resolved
Then the attack bonus is reduced by 2 (instead of 5)
"""
from __future__ import annotations
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
AbilitySpec,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def _make_combatant(
cid: str,
*,
side: str,
bab: int = 1,
attack_bonus: int = 0,
natural: bool = False,
secondary: bool = False,
features: tuple[str, ...] = (),
) -> Combatant:
"""Build a minimal Combatant with one attack."""
attack = AttackSpec(
id=f"{cid}-w",
name="weapon",
kind="melee",
attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])],
natural=natural,
secondary=secondary,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
bab=bab,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=10, flat_footed=10),
initiative_mod=0,
speed_land_ft=30,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
features=[AbilitySpec(name=f, category="feat") for f in features],
)
def _make_grid() -> Grid:
"""8x8 floor grid."""
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def _make_engine(
queue: list[int],
attacker: Combatant,
target: Combatant,
attacker_pos: tuple[int, int] = (1, 1),
target_pos: tuple[int, int] = (1, 2),
) -> tuple[CombatEngine, CombatantState, CombatantState]:
"""Create engine with two adjacent combatants and return (engine, atk_state, tgt_state)."""
a = CombatantState(combatant=attacker, side="players", pos=attacker_pos,
hp=attacker.hp_max)
t = CombatantState(combatant=target, side="monsters", pos=target_pos,
hp=target.hp_max)
engine = CombatEngine(ScriptedRng(queue), _make_grid(), [a, t])
return engine, a, t
def _swing_lines(engine: CombatEngine, target_id: str) -> list[str]:
"""Transcript lines that resolved a swing against the target."""
return [line for line in engine._transcript if f"vs {target_id}" in line]
# BAB iteratives
def test_natural_weapon_no_bab_iteratives() -> None:
"""Natural weapon with BAB 16 produces exactly 1 swing, not 4.
PF1e (CRB "Natural Attacks"): you do not receive additional natural
attacks for a high base attack bonus.
"""
atk = _make_combatant("a1", side="players", bab=16, attack_bonus=18, natural=True)
tgt = _make_combatant("t1", side="monsters", bab=1)
# RNG: [d20=15] attack roll; on hit -> damage dice (1d6=4)
engine, a, t = _make_engine([15, 4], atk, tgt)
engine._full_attack(a, t)
swings = _swing_lines(engine, "t1")
assert len(swings) == 1, f"natural weapon should swing once, got {len(swings)}: {swings}"
def test_manufactured_weapon_gets_iteratives() -> None:
"""Non-natural weapon with BAB 16 produces 4 iterative swings (16/11/6/1)."""
atk = _make_combatant("a2", side="players", bab=16, attack_bonus=18)
tgt = _make_combatant("t2", side="monsters", bab=1)
# RNG: 4 swings, each = 1 attack d20 (15) + 1 damage roll (3); target
# survives 4 x 3 = 12 damage (hp_max 20) so all iteratives resolve.
queue = [15, 3] * 4
engine, a, t = _make_engine(queue, atk, tgt)
engine._full_attack(a, t)
swings = _swing_lines(engine, "t2")
assert len(swings) == 4, f"manufactured weapon should swing 4x, got {len(swings)}: {swings}"
def test_secondary_natural_attack_penalty() -> None:
"""Secondary natural attack has -5 penalty on attack bonus.
PF1e (CRB "Natural Attacks"): secondary natural attacks are at
base attack bonus -5.
"""
atk = _make_combatant("a3", side="players", bab=1, attack_bonus=10,
natural=True, secondary=True)
tgt = _make_combatant("t3", side="monsters", bab=1)
# RNG: [d20=15] attack -> 10 - 5 = 5; total = 15 + 5 = 20 >= AC 15 -> hit; damage 1d6=4
engine, a, t = _make_engine([15, 4], atk, tgt)
engine._full_attack(a, t)
swings = _swing_lines(engine, "t3")
assert len(swings) == 1
# base_bonus is 5 (10 - 5 secondary penalty)
assert "d20=15+5=20" in swings[0]
def test_multiattack_feat_reduces_secondary_penalty() -> None:
"""Multiattack feat reduces secondary natural penalty from -5 to -2.
PF1e (CRB "Multiattack"): secondary natural attacks are at
base attack bonus -2 (instead of -5).
"""
atk = _make_combatant("a4", side="players", bab=1, attack_bonus=10,
natural=True, secondary=True,
features=("Multiattack",))
tgt = _make_combatant("t4", side="monsters", bab=1)
# RNG: [d20=15] attack -> 10 - 2 = 8; total = 15 + 8 = 23 >= AC 15 -> hit; damage 1d6=4
engine, a, t = _make_engine([15, 4], atk, tgt)
engine._full_attack(a, t)
swings = _swing_lines(engine, "t4")
assert len(swings) == 1
# base_bonus is 8 (10 - 2 Multiattack)
assert "d20=15+8=23" in swings[0]