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 :
|
||||
|
||||
- 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
|
||||
de confirmation est un jet d'attaque) ; la RD s'applique après
|
||||
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
|
||||
natural weapons in a single full attack are not modeled.
|
||||
- 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
|
||||
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
|
||||
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
|
||||
)
|
||||
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)
|
||||
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
||||
# 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)
|
||||
if crit:
|
||||
damage *= weapon.crit_mult
|
||||
damage += sneak_total
|
||||
damage += sneak_total + energy_dice
|
||||
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
|
||||
# energy damage dealt along with an attack" — DR reduces only the
|
||||
# non-energy share of the damage (physical dice, flat bonuses and
|
||||
# precision dice); energy components are cut solely by energy
|
||||
# resistance/immunity in the per-component step below.
|
||||
mult = weapon.crit_mult if crit else 1
|
||||
# resistance/immunity in the per-component step below. The energy
|
||||
# share is its 1x dice: special-ability dice never ride the crit
|
||||
# multiplier (CRB Critical Hits exception).
|
||||
energy_share = sum(
|
||||
roll * mult
|
||||
roll
|
||||
for comp, roll in zip(weapon.damage, rolls, strict=True)
|
||||
if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
|
||||
)
|
||||
physical = max(0, damage - energy_share)
|
||||
damage = self._apply_dr(physical, weapon, target) + energy_share
|
||||
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:
|
||||
@@ -2739,7 +2753,6 @@ class CombatEngine:
|
||||
weapon: AttackSpec,
|
||||
rolls: list[int],
|
||||
*,
|
||||
crit: bool,
|
||||
target: CombatantState,
|
||||
) -> int:
|
||||
"""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
|
||||
(damage_bonus, ability/damage modifiers, Power Attack) and the
|
||||
untyped sneak precision dice are never eaten by energy resistance.
|
||||
Each component's share of the final damage is its own roll times
|
||||
the crit multiplier (engine convention: every damage die is
|
||||
multiplied on a crit) and its reduction is capped at that share.
|
||||
Each component's share of the final damage is its own roll at 1x:
|
||||
energy (weapon special ability) dice are never multiplied on a crit
|
||||
(CRB Critical Hits exception), so no crit factor rides this step.
|
||||
A component carrying several energy types uses the largest matching
|
||||
resistance (documented simplification: one die roll cannot be split
|
||||
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
|
||||
old lumped-total path.
|
||||
"""
|
||||
mult = weapon.crit_mult if crit else 1
|
||||
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)
|
||||
|
||||
def _energy_reduction(
|
||||
|
||||
@@ -125,6 +125,23 @@ def test_fire_immunity_negates_only_fire_component() -> None:
|
||||
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:
|
||||
"""(c) a physical-only weapon vs fire resistance is untouched."""
|
||||
weapon = _attack("sword", (("1d6", ("slashing",)),))
|
||||
|
||||
Reference in New Issue
Block a user