feat(rules): add firearm misfire and the quick clear deed

Ultimate Combat Firearm Rules: a firearm attack rolling a natural 1 gains the
broken condition (weapon_broken, logged MISFIRE). Quick Clear now actually
clears the first misfired held firearm for 1 grit (reported no-op without
grit), and occupies the standard budget slot via _effective_slot; the
swift-consumed filter is slot-based so the standard deed survives.

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-13 16:41:40 +02:00
parent b579cb4934
commit a2a80b8690
3 changed files with 297 additions and 14 deletions
+45 -7
View File
@@ -2755,6 +2755,21 @@ class CombatEngine:
attacker, target, ranged=self._is_ranged_attack(weapon)
)
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
# PF1e (Ultimate Combat, Firearm Rules — Misfires): "If the natural
# result of your attack roll falls within a firearm's misfire value,
# that shot misses [...] When a firearm misfires, it gains the broken
# condition." The model uses the minimum misfire value (1): a natural
# 1 with a firearm (``firearm: true``) breaks the weapon — it joins
# ``weapon_broken`` and is unusable until Quick Clear or a repair.
# Bounded to firearms; the exploding-early-firearm and increased
# misfire-value rules are not modeled (a broken gun cannot be fired
# again here, so it cannot misfire twice).
if weapon.firearm and roll == _NATURAL_ONE:
attacker.weapon_broken.add(weapon.id)
self._log(
f"round {self._current_round} {attacker.combatant.id}: "
f"{weapon.name} MISFIRE (broken)"
)
damage = 0
concealment_miss = False
crit = False
@@ -5333,17 +5348,36 @@ class CombatEngine:
f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)"
)
elif deed_name == "quick_clear":
# Quick Clear: swift action, clears misfire
# PF1e (Ultimate Combat, Deeds — Quick Clear): "As a standard
# action, you can spend 1 point from your grit pool to remove the
# broken condition from a single firearm you are holding, as long
# as that condition was gained by a firearm misfire." The move
# variant (clear ALL misfired firearms as part of a move action)
# is not modeled: the deed always targets one firearm.
broken_firearm = next(
(
weapon.id
for weapon in state.combatant.attacks
if weapon.firearm and weapon.id in state.weapon_broken
),
None,
)
if broken_firearm is None:
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"deed Quick Clear - no misfire to clear"
)
return
if not state.spend_resource("grit", 1):
self._log(
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
)
return
state.weapon_broken.discard(broken_firearm)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"deed Quick Clear - firearm cleared"
f"deed Quick Clear - {broken_firearm} cleared"
)
# In a full implementation, this would clear the misfire condition
def _execute_spell_combat(
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
@@ -7398,7 +7432,6 @@ def _choose_spell_action(
return None
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
# PF1e (SRD Conditions, nauseated): "The only action such a character can
# take is a single move action per turn" — genuine move actions only: the
# 5-foot step and dropping prone are free actions, not move actions, and
@@ -7460,6 +7493,10 @@ def _effective_slot(
return _cast_time_slot(cast_time(action))
if action.kind == "hex" and action.hex_name == "cackle":
return "free"
if action.kind == "deed" and action.deed_name == "quick_clear":
# PF1e (Ultimate Combat, Deeds — Quick Clear): a STANDARD action
# (unlike the swift grit deeds); the kind-level deed slot stays swift.
return "standard"
if action.kind == "retrieve_weapon" and free_draw:
return "free"
return _action_economy_slot(action.kind)
@@ -7474,13 +7511,14 @@ def _without_swift_bonus(
An immediate action consumes the combatant's next swift action; the
policy-returned sequence is filtered so no swift bonus fires that turn —
including a swift-action cast_spell, which occupies the same slot.
including a swift-action cast_spell, which occupies the same slot. The
check is by effective slot so the standard-action Quick Clear deed
(Ultimate Combat) survives.
"""
return tuple(
a
for a in actions
if a.kind not in _SWIFT_BONUS_KINDS
and not (a.kind == "cast_spell" and _effective_slot(a, cast_time) == "swift")
if _effective_slot(a, cast_time) != "swift"
)