Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- `GiveItems`, a command that hands items and coins from one party member to another in zero game time — the distribute-the-load move for shifting weight off an overloaded companion so the party's marching rate recovers. Legal in town and while exploring (not mid-encounter or in battle); both members must be able-bodied and distinct. A given magic item releases its worn effects and lands unequipped in the recipient's pack, mundane items merge into a like stack, valuables and coins move across, and the transfer emits a player-visible `ItemsGivenEvent`. The giver must actually carry what's named, and a revealed cursed item cannot be handed off.

## [1.2.1] - 2026-07-20

### Fixed
Expand Down
40 changes: 40 additions & 0 deletions src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"Evade",
"ExtinguishSource",
"ForceDoor",
"GiveItems",
"GrantCoins",
"GrantItem",
"IdentifyItem",
Expand Down Expand Up @@ -597,6 +598,44 @@ class DropItems(Command):
coins: Coins = Coins()


class GiveItems(Command):
"""Hand items and coins from one party member to another (zero time).

The distribute-the-load move: `character_id` is the giver, `recipient_id` the
companion who takes the goods. Each `item_ids` entry gives one unit (repeat an
id for more); a given magic item releases any worn effects first and lands
unequipped in the recipient's pack. Legal in town and while exploring — not
mid-encounter or in battle. Both members must be able-bodied.

Modes:
`town`, `exploring`

Rejections:
- `session.command.wrong_mode` — an encounter or battle is underway, or the
game is over.
- `session.command.unknown_member` — `character_id` or `recipient_id` names
no party member.
- `session.command.member_incapacitated` — the giver or recipient cannot
act.
- `exploration.give.same_member` — giver and recipient are the same member.
- `items.curse.stuck` — a revealed cursed item cannot be handed off.
- `exploration.item.not_carried` — the giver lacks an item or the coins.

Events:
[`ItemsGivenEvent`][osrlib.crawl.events.ItemsGivenEvent] with what changed
hands. A worn magic item's effects release
([`EffectReleasedEvent`][osrlib.core.events.EffectReleasedEvent]).
"""

allowed_modes: ClassVar[frozenset[SessionMode]] = _FIELD_MODES

command_type: Literal["give_items"] = "give_items"
character_id: str
recipient_id: str
item_ids: tuple[str, ...] = ()
coins: Coins = Coins()


class LightSource(Command):
"""Light a torch or lantern, or ignite dropped oil (one round).

Expand Down Expand Up @@ -1662,6 +1701,7 @@ def _expression_must_parse(cls, value: str) -> str:
RemoveTreasureTrap,
TakeTreasure,
DropItems,
GiveItems,
LightSource,
ExtinguishSource,
EquipItem,
Expand Down
2 changes: 1 addition & 1 deletion src/osrlib/crawl/encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ def _handle_drop_during_encounter(session, command: DropItems) -> tuple[list[Rej
member, rejections = exploration._member_able(session, command.character_id)
if rejections:
return rejections, []
rejections = exploration._validate_drops(member, command)
rejections = exploration._validate_carried(member, command.item_ids, command.coins)
if rejections:
return rejections, []
in_pursuit = state.pursuit is not None
Expand Down
19 changes: 19 additions & 0 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"ItemIdentifiedEvent",
"ItemUsedEvent",
"ItemsDroppedEvent",
"ItemsGivenEvent",
"LightEvent",
"ListenedEvent",
"LocationEnteredEvent",
Expand Down Expand Up @@ -221,6 +222,23 @@ class ItemsDroppedEvent(Event):
coins_gp_value: int = 0


class ItemsGivenEvent(Event):
"""Items or coins handed from one party member to another.

`character_id` is the giver, `recipient_id` the companion who took the goods.
"""

allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.given"})

event_type: Literal["items_given"] = "items_given"
code: str = "exploration.item.given"
visibility: Visibility = Visibility.PLAYER
character_id: str
recipient_id: str
item_ids: tuple[str, ...] = ()
coins_gp_value: int = 0


class LightEvent(Event):
"""A light source changed state.

Expand Down Expand Up @@ -690,6 +708,7 @@ class DiceRolledEvent(Event):
TrapEvent,
ItemAcquiredEvent,
ItemsDroppedEvent,
ItemsGivenEvent,
LightEvent,
RestedEvent,
FatigueEvent,
Expand Down
89 changes: 85 additions & 4 deletions src/osrlib/crawl/exploration.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
EquipItem,
ExtinguishSource,
ForceDoor,
GiveItems,
InspectTreasure,
LightSource,
ListenAtDoor,
Expand Down Expand Up @@ -114,6 +115,7 @@
ItemAcquiredEvent,
ItemIdentifiedEvent,
ItemsDroppedEvent,
ItemsGivenEvent,
ItemUsedEvent,
LightEvent,
ListenedEvent,
Expand Down Expand Up @@ -1501,16 +1503,21 @@ def _handle_drop_items(session, command: DropItems) -> tuple[list[Rejection], li
member, rejections = _member_able(session, command.character_id)
if rejections:
return rejections, []
rejections = _validate_drops(member, command)
rejections = _validate_carried(member, command.item_ids, command.coins)
if rejections:
return rejections, []
events = _apply_drop(session, member, command, to_pile=True)
return [], events


def _validate_drops(member, command: DropItems) -> list[Rejection]:
def _validate_carried(member, item_ids: tuple[str, ...], coins: Coins) -> list[Rejection]:
"""Guard the item/coin handout shared by drop and give.

The member must carry every named item and coin, and no revealed cursed item
may leave its bearer.
"""
counts: dict[str, int] = {}
for item_id in command.item_ids:
for item_id in item_ids:
magic = member.inventory.magic_item(item_id)
if magic is not None:
if magic.cursed_revealed:
Expand All @@ -1530,11 +1537,84 @@ def _validate_drops(member, command: DropItems) -> list[Rejection]:
return [Rejection(code="exploration.item.not_carried", params={"item": item_id})]
purse = member.inventory.purse
for denomination in ("pp", "gp", "ep", "sp", "cp"):
if getattr(command.coins, denomination) > getattr(purse, denomination):
if getattr(coins, denomination) > getattr(purse, denomination):
return [Rejection(code="exploration.item.not_carried", params={"item": denomination})]
return []


def _handle_give_items(session, command: GiveItems) -> tuple[list[Rejection], list[Event]]:
giver, rejections = _member_able(session, command.character_id)
if rejections:
return rejections, []
if command.recipient_id == command.character_id:
return [Rejection(code="exploration.give.same_member", params={"character": command.character_id})], []
recipient, rejections = _member_able(session, command.recipient_id)
if rejections:
return rejections, []
rejections = _validate_carried(giver, command.item_ids, command.coins)
if rejections:
return rejections, []
events = _apply_give(session, giver, recipient, command)
return [], events


def _grant_mundane(member, item_id: str, quantity: int = 1) -> None:
"""Add a mundane equipment item to a member's pack, merging with any like stack."""
existing = next(
(
instance
for instance in member.inventory.items
if not isinstance(instance, MagicItemInstance) and instance.template.id == item_id
),
None,
)
if existing is not None:
existing.quantity += quantity
else:
member.inventory.items.append(ItemInstance(template=load_equipment().get(item_id), quantity=quantity))


def _apply_give(session, giver, recipient, command: GiveItems) -> list[Event]:
"""Move the named goods and coins from giver to recipient (zero time).

A given magic item releases any worn effects first, then lands unequipped in
the recipient's pack; mundane items merge into a like stack; coins move purse
to purse.
"""
events: list[Event] = []
for item_id in command.item_ids:
magic = giver.inventory.magic_item(item_id)
if magic is not None:
events.extend(_release_instance_effects(session, magic))
_remove_magic_instance(giver, magic)
recipient.inventory.items.append(magic)
continue
valuable = next(
(candidate for candidate in giver.inventory.valuables if candidate.instance_id == item_id), None
)
if valuable is not None:
giver.inventory.valuables.remove(valuable)
recipient.inventory.valuables.append(valuable)
continue
_consume_item(giver, item_id)
_grant_mundane(recipient, item_id)
giver_purse = giver.inventory.purse
recipient_purse = recipient.inventory.purse
for denomination in ("pp", "gp", "ep", "sp", "cp"):
moved = getattr(command.coins, denomination)
setattr(giver_purse, denomination, getattr(giver_purse, denomination) - moved)
setattr(recipient_purse, denomination, getattr(recipient_purse, denomination) + moved)
events.append(
ItemsGivenEvent(
character_id=giver.id,
recipient_id=recipient.id,
item_ids=tuple(command.item_ids),
coins_gp_value=command.coins.value_gp,
)
)
return events


def _apply_drop(session, member, command: DropItems, *, to_pile: bool) -> list[Event]:
"""Remove the dropped goods; onto the cell's pile, or scattered (pursuit bait)."""
pile = None
Expand Down Expand Up @@ -2886,6 +2966,7 @@ def _temple_cleric(spell) -> Character:
RemoveTreasureTrap: _handle_remove_treasure_trap,
TakeTreasure: _handle_take_treasure,
DropItems: _handle_drop_items,
GiveItems: _handle_give_items,
LightSource: _handle_light_source,
ExtinguishSource: _handle_extinguish_source,
EquipItem: _handle_equip_item,
Expand Down
7 changes: 7 additions & 0 deletions src/osrlib/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ def _turning(event: UndeadTurnedEvent, outcome: str) -> str:
+ (f"{event.coins_gp_value} gp in coin" if event.coins_gp_value else "")
+ "."
),
"exploration.item.given": lambda event: (
f"{event.character_id} gives "
+ (", ".join(event.item_ids) if event.item_ids else "")
+ (" and " if event.item_ids and event.coins_gp_value else "")
+ (f"{event.coins_gp_value} gp in coin" if event.coins_gp_value else "")
+ f" to {event.recipient_id}."
),
"exploration.light.lit": lambda event: f"{event.character_id} lights a {event.source}.",
"exploration.light.extinguished": lambda event: f"{event.character_id} extinguishes the {event.source}.",
"exploration.light.failed": lambda event: f"{event.character_id} fumbles with the tinder box — no flame.",
Expand Down
1 change: 1 addition & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def sample_command(command_class):
"RemoveTreasureTrap": dict(character_id="pc-1", feature_id="chest-1"),
"TakeTreasure": dict(feature_id="chest-1"),
"DropItems": dict(character_id="pc-1", item_ids=("torch",)),
"GiveItems": dict(character_id="pc-1", recipient_id="pc-2", item_ids=("torch",)),
"LightSource": dict(character_id="pc-1", item_id="torch"),
"ExtinguishSource": dict(character_id="pc-1"),
"EquipItem": dict(character_id="pc-1", item_id="sword"),
Expand Down
2 changes: 1 addition & 1 deletion tests/test_crawl_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def command_strategy():
if field_name == "command_type":
continue
annotation = str(field.annotation)
if field_name in ("character_id",):
if field_name in ("character_id", "recipient_id"):
fields[field_name] = st.sampled_from(CHARACTER_IDS)
elif field_name in ("item_id",):
fields[field_name] = st.sampled_from(ITEM_IDS)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_events_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ def sample_event(event_class, code):
"TrapEvent": dict(trap_ref="dungeon:1:room-3", character_id="pc-1"),
"ItemAcquiredEvent": dict(character_id="pc-1", item_ids=("torch",), coins_gp_value=100),
"ItemsDroppedEvent": dict(character_id="pc-1", item_ids=("rations_standard",), coins_gp_value=200),
"ItemsGivenEvent": dict(
character_id="pc-1", recipient_id="pc-2", item_ids=("rations_standard",), coins_gp_value=200
),
"LightEvent": dict(character_id="pc-1", source="torch"),
"RestedEvent": dict(kind="night"),
"FatigueEvent": dict(),
Expand Down
Loading
Loading