fix(rules): scale Power Attack for two-handed weapons and apply its CMB penalty

This commit is contained in:
2026-09-11 00:19:06 +02:00
parent 890b7959d4
commit 1960b8f655
5 changed files with 213 additions and 8 deletions
+174
View File
@@ -0,0 +1,174 @@
"""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