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:
@@ -0,0 +1,227 @@
|
||||
"""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,)
|
||||
Reference in New Issue
Block a user