fix(rules): stop steal from taking held weapons
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 a successful steal now takes nothing and logs "nothing stealable — wielded weapons require disarm". README updated; the retrieve_weapon tests keep locking the restoration mechanism directly. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1059,9 +1059,12 @@ Règles modélisées :
|
|||||||
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
|
conformance L7 (SRD « Trip ») : un trip qui « fails by 10 or more » renverse
|
||||||
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick →
|
l'**attaquant** (`prone` 1 round, `total <= CMD - 10`) ; dirty trick →
|
||||||
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
|
condition choisie par `Action.trick_condition` (défaut `dazzled` 1 round ;
|
||||||
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → l'arme de
|
`blinded`/`deafened`/`entangled`/`nauseated` possibles) ; steal → le modèle ne porte
|
||||||
mêlée de la cible est volée (exclue de `usable_attacks()`, récupérable via
|
aucun objet transporté non tenu : en application de l'APG « Steal » (« Items
|
||||||
`Action(kind="retrieve_weapon")`) ; reposition → déplacement de 5 ft de la
|
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
|
cible vers le premier voisin libre (direction sélectionnable via
|
||||||
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant
|
`Action.direction`) ; drag → traction de 5 ft de la cible, l'attaquant
|
||||||
occupant sa case d'origine. `default_policy` ne
|
occupant sa case d'origine. `default_policy` ne
|
||||||
|
|||||||
@@ -4448,16 +4448,15 @@ 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
|
||||||
weapon = next((w for w in target.usable_attacks() if w.kind == "melee"), None)
|
# PF1e (APG Steal): "Items held in the hands (such as wielded weapons
|
||||||
if weapon is None:
|
# or wands) also cannot be taken with the steal maneuver—you must use
|
||||||
outcome = "SUCCESS (nothing to steal)"
|
# the disarm combat maneuver instead." The model carries no non-held
|
||||||
else:
|
# items, so every stealable target is held: a success takes nothing.
|
||||||
target.weapon_stolen.add(weapon.id)
|
|
||||||
outcome = f"SUCCESS (stole {weapon.id})"
|
|
||||||
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} -> {outcome}"
|
f"CMD {m.target_cmd} -> SUCCESS (nothing stealable — wielded "
|
||||||
|
f"weapons require disarm)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _maneuver_reposition(
|
def _maneuver_reposition(
|
||||||
|
|||||||
@@ -355,17 +355,20 @@ def test_dirty_trick_fails_on_natural_1() -> None:
|
|||||||
assert engine._transcript[-1].endswith("-> FAILED")
|
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)
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
b_spec = make_combatant("b")
|
b_spec = make_combatant("b")
|
||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
hp_before = b.hp
|
|
||||||
engine = make_engine([1, 12], [a, b])
|
engine = make_engine([1, 12], [a, b])
|
||||||
engine._execute_maneuver(a, b, "steal")
|
engine._execute_maneuver(a, b, "steal")
|
||||||
assert "b-w" in b.weapon_stolen
|
assert not b.weapon_stolen
|
||||||
assert engine.weapon_for(b, a) is None
|
assert engine.weapon_for(b, a) is not None # the sword stays usable
|
||||||
assert b.hp == hp_before
|
assert "nothing stealable — wielded weapons require disarm" in engine._transcript[-1]
|
||||||
|
|
||||||
|
|
||||||
def test_steal_nothing_to_steal() -> None:
|
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 = make_engine([12], [a, b])
|
||||||
engine._execute_maneuver(a, b, "steal")
|
engine._execute_maneuver(a, b, "steal")
|
||||||
assert not b.weapon_stolen
|
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:
|
def test_reposition_moves_to_first_free_neighbor() -> None:
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
L7 of the ``llm-policy-contract`` plan: ``Action`` gains a
|
L7 of the ``llm-policy-contract`` plan: ``Action`` gains a
|
||||||
``retrieve_weapon`` kind and a ``weapon_id`` field so a policy can restore 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
|
weapon rendered unusable in ``state.weapon_stolen``. The weapon is removed
|
||||||
``state.weapon_stolen`` and becomes usable again via ``usable_attacks()``.
|
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
|
No default policy emits ``retrieve_weapon``; it is only accepted by
|
||||||
``_execute`` (usage via custom policy / LLM).
|
``_execute`` (usage via custom policy / LLM).
|
||||||
@@ -77,9 +79,11 @@ def test_retrieve_restores_stolen_weapon() -> None:
|
|||||||
b_spec = make_combatant("b")
|
b_spec = make_combatant("b")
|
||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([1, 12], [a, b])
|
# The steal maneuver no longer takes wielded weapons (APG Steal), so the
|
||||||
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
|
# stolen state is set directly — retrieve_weapon is the mechanism under
|
||||||
assert "b-w" in b.weapon_stolen
|
# test here.
|
||||||
|
b.weapon_stolen.add("b-w")
|
||||||
|
engine = make_engine([], [a, b])
|
||||||
assert "b-w" not in _weapon_ids(b)
|
assert "b-w" not in _weapon_ids(b)
|
||||||
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
|
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
|
||||||
assert not b.weapon_stolen
|
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"}
|
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:
|
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)
|
a_spec = make_combatant("a", attack_bonus=2)
|
||||||
b_spec = make_combatant("b")
|
b_spec = make_combatant("b")
|
||||||
a = make_state(a_spec, "players", (1, 1))
|
a = make_state(a_spec, "players", (1, 1))
|
||||||
b = make_state(b_spec, "monsters", (1, 2))
|
b = make_state(b_spec, "monsters", (1, 2))
|
||||||
engine = make_engine([1, 12], [a, b])
|
engine = make_engine([1, 12], [a, b])
|
||||||
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
|
engine._execute(a, Action(kind="maneuver", target_id="b", maneuver="steal"))
|
||||||
assert "b-w" in b.weapon_stolen
|
assert not b.weapon_stolen
|
||||||
assert engine.weapon_for(b, a) is None
|
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"))
|
engine._execute(b, Action(kind="retrieve_weapon", weapon_id="b-w"))
|
||||||
assert not b.weapon_stolen
|
assert not b.weapon_stolen
|
||||||
assert engine.weapon_for(b, a) is not None
|
assert engine.weapon_for(b, a) is not None
|
||||||
|
|||||||
Reference in New Issue
Block a user