feat(rules): carried items + real steal transfers, unarmed-disarm auto-pickup

- Combatant.carried_items (APG Steal: non-wielded, non-closely-worn gear),
  loaded from JSON, seeded into CombatantState, exposed in the snapshot.
- _maneuver_steal: success transfers the first carried item victim -> thief
  (logged); empty list keeps the documented nothing-stealable behaviour.
- _maneuver_disarm: unarmed attacker's success auto-picks the dropped weapon
  into its carried_items (SRD Disarm); armed disarms unchanged. Dropped-weapon
  recovery via retrieve_weapon (move action) verified + pinned by tests.
- data/pcs: gear transcribed from Foundry sheet inventories (esha 13,
  jeanne 3, misty 9, nairda 7, oni 6, tammara 20); harvie/ierlieth/
  esha_mount and all monsters empty (no stealable inventory), noted in README.
This commit is contained in:
2026-09-20 16:05:07 +02:00
parent 74f5d64c43
commit d0f7f3a107
14 changed files with 260 additions and 22 deletions
+49 -10
View File
@@ -299,7 +299,14 @@ Le `reach_ft` d'une attaque naturelle (morsure, sabots) n'est **pas** une arme
à allonge : il encode l'allonge naturelle de la créature, qui reste un disque. à allonge : il encode l'allonge naturelle de la créature, qui reste un disque.
`spell_resistance` (entier), `resistances` (dict type → montant) et `spell_resistance` (entier), `resistances` (dict type → montant) et
`immunities` (liste de types) sont optionnels. `immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc. Le tableau `carried_items` (optionnel, défaut vide) énumère les **objets
transportés non maniés et non portés serré** — potions, scrolls, outils,
bourses, clés, vivres… — seuls objets volables par la manœuvre « Steal » de
l'APG (voir « Manœuvres de combat »). Les armes maniées vivent dans
`attacks` (elles se prennent par disarm, pas par steal) ; l'équipement porté
serré (armure, sac à dos, vêtements, anneaux) ne peut pas être volé et n'a
pas à être encodé — la CA des JSON reste une valeur finale. Deux monstres
d'exemple sont fournis : gobelin et orc.
### Fiches de personnages (PJ) ### Fiches de personnages (PJ)
@@ -318,7 +325,17 @@ uv run pf1e-sim --map data/maps/sample_arena.yaml \
Les armes placeholder des fiches sans dégâts encodés sont omises (notées Les armes placeholder des fiches sans dégâts encodés sont omises (notées
dans `notes` le cas échéant) ; le champ `source` de chaque fichier pointe dans `notes` le cas échéant) ; le champ `source` de chaque fichier pointe
vers la fiche Foundry d'origine. vers la fiche Foundry d'origine. Les `carried_items` (objets volables par
« Steal ») sont transcrits des inventaires `inventory.items` des fiches
Foundry (esha 13, jeanne 3, misty 9, nairda 7, oni 6, tammara 20), filtrés
conformément à l'APG : armes maniées (déjà dans `attacks`), équipement porté
serré (armures, anneaux, amulettes, capes, ceintures, wayfinders, vêtements,
sacs à dos) et contenants portés exclus. Trois fiches n'ont **aucun** objet
volable : `harvie.json` (fiche sans inventaire), `ierlieth.json` (seul un
« Backpack of Naidra » — sac à dos = porté serré, non volable) et
`esha_mount.json` (monture sans inventaire propre) — listes laissées vides.
Les monstres (`data/monsters/*.json`, issu du bestiaire, sans fiche Foundry
ni inventaire encodé) portent `carried_items` vide.
> **`data/pcs/*.json` est la référence canonique des PJ.** Le chargeur > **`data/pcs/*.json` est la référence canonique des PJ.** Le chargeur
> `loaders/foundry.py` est un outil de migration **one-shot et incomplet** : > `loaders/foundry.py` est un outil de migration **one-shot et incomplet** :
@@ -1504,7 +1521,16 @@ Règles modélisées :
trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » : trip → `prone` ; disarm → l'arme tenue est **au sol** (SRD « Disarm » :
« your target drops one item it is carrying » — `weapon_dropped`, exclue « your target drops one item it is carrying » — `weapon_dropped`, exclue
de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou de `usable_attacks()` jusqu'à un `retrieve_weapon` ; un échec de 10 ou
plus fait tomber **l'arme de l'attaquant**) ; bull_rush → pousse la plus fait tomber **l'arme de l'attaquant**) ; conformance (SRD « Disarm »)
: « If you successfully disarm your opponent without using a weapon, you
may automatically pick up the item dropped » — un désarmement **réussi**
par un attaquant **sans aucune arme de mêlée utilisable** (tentative à
mains nues) saisit aussitôt l'arme tombée : elle quitte le sol et entre
dans les **objets transportés** du désarmeur (`carried_items`, journalisé
`unarmed disarm pickup`) — simplification documentée : l'arme saisie
n'étant pas une `AttackSpec` du désarmeur, elle est **tenue, pas maniée**
(pas utilisable pour attaquer) ; l'arme reste exclue des attaques de la
victime jusqu'à son propre `retrieve_weapon` ; bull_rush → pousse la
cible (direction sélectionnable via `Action.direction`) ; conformance L6 cible (direction sélectionnable via `Action.direction`) ; conformance L6
(SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which (SRD « Bull Rush ») : la poussée vaut 5 ft plus « For every 5 by which
your attack exceeds your opponent's CMD you can push the target back an your attack exceeds your opponent's CMD you can push the target back an
@@ -1558,12 +1584,23 @@ Règles modélisées :
« Dirty Trick » ; « Dirty Trick » ;
`blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles `blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles
(liste exacte de l'APG), `nauseated` exclu car hors de (liste exacte de l'APG), `nauseated` exclu car hors de
la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le modèle ne porte la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le
aucun objet transporté non tenu : en application de l'APG « Steal » (« Items champ `carried_items` du JSON de combattant porte les **objets transportés
held in the hands (such as wielded weapons or wands) also cannot be taken non maniés et non portés serré** (potions, scrolls, outils, bourses,
with the steal maneuver—you must use the disarm combat maneuver instead »), vivres…) — seuls objets volables selon l'APG « Steal » (« any item that is
une réussite ne vole rien (journalisé `nothing stealable — wielded weapons neither held nor hidden in a bag or pack [...] Items that are closely worn
require disarm`) ; reposition → déplacement de la cible de 5 ft plus (such as armor, backpacks, boots, clothing, or rings) cannot be taken [...]
Items held in the hands (such as wielded weapons or wands) also cannot be
taken with the steal maneuver—you must use the disarm combat maneuver
instead ») : une réussite **transfère un objet** de la liste de la cible
vers celle du voleur (journalisé `took <objet>`) ; simplifications
documentées : le moteur prend le **premier** objet de la liste (le choix
RAW de l'objet n'a pas de slot d'action), la main libre obligatoire et le
+5 CMD des objets attachés (capes, armes fourrées, bourses) ne sont pas
modélisés ; si la cible n'a **aucun objet transporté**, une réussite ne
vole rien (journalisé `nothing stealable — wielded weapons
require disarm`) ; les armes maniées (`attacks`) restent insaisissables
par steal — il faut désarmer ; reposition → déplacement de la cible de 5 ft plus
« For every 5 by which your attack exceeds your opponent's CMD, you can « For every 5 by which your attack exceeds your opponent's CMD, you can
move the target an additional 5 feet » (APG « Reposition ») — `1 + move the target an additional 5 feet » (APG « Reposition ») — `1 +
marge//5` cases le long de la direction choisie (premier voisin libre par marge//5` cases le long de la direction choisie (premier voisin libre par
@@ -1702,7 +1739,9 @@ Règles modélisées :
state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) : state) -> tuple[Action, ...]`). Schéma enrichi (E1-E5) :
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
weapon_broken/weapon_destroyed/weapon_stolen/weapon_dropped, grappled_with, moved_this_turn, power_attack, weapon_broken/weapon_destroyed/weapon_stolen/weapon_dropped, carried_items
(tuple dans l'ordre de la liste — les objets transportés volables, APG
« Steal »), grappled_with, moved_this_turn, power_attack,
name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit name, level, size, natural_reach_ft (S4c : allonge naturelle du gabarit
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle, Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
+16
View File
@@ -104,5 +104,21 @@
], ],
"spells": [ "spells": [
"Unseen Servant" "Unseen Servant"
],
"carried_items": [
"Arrow",
"Blunt Arrow",
"Dye Arrow",
"Smoke Arrow",
"Tangleshot Arrow",
"Whistling Arrow",
"Thieves' Tools, common",
"Clefs de la loge",
"Badge d'éclaireur",
"Rope",
"Pomme",
"La pomme de Notch",
"Pierre de communication"
] ]
} }
+2 -1
View File
@@ -61,5 +61,6 @@
"armor_spell_failure": 0, "armor_spell_failure": 0,
"resources": {}, "resources": {},
"metamagic_feats": [], "metamagic_feats": [],
"skills": {} "skills": {},
"carried_items": []
} }
+2 -1
View File
@@ -83,5 +83,6 @@
"spell_slots": {}, "spell_slots": {},
"resources": {}, "resources": {},
"metamagic_feats": [], "metamagic_feats": [],
"skills": {} "skills": {},
"carried_items": []
} }
+2 -1
View File
@@ -82,5 +82,6 @@
"spell_slots": {}, "spell_slots": {},
"resources": {}, "resources": {},
"metamagic_feats": [], "metamagic_feats": [],
"skills": {} "skills": {},
"carried_items": []
} }
+2 -1
View File
@@ -80,5 +80,6 @@
"spell_slots": {}, "spell_slots": {},
"resources": {}, "resources": {},
"metamagic_feats": [], "metamagic_feats": [],
"skills": {} "skills": {},
"carried_items": ["Saddle (riding)", "Feed", "Trail Rations"]
} }
+13 -1
View File
@@ -172,5 +172,17 @@
], ],
"resistances": { "resistances": {
"electricity": 5 "electricity": 5
} },
"carried_items": [
"Pouch, Spell Component",
"Tools, Thieves'",
"Thieves' Tools, masterwork",
"Clef de la Loge de Sauerton",
"+1 Mithral Arrow",
"Bouteille de ??? à moitié vide",
"Scroll of Detect Secret Doors",
"Scroll of Cure Light Wounds",
"Wand of Infernal Healing"
]
} }
+10
View File
@@ -117,5 +117,15 @@
"Ice Slick", "Ice Slick",
"Darkness", "Darkness",
"Flurry of Snowballs" "Flurry of Snowballs"
],
"carried_items": [
"Spell Component Pouch",
"Holy symbol (Wood)",
"Arrow",
"Lettres dans une chambre de bandits",
"Clefs de la loge",
"Badge des éclaireurs",
"Potion de soin de Nairda"
] ]
} }
+9
View File
@@ -126,5 +126,14 @@
"Delay Poison", "Delay Poison",
"Ironskin", "Ironskin",
"Restoration, Lesser" "Restoration, Lesser"
],
"carried_items": [
"Arrow",
"Holy symbol (Wood)",
"Clefs de la loge",
"Eh je suis Éclaireuse maintenant",
"Kit, Fishing",
"Alchemist's Fire"
] ]
} }
+23
View File
@@ -206,5 +206,28 @@
"Delay Poison", "Delay Poison",
"Aid", "Aid",
"Drunkard's Breath" "Drunkard's Breath"
],
"carried_items": [
"Mess Kit",
"Gunsmith's Kit",
"Holy symbol (Wood)",
"Spell component pouch",
"Powder horn",
"Firearm Bullet",
"Alchemical Cartridge (Paper, Bullet Or Pellet)",
"Black Powder (Dose)",
"Matériaux pour munitions",
"Rope",
"Bouteille d'alcool (poste des bandits)",
"Clefs de la loge (4)",
"Black Powder (Dose) en stock",
"Firearm Bullet en stock",
"Alchemical Cartridge (Paper, Bullet Or Pellet) en stock",
"Alcool de poison d'araignée ogre",
"Badge des éclaireurs",
"Bouteille d'alcool fort (genre plus que du désinfectant)",
"Une médaille de Caiden Cailean",
"Potion of Cure Light Wounds"
] ]
} }
+45 -4
View File
@@ -854,6 +854,11 @@ class CombatantState:
# Runtime weapon theft (not in the frozen data model): weapon ids taken by # Runtime weapon theft (not in the frozen data model): weapon ids taken by
# steal maneuvers. # steal maneuvers.
weapon_stolen: set[str] = field(default_factory=set) weapon_stolen: set[str] = field(default_factory=set)
# Runtime carried items (APG "Steal"): mutable copy of
# ``combatant.carried_items``; a successful steal transfers one item from
# the victim's list to the thief's, and an unarmed successful disarm's
# automatic pickup appends the dropped weapon there.
carried_items: list[str] = field(default_factory=list)
# Runtime disarmed weapons (not in the frozen data model): weapon ids the # Runtime disarmed weapons (not in the frozen data model): weapon ids the
# combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your # combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your
# opponent drops the weapon"). A retrieve_weapon move action picks the # opponent drops the weapon"). A retrieve_weapon move action picks the
@@ -965,6 +970,8 @@ class CombatantState:
def __post_init__(self) -> None: def __post_init__(self) -> None:
if not self.spell_slots_left: if not self.spell_slots_left:
self.spell_slots_left = dict(self.combatant.spell_slots) self.spell_slots_left = dict(self.combatant.spell_slots)
if not self.carried_items:
self.carried_items = list(self.combatant.carried_items)
def spend_resource(self, name: str, n: int = 1) -> bool: def spend_resource(self, name: str, n: int = 1) -> bool:
"""Spend n points of a resource. Returns True if successful, False if insufficient.""" """Spend n points of a resource. Returns True if successful, False if insufficient."""
@@ -1655,6 +1662,7 @@ class CombatEngine:
"weapon_destroyed": tuple(sorted(state.weapon_destroyed)), "weapon_destroyed": tuple(sorted(state.weapon_destroyed)),
"weapon_stolen": tuple(sorted(state.weapon_stolen)), "weapon_stolen": tuple(sorted(state.weapon_stolen)),
"weapon_dropped": tuple(sorted(state.weapon_dropped)), "weapon_dropped": tuple(sorted(state.weapon_dropped)),
"carried_items": tuple(state.carried_items),
"grappled_with": state.grappled_with, "grappled_with": state.grappled_with,
"moved_this_turn": state.moved_this_turn, "moved_this_turn": state.moved_this_turn,
"power_attack": state.power_attack, "power_attack": state.power_attack,
@@ -5639,6 +5647,23 @@ class CombatEngine:
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS ({weapon.name} dropped)" f"CMD {m.target_cmd} -> SUCCESS ({weapon.name} dropped)"
) )
# PF1e (SRD Disarm): "If you successfully disarm your opponent
# without using a weapon, you may automatically pick up the item
# dropped." The engine does not track WHICH weapon a maneuver used,
# so "without using a weapon" = the attacker has no usable melee
# weapon at all (a true unarmed attempt). The picked-up item is not
# one of the attacker's AttackSpecs, so it lands in the attacker's
# carried_items (held, not wielded — the attacker cannot swing it;
# documented simplification).
attacker_armed = any(
w.kind == "melee" for w in state.usable_attacks()
)
if not attacker_armed:
state.carried_items.append(weapon.name)
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"unarmed disarm pickup ({weapon.name} taken)"
)
def _maneuver_bull_rush( def _maneuver_bull_rush(
self, self,
@@ -5936,16 +5961,32 @@ class CombatEngine:
if not m.success: if not m.success:
self._maneuver_fail(state, target, "steal", m) self._maneuver_fail(state, target, "steal", m)
return return
# PF1e (APG Steal): "Items held in the hands (such as wielded weapons # PF1e (APG Steal): "This maneuver can be used in melee to take any
# or wands) also cannot be taken with the steal maneuver—you must use # item that is neither held nor hidden in a bag or pack [...] Items
# the disarm combat maneuver instead." The model carries no non-held # held in the hands (such as wielded weapons or wands) also cannot be
# items, so every stealable target is held: a success takes nothing. # taken with the steal maneuver—you must use the disarm combat
# maneuver instead." Only ``carried_items`` (non-wielded, non-closely-
# worn gear) are stealable; weapons live in ``attacks`` and stay
# immune here. The RAW lets the thief choose the item ("You must
# select the item to be taken before the check is made") — the action
# carries no item slot, so the engine takes the FIRST carried item
# (deterministic list order). The RAW "at least one hand free" gate is
# not modeled (the engine has no hand bookkeeping).
if not target.carried_items:
self._log( self._log(
f"round {self._current_round} {state.combatant.id}: steal vs " f"round {self._current_round} {state.combatant.id}: steal vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} " f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded " f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded "
f"weapons require disarm)" f"weapons require disarm)"
) )
return
item = target.carried_items.pop(0)
state.carried_items.append(item)
self._log(
f"round {self._current_round} {state.combatant.id}: steal vs "
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
f"CMD {m.target_cmd} -> SUCCESS (took {item})"
)
def _maneuver_reposition( def _maneuver_reposition(
self, self,
+3
View File
@@ -242,6 +242,9 @@ def load_combatant(path: Path) -> Combatant:
The ``armor_spell_failure`` percent and the ``arcane_caster`` flag The ``armor_spell_failure`` percent and the ``arcane_caster`` flag
(SRD "Arcane Spells and Armor") are plain fields of the schema (defaults (SRD "Arcane Spells and Armor") are plain fields of the schema (defaults
``0`` and ``false``), so their JSON keys load directly as well. ``0`` and ``false``), so their JSON keys load directly as well.
The ``carried_items`` list (APG "Steal" — non-wielded, stealable gear) is
a plain ``tuple[str, ...]`` field of the schema (default empty), so a JSON
``carried_items`` key loads directly too.
""" """
try: try:
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8")
+12
View File
@@ -382,6 +382,18 @@ class Combatant(_FrozenModel):
# PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of # PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of
# LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral). # LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral).
alignment: str = "N" alignment: str = "N"
# PF1e (APG "Steal"): carried items that are NEITHER wielded in a hand
# NOR closely worn - potions, scrolls, tools, pouches, keys, rations...
# "This maneuver can be used in melee to take any item that is neither
# held nor hidden in a bag or pack [...] Items that are closely worn
# (such as armor, backpacks, boots, clothing, or rings) cannot be taken
# with this maneuver. Items held in the hands (such as wielded weapons
# or wands) also cannot be taken with the steal maneuver - you must use
# the disarm combat maneuver instead." Wielded weapons live in
# ``attacks``; closely worn gear is not modeled (the AC in the JSON is
# a final value), so this list holds only stealable carried items.
# rules/advanced-players-guide/steal.md
carried_items: tuple[str, ...] = ()
@field_validator("alignment") @field_validator("alignment")
@classmethod @classmethod
+69
View File
@@ -617,6 +617,75 @@ def test_steal_nothing_to_steal() -> None:
assert "nothing stealable" in engine._transcript[-1] assert "nothing stealable" in engine._transcript[-1]
def test_steal_takes_one_carried_item() -> None:
# APG Steal: "If your attack is successful, you may take one item from
# your opponent" — one non-wielded, non-closely-worn carried item moves
# from the victim's list to the thief's (the engine takes the FIRST
# carried item; the RAW item choice has no action slot). The wielded
# sword stays with the victim.
a_spec = make_combatant("a", attack_bonus=2)
b_spec = make_combatant("b").model_copy(
update={"carried_items": ("potion of cure light wounds", "thieves' tools")}
)
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "steal")
assert b.carried_items == ["thieves' tools"]
assert a.carried_items == ["potion of cure light wounds"]
assert "took potion of cure light wounds" in engine._transcript[-1]
assert engine.weapon_for(b, a) is not None # the sword is untouched
# The observation snapshot exposes the transfer.
assert engine._combatant_snapshot(a)["carried_items"] == (
"potion of cure light wounds",
)
assert engine._combatant_snapshot(b)["carried_items"] == ("thieves' tools",)
def test_disarmed_actor_recovers_weapon_with_a_move_action() -> None:
# SRD Disarm + "Picking up an item": the dropped sword lies in the
# victim's own square; retrieve_weapon (a move action) restores it and
# the weapon is usable again for a real attack.
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert b.weapon_dropped == {"b-w"}
assert engine.weapon_for(b, a) is None
engine._retrieve_weapon(b, "b-w")
assert not b.weapon_dropped
assert engine.weapon_for(b, a) is not None
assert "retrieve b-w (restored)" in engine._transcript[-1]
def test_unarmed_disarm_success_auto_picks_up_the_weapon() -> None:
# SRD Disarm: "If you successfully disarm your opponent without using a
# weapon, you may automatically pick up the item dropped." a is unarmed
# (no melee weapon at all): the dropped sword lands in a's carried_items
# (held, not wielded) with no retrieve move action spent.
a_spec = make_combatant("a", attack_bonus=2).model_copy(update={"attacks": []})
b_spec = make_combatant("b")
a = make_state(a_spec, "players", (1, 1))
b = make_state(b_spec, "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert b.weapon_dropped == {"b-w"}
assert a.carried_items == ["sword"]
assert "unarmed disarm pickup (sword taken)" in engine._transcript[-1]
def test_armed_disarm_success_does_not_pick_up() -> None:
# The automatic pickup is reserved to unarmed disarms: an armed success
# leaves the weapon on the ground (victim's weapon_dropped only).
a = make_state(make_combatant("a", attack_bonus=2), "players", (1, 1))
b = make_state(make_combatant("b"), "monsters", (1, 2))
engine = make_engine([1, 12], [a, b])
engine._execute_maneuver(a, b, "disarm")
assert b.weapon_dropped == {"b-w"}
assert a.carried_items == []
assert engine._transcript[-1].endswith("SUCCESS (sword dropped)")
def test_reposition_distance_scales_with_margin() -> None: def test_reposition_distance_scales_with_margin() -> None:
# APG Reposition: "For every 5 by which your attack exceeds your # APG Reposition: "For every 5 by which your attack exceeds your
# opponent's CMD, you can move the target an additional 5 feet." # opponent's CMD, you can move the target an additional 5 feet."