fix(rules): scale Power Attack for two-handed weapons and apply its CMB penalty
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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