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
+17 -4
View File
@@ -241,7 +241,9 @@ Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
`spell_resistance` (entier), `resistances` (dict type → montant) et
Le booléen `two_handed` (optionnel, défaut `false`) marque une arme tenue à
deux mains : Power Attack donne alors +3X (et non +2X) aux dégâts (conformance
B16). `spell_resistance` (entier), `resistances` (dict type → montant) et
`immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc.
@@ -752,7 +754,11 @@ Règles modélisées :
de CMB ; le don « Improved <Maneuver> » correspondant (Improved Trip, …)
annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la
manœuvre est perdue. L'AoO de manœuvre consomme le budget d'AoO du round de
la cible (Combat Reflexes inclus).
la cible (Combat Reflexes inclus). Conformance B16 (CRB « Power Attack ») :
la pénalité −X de Power Attack actif s'applique **au jet de CMB** de la
manœuvre (X = BAB//4 + 1) — la pénalité couvre « les jets d'attaque de mêlée
et les jets de manœuvres de combat » ; l'escape par compétence n'est pas
pénalisé (un test de compétence n'est pas un jet de manœuvre).
trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la
@@ -967,8 +973,15 @@ la lecture mais non appliquée dans la résolution.
catégorie feat/class/racial/trait) et fabriques de dons (Weapon Focus ciblé
par arme, Toughness lié au niveau, Power Attack actif −X attaque / +2X
dégâts, X = BAB//4 + 1, mêlée uniquement, activé comme action libre via le
kind `activate_power_attack` qui pose `state.power_attack`). Les
modificateurs sont collectés par
kind `activate_power_attack` qui pose `state.power_attack`). Conformance
B16 (CRB « Power Attack ») : le bonus de dégâts passe à **+3X (×1,5)** pour
une arme marquée `two_handed: true` dans le JSON de l'attaque (arme à deux
mains, arme à une main tenue à deux mains, ou attaque naturelle primaire à
×1,5 de Force) ; la pénalité −X s'applique aussi **aux jets de manœuvre de
combat** (CMB, hors escape par compétence) ; la division par deux (×0,5,
+1X) pour une arme tenue à la main secondaire ou une attaque naturelle
secondaire n'est **pas modélisée** (le moteur ne gère pas le combat à deux
armes). Les modificateurs sont collectés par
`_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des
+2 -1
View File
@@ -28,7 +28,8 @@
"damage": [{ "formula": "2d4", "types": ["slashing"] }],
"damage_bonus": 4,
"crit_range": 18,
"crit_mult": 2
"crit_mult": 2,
"two_handed": true
},
{
"name": "javelin",
+15 -2
View File
@@ -2260,7 +2260,14 @@ class CombatEngine:
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
)
damage += resolve_modifiers(dmg_mods)
damage += pa_amt * 2 if weapon.kind == "melee" else 0
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
# bonus increased by half) with a two-handed weapon, a one-handed
# weapon used in two hands, or a primary natural with 1.5x Strength
# (modeled via the weapon's `two_handed` flag). The off-hand /
# secondary-natural halving (-50%, +1X) is NOT modeled: two-weapon
# fighting is not in the engine (documented simplification).
if weapon.kind == "melee":
damage += pa_amt * (3 if weapon.two_handed else 2)
if crit:
damage *= weapon.crit_mult
damage += sneak_total
@@ -3607,7 +3614,13 @@ class CombatEngine:
if not state.active:
return
attacker_cmb = self._maneuver_cmb(state)
# PF1e (CRB Power Attack): the -X penalty applies to all melee attack
# rolls AND combat maneuver checks while the feat is active. The
# escape option that substitutes an Acrobatics/Escape Artist skill
# check is not penalized (a skill check is not a CMB check) — see
# _maneuver_escape.
pa_amt = self._power_attack_amt(state)
attacker_cmb = self._maneuver_cmb(state) - pa_amt
target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20()
total = roll + attacker_cmb
+5 -1
View File
@@ -239,7 +239,10 @@ class AttackSpec(_FrozenModel):
the same entry twice. `reach_ft` applies to melee/touch kinds,
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
weapon (gun) whose shots resolve against touch AC within the first range
increment and against full AC beyond it.
increment and against full AC beyond it. `two_handed` marks a weapon swung
in two hands (two-handed weapon, one-handed weapon used two hands, or a
primary natural with 1.5x Strength) — PF1e Power Attack rolls +50% damage
with such a weapon (CRB "Power Attack").
"""
id: str
@@ -254,6 +257,7 @@ class AttackSpec(_FrozenModel):
range_increment_ft: int | None = None
count: int = Field(default=1, ge=1)
firearm: bool = False
two_handed: bool = False
class DamageReduction(_FrozenModel):
+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