From 1960b8f655aa118ffcbc47ca5a57ea247a9c5c76 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 00:19:06 +0200 Subject: [PATCH] fix(rules): scale Power Attack for two-handed weapons and apply its CMB penalty --- README.md | 21 ++++- data/monsters/orc.json | 3 +- src/pf1e_simulator/combat.py | 17 +++- src/pf1e_simulator/models.py | 6 +- tests/test_conformance_pa.py | 174 +++++++++++++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 tests/test_conformance_pa.py diff --git a/README.md b/README.md index 2d1c040..1918437 100644 --- a/README.md +++ b/README.md @@ -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 » 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 diff --git a/data/monsters/orc.json b/data/monsters/orc.json index bd2febe..12e5e7e 100644 --- a/data/monsters/orc.json +++ b/data/monsters/orc.json @@ -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", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 9089866..6cd1550 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 1edce90..3beaca8 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -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): diff --git a/tests/test_conformance_pa.py b/tests/test_conformance_pa.py new file mode 100644 index 0000000..75527bb --- /dev/null +++ b/tests/test_conformance_pa.py @@ -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