diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a46a6f..5c1c5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 571e39e..609c717 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -51,6 +51,7 @@ "Evade", "ExtinguishSource", "ForceDoor", + "GiveItems", "GrantCoins", "GrantItem", "IdentifyItem", @@ -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). @@ -1662,6 +1701,7 @@ def _expression_must_parse(cls, value: str) -> str: RemoveTreasureTrap, TakeTreasure, DropItems, + GiveItems, LightSource, ExtinguishSource, EquipItem, diff --git a/src/osrlib/crawl/encounter.py b/src/osrlib/crawl/encounter.py index 2ac9607..edfc435 100644 --- a/src/osrlib/crawl/encounter.py +++ b/src/osrlib/crawl/encounter.py @@ -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 diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 8582331..41d4b6e 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -47,6 +47,7 @@ "ItemIdentifiedEvent", "ItemUsedEvent", "ItemsDroppedEvent", + "ItemsGivenEvent", "LightEvent", "ListenedEvent", "LocationEnteredEvent", @@ -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. @@ -690,6 +708,7 @@ class DiceRolledEvent(Event): TrapEvent, ItemAcquiredEvent, ItemsDroppedEvent, + ItemsGivenEvent, LightEvent, RestedEvent, FatigueEvent, diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index a9127a1..6b1e15a 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -69,6 +69,7 @@ EquipItem, ExtinguishSource, ForceDoor, + GiveItems, InspectTreasure, LightSource, ListenAtDoor, @@ -114,6 +115,7 @@ ItemAcquiredEvent, ItemIdentifiedEvent, ItemsDroppedEvent, + ItemsGivenEvent, ItemUsedEvent, LightEvent, ListenedEvent, @@ -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: @@ -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 @@ -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, diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index 407533b..d0e689b 100644 --- a/src/osrlib/messages.py +++ b/src/osrlib/messages.py @@ -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.", diff --git a/tests/test_commands.py b/tests/test_commands.py index dbc802d..6e75883 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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"), diff --git a/tests/test_crawl_properties.py b/tests/test_crawl_properties.py index 7593b2b..7498366 100644 --- a/tests/test_crawl_properties.py +++ b/tests/test_crawl_properties.py @@ -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) diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index 0229a0b..acab307 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -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(), diff --git a/tests/test_exploration.py b/tests/test_exploration.py index b6df33d..1f6b804 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -11,6 +11,7 @@ from osrlib.core.clock import ROUNDS_PER_TURN, TimeUnit from osrlib.core.effects import Condition, has_condition from osrlib.core.events import Visibility +from osrlib.core.items import Coins, ValuableInstance from osrlib.core.rng import RngStream from osrlib.core.ruleset import Ruleset from osrlib.crawl import exploration @@ -20,6 +21,8 @@ EnterDungeon, ExtinguishSource, ForceDoor, + GiveItems, + GrantCoins, GrantItem, InspectTreasure, LightSource, @@ -678,6 +681,113 @@ def test_flag_off_tracks_but_never_penalizes(self): assert exploration._fatigue_threshold(session) == 6 +class TestGiveItems: + """Handing goods and coins between members — the distribute-the-load move.""" + + def test_give_moves_coins_and_a_mundane_item_between_members(self): + session = quiet_session() + entered(session) + session.execute(GrantCoins(character_id="character-0001", coins=Coins(gp=100))) + spikes_before = sum( + i.quantity for i in session.member("character-0002").inventory.items if i.template.id == "iron_spikes" + ) + result = session.execute( + GiveItems( + character_id="character-0001", + recipient_id="character-0002", + item_ids=("iron_spikes",), + coins=Coins(gp=40), + ) + ) + assert result.accepted + given = next(event for event in result.events if event.code == "exploration.item.given") + assert given.recipient_id == "character-0002" + assert given.coins_gp_value == 40 + assert session.member("character-0001").inventory.purse.gp == 60 + assert session.member("character-0002").inventory.purse.gp == 40 + spikes_after = sum( + i.quantity for i in session.member("character-0002").inventory.items if i.template.id == "iron_spikes" + ) + assert spikes_after == spikes_before + 1 + + def test_give_moves_a_valuable(self): + session = quiet_session() + entered(session) + giver = session.member("character-0001") + gem = ValuableInstance(instance_id="valuable-9001", kind="gem", name="ruby", value_gp=500, weight_coins=10) + giver.inventory.valuables.append(gem) + result = session.execute( + GiveItems(character_id="character-0001", recipient_id="character-0003", item_ids=("valuable-9001",)) + ) + assert result.accepted + assert all(v.instance_id != "valuable-9001" for v in giver.inventory.valuables) + assert any(v.instance_id == "valuable-9001" for v in session.member("character-0003").inventory.valuables) + + def test_give_is_zero_time(self): + session = quiet_session() + entered(session) + session.execute(GrantCoins(character_id="character-0001", coins=Coins(gp=10))) + before = session.clock.rounds + session.execute(GiveItems(character_id="character-0001", recipient_id="character-0002", coins=Coins(gp=10))) + assert session.clock.rounds == before + + def test_give_relieves_an_overloaded_member_and_unfreezes_the_party(self): + session = quiet_session() + entered(session) + # 1,601 coins tops the 1,600-coin maximum load: movement drops to 0, and + # the party moves at the slowest living member's rate — everyone freezes. + session.member("character-0001").inventory.purse.gp = 1601 + assert session.member("character-0001").movement_rate(session.ruleset) == 0 + assert exploration.exploration_rate(session) == 0 + session.execute(GiveItems(character_id="character-0001", recipient_id="character-0002", coins=Coins(gp=800))) + assert session.member("character-0001").movement_rate(session.ruleset) > 0 + assert exploration.exploration_rate(session) > 0 + + def test_give_rejects_when_the_giver_lacks_the_item(self): + session = quiet_session() + entered(session) + result = session.execute( + GiveItems(character_id="character-0002", recipient_id="character-0001", item_ids=("plate_mail",)) + ) + assert not result.accepted + assert result.rejections[0].code == "exploration.item.not_carried" + + def test_give_rejects_more_coin_than_carried(self): + session = quiet_session() + entered(session) + result = session.execute( + GiveItems(character_id="character-0001", recipient_id="character-0002", coins=Coins(gp=999999)) + ) + assert not result.accepted + assert result.rejections[0].code == "exploration.item.not_carried" + + def test_give_rejects_to_self(self): + session = quiet_session() + entered(session) + result = session.execute( + GiveItems(character_id="character-0001", recipient_id="character-0001", coins=Coins(gp=0)) + ) + assert not result.accepted + assert result.rejections[0].code == "exploration.give.same_member" + + def test_give_rejects_unknown_recipient(self): + session = quiet_session() + entered(session) + result = session.execute( + GiveItems(character_id="character-0001", recipient_id="character-9999", item_ids=("iron_spikes",)) + ) + assert not result.accepted + assert result.rejections[0].code == "session.command.unknown_member" + + def test_give_is_allowed_in_town(self): + session = quiet_session() # a fresh session starts in town, before EnterDungeon + assert session.mode.value == "town" + result = session.execute( + GiveItems(character_id="character-0001", recipient_id="character-0002", item_ids=("iron_spikes",)) + ) + assert result.accepted + + class TestLocationEffects: def test_burning_oil_pool_damages_passers_through(self): session = quiet_session(seed=17) diff --git a/tools/docs/rejection_codes.json b/tools/docs/rejection_codes.json index 0cd7f6e..7ecd78e 100644 --- a/tools/docs/rejection_codes.json +++ b/tools/docs/rejection_codes.json @@ -47,6 +47,7 @@ "exploration.door.wedged": "The party tried to close or re-wedge a door that a spike or similar item is already holding open.", "exploration.feature.emptied": "The party tried to take treasure from a cache that has already been emptied.", "exploration.feature.unknown": "The command references a feature at the party's location that doesn't exist, or that isn't the kind of feature (such as a treasure cache) the action expects.", + "exploration.give.same_member": "A character was told to give items or coins to themselves; the giver and recipient must be different party members.", "exploration.item.not_carried": "The character or party doesn't currently have the referenced item, coin amount, or dropped item in their possession at this location.", "exploration.item.not_equipped": "The character tried to unequip an item that isn't currently worn or wielded.", "exploration.light.no_flame": "The party has no open flame currently burning to light this from, and the character isn't carrying a tinder box to strike a new one.",