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(
|
||||
|
||||
@@ -242,6 +242,9 @@ def load_combatant(path: Path) -> Combatant:
|
||||
The ``armor_spell_failure`` percent and the ``arcane_caster`` flag
|
||||
(SRD "Arcane Spells and Armor") are plain fields of the schema (defaults
|
||||
``0`` and ``false``), so their JSON keys load directly as well.
|
||||
The ``carried_items`` list (APG "Steal" — non-wielded, stealable gear) is
|
||||
a plain ``tuple[str, ...]`` field of the schema (default empty), so a JSON
|
||||
``carried_items`` key loads directly too.
|
||||
"""
|
||||
try:
|
||||
raw = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -382,6 +382,18 @@ class Combatant(_FrozenModel):
|
||||
# PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of
|
||||
# LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral).
|
||||
alignment: str = "N"
|
||||
# PF1e (APG "Steal"): carried items that are NEITHER wielded in a hand
|
||||
# NOR closely worn - potions, scrolls, tools, pouches, keys, rations...
|
||||
# "This maneuver can be used in melee to take any item that is neither
|
||||
# held nor hidden in a bag or pack [...] Items that are closely worn
|
||||
# (such as armor, backpacks, boots, clothing, or rings) cannot be taken
|
||||
# with this maneuver. 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." Wielded weapons live in
|
||||
# ``attacks``; closely worn gear is not modeled (the AC in the JSON is
|
||||
# a final value), so this list holds only stealable carried items.
|
||||
# rules/advanced-players-guide/steal.md
|
||||
carried_items: tuple[str, ...] = ()
|
||||
|
||||
@field_validator("alignment")
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user