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:
2026-09-12 11:21:35 +02:00
parent a9b33e4055
commit a4f7f4e5de
3 changed files with 46 additions and 14 deletions
+4 -1
View File
@@ -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
+25 -13
View File
@@ -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(
+17
View File
@@ -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",)),))