fix(rules): Up Close and Deadly deed requires a firearm attack (Ultimate Combat)

This commit is contained in:
2026-09-16 19:55:53 +02:00
parent 2807f719ca
commit 350844fc68
3 changed files with 43 additions and 12 deletions
+3 -1
View File
@@ -1419,7 +1419,9 @@ Règles modélisées :
demi-niveau du canaliseur + modificateur de Charisme` pour demi-dégâts 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` — 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 (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), 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 — 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 Quick Clear » : « As a standard action, you can spend 1 point from your grit
+12 -4
View File
@@ -3096,13 +3096,21 @@ class CombatEngine:
) -> int: ) -> int:
"""Consume the Up Close and Deadly deed on the first attack resolved. """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 The deed applies +1d6 to a FIREARM attack that hits a target within
reset after the first attack in all cases (the benefit is lost if the 30 ft (Ultimate Combat "Deeds - Up Close and Deadly": "makes a
attack is out of range or not ranged). 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: if not attacker.deed_up_close_and_deadly:
return damage 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) bonus = self._rng.roll_die(6)
damage += bonus damage += bonus
target.hp -= bonus target.hp -= bonus
+28 -7
View File
@@ -5,8 +5,9 @@ Covers:
action when the combatant has the Power Attack feat, and is a logged no-op action when the combatant has the Power Attack feat, and is a logged no-op
otherwise. otherwise.
- ``deed_up_close_and_deadly`` consumption: the +1d6 bonus applies to the - ``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 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 or not ranged. with no bonus when the first attack is out of range, not ranged, or not a
firearm.
""" """
from __future__ import annotations from __future__ import annotations
@@ -33,6 +34,7 @@ def _combatant(
kind: str = "melee", kind: str = "melee",
range_increment_ft: int | None = None, range_increment_ft: int | None = None,
features: list | None = None, features: list | None = None,
firearm: bool = False,
) -> Combatant: ) -> Combatant:
attack = AttackSpec( attack = AttackSpec(
id=f"{cid}-w", id=f"{cid}-w",
@@ -41,6 +43,7 @@ def _combatant(
attack_bonus=5, attack_bonus=5,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
range_increment_ft=range_increment_ft, range_increment_ft=range_increment_ft,
firearm=firearm,
) )
return Combatant( return Combatant(
id=cid, id=cid,
@@ -124,11 +127,15 @@ class TestActivatePowerAttack:
class TestDeedUpCloseAndDeadly: class TestDeedUpCloseAndDeadly:
"""Given: an attacker with deed_up_close_and_deadly set """Given: an attacker with deed_up_close_and_deadly set
When: an attack is resolved When: an attack is resolved
Then: the first ranged attack within 30 ft gains +1d6 and the flag resets; Then: the first firearm attack within 30 ft gains +1d6 and the flag
otherwise the flag resets with no bonus.""" 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: def test_firearm_within_30ft_adds_d6_and_resets(self) -> None:
attacker = _combatant("gunner", kind="ranged", range_increment_ft=60) attacker = _combatant(
"gunner", kind="ranged", range_increment_ft=60, firearm=True
)
target = _foe() target = _foe()
engine, a, t = _engine(attacker, target, [15, 4, 3]) engine, a, t = _engine(attacker, target, [15, 4, 3])
a.deed_up_close_and_deadly = True a.deed_up_close_and_deadly = True
@@ -137,8 +144,22 @@ class TestDeedUpCloseAndDeadly:
assert result.damage == 4 + 3 # 1d8(4) + 1d6(3) assert result.damage == 4 + 3 # 1d8(4) + 1d6(3)
assert a.deed_up_close_and_deadly is False 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: 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() target = _foe()
engine, a, t = _engine(attacker, target, [15, 4]) engine, a, t = _engine(attacker, target, [15, 4])
a.deed_up_close_and_deadly = True a.deed_up_close_and_deadly = True