fix(rules): scale Power Attack for two-handed weapons and apply its CMB penalty
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user