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:
@@ -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.
|
||||
`spell_resistance` (entier), `resistances` (dict type → montant) et
|
||||
`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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
> `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 » :
|
||||
« your target drops one item it is carrying » — `weapon_dropped`, exclue
|
||||
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
|
||||
(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
|
||||
@@ -1558,12 +1584,23 @@ Règles modélisées :
|
||||
« Dirty Trick » ;
|
||||
`blinded`/`dazzled`/`deafened`/`entangled`/`shaken`/`sickened` possibles
|
||||
(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
|
||||
aucun objet transporté non tenu : en application de l'APG « Steal » (« 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 ne vole rien (journalisé `nothing stealable — wielded weapons
|
||||
require disarm`) ; reposition → déplacement de la cible de 5 ft plus
|
||||
la liste RAW, et rejeté à l'exécution avec journalisation) ; steal → le
|
||||
champ `carried_items` du JSON de combattant porte les **objets transportés
|
||||
non maniés et non portés serré** (potions, scrolls, outils, bourses,
|
||||
vivres…) — seuls objets volables selon l'APG « Steal » (« 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 [...]
|
||||
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
|
||||
move the target an additional 5 feet » (APG « Reposition ») — `1 +
|
||||
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) :
|
||||
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||
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
|
||||
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
||||
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
||||
|
||||
@@ -104,5 +104,21 @@
|
||||
],
|
||||
"spells": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,5 +61,6 @@
|
||||
"armor_spell_failure": 0,
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"skills": {},
|
||||
"carried_items": []
|
||||
}
|
||||
|
||||
@@ -83,5 +83,6 @@
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"skills": {},
|
||||
"carried_items": []
|
||||
}
|
||||
|
||||
@@ -82,5 +82,6 @@
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"skills": {},
|
||||
"carried_items": []
|
||||
}
|
||||
|
||||
@@ -80,5 +80,6 @@
|
||||
"spell_slots": {},
|
||||
"resources": {},
|
||||
"metamagic_feats": [],
|
||||
"skills": {}
|
||||
"skills": {},
|
||||
"carried_items": ["Saddle (riding)", "Feed", "Trail Rations"]
|
||||
}
|
||||
|
||||
+13
-1
@@ -172,5 +172,17 @@
|
||||
],
|
||||
"resistances": {
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,5 +117,15 @@
|
||||
"Ice Slick",
|
||||
"Darkness",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-1
@@ -126,5 +126,14 @@
|
||||
"Delay Poison",
|
||||
"Ironskin",
|
||||
"Restoration, Lesser"
|
||||
]
|
||||
],
|
||||
"carried_items": [
|
||||
|
||||
"Arrow",
|
||||
"Holy symbol (Wood)",
|
||||
"Clefs de la loge",
|
||||
"Eh je suis Éclaireuse maintenant",
|
||||
"Kit, Fishing",
|
||||
"Alchemist's Fire"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -206,5 +206,28 @@
|
||||
"Delay Poison",
|
||||
"Aid",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -854,6 +854,11 @@ class CombatantState:
|
||||
# Runtime weapon theft (not in the frozen data model): weapon ids taken by
|
||||
# steal maneuvers.
|
||||
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
|
||||
# combatant DROPPED on a failed disarm maneuver (SRD Disarm: "your
|
||||
# opponent drops the weapon"). A retrieve_weapon move action picks the
|
||||
@@ -965,6 +970,8 @@ class CombatantState:
|
||||
def __post_init__(self) -> None:
|
||||
if not self.spell_slots_left:
|
||||
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:
|
||||
"""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_stolen": tuple(sorted(state.weapon_stolen)),
|
||||
"weapon_dropped": tuple(sorted(state.weapon_dropped)),
|
||||
"carried_items": tuple(state.carried_items),
|
||||
"grappled_with": state.grappled_with,
|
||||
"moved_this_turn": state.moved_this_turn,
|
||||
"power_attack": state.power_attack,
|
||||
@@ -5639,6 +5647,23 @@ class CombatEngine:
|
||||
f"{target.combatant.id} d20={m.roll}+{m.attacker_cmb}={m.total} "
|
||||
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(
|
||||
self,
|
||||
@@ -5936,15 +5961,31 @@ class CombatEngine:
|
||||
if not m.success:
|
||||
self._maneuver_fail(state, target, "steal", m)
|
||||
return
|
||||
# PF1e (APG Steal): "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." The model carries no non-held
|
||||
# items, so every stealable target is held: a success takes nothing.
|
||||
# PF1e (APG Steal): "This maneuver can be used in melee to take any
|
||||
# item that is neither held nor hidden in a bag or pack [...] 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." 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(
|
||||
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 (nothing stealable — wielded "
|
||||
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 (nothing stealable — wielded "
|
||||
f"weapons require disarm)"
|
||||
f"CMD {m.target_cmd} -> SUCCESS (took {item})"
|
||||
)
|
||||
|
||||
def _maneuver_reposition(
|
||||
|
||||
@@ -242,6 +242,9 @@ def load_combatant(path: Path) -> Combatant:
|
||||
The ``armor_spell_failure`` percent and the ``arcane_caster`` flag
|
||||
(SRD "Arcane Spells and Armor") are plain fields of the schema (defaults
|
||||
``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:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -382,6 +382,18 @@ class Combatant(_FrozenModel):
|
||||
# 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).
|
||||
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")
|
||||
@classmethod
|
||||
|
||||
@@ -617,6 +617,75 @@ def test_steal_nothing_to_steal() -> None:
|
||||
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:
|
||||
# APG Reposition: "For every 5 by which your attack exceeds your
|
||||
# opponent's CMD, you can move the target an additional 5 feet."
|
||||
|
||||
Reference in New Issue
Block a user