diff --git a/README.md b/README.md index 0ae99fd..eac35d0 100644 --- a/README.md +++ b/README.md @@ -430,7 +430,11 @@ Règles modélisées : contient une action `move` ET que le combattant a un BAB ≥ +1, `retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw` résolu par `_effective_slot`) et n'entame pas le budget mouvement ; - `repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ; **swift** = `swift`, `deed`, + `repair_weapon` reste mouvement (la règle RAW ne couvre que le dégainement) ; + exception S5a (Ultimate Combat, deed « Quick Clear ») : `deed` avec + `deed_name="quick_clear"` occupe le compartiment **standard** (résolu par + `_effective_slot`, le kind `deed` restant swift pour up_close_and_deadly) ; + **swift** = `swift`, `deed`, `inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`, `full_round`, `spell_combat` (sort + attaque à outrance dans le même round), `run` (round complet en RAW — il consacre le standard ET le mouvement, tout @@ -1385,12 +1389,26 @@ Règles modélisées : (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 ; sinon le bonus est perdu ; le drapeau est réinitialisé dans tous les cas), - deed `quick_clear` (limite documentée, conformance L10 : le moteur classe - le kind `deed` en **swift**, alors que la SRD fait de Quick Clear une - **action standard** — ou **move** avec All Firearms — ; de plus, sans - modèle de misfire/condition « broken » pour les armes à feu, le deed ne - fait que dépenser 1 grit et journaliser « firearm cleared » : no-op - fonctionnel), + 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 + pool to remove the broken condition from a single firearm you are holding, + as long as that condition was gained by a firearm misfire ») — le deed + dépense 1 grit et répare la **première** arme à feu brisée par un raté + (ordre des attaques, déterministe) ; sans grit, l'action est un no-op + journalisé ; sans arme à feu brisée, aucun grit n'est dépensé. Le deed + occupe le compartiment **standard** du budget (exception par action résolue + par `_effective_slot`, comme cackle pour les hex ; le kind `deed` reste + swift pour up_close_and_deadly, et le filtre swift-consommé-par-immédiate + ne jette plus le quick_clear). Simplifications documentées : la variante + RAW « as part of a move action to clear all firearms » n'est pas modélisée + (un seul firearm par deed), et Quick Clear ne répare que le broken issu + d'un misfire — le broken d'un sunder se répare via `repair_weapon`. + Le misfire lui-même (Ultimate Combat « Firearm Rules — Misfires » : « When + a firearm misfires, it gains the broken condition ») : une attaque d'arme à + feu (`firearm: true`) sur un **1 naturel** casse l'arme (`weapon_broken`, + journalisé « MISFIRE (broken) ») — valeur de misfire minimale 1 ; la + progression de la valeur de misfire et l'explosion des early firearms + re-cassées ne sont pas modélisées (une arme brisée ne peut plus tirer ici)), `inspire_courage` + `spell_combat` (misty — Spell Combat : action de round complet, incante un sort standard et porte toutes ses attaques de mêlée à **−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4c92ce9..92befd8 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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" ) diff --git a/tests/test_firearm_misfire.py b/tests/test_firearm_misfire.py new file mode 100644 index 0000000..3b83fe9 --- /dev/null +++ b/tests/test_firearm_misfire.py @@ -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,)