"""B16 PF1e conformance: Power Attack two-handed scaling + CMB penalty. CRB "Power Attack": a -X penalty on all melee attack rolls AND combat maneuver checks buys +2X melee damage; that damage bonus is increased by half (+3X) with a two-handed weapon, a one-handed weapon used in two hands, or a primary natural weapon that adds 1.5x Strength, and halved (+1X) with an off-hand / secondary natural weapon (NOT modeled — no two-weapon fighting in the engine). Sections: (a) two-handed Power Attack deals +3X damage (b) one-handed Power Attack still deals +2X damage (unchanged) (c) the Power Attack -X penalty reduces the combat maneuver check (CMB) (d) AttackSpec.two_handed validates True and defaults False (e) the orc falchion loads with two_handed=True """ from __future__ import annotations from pathlib import Path from pf1e_simulator.abilities import AbilitySpec from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.loaders.combatant import load_combatant from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng MONSTERS_DIR = Path(__file__).resolve().parents[1] / "data" / "monsters" def _sword(cid: str, *, two_handed: bool = False) -> AttackSpec: return AttackSpec( id=f"{cid}-sword", name="longsword", kind="melee", attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d6"), types=["slashing"])], two_handed=two_handed, ) def _combatant( cid: str, *, bab: int = 5, ac: int = 10, attacks: tuple[AttackSpec, ...] = (), features: tuple[str, ...] = (), cmd: int | None = None, ) -> Combatant: return Combatant( id=cid, name=cid, level=1, size="Medium", creature_type="living", abilities=AbilityScores( str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10, cha_score=10, ), hp_max=100, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=bab, initiative_mod=0, speed_land_ft=30, saves=Saves(fort=0, ref=0, will=0), attacks=list(attacks) or [_sword(cid)], features=[AbilitySpec(name=f, category="feat") for f in features], cmd=cmd, ) def _engine( combatants: list[Combatant], positions: list[tuple[int, int]], sides: list[str], rng: list[int], ) -> CombatEngine: states = [ CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max) for c, pos, side in zip(combatants, positions, sides, strict=True) ] legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) grid = Grid.from_spec(spec) return CombatEngine(ScriptedRng(rng), grid, states) # ── (a)+(b): two-handed Power Attack deals +3X, one-handed +2X ─────────────── def test_two_handed_power_attack_deals_3x_damage() -> None: atk = _combatant( "atk", bab=5, attacks=(_sword("atk", two_handed=True),), features=("Power Attack",), ) tgt = _combatant("tgt") eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4]) a = eng._states[0] a.power_attack = True # bab 5 -> X = 5//4 + 1 = 2. Attack roll 15, weapon 1d6 = 4. result = eng.resolve_attack(a, eng._states[1], _sword("atk", two_handed=True)) assert result.hit is True # 4 (weapon) + 3*2 (two-handed Power Attack) = 10. assert result.damage == 10 def test_one_handed_power_attack_deals_2x_damage() -> None: atk = _combatant("atk", bab=5, features=("Power Attack",)) tgt = _combatant("tgt") eng = _engine([atk, tgt], [(0, 0), (0, 1)], ["players", "monsters"], [15, 4]) a = eng._states[0] a.power_attack = True result = eng.resolve_attack(a, eng._states[1], _sword("atk")) assert result.hit is True # 4 (weapon) + 2*2 (one-handed Power Attack) = 8 — unchanged. assert result.damage == 8 # ── (c): the Power Attack penalty reduces the CMB check ────────────────────── def test_power_attack_penalty_reduces_cmb_check() -> None: # bab 5 -> CMB 5, X = 2. d20 = 10, CMD 14: without the penalty 15 >= 14 # (trip lands); with the -2 Power Attack penalty 13 < 14 (trip fails). a = _combatant("a", bab=5, features=("Power Attack", "Improved Trip")) tgt = _combatant("tgt", cmd=14) eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [10]) s = eng._states[0] s.power_attack = True eng._execute_maneuver(s, eng._states[1], "trip") assert not any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) assert any("d20=10+3=13" in line for line in eng._transcript) def test_trip_lands_without_power_attack_penalty() -> None: # Control: same geometry, Power Attack inactive -> 15 >= 14 succeeds. a = _combatant("a", bab=5, features=("Power Attack", "Improved Trip")) tgt = _combatant("tgt", cmd=14) eng = _engine([a, tgt], [(1, 1), (1, 2)], ["players", "monsters"], [10]) eng._execute_maneuver(eng._states[0], eng._states[1], "trip") assert any( ac.condition.name == "prone" for ac in eng._states[1].active_conditions ) # ── (d): AttackSpec.two_handed validates and defaults False ────────────────── def test_attack_spec_two_handed_validates_and_defaults() -> None: assert _sword("x", two_handed=True).two_handed is True assert _sword("x").two_handed is False # ── (e): the orc falchion loads with two_handed=True ───────────────────────── def test_orc_falchion_loads_two_handed() -> None: orc = load_combatant(MONSTERS_DIR / "orc.json") falchion = next(a for a in orc.attacks if a.name == "falchion") assert falchion.two_handed is True javelin = next(a for a in orc.attacks if a.name == "javelin") assert javelin.two_handed is False