From 350844fc68d12e0b56958df40e126623fecabbf9 Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 19:55:53 +0200 Subject: [PATCH] fix(rules): Up Close and Deadly deed requires a firearm attack (Ultimate Combat) --- README.md | 4 +++- src/pf1e_simulator/combat.py | 16 ++++++++++++---- tests/test_engine_fixes.py | 35 ++++++++++++++++++++++++++++------- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index d97d885..8dd1af6 100644 --- a/README.md +++ b/README.md @@ -1419,7 +1419,9 @@ Règles modélisées : demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts — CRB Channel Energy ; le mode soin n'accorde pas de sauvegarde), sortilège `evil_eye` (nairda), deed `up_close_and_deadly` (tammara, action bonus +1d6 à ≤ 30 ft — consommé sur la première attaque - résolue : si elle est à distance ET à ≤ 30 ft, +1d6 s'ajoute aux dégâts ; + résolue : si elle est une attaque **d'arme à feu** à distance ET à ≤ 30 ft + (Ultimate Combat « Deeds — Up Close and Deadly » : « a successful attack + with a firearm against a target within 30 feet »), +1d6 s'ajoute aux dégâts ; sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), deed `quick_clear` (S5a — conformance implémentée, Ultimate Combat « Deeds — Quick Clear » : « As a standard action, you can spend 1 point from your grit diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 69afa2f..b2922e2 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3096,13 +3096,21 @@ class CombatEngine: ) -> int: """Consume the Up Close and Deadly deed on the first attack resolved. - The deed applies +1d6 to a ranged attack within 30 ft; the flag is - reset after the first attack in all cases (the benefit is lost if the - attack is out of range or not ranged). + The deed applies +1d6 to a FIREARM attack that hits a target within + 30 ft (Ultimate Combat "Deeds - Up Close and Deadly": "makes a + successful attack with a firearm against a target within 30 feet"); + the flag is reset after the first attack in all cases (the benefit is + lost if the attack misses, is not a firearm, is out of range or is + not ranged at all). """ if not attacker.deed_up_close_and_deadly: return damage - if hit and self._is_ranged_attack(weapon) and dist_ft <= _UP_CLOSE_RANGE_FT: + if ( + hit + and weapon.firearm + and self._is_ranged_attack(weapon) + and dist_ft <= _UP_CLOSE_RANGE_FT + ): bonus = self._rng.roll_die(6) damage += bonus target.hp -= bonus diff --git a/tests/test_engine_fixes.py b/tests/test_engine_fixes.py index 5653553..29cfd68 100644 --- a/tests/test_engine_fixes.py +++ b/tests/test_engine_fixes.py @@ -5,8 +5,9 @@ Covers: action when the combatant has the Power Attack feat, and is a logged no-op otherwise. - ``deed_up_close_and_deadly`` consumption: the +1d6 bonus applies to the - first ranged attack within 30 ft and the flag is reset; the flag is reset - with no bonus when the first attack is out of range or not ranged. + first firearm attack within 30 ft and the flag is reset; the flag is reset + with no bonus when the first attack is out of range, not ranged, or not a + firearm. """ from __future__ import annotations @@ -33,6 +34,7 @@ def _combatant( kind: str = "melee", range_increment_ft: int | None = None, features: list | None = None, + firearm: bool = False, ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", @@ -41,6 +43,7 @@ def _combatant( attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], range_increment_ft=range_increment_ft, + firearm=firearm, ) return Combatant( id=cid, @@ -124,11 +127,15 @@ class TestActivatePowerAttack: class TestDeedUpCloseAndDeadly: """Given: an attacker with deed_up_close_and_deadly set When: an attack is resolved - Then: the first ranged attack within 30 ft gains +1d6 and the flag resets; - otherwise the flag resets with no bonus.""" + Then: the first firearm attack within 30 ft gains +1d6 and the flag + resets; otherwise the flag resets with no bonus (Ultimate Combat deed: + "makes a successful attack with a firearm against a target within 30 + feet").""" - def test_ranged_within_30ft_adds_d6_and_resets(self) -> None: - attacker = _combatant("gunner", kind="ranged", range_increment_ft=60) + def test_firearm_within_30ft_adds_d6_and_resets(self) -> None: + attacker = _combatant( + "gunner", kind="ranged", range_increment_ft=60, firearm=True + ) target = _foe() engine, a, t = _engine(attacker, target, [15, 4, 3]) a.deed_up_close_and_deadly = True @@ -137,8 +144,22 @@ class TestDeedUpCloseAndDeadly: assert result.damage == 4 + 3 # 1d8(4) + 1d6(3) assert a.deed_up_close_and_deadly is False + def test_non_firearm_ranged_gets_no_bonus(self) -> None: + # A bow is a ranged attack within 30 ft but NOT a firearm: the deed + # gives no bonus and is consumed on the resolved attack. + attacker = _combatant("archer", kind="ranged", range_increment_ft=60) + target = _foe() + engine, a, t = _engine(attacker, target, [15, 4]) + a.deed_up_close_and_deadly = True + result = engine.resolve_attack(a, t, attacker.attacks[0]) + assert result.hit is True + assert result.damage == 4 # no +1d6 (not a firearm) + assert a.deed_up_close_and_deadly is False + def test_out_of_range_resets_without_bonus(self) -> None: - attacker = _combatant("gunner", kind="ranged", range_increment_ft=60) + attacker = _combatant( + "gunner", kind="ranged", range_increment_ft=60, firearm=True + ) target = _foe() engine, a, t = _engine(attacker, target, [15, 4]) a.deed_up_close_and_deadly = True