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
+47 -6
View File
@@ -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(