diff --git a/README.md b/README.md index e19a580..692ce4d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 93c7180..8392dbf 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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( diff --git a/tests/test_conformance_energy.py b/tests/test_conformance_energy.py index 2b40484..cc522f0 100644 --- a/tests/test_conformance_energy.py +++ b/tests/test_conformance_energy.py @@ -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",)),))