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:
@@ -430,7 +430,11 @@ Règles modélisées :
|
|||||||
contient une action `move` ET que le combattant a un BAB ≥ +1,
|
contient une action `move` ET que le combattant a un BAB ≥ +1,
|
||||||
`retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw`
|
`retrieve_weapon` occupe le compartiment **libre** (drapeau `free_draw`
|
||||||
résolu par `_effective_slot`) et n'entame pas le budget mouvement ;
|
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`,
|
`inspire_courage` ; **round complet** = `full_attack`, `charge`, `withdraw`,
|
||||||
`full_round`, `spell_combat` (sort + attaque à outrance dans le même round),
|
`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
|
`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
|
(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 ;
|
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),
|
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
|
deed `quick_clear` (S5a — conformance implémentée, Ultimate Combat « Deeds —
|
||||||
le kind `deed` en **swift**, alors que la SRD fait de Quick Clear une
|
Quick Clear » : « As a standard action, you can spend 1 point from your grit
|
||||||
**action standard** — ou **move** avec All Firearms — ; de plus, sans
|
pool to remove the broken condition from a single firearm you are holding,
|
||||||
modèle de misfire/condition « broken » pour les armes à feu, le deed ne
|
as long as that condition was gained by a firearm misfire ») — le deed
|
||||||
fait que dépenser 1 grit et journaliser « firearm cleared » : no-op
|
dépense 1 grit et répare la **première** arme à feu brisée par un raté
|
||||||
fonctionnel),
|
(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
|
`inspire_courage` + `spell_combat` (misty — Spell Combat : action de round
|
||||||
complet, incante un sort standard et porte toutes ses attaques de mêlée à
|
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
|
**−2** (CRB Magus, conformance — la pénalité s'applique à chaque jet
|
||||||
|
|||||||
@@ -2755,6 +2755,21 @@ class CombatEngine:
|
|||||||
attacker, target, ranged=self._is_ranged_attack(weapon)
|
attacker, target, ranged=self._is_ranged_attack(weapon)
|
||||||
)
|
)
|
||||||
hit = roll == _NATURAL_TWENTY or (roll != _NATURAL_ONE and total >= ac)
|
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
|
damage = 0
|
||||||
concealment_miss = False
|
concealment_miss = False
|
||||||
crit = False
|
crit = False
|
||||||
@@ -5333,17 +5348,36 @@ class CombatEngine:
|
|||||||
f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)"
|
f"deed Up Close and Deadly activated (+1d6 dmg within 30ft)"
|
||||||
)
|
)
|
||||||
elif deed_name == "quick_clear":
|
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):
|
if not state.spend_resource("grit", 1):
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
f"round {self._current_round} {state.combatant.id}: deed failed (no grit)"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
state.weapon_broken.discard(broken_firearm)
|
||||||
self._log(
|
self._log(
|
||||||
f"round {self._current_round} {state.combatant.id}: "
|
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(
|
def _execute_spell_combat(
|
||||||
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
self, state: CombatantState, target: CombatantState | None, spell_name: str | None
|
||||||
@@ -7398,7 +7432,6 @@ def _choose_spell_action(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
_SWIFT_BONUS_KINDS: frozenset[str] = frozenset({"deed", "inspire_courage", "swift"})
|
|
||||||
# PF1e (SRD Conditions, nauseated): "The only action such a character can
|
# PF1e (SRD Conditions, nauseated): "The only action such a character can
|
||||||
# take is a single move action per turn" — genuine move actions only: the
|
# 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
|
# 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))
|
return _cast_time_slot(cast_time(action))
|
||||||
if action.kind == "hex" and action.hex_name == "cackle":
|
if action.kind == "hex" and action.hex_name == "cackle":
|
||||||
return "free"
|
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:
|
if action.kind == "retrieve_weapon" and free_draw:
|
||||||
return "free"
|
return "free"
|
||||||
return _action_economy_slot(action.kind)
|
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
|
An immediate action consumes the combatant's next swift action; the
|
||||||
policy-returned sequence is filtered so no swift bonus fires that turn —
|
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(
|
return tuple(
|
||||||
a
|
a
|
||||||
for a in actions
|
for a in actions
|
||||||
if a.kind not in _SWIFT_BONUS_KINDS
|
if _effective_slot(a, cast_time) != "swift"
|
||||||
and not (a.kind == "cast_spell" and _effective_slot(a, cast_time) == "swift")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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