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
+52 -49
View File
@@ -1,53 +1,56 @@
{ {
"name": "Celestial Wolf", "name": "Celestial Wolf",
"level": 2, "level": 2,
"size": "Medium", "size": "Medium",
"abilities": { "abilities": {
"str_score": 13, "str_score": 13,
"dex_score": 15, "dex_score": 15,
"con_score": 12, "con_score": 12,
"int_score": 2, "int_score": 2,
"wis_score": 12, "wis_score": 12,
"cha_score": 6 "cha_score": 6
}, },
"hp_max": 15, "hp_max": 15,
"ac": { "ac": {
"total": 14, "total": 14,
"touch": 13, "touch": 13,
"flat_footed": 12 "flat_footed": 12
}, },
"bab": 1, "bab": 1,
"initiative_mod": 2, "initiative_mod": 2,
"speed_land_ft": 50, "speed_land_ft": 50,
"saves": { "saves": {
"fort": 4, "fort": 4,
"ref": 5, "ref": 5,
"will": 2 "will": 2
}, },
"attacks": [ "attacks": [
{
"id": "celestial_wolf:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{ {
"id": "celestial_wolf:bite", "formula": "1d6+1",
"name": "Bite", "types": [
"kind": "melee", "piercing"
"attack_bonus": 3, ]
"damage": [
{
"formula": "1d6+1",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
} }
], ],
"source": "summon monster II", "damage_bonus": 1,
"features": [], "crit_range": 20,
"spells": [], "crit_mult": 2,
"casting_ability": "wis", "reach_ft": 5,
"resources": {}, "count": 1,
"metamagic_feats": [], "natural": true
"skills": {} }
],
"source": "summon monster II",
"features": [],
"spells": [],
"casting_ability": "wis",
"resources": {},
"metamagic_feats": [],
"skills": {}
} }
+67 -43
View File
@@ -1,46 +1,70 @@
{ {
"name": "Giant Belostomatid", "name": "Giant Belostomatid",
"level": 3, "level": 3,
"size": "Large", "size": "Large",
"cr": "3", "cr": "3",
"xp": 800, "xp": 800,
"source": "Bestiary > Giant Belostomatid", "source": "Bestiary > Giant Belostomatid",
"notes": "Digestive enzyme (DC 15 Fort or paralyzed 1d4 rounds + 1d2 Con damage/round) and grab on claws not modeled in Phase 0; Int - (mindless) modeled as 0; immune to mind-affecting not modeled; swim 40 ft / fly 20 ft (poor) not modeled, land speed only.", "notes": "Digestive enzyme (DC 15 Fort or paralyzed 1d4 rounds + 1d2 Con damage/round) and grab on claws not modeled in Phase 0; Int - (mindless) modeled as 0; immune to mind-affecting not modeled; swim 40 ft / fly 20 ft (poor) not modeled, land speed only.",
"abilities": { "abilities": {
"str_score": 15, "str_score": 15,
"dex_score": 13, "dex_score": 13,
"con_score": 16, "con_score": 16,
"int_score": 0, "int_score": 0,
"wis_score": 11, "wis_score": 11,
"cha_score": 2 "cha_score": 2
}, },
"hp_max": 30, "hp_max": 30,
"ac": { "total": 15, "touch": 10, "flat_footed": 14 }, "ac": {
"bab": 3, "total": 15,
"initiative_mod": 1, "touch": 10,
"speed_land_ft": 20, "flat_footed": 14
"saves": { "fort": 7, "ref": 2, "will": 1 }, },
"attacks": [ "bab": 3,
"initiative_mod": 1,
"speed_land_ft": 20,
"saves": {
"fort": 7,
"ref": 2,
"will": 1
},
"attacks": [
{
"name": "bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{ {
"name": "bite", "formula": "1d6",
"kind": "melee", "types": [
"attack_bonus": 4, "piercing"
"damage": [{ "formula": "1d6", "types": ["piercing"] }], ]
"damage_bonus": 2,
"crit_mult": 2,
"reach_ft": 10
},
{
"name": "claw",
"kind": "melee",
"attack_bonus": 4,
"damage": [{ "formula": "1d4", "types": ["slashing"] }],
"damage_bonus": 2,
"crit_mult": 2,
"count": 2,
"reach_ft": 10
} }
], ],
"cmb": 6, "damage_bonus": 2,
"cmd": 17 "crit_mult": 2,
} "reach_ft": 10,
"natural": true
},
{
"name": "claw",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 2,
"crit_mult": 2,
"count": 2,
"reach_ft": 10,
"natural": true
}
],
"cmb": 6,
"cmd": 17
}
+78 -72
View File
@@ -1,76 +1,82 @@
{ {
"name": "Harvie", "name": "Harvie",
"level": 2, "level": 2,
"size": "Small", "size": "Small",
"abilities": { "abilities": {
"str_score": 10, "str_score": 10,
"dex_score": 15, "dex_score": 15,
"con_score": 12, "con_score": 12,
"int_score": 2, "int_score": 2,
"wis_score": 12, "wis_score": 12,
"cha_score": 6 "cha_score": 6
}, },
"hp_max": 15, "hp_max": 15,
"ac": { "ac": {
"total": 14, "total": 14,
"touch": 13, "touch": 13,
"flat_footed": 12 "flat_footed": 12
}, },
"bab": 1, "bab": 1,
"initiative_mod": 2, "initiative_mod": 2,
"speed_land_ft": 10, "speed_land_ft": 10,
"speed_fly_ft": 80, "speed_fly_ft": 80,
"saves": { "saves": {
"fort": 4, "fort": 4,
"ref": 5, "ref": 5,
"will": 2 "will": 2
}, },
"attacks": [ "attacks": [
{
"id": "harvie:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{ {
"id": "harvie:bite", "formula": "1d4",
"name": "Bite", "types": [
"kind": "melee", "piercing"
"attack_bonus": 3, ]
"damage": [
{
"formula": "1d4",
"types": ["piercing"]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"id": "harvie:claw",
"name": "Claw",
"kind": "melee",
"attack_bonus": 3,
"damage": [
{
"formula": "1d3",
"types": ["slashing"]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
} }
], ],
"source": "fiches_personnages/harvie_sheet.json", "damage_bonus": 0,
"features": [ "crit_range": 20,
"Friendly Face", "crit_mult": 2,
"Fey-touched", "reach_ft": 5,
"Animal (race)" "count": 1,
], "natural": true
"spells": [], },
"casting_ability": "wis", {
"spell_slots": {}, "id": "harvie:claw",
"resources": {}, "name": "Claw",
"metamagic_feats": [], "kind": "melee",
"skills": {} "attack_bonus": 3,
"damage": [
{
"formula": "1d3",
"types": [
"slashing"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
}
],
"source": "fiches_personnages/harvie_sheet.json",
"features": [
"Friendly Face",
"Fey-touched",
"Animal (race)"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
} }
+58 -55
View File
@@ -1,59 +1,62 @@
{ {
"name": "Ierlieth", "name": "Ierlieth",
"level": 4, "level": 4,
"size": "Tiny", "size": "Tiny",
"abilities": { "abilities": {
"str_score": 8, "str_score": 8,
"dex_score": 17, "dex_score": 17,
"con_score": 11, "con_score": 11,
"int_score": 10, "int_score": 10,
"wis_score": 10, "wis_score": 10,
"cha_score": 9 "cha_score": 9
}, },
"hp_max": 29, "hp_max": 29,
"ac": { "ac": {
"total": 17, "total": 17,
"touch": 15, "touch": 15,
"flat_footed": 14 "flat_footed": 14
}, },
"bab": 3, "bab": 3,
"initiative_mod": 3, "initiative_mod": 3,
"speed_land_ft": 15, "speed_land_ft": 15,
"speed_fly_ft": 40, "speed_fly_ft": 40,
"saves": { "saves": {
"fort": 3, "fort": 3,
"ref": 6, "ref": 6,
"will": 3 "will": 3
}, },
"attacks": [ "attacks": [
{
"id": "ierlieth:bite",
"name": "Bite",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{ {
"id": "ierlieth:bite", "formula": "1d4",
"name": "Bite", "types": [
"kind": "melee", "piercing"
"attack_bonus": 4, ]
"damage": [
{
"formula": "1d4",
"types": ["piercing"]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
} }
], ],
"source": "fiches_personnages/ierlieth_sheet.json", "damage_bonus": 1,
"features": [ "crit_range": 20,
"Drake (race)", "crit_mult": 2,
"Toughness", "reach_ft": 5,
"Power Attack" "count": 1,
], "natural": true
"spells": [], }
"casting_ability": "wis", ],
"spell_slots": {}, "source": "fiches_personnages/ierlieth_sheet.json",
"resources": {}, "features": [
"metamagic_feats": [], "Drake (race)",
"skills": {} "Toughness",
"Power Attack"
],
"spells": [],
"casting_ability": "wis",
"spell_slots": {},
"resources": {},
"metamagic_feats": [],
"skills": {}
} }
+164 -162
View File
@@ -1,166 +1,168 @@
{ {
"name": "Misty", "name": "Misty",
"level": 4, "level": 4,
"size": "Medium", "size": "Medium",
"abilities": { "abilities": {
"str_score": 12, "str_score": 12,
"dex_score": 19, "dex_score": 19,
"con_score": 14, "con_score": 14,
"int_score": 16, "int_score": 16,
"wis_score": 7, "wis_score": 7,
"cha_score": 18 "cha_score": 18
}, },
"hp_max": 31, "hp_max": 31,
"ac": { "ac": {
"total": 20, "total": 20,
"touch": 14, "touch": 14,
"flat_footed": 16 "flat_footed": 16
}, },
"bab": 3, "bab": 3,
"initiative_mod": 4, "initiative_mod": 4,
"speed_land_ft": 30, "speed_land_ft": 30,
"saves": { "saves": {
"fort": 7, "fort": 7,
"ref": 9, "ref": 9,
"will": 3 "will": 3
}, },
"casting_ability": "cha", "casting_ability": "cha",
"spell_slots": { "spell_slots": {
"1": 4, "1": 4,
"2": 3 "2": 3
}, },
"resources": { "resources": {
"bardic_rounds": 12 "bardic_rounds": 12
}, },
"attacks": [ "attacks": [
{
"name": "MWK Rapier",
"kind": "melee",
"attack_bonus": 8,
"damage": [
{ {
"name": "MWK Rapier", "formula": "1d6",
"kind": "melee", "types": [
"attack_bonus": 8, "piercing"
"damage": [ ]
{
"formula": "1d6",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 18,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Dagger",
"kind": "melee",
"attack_bonus": 7,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 19,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Talons (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Bite (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
},
{
"name": "Unarmed Strike",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d3",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
} }
], ],
"source": "fiches_personnages/misty_sheet.json", "damage_bonus": 1,
"features": [ "crit_range": 18,
"Harmonic Spell", "crit_mult": 2,
"Weapon Finesse", "reach_ft": 5,
"Axe to Grind (combat)", "count": 1
"Spell Vulnerability (divination; drawback)", },
"Born Damned (race: tiefling)", {
"Gifted Adept (dispel magic; magic)", "name": "Dagger",
"Prehensile Tail (tiefling daemon-spawn)", "kind": "melee",
"Scaled Skin (electricity) (tiefling daemon-spawn)", "attack_bonus": 7,
"Maw or Claw (Bite) (tiefling daemon-spawn)" "damage": [
], {
"spells": [ "formula": "1d4",
"Prestidigitation", "types": [
"Oath of Anonymity", "piercing"
"Detect Magic", ]
"Read Magic", }
"Disrupt Undead", ],
"Ray of Frost", "damage_bonus": 1,
"Summon Instrument", "crit_range": 19,
"Mage Hand", "crit_mult": 2,
"Open/Close", "reach_ft": 5,
"Arcane Mark", "count": 1,
"Acid Splash", "range_increment_ft": 10
"Grasp", },
"Shield", {
"Cure Light Wounds", "name": "Talons (attaque naturelle)",
"Color Spray", "kind": "melee",
"Saving Finale", "attack_bonus": 4,
"Vanish", "damage": [
"Shocking Grasp", {
"Burning Hands", "formula": "1d4",
"Alarm", "types": [
"Scorching Ray", "slashing"
"Bladed Dash", ]
"Mirror Image", }
"Heroism" ],
] "damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
},
{
"name": "Bite (attaque naturelle)",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d4",
"types": [
"piercing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
},
{
"name": "Unarmed Strike",
"kind": "melee",
"attack_bonus": 4,
"damage": [
{
"formula": "1d3",
"types": [
"bludgeoning"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
}
],
"source": "fiches_personnages/misty_sheet.json",
"features": [
"Harmonic Spell",
"Weapon Finesse",
"Axe to Grind (combat)",
"Spell Vulnerability (divination; drawback)",
"Born Damned (race: tiefling)",
"Gifted Adept (dispel magic; magic)",
"Prehensile Tail (tiefling daemon-spawn)",
"Scaled Skin (electricity) (tiefling daemon-spawn)",
"Maw or Claw (Bite) (tiefling daemon-spawn)"
],
"spells": [
"Prestidigitation",
"Oath of Anonymity",
"Detect Magic",
"Read Magic",
"Disrupt Undead",
"Ray of Frost",
"Summon Instrument",
"Mage Hand",
"Open/Close",
"Arcane Mark",
"Acid Splash",
"Grasp",
"Shield",
"Cure Light Wounds",
"Color Spray",
"Saving Finale",
"Vanish",
"Shocking Grasp",
"Burning Hands",
"Alarm",
"Scorching Ray",
"Bladed Dash",
"Mirror Image",
"Heroism"
]
} }
+123 -122
View File
@@ -1,126 +1,127 @@
{ {
"name": "Oni Triumvir", "name": "Oni Triumvir",
"level": 4, "level": 4,
"size": "Medium", "size": "Medium",
"abilities": { "abilities": {
"str_score": 12, "str_score": 12,
"dex_score": 14, "dex_score": 14,
"con_score": 14, "con_score": 14,
"int_score": 12, "int_score": 12,
"wis_score": 18, "wis_score": 18,
"cha_score": 16 "cha_score": 16
}, },
"hp_max": 36, "hp_max": 36,
"ac": { "ac": {
"total": 19, "total": 19,
"touch": 18, "touch": 18,
"flat_footed": 17 "flat_footed": 17
}, },
"bab": 4, "bab": 4,
"initiative_mod": 2, "initiative_mod": 2,
"speed_land_ft": 30, "speed_land_ft": 30,
"saves": { "saves": {
"fort": 7, "fort": 7,
"ref": 7, "ref": 7,
"will": 9 "will": 9
}, },
"casting_ability": "wis", "casting_ability": "wis",
"spell_slots": { "spell_slots": {
"1": 4, "1": 4,
"2": 3 "2": 3
}, },
"resources": { "resources": {
"channel_energy": 6 "channel_energy": 6
}, },
"attacks": [ "attacks": [
{
"name": "Longbow",
"kind": "ranged",
"attack_bonus": 6,
"damage": [
{ {
"name": "Longbow", "formula": "1d8",
"kind": "ranged", "types": [
"attack_bonus": 6, "piercing"
"damage": [ ]
{
"formula": "1d8",
"types": [
"piercing"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 3,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 100
},
{
"name": "Alchemist's Fire",
"kind": "touch",
"attack_bonus": 6,
"damage": [
{
"formula": "1d6",
"types": [
"fire"
]
}
],
"damage_bonus": 0,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"range_increment_ft": 10
},
{
"name": "Attaques naturelles (Shifter Claws / formes fées)",
"kind": "melee",
"attack_bonus": 5,
"damage": [
{
"formula": "1d6",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1
} }
], ],
"source": "fiches_personnages/oni_sheet.json", "damage_bonus": 0,
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).", "crit_range": 20,
"features": [ "crit_mult": 3,
"Summon Good Monster", "reach_ft": 5,
"Sacred Summons", "count": 1,
"Selective Channeling", "range_increment_ft": 100
"Extremely Fashionable", },
"Exalted of the Society (Cleric, Pathfinder Society)", {
"Bitter", "name": "Alchemist's Fire",
"Ambitious" "kind": "touch",
], "attack_bonus": 6,
"spells": [ "damage": [
"Guidance", {
"Detect Magic", "formula": "1d6",
"Stabilize", "types": [
"Read Magic", "fire"
"Detect Poison", ]
"Purify Food and Drink", }
"Summon Monster I", ],
"Shield of Faith", "damage_bonus": 0,
"Forbid Action", "crit_range": 20,
"Cure Light Wounds", "crit_mult": 2,
"Hidden Diplomacy", "reach_ft": 5,
"Protection from Evil", "count": 1,
"Bless", "range_increment_ft": 10
"Summon Monster II", },
"Cure Moderate Wounds", {
"Sound Burst", "name": "Attaques naturelles (Shifter Claws / formes fées)",
"Suppress Charms and Compulsions", "kind": "melee",
"Delay Poison", "attack_bonus": 5,
"Ironskin", "damage": [
"Restoration, Lesser" {
] "formula": "1d6",
"types": [
"slashing"
]
}
],
"damage_bonus": 1,
"crit_range": 20,
"crit_mult": 2,
"reach_ft": 5,
"count": 1,
"natural": true
}
],
"source": "fiches_personnages/oni_sheet.json",
"notes": "Armes placeholder Foundry sans dégâts encodés omises : 'une Pioche' (non maîtrisée) et 'Weapon' (à remplacer par la vraie arme).",
"features": [
"Summon Good Monster",
"Sacred Summons",
"Selective Channeling",
"Extremely Fashionable",
"Exalted of the Society (Cleric, Pathfinder Society)",
"Bitter",
"Ambitious"
],
"spells": [
"Guidance",
"Detect Magic",
"Stabilize",
"Read Magic",
"Detect Poison",
"Purify Food and Drink",
"Summon Monster I",
"Shield of Faith",
"Forbid Action",
"Cure Light Wounds",
"Hidden Diplomacy",
"Protection from Evil",
"Bless",
"Summon Monster II",
"Cure Moderate Wounds",
"Sound Burst",
"Suppress Charms and Compulsions",
"Delay Poison",
"Ironskin",
"Restoration, Lesser"
]
} }
+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),
+8 -2
View File
@@ -242,14 +242,18 @@ class AttackSpec(_FrozenModel):
increment and against full AC beyond it. `two_handed` marks a weapon swung 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 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 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"): 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 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 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). 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]