diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 19a0639..d57b4be 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -180,6 +180,7 @@ _CHARGE_AC_PENALTY = 2 # PF1e: -2 AC until start of next turn after charging _CHARGE_MIN_CELLS = 2 # PF1e: charge must move at least 10 ft (2 squares) _UP_CLOSE_RANGE_FT = 30 # PF1e: Up Close and Deadly works within 30 ft _GRAPPLED_CMB_PENALTY = 2 # PF1e: grappled/pinned -2 on non-grapple/escape CMB +_MINIMUM_HIT_DAMAGE = 1 # PF1e (CRB "Damage" - Minimum Damage): a hit deals >= 1 _DISARM_RETALIATION_MARGIN = 10 # PF1e SRD: disarm fails by 10 -> attacker drops # PF1e: Point-Blank Shot grants +1/+1 within 30 ft with ranged weapons _POINT_BLANK_RANGE_FT = 30 @@ -2981,9 +2982,15 @@ class CombatEngine: ) physical = max(0, damage - energy_share) damage = self._apply_dr(physical, weapon, target) + energy_share - return crit, False, self._apply_energy_resistance_per_component( + final = self._apply_energy_resistance_per_component( damage, weapon, rolls, target=target ) + # PF1e (CRB "Damage" — Minimum Damage): "If penalties reduce the + # damage result to less than 1, a hit still deals 1 point of + # nonlethal damage." The engine has no nonlethal damage track + # (documented approximation): the floor is applied as 1 point of + # normal damage on a landed hit. + return crit, False, max(_MINIMUM_HIT_DAMAGE, final) def _attack_cover_bonus( self, attacker: CombatantState, target: CombatantState, *, ranged: bool diff --git a/tests/test_combat.py b/tests/test_combat.py index 0c7d6a8..9b27bb5 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -200,15 +200,38 @@ def test_dr_reduces_damage() -> None: assert b.hp == 4 -def test_dr_floors_damage_at_zero() -> None: +def test_dr_floors_hit_at_one_minimum_damage() -> None: + # PF1e (CRB "Damage" - Minimum Damage): "If penalties reduce the damage + # result to less than 1, a hit still deals 1 point of nonlethal damage" + # (no nonlethal track: floored to 1 point of normal damage). a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b", dr=DamageReduction(amount=5)) a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) engine = make_engine([12, 3], [a, b]) result = engine.resolve_attack(a, b, a_spec.attacks[0]) - assert result.damage == 0 - assert b.hp == 6 + assert result.hit is True + assert result.damage == 1 + assert b.hp == 5 + + +def test_energy_resistance_floors_hit_at_one() -> None: + a_spec = make_combatant("a", attack_bonus=2) + b_spec = make_combatant("b").model_copy(update={"resistances": {"fire": 10}}) + fire = AttackSpec( + id="a-fire", + name="flame blade", + kind="melee", + attack_bonus=2, + damage=[DamageComponent(formula=parse_dice("1d6"), types=["fire"])], + ) + a = make_state(a_spec, "players", (1, 1)) + b = make_state(b_spec, "monsters", (1, 2)) + engine = make_engine([12, 5], [a, b]) + result = engine.resolve_attack(a, b, fire) + assert result.hit is True + assert result.damage == 1 + assert b.hp == 5 def test_dr_bypass_ignores_reduction() -> None: diff --git a/tests/test_conformance_energy.py b/tests/test_conformance_energy.py index cc522f0..3cdda81 100644 --- a/tests/test_conformance_energy.py +++ b/tests/test_conformance_energy.py @@ -251,12 +251,14 @@ def test_dr_spares_the_energy_component_of_a_mixed_attack() -> None: def test_physical_only_damage_vs_dr_unchanged() -> None: """(h) a physical-only weapon keeps the exact old DR arithmetic (regression - lock for the split): 1d8 = 3 vs DR 5 floors to 0.""" + lock for the split): 1d8 = 3 vs DR 5 reduces below 1 — the CRB "Damage" + Minimum Damage floor then leaves a landed hit dealing 1 (nonlethal is not + modeled: 1 point of normal damage).""" weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),)) hero = _combatant("hero", attacks=(weapon,)) foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)}) eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"], [15, 3]) result = eng.resolve_attack(eng._states[0], eng._states[1], weapon) - assert result.damage == 0 - assert eng._states[1].hp == 100 + assert result.damage == 1 + assert eng._states[1].hp == 99 # the Minimum Damage floor lands 1 point