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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user