"""S5a conformance: firearm misfire and the Quick Clear deed. 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: a natural 1 with a ``firearm: true`` weapon breaks it (``weapon_broken``), until cleared. 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." """ from __future__ import annotations from typing import Literal from pf1e_simulator.combat import ( Action, CombatantState, CombatEngine, _action_economy_filter, _effective_slot, _without_swift_bonus, ) from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import ScriptedRng def _attack( cid: str, *, kind: Literal["melee", "ranged", "touch"], firearm: bool ) -> AttackSpec: return AttackSpec( id=f"{cid}-gun" if firearm else f"{cid}-bow", name="pistol" if firearm else "short bow", kind=kind, attack_bonus=5, damage=[DamageComponent(formula=parse_dice("1d8"), types=["piercing"])], range_increment_ft=20 if kind != "melee" else None, firearm=firearm, ) def _combatant(cid: str, *, firearm: bool, grit: int = 0) -> Combatant: return Combatant( id=cid, name=cid, level=4, size="Medium", abilities=AbilityScores( str_score=12, dex_score=14, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=20, ac=ACProfile(total=15, touch=12, flat_footed=14), bab=4, initiative_mod=2, speed_land_ft=30, saves=Saves(fort=3, ref=4, will=3), attacks=[_attack(cid, kind="touch" if firearm else "ranged", firearm=firearm)], resources={"grit": grit} if grit else {}, ) def _state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: state = CombatantState( combatant=combatant, side=side, pos=pos, hp=combatant.hp_max ) state.resources_left = dict(combatant.resources) return state def _grid() -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) return Grid.from_spec(spec) def _engine(queue: list[int], states: list[CombatantState]) -> CombatEngine: return CombatEngine(ScriptedRng(queue), _grid(), states) def test_firearm_natural_one_breaks_the_weapon() -> None: """Given: a gunner firing a firearm When: the attack roll comes up a natural 1 Then: the shot misses and the firearm gains the broken condition.""" gunner = _combatant("gun", firearm=True) shooter = _state(gunner, "players", (1, 1)) foe = _state(_combatant("foe", firearm=False), "monsters", (1, 2)) engine = _engine([1], [shooter, foe]) result = engine.resolve_attack(shooter, foe, gunner.attacks[0]) assert result.hit is False assert "gun-gun" in shooter.weapon_broken assert any("MISFIRE (broken)" in line for line in engine._transcript) def test_non_firearm_natural_one_does_not_break() -> None: """Given: an archer shooting a non-firearm ranged weapon When: the attack roll comes up a natural 1 Then: the shot misses but nothing breaks (misfire is firearm-only).""" archer = _combatant("bow", firearm=False) shooter = _state(archer, "players", (1, 1)) foe = _state(_combatant("foe", firearm=True), "monsters", (1, 2)) engine = _engine([1], [shooter, foe]) result = engine.resolve_attack(shooter, foe, archer.attacks[0]) assert result.hit is False assert shooter.weapon_broken == set() assert not any("MISFIRE" in line for line in engine._transcript) def test_broken_firearm_is_not_usable() -> None: """Given: a firearm broken by a misfire When: the wielder's usable attacks are listed Then: the broken firearm is excluded.""" gunner = _combatant("gun", firearm=True) shooter = _state(gunner, "players", (1, 1)) shooter.weapon_broken.add("gun-gun") assert [w.id for w in shooter.usable_attacks()] == [] def test_quick_clear_repairs_firearm_and_spends_grit() -> None: """Given: a gunner with a misfired firearm and 1 grit When: the Quick Clear deed is executed Then: the grit is spent and the firearm is no longer broken.""" gunner = _combatant("gun", firearm=True, grit=2) shooter = _state(gunner, "players", (1, 1)) shooter.weapon_broken.add("gun-gun") engine = _engine([], [shooter]) engine._execute(shooter, Action(kind="deed", deed_name="quick_clear")) assert shooter.resources_left["grit"] == 1 assert "gun-gun" not in shooter.weapon_broken assert any("Quick Clear - gun-gun cleared" in line for line in engine._transcript) def test_quick_clear_without_grit_is_a_reported_noop() -> None: """Given: a gunner with a misfired firearm and no grit When: the Quick Clear deed is executed Then: the firearm stays broken and the failure is reported.""" gunner = _combatant("gun", firearm=True) shooter = _state(gunner, "players", (1, 1)) shooter.weapon_broken.add("gun-gun") engine = _engine([], [shooter]) engine._execute(shooter, Action(kind="deed", deed_name="quick_clear")) assert "gun-gun" in shooter.weapon_broken assert any("deed failed (no grit)" in line for line in engine._transcript) def test_quick_clear_without_misfire_spends_no_grit() -> None: """Given: a gunner with grit but no misfired firearm When: the Quick Clear deed is executed Then: no grit is spent and the no-op is reported.""" gunner = _combatant("gun", firearm=True, grit=3) shooter = _state(gunner, "players", (1, 1)) engine = _engine([], [shooter]) engine._execute(shooter, Action(kind="deed", deed_name="quick_clear")) assert shooter.resources_left["grit"] == 3 assert any("no misfire to clear" in line for line in engine._transcript) def test_quick_clear_leaves_non_firearm_breakage_alone() -> None: """Given: a broken non-firearm weapon (e.g. sundered) and a firearm When: Quick Clear runs Then: only a broken firearm could be cleared; the sundered weapon stays.""" gunner = _combatant("gun", firearm=True, grit=1) shooter = _state(gunner, "players", (1, 1)) shooter.weapon_broken.add("gun-bow") engine = _engine([], [shooter]) engine._execute(shooter, Action(kind="deed", deed_name="quick_clear")) assert "gun-bow" in shooter.weapon_broken assert shooter.resources_left["grit"] == 1 assert any("no misfire to clear" in line for line in engine._transcript) def test_quick_clear_occupies_the_standard_slot() -> None: """Given: the Quick Clear deed (Ultimate Combat: a standard action) When: the budget filter walks (deed, attack) Then: the deed keeps the standard slot and the attack is dropped.""" clear = Action(kind="deed", deed_name="quick_clear") strike = Action(kind="attack", target_id="foe") assert _effective_slot(clear, lambda _: "standard") == "standard" assert _action_economy_filter((clear, strike)) == (clear,) def test_other_deeds_stay_swift() -> None: """Given: the swift grit deeds (Up Close and Deadly, inspire-style bonus) When: the budget filter walks (deed, attack) Then: the deed still occupies the swift slot and both actions survive.""" deed = Action(kind="deed", deed_name="up_close_and_deadly", target_id="foe") strike = Action(kind="attack", target_id="foe") assert _effective_slot(deed, lambda _: "standard") == "swift" assert _action_economy_filter((deed, strike)) == (deed, strike) def test_quick_clear_survives_the_swift_consumption_filter() -> None: """Given: an immediate already consumed this turn's swift When: the swift-bonus filter walks (deed quick_clear, inspire_courage) Then: only the swift inspire_courage is dropped, the standard deed stays.""" clear = Action(kind="deed", deed_name="quick_clear") inspire = Action(kind="inspire_courage", target_id="foe") assert _without_swift_bonus((clear, inspire)) == (clear,)