"""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]