175 lines
6.2 KiB
Python
175 lines
6.2 KiB
Python
"""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
|