fix(rules): exclude weapon special ability dice from critical multiplication
PF1e (CRB Critical Hits): "additional damage dice from weapon special abilities (such as flaming) are not multiplied when you score a critical hit". _resolve_hit_damage now multiplies only the physical dice plus flat bonuses by crit_mult; energy-typed component dice are added at 1x (the energy share on the DR path and the per-component resistance step drop their crit factor). Sneak precision dice were already excluded. README and the module convention list updated. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -303,7 +303,10 @@ vers la fiche Foundry d'origine.
|
|||||||
Règles modélisées :
|
Règles modélisées :
|
||||||
|
|
||||||
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
|
- 1 naturel = échec automatique ; 20 naturel = touche + menace de critique.
|
||||||
- Critique : (dés + bonus) × multiplicateur ; le 20 naturel au jet de
|
- Critique : (dés physiques + bonus plat) × multiplicateur ; les dés
|
||||||
|
énergétiques des spécificités d'arme (flaming, etc.) et les dés de précision
|
||||||
|
ne sont PAS multipliés (exception CRB « Critical Hits ») ; le 20 naturel au
|
||||||
|
jet de
|
||||||
confirmation confirme automatiquement la critique (conformance P2 — le jet
|
confirmation confirme automatiquement la critique (conformance P2 — le jet
|
||||||
de confirmation est un jet d'attaque) ; la RD s'applique après
|
de confirmation est un jet d'attaque) ; la RD s'applique après
|
||||||
multiplication, un type de contournement annule la RD — une RD à plusieurs
|
multiplication, un type de contournement annule la RD — une RD à plusieurs
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ Phase 0 documented deviations from PF1e (conventions):
|
|||||||
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
|
``count == 1`` (natural multi-attacks do not gain iteratives). Multiple
|
||||||
natural weapons in a single full attack are not modeled.
|
natural weapons in a single full attack are not modeled.
|
||||||
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
- Natural 1 always misses; natural 20 always hits and threatens a crit.
|
||||||
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
|
- A confirmed crit multiplies the base damage (physical dice + flat bonus)
|
||||||
|
by crit_mult; energy weapon-special-ability dice (flaming etc.) and
|
||||||
|
precision dice are NOT multiplied (CRB Critical Hits exception).
|
||||||
- DR applies once, after crit multiplication, to the PHYSICAL portion of the
|
- DR applies once, after crit multiplication, to the PHYSICAL portion of the
|
||||||
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
|
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
|
||||||
dice) — energy damage dealt along with an attack is never reduced by DR
|
dice) — energy damage dealt along with an attack is never reduced by DR
|
||||||
@@ -2532,7 +2534,18 @@ class CombatEngine:
|
|||||||
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
)
|
)
|
||||||
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
||||||
damage = sum(rolls) + weapon.damage_bonus
|
# PF1e (CRB, Critical Hits): "Precision damage ... and additional
|
||||||
|
# damage dice from weapon special abilities (such as flaming) are not
|
||||||
|
# multiplied when you score a critical hit." Only the base physical
|
||||||
|
# dice ride the multiplier with the flat bonuses; energy-typed
|
||||||
|
# component dice (flaming/frost/shock/...) are added at 1x.
|
||||||
|
physical_dice = sum(
|
||||||
|
roll
|
||||||
|
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
||||||
|
if _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
||||||
|
)
|
||||||
|
energy_dice = sum(rolls) - physical_dice
|
||||||
|
damage = physical_dice + weapon.damage_bonus
|
||||||
damage += resolve_modifiers(dmg_mods)
|
damage += resolve_modifiers(dmg_mods)
|
||||||
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
||||||
# bonus increased by half) with a two-handed weapon, a one-handed
|
# bonus increased by half) with a two-handed weapon, a one-handed
|
||||||
@@ -2547,22 +2560,23 @@ class CombatEngine:
|
|||||||
damage += pa_amt * (3 if weapon.two_handed else 2)
|
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 + energy_dice
|
||||||
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
|
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
|
||||||
# energy damage dealt along with an attack" — DR reduces only the
|
# energy damage dealt along with an attack" — DR reduces only the
|
||||||
# non-energy share of the damage (physical dice, flat bonuses and
|
# non-energy share of the damage (physical dice, flat bonuses and
|
||||||
# precision dice); energy components are cut solely by energy
|
# precision dice); energy components are cut solely by energy
|
||||||
# resistance/immunity in the per-component step below.
|
# resistance/immunity in the per-component step below. The energy
|
||||||
mult = weapon.crit_mult if crit else 1
|
# share is its 1x dice: special-ability dice never ride the crit
|
||||||
|
# multiplier (CRB Critical Hits exception).
|
||||||
energy_share = sum(
|
energy_share = sum(
|
||||||
roll * mult
|
roll
|
||||||
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
||||||
if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
||||||
)
|
)
|
||||||
physical = max(0, damage - energy_share)
|
physical = max(0, damage - energy_share)
|
||||||
damage = self._apply_dr(physical, weapon, target) + energy_share
|
damage = self._apply_dr(physical, weapon, target) + energy_share
|
||||||
return crit, False, self._apply_energy_resistance_per_component(
|
return crit, False, self._apply_energy_resistance_per_component(
|
||||||
damage, weapon, rolls, crit=crit, target=target
|
damage, weapon, rolls, target=target
|
||||||
)
|
)
|
||||||
|
|
||||||
def _concealment_chance(self, target: CombatantState) -> int:
|
def _concealment_chance(self, target: CombatantState) -> int:
|
||||||
@@ -2739,7 +2753,6 @@ class CombatEngine:
|
|||||||
weapon: AttackSpec,
|
weapon: AttackSpec,
|
||||||
rolls: list[int],
|
rolls: list[int],
|
||||||
*,
|
*,
|
||||||
crit: bool,
|
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
|
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
|
||||||
@@ -2750,9 +2763,9 @@ class CombatEngine:
|
|||||||
loses only its fire portion; the slashing dice, the flat bonuses
|
loses only its fire portion; the slashing dice, the flat bonuses
|
||||||
(damage_bonus, ability/damage modifiers, Power Attack) and the
|
(damage_bonus, ability/damage modifiers, Power Attack) and the
|
||||||
untyped sneak precision dice are never eaten by energy resistance.
|
untyped sneak precision dice are never eaten by energy resistance.
|
||||||
Each component's share of the final damage is its own roll times
|
Each component's share of the final damage is its own roll at 1x:
|
||||||
the crit multiplier (engine convention: every damage die is
|
energy (weapon special ability) dice are never multiplied on a crit
|
||||||
multiplied on a crit) and its reduction is capped at that share.
|
(CRB Critical Hits exception), so no crit factor rides this step.
|
||||||
A component carrying several energy types uses the largest matching
|
A component carrying several energy types uses the largest matching
|
||||||
resistance (documented simplification: one die roll cannot be split
|
resistance (documented simplification: one die roll cannot be split
|
||||||
across the listed types). Physical-only components match nothing in
|
across the listed types). Physical-only components match nothing in
|
||||||
@@ -2762,9 +2775,8 @@ class CombatEngine:
|
|||||||
attack); single-component weapons keep the exact arithmetic of the
|
attack); single-component weapons keep the exact arithmetic of the
|
||||||
old lumped-total path.
|
old lumped-total path.
|
||||||
"""
|
"""
|
||||||
mult = weapon.crit_mult if crit else 1
|
|
||||||
for comp, roll in zip(weapon.damage, rolls, strict=True):
|
for comp, roll in zip(weapon.damage, rolls, strict=True):
|
||||||
damage -= self._energy_reduction(roll * mult, frozenset(comp.types), target)
|
damage -= self._energy_reduction(roll, frozenset(comp.types), target)
|
||||||
return max(0, damage)
|
return max(0, damage)
|
||||||
|
|
||||||
def _energy_reduction(
|
def _energy_reduction(
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ def test_fire_immunity_negates_only_fire_component() -> None:
|
|||||||
assert eng._states[1].hp == 94
|
assert eng._states[1].hp == 94
|
||||||
|
|
||||||
|
|
||||||
|
def test_crit_does_not_multiply_flaming_dice() -> None:
|
||||||
|
"""CRB Critical Hits: "additional damage dice from weapon special
|
||||||
|
abilities (such as flaming) are not multiplied when you score a critical
|
||||||
|
hit" — the slashing die rides the x2, the fire die stays at 1x."""
|
||||||
|
weapon = _attack("fiery-blade", _MIXED)
|
||||||
|
hero = _combatant("hero", attacks=(weapon,))
|
||||||
|
foe = _combatant("foe")
|
||||||
|
# d20=20 threatens, confirm 20 auto-crits, slashing 1d6=6, fire 1d6=4.
|
||||||
|
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
|
||||||
|
[20, 20, 6, 4])
|
||||||
|
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
|
||||||
|
assert result.crit is True
|
||||||
|
# 6x2 + 4 = 16 — NOT (6+4)x2 = 20 (the old multiply-everything convention).
|
||||||
|
assert result.damage == 16
|
||||||
|
assert eng._states[1].hp == 84
|
||||||
|
|
||||||
|
|
||||||
def test_physical_only_weapon_ignores_energy_resistance() -> None:
|
def test_physical_only_weapon_ignores_energy_resistance() -> None:
|
||||||
"""(c) a physical-only weapon vs fire resistance is untouched."""
|
"""(c) a physical-only weapon vs fire resistance is untouched."""
|
||||||
weapon = _attack("sword", (("1d6", ("slashing",)),))
|
weapon = _attack("sword", (("1d6", ("slashing",)),))
|
||||||
|
|||||||
Reference in New Issue
Block a user