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
+69
View File
@@ -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."