diff --git a/README.md b/README.md index d1fc5b8..e19a580 100644 --- a/README.md +++ b/README.md @@ -1059,9 +1059,12 @@ Règles modélisées : conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick → condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ; - `blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de - mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via - `Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la + `blinded`/`deafened`/`entangled`/`nauseated` possibles) ; 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 5 ft de la cible vers le premier voisin libre (direction sélectionnable via `Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant occupant sa case d'origine. `default_policy` ne diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index f098fe5..c9e1d46 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4448,16 +4448,15 @@ class CombatEngine: if not m.success: self._maneuver_fail(state, target, "steal", m) return - weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None) - if weapon is None: - outcome = "SUCCESS (nothing to steal)" - else: - target.weapon_stolen.add(weapon.id) - outcome = f"SUCCESS (stole {weapon.id})" + # 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. 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} -> {outcome}" + f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded " + f"weapons require disarm)" ) def _maneuver_reposition( diff --git a/tests/test_maneuvers.py b/tests/test_maneuvers.py index 093c6b4..77655c1 100644 --- a/tests/test_maneuvers.py +++ b/tests/test_maneuvers.py @@ -355,17 +355,20 @@ def test_dirty_trick_fails_on_natural_1() -> None: assert engine._transcript[-1].endswith("-> FAILED") -def test_steal_takes_melee_weapon() -> None: +def test_steal_refuses_wielded_weapon() -> None: + # 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 target's sword is wielded: the success + # steals nothing. a_spec = make_combatant("a", attack_bonus=2) b_spec = make_combatant("b") a = make_state(a_spec, "players", (1, 1)) b = make_state(b_spec, "monsters", (1, 2)) - hp_before = b.hp engine = make_engine([1, 12], [a, b]) engine._execute_maneuver(a, b, "steal") - assert "b-w" in b.weapon_stolen - assert engine.weapon_for(b, a) is None - assert b.hp == hp_before + assert not b.weapon_stolen + assert engine.weapon_for(b, a) is not None # the sword stays usable + assert "nothing stealable — wielded weapons require disarm" in engine._transcript[-1] def test_steal_nothing_to_steal() -> None: @@ -376,7 +379,7 @@ def test_steal_nothing_to_steal() -> None: engine = make_engine([12], [a, b]) engine._execute_maneuver(a, b, "steal") assert not b.weapon_stolen - assert "nothing to steal" in engine._transcript[-1] + assert "nothing stealable" in engine._transcript[-1] def test_reposition_moves_to_first_free_neighbor() -> None: diff --git a/tests/test_retrieve_weapon.py b/tests/test_retrieve_weapon.py index a9e1bcf..07b9843 100644 --- a/tests/test_retrieve_weapon.py +++ b/tests/test_retrieve_weapon.py @@ -2,8 +2,10 @@ L7 of the ``llm-policy-contract`` plan: ``Action`` gains a ``retrieve_weapon`` kind and a ``weapon_id`` field so a policy can restore a -weapon that a steal maneuver took from its owner. The weapon is removed from -``state.weapon_stolen`` and becomes usable again via ``usable_attacks()``. +weapon rendered unusable in ``state.weapon_stolen``. The weapon is removed +from ``state.weapon_stolen`` and becomes usable again via ``usable_attacks()``. +(The steal maneuver no longer fills this set: APG Steal forbids taking wielded +weapons.) No default policy emits ``retrieve_weapon``; it is only accepted by ``_execute`` (usage via custom policy / LLM). @@ -77,9 +79,11 @@ def test_retrieve_restores_stolen_weapon() -> None: 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(a, Action(kind="maneuver", target_id="b", maneuver="steal")) - assert "b-w" in b.weapon_stolen + # The steal maneuver no longer takes wielded weapons (APG Steal), so the + # stolen state is set directly — retrieve_weapon is the mechanism under + # test here. + b.weapon_stolen.add("b-w") + engine = make_engine([], [a, b]) assert "b-w" not in _weapon_ids(b) engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w")) assert not b.weapon_stolen @@ -114,17 +118,21 @@ def test_retrieve_unknown_id_logs_nothing_to_retrieve() -> None: assert b.weapon_stolen == {"b-w"} -# --- Test 4: real steal then retrieve restores the weapon -------------------- +# --- Test 4: steal leaves wielded weapons; retrieve restores a stolen id ----- def test_steal_then_retrieve_restores_weapon() -> None: + # APG Steal: a wielded weapon cannot be taken — the maneuver provokes, + # succeeds, and steals nothing; the owner keeps swinging. A weapon that IS + # stolen (model state set directly) is restored by retrieve_weapon. a_spec = make_combatant("a", attack_bonus=2) 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(a, Action(kind="maneuver", target_id="b", maneuver="steal")) - assert "b-w" in b.weapon_stolen - assert engine.weapon_for(b, a) is None + assert not b.weapon_stolen + assert engine.weapon_for(b, a) is not None + b.weapon_stolen.add("b-w") engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w")) assert not b.weapon_stolen assert engine.weapon_for(b, a) is not None