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:
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user