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 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 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. 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. `immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc. 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, …) de CMB ; le don « Improved <Maneuver> » correspondant (Improved Trip, …)
annule cette provocation ; si l'AoO fait passer l'attaquant sous 0 pv, la 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 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 trip → `prone` ; disarm → `disarmed` (−4 attaque) ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; cible (direction sélectionnable via `Action.direction`) ;
grapple → `grappled`/`pinned` ; escape → **une** sauvegarde (bonus statique de la 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é 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 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 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 kind `activate_power_attack` qui pose `state.power_attack`). Conformance
modificateurs sont collectés par 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 `_stat_modifiers` avec filtrage par arme (`weapon_filter`). Le champ
`features` de `Combatant` contient les aptitudes permanentes ; le chargeur `features` de `Combatant` contient les aptitudes permanentes ; le chargeur
(`loaders/combatant.py`) résout les noms de dons des fiches vers des (`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": [{ "formula": "2d4", "types": ["slashing"] }],
"damage_bonus": 4, "damage_bonus": 4,
"crit_range": 18, "crit_range": 18,
"crit_mult": 2 "crit_mult": 2,
"two_handed": true
}, },
{ {
"name": "javelin", "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 sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
) )
damage += resolve_modifiers(dmg_mods) 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: if crit:
damage *= weapon.crit_mult damage *= weapon.crit_mult
damage += sneak_total damage += sneak_total
@@ -3607,7 +3614,13 @@ class CombatEngine:
if not state.active: if not state.active:
return 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) target_cmd = self._maneuver_cmd(target)
roll = self._rng.d20() roll = self._rng.d20()
total = roll + attacker_cmb 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, the same entry twice. `reach_ft` applies to melee/touch kinds,
`range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch `range_increment_ft` to ranged/touch kinds. `firearm` marks a ranged-touch
weapon (gun) whose shots resolve against touch AC within the first range 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 id: str
@@ -254,6 +257,7 @@ class AttackSpec(_FrozenModel):
range_increment_ft: int | None = None range_increment_ft: int | None = None
count: int = Field(default=1, ge=1) count: int = Field(default=1, ge=1)
firearm: bool = False firearm: bool = False
two_handed: bool = False
class DamageReduction(_FrozenModel): 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