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:
@@ -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