diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6d858..eb53d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- `TakeTreasure.recipient_id` (optional) — names the single party member who fills their pack from the cache or pile, in place of the default spread. The named member is also the one who reaches in, so they are the character an unresolved treasure trap springs on. Goods beyond that member's own maximum load stay on the cell. +- `ItemsLeftBehindEvent` (`exploration.item.left_behind`, player visibility) — reports the goods and coin a haul left in the drop pile on the party's cell because the carriers had no capacity for them. + ### Fixed +- A recovered haul now spreads across the living party instead of landing entirely on the first member in marching order. The old behavior was not merely unfair, it was party-stopping: one pickup of 600 sp and 900 cp on an already-laden character crossed the SRD's 1,600-coin maximum load, dropped that character's movement rate to 0, and — since "the movement rate of the party as a whole is determined by the speed of the slowest member" — froze the entire party until a player unwound it by hand with `GiveItems`. `TakeTreasure` now assigns items to a character whose class may use them (the same class policies `validate_equip` enforces, plus a magic item's own `usable_by`, ignoring equipped state), divides gems and jewellery by worth rather than by count, and divides coins evenly denomination by denomination — every coin weighing 1, an even split of a denomination is even in wealth and in weight at once. Nothing is ever loaded past the maximum load, so a pickup cannot immobilise a carrier that some other arrangement would have kept moving; whatever the party cannot carry stays in the cell's drop pile with an `ItemsLeftBehindEvent` rather than being destroyed, goods packing best-first so a party out of capacity keeps the platinum and leaves the copper. The pass is deterministic and spends no RNG draw — ties break on marching order — and the XP award is unchanged, since `party_valuation_cp` sums the whole party however the goods are split. `TakeTreasure` now emits one `ItemAcquiredEvent` per member who took something, which moved the Phase 4 and Phase 5 scenario goldens. - Encounter distance is now bounded by the space the encounter happens in. The 2d6 × 10' dungeon roll ignored the party's surroundings entirely, so an encounter keyed to a 20' room could open at 120' — six times the room's width — and a rolled distance routinely exceeded the whole dungeon level. The SRD rolls only "if there is uncertainty", since "the situation in which the encounter occurs often determines how far away the monster is"; the walls around the party's cell are that situation, so a rolled distance now caps at the longest unobstructed straight sight line from that cell across the four grid directions, floored at one cell (10'). A corridor still affords its full sight line and the printed 20'–120' band; a room affords no more than the room; shut and undiscovered-secret doors stop the line where they stop the party. Darkness never shortens it — light governs surprise, not distance — and a caller-supplied `distance_feet` (every referee spawn: `SpawnMonsters`, `SpawnNpcParty`) is never capped. The cap reads the rolled result and never the RNG stream, so no draw sequence changes; rolled distances in cramped spaces do change, which moved the Phase 4 and Phase 5 scenario goldens. ## [1.3.0] - 2026-07-24 diff --git a/docs/adaptations.md b/docs/adaptations.md index 0834386..8b89ffd 100644 --- a/docs/adaptations.md +++ b/docs/adaptations.md @@ -717,10 +717,59 @@ springing on movement (the party walks around the known pit); the player-facing without springing — an unknown trap's spring die stays referee-only. A trap transition (slides) relocates the whole party — the party model has one location (pinned simplification). The darts volley resolves as one damage application whose -rolls are every dart's die. `TakeTreasure` delivers contents to the first living -member in marching order, who is also the treasure trap's triggerer (pinned; the -command carries no character). Locked by `test_exploration.py::TestSearching`, -`TestTreasureTraps`, and `TestTrapResolutionCensus`. +rolls are every dart's die. `TakeTreasure`'s treasure trap springs on the character +who reaches in: the command's `recipient_id` when one is named, otherwise the first +living member in marching order (pinned). Locked by +`test_exploration.py::TestSearching`, `TestTreasureTraps`, and +`TestTrapResolutionCensus`. + +### A recovered haul spreads across the party: usable items, even wealth, everyone mobile + +RAW never says who physically carries the loot — it says the opposite, that the split +is the players' business: "Awarded XP is always divided evenly, irrespective of how +the players decide to divide the treasure" (*Awarding XP*). What RAW *does* pin are +the consequences: "The maximum load any character can carry is 1,600 coins of weight. +Characters carrying more than this cannot move", and "The movement rate of the party +as a whole is determined by the speed of the slowest member" (*Time, Weight, +Movement*). Together those make a single-carrier default a party-stopping bug rather +than a fairness quibble — one pickup on one character can freeze everyone. There is +no tabletop referee here to say "spread it out", so the engine does, as a documented +adaptation: + +- **Items go where they are useful.** Each item prefers a carrier whose class may use + it, judged by the same class policies `validate_equip` enforces plus a magic item's + own `usable_by` — the fighter takes the plate mail, the magic-user the arcane wand, + the cleric the divine staff. Equipped state is deliberately *not* consulted: the + question is where the item belongs, not whether their hands are full this instant. + When nobody's class can use it, it still goes in somebody's pack. +- **Gems and jewellery divide by worth, not by count.** Three 50 gp gems against one + 300 gp ring is even in objects and lopsided in wealth, and wealth is what a share + means. Value is also cheap to move — a 1,000 gp gem weighs one coin — so balancing + it costs the party nothing in mobility. Pieces deal richest-first, each to whoever + currently holds the least by value. +- **Coins divide evenly, denomination by denomination.** Every coin weighs 1 whatever + its metal, so an even split of a denomination is even in worth *and* even in weight + — the one place where a fair share and a mobile party want the same thing. Coins + are not used to compensate for a big gem: matching a 1,000 gp gem in coin means + 1,000 coins of weight, which is precisely the immobilisation being fixed. +- **Nothing is ever loaded past the maximum load.** A carrier who would cross 1,600 + coins does not take the parcel; the search moves on. A take therefore cannot + immobilise anyone that some other arrangement would have kept moving. +- **What will not fit stays where it lies**, in the drop pile on the party's cell, + with an `exploration.item.left_behind` event. Treasure is never destroyed: the + party lightens up and comes back. Goods pack best-first — items, then valuables, + then coins richest denomination first — so a party out of capacity keeps the + platinum and leaves the copper. + +The order is items, then valuables, then coins: the lumpy, class-constrained goods go +out while every carrier still has room to honour the constraint, and the fluid wealth +follows to even things up. The whole pass is deterministic and spends no RNG draw — +ties break on marching order, never on a die. It is automatic bookkeeping, not a +ruling: `GiveItems` and `DropItems` rearrange it freely, and the XP award is untouched +either way, since `party_valuation_cp` sums the whole party. `TakeTreasure` also +accepts an explicit `recipient_id`, which narrows the carriers to that one member +(still capped at their own maximum load). Locked by +`test_exploration.py::TestTreasureDistribution`. ### Light, darkness, and location-bound effects diff --git a/src/osrlib/crawl/commands.py b/src/osrlib/crawl/commands.py index 609c717..30b8a02 100644 --- a/src/osrlib/crawl/commands.py +++ b/src/osrlib/crawl/commands.py @@ -534,8 +534,24 @@ class TakeTreasure(Command): The party must be exploring a dungeon (see [`EnterDungeon`][osrlib.crawl.commands.EnterDungeon]). `feature_id` names an authored cache, an engine-generated cache, or the literal `pile` for goods - dropped on the cell. The leading living member carries everything; taking a - trapped cache with its trap unresolved risks springing it. + dropped on the cell. + + By default the haul **spreads across the living members**: items go to a + character whose class can use them (the fighter takes the plate mail, the + magic-user the arcane scroll), gems and jewellery divide by worth, and coins + divide evenly denomination by denomination. Nothing is ever loaded past the + 1,600-coin maximum load, so a pickup cannot immobilise the party — the group + moves at its slowest member's rate, so one mule stops everyone. Name + `recipient_id` to override: that member alone fills their pack, up to their own + maximum load. Whatever exceeds the carriers' capacity stays in the drop pile on + the cell — nothing is destroyed, and the party can lighten up and come back for + it. This first pass is automatic bookkeeping, not a ruling: rearrange it freely + with [`GiveItems`][osrlib.crawl.commands.GiveItems], and note that XP divides + evenly however the goods end up split, per RAW. + + The named recipient — or the leading living member when none is named — is the + one who reaches in, so taking a trapped cache with its trap unresolved risks + springing it on them. Modes: `exploring` @@ -543,12 +559,16 @@ class TakeTreasure(Command): Rejections: - `session.command.wrong_mode` — the session is not exploring a dungeon. - `session.command.no_living_members` — no one is left to carry. + - `session.command.unknown_member` — `recipient_id` names no party member. + - `session.command.member_incapacitated` — the recipient cannot act. - `exploration.feature.unknown` — nothing by that id on this cell. - `exploration.feature.emptied` — the cache has already been emptied. Events: - [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent] listing the - goods and coin value. An unresolved treasure trap rolls first + One [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent] per member + who took something, listing their goods and coin value, in marching order; + an [`ItemsLeftBehindEvent`][osrlib.crawl.events.ItemsLeftBehindEvent] when + the party could not carry it all. An unresolved treasure trap rolls first ([`DetectionRolledEvent`][osrlib.crawl.events.DetectionRolledEvent], a [`TrapEvent`][osrlib.crawl.events.TrapEvent], and the trap's resolution when it springs). Under the immediate XP timing an @@ -560,6 +580,7 @@ class TakeTreasure(Command): command_type: Literal["take_treasure"] = "take_treasure" feature_id: str + recipient_id: str | None = None class DropItems(Command): diff --git a/src/osrlib/crawl/events.py b/src/osrlib/crawl/events.py index 41d4b6e..ed4f08b 100644 --- a/src/osrlib/crawl/events.py +++ b/src/osrlib/crawl/events.py @@ -48,6 +48,7 @@ "ItemUsedEvent", "ItemsDroppedEvent", "ItemsGivenEvent", + "ItemsLeftBehindEvent", "LightEvent", "ListenedEvent", "LocationEnteredEvent", @@ -222,6 +223,24 @@ class ItemsDroppedEvent(Event): coins_gp_value: int = 0 +class ItemsLeftBehindEvent(Event): + """Treasure the party could not carry, left where it lay. + + Emitted by [`TakeTreasure`][osrlib.crawl.commands.TakeTreasure] when a haul + exceeds the carriers' remaining maximum load: the remainder lands in the drop + pile on the party's cell, so nothing is destroyed and a lightened party can come + back for it. + """ + + allowed_codes: ClassVar[frozenset[str]] = frozenset({"exploration.item.left_behind"}) + + event_type: Literal["items_left_behind"] = "items_left_behind" + code: str = "exploration.item.left_behind" + visibility: Visibility = Visibility.PLAYER + item_ids: tuple[str, ...] = () + coins_gp_value: int = 0 + + class ItemsGivenEvent(Event): """Items or coins handed from one party member to another. @@ -708,6 +727,7 @@ class DiceRolledEvent(Event): TrapEvent, ItemAcquiredEvent, ItemsDroppedEvent, + ItemsLeftBehindEvent, ItemsGivenEvent, LightEvent, RestedEvent, diff --git a/src/osrlib/crawl/exploration.py b/src/osrlib/crawl/exploration.py index 9cad8bc..e60615e 100644 --- a/src/osrlib/crawl/exploration.py +++ b/src/osrlib/crawl/exploration.py @@ -40,17 +40,21 @@ from osrlib.core.effects import EFFECTS_STREAM, Condition, EffectDefinition, ModifierSpec from osrlib.core.events import Event, SavingThrowRolledEvent from osrlib.core.items import ( + COIN_VALUES_CP, + MAX_LOAD_COINS, Coins, ItemInstance, MagicItemCategory, MagicItemInstance, equip, magic_item_template, + tracked_weight_coins, unequip, usable_by_class, validate_equip, validate_unequip, ) +from osrlib.core.ruleset import EncumbranceMode from osrlib.core.spells import ( MAGIC_STREAM, CastContext, @@ -117,6 +121,7 @@ ItemIdentifiedEvent, ItemsDroppedEvent, ItemsGivenEvent, + ItemsLeftBehindEvent, ItemUsedEvent, LightEvent, ListenedEvent, @@ -1446,7 +1451,13 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio living = session.party.living_members() if not living: return [Rejection(code="session.command.no_living_members")], [] - taker = living[0] + carriers = living + if command.recipient_id is not None: + recipient, rejections = _member_able(session, command.recipient_id) + if rejections: + return rejections, [] + carriers = [recipient] + opener = carriers[0] # whoever reaches in: the named recipient, else the leader state = session.dungeon_state events: list[Event] = [] if command.feature_id == "pile": @@ -1454,12 +1465,8 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio pile = state.piles.get(ref) if pile is None: return [Rejection(code="exploration.feature.unknown", params={"feature": "pile"})], [] - item_ids = _transfer_pile(taker, pile) del state.piles[ref] - events.append( - ItemAcquiredEvent(character_id=taker.id, item_ids=tuple(item_ids), coins_gp_value=pile.coins.value_gp) - ) - events.extend(_immediate_treasure_award(session, pile.coins.value_cp, pile.valuables)) + events.extend(_take_haul(session, carriers, pile)) turn_events, _ = _spend_turn(session) events.extend(turn_events) return [], events @@ -1467,20 +1474,9 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio if cache is not None: if cache.cell_ref != _cell_ref(session): return [Rejection(code="exploration.feature.unknown", params={"feature": command.feature_id})], [] - item_ids = [] - for valuable in cache.valuables: - taker.inventory.valuables.append(valuable) - item_ids.append(valuable.instance_id) - for magic_item in cache.magic_items: - taker.inventory.items.append(magic_item) - item_ids.append(magic_item.instance_id) - purse = taker.inventory.purse - for denomination in ("pp", "gp", "ep", "sp", "cp"): - setattr(purse, denomination, getattr(purse, denomination) + getattr(cache.coins, denomination)) - coins_value = cache.coins.value_gp + haul = DropPile(coins=cache.coins, valuables=list(cache.valuables), magic_items=list(cache.magic_items)) del state.generated_caches[command.feature_id] - events.append(ItemAcquiredEvent(character_id=taker.id, item_ids=tuple(item_ids), coins_gp_value=coins_value)) - events.extend(_immediate_treasure_award(session, cache.coins.value_cp, cache.valuables)) + events.extend(_take_haul(session, carriers, haul)) turn_events, _ = _spend_turn(session) events.extend(turn_events) return [], events @@ -1500,60 +1496,391 @@ def _handle_take_treasure(session, command: TakeTreasure) -> tuple[list[Rejectio events.append(DetectionRolledEvent(kind="trap_spring", chance=2, roll=spring_roll, passed=sprung)) if sprung: state.sprung_traps.append(ref) - events.append(TrapEvent(code="exploration.trap.sprung", trap_ref=ref, character_id=taker.id)) - events.extend(_resolve_trap(session, trap, triggerer=taker)) + events.append(TrapEvent(code="exploration.trap.sprung", trap_ref=ref, character_id=opener.id)) + events.extend(_resolve_trap(session, trap, triggerer=opener)) elif ref in state.found_traps: # The party knows the trap is there and sees it fail to fire; an # unknown trap that doesn't spring stays referee-only (no leak). - events.append(TrapEvent(code="exploration.trap.safe", trap_ref=ref, character_id=taker.id)) - equipment = load_equipment() - item_ids: list[str] = [] - for item_id in feature.item_ids: - taker.inventory.items.append(ItemInstance(template=equipment.get(item_id))) - item_ids.append(item_id) - for spec in feature.valuables: + events.append(TrapEvent(code="exploration.trap.safe", trap_ref=ref, character_id=opener.id)) + # The spring check may just have felled somebody: the packs get filled by + # whoever is still standing, and a named recipient who went down hands the job + # back to the party rather than stranding the haul on a corpse. + carriers = [member for member in carriers if not incapacitated(member)] or session.party.living_members() + from osrlib.core.items import ValuableInstance + + haul = DropPile( + coins=feature.coins, + items=[DroppedItem(item_id=item_id, quantity=1) for item_id in feature.item_ids], # Authored named valuables (the example's MacGuffin) instantiate on take. - from osrlib.core.items import ValuableInstance - - valuable = ValuableInstance( - instance_id=session.allocator.allocate("valuable"), - kind=spec.kind, - name=spec.name, - value_gp=spec.value_gp, - weight_coins=spec.weight_coins, - ) - taker.inventory.valuables.append(valuable) - item_ids.append(valuable.instance_id) - purse = taker.inventory.purse - for denomination in ("pp", "gp", "ep", "sp", "cp"): - setattr(purse, denomination, getattr(purse, denomination) + getattr(feature.coins, denomination)) - state.emptied_caches.append(ref) - events.append( - ItemAcquiredEvent(character_id=taker.id, item_ids=tuple(item_ids), coins_gp_value=feature.coins.value_gp) + valuables=[ + ValuableInstance( + instance_id=session.allocator.allocate("valuable"), + kind=spec.kind, + name=spec.name, + value_gp=spec.value_gp, + weight_coins=spec.weight_coins, + ) + for spec in feature.valuables + ], ) - taken_valuables = [valuable for valuable in taker.inventory.valuables if valuable.instance_id in item_ids] - events.extend(_immediate_treasure_award(session, feature.coins.value_cp, taken_valuables)) + state.emptied_caches.append(ref) + events.extend(_take_haul(session, carriers, haul)) turn_events, _ = _spend_turn(session) events.extend(turn_events) return [], events -def _transfer_pile(taker, pile: DropPile) -> list[str]: +# ---------------------------------------------------------------------- hauling treasure + +_DENOMINATIONS: tuple[str, ...] = tuple(COIN_VALUES_CP) +"""The coin denominations, most valuable first — every coin weighs 1, so richest-first +is also best-value-per-coin-of-weight: the order a party short of capacity packs them.""" + +_CLASS_USE_CODES = frozenset( + { + "items.equip.armour_forbidden", + "items.equip.armour_not_allowed", + "items.equip.shield_forbidden", + "items.equip.weapon_forbidden", + "items.equip.weapon_not_allowed", + "items.equip.not_usable", + } +) +"""The [`validate_equip`][osrlib.core.items.validate_equip] rejections that mean *this +class* may not use the item. `items.equip.not_equippable` is deliberately absent: plain +gear and ammunition are equippable by nobody, which is no reason to steer them.""" + +_SPREAD_MODES: dict[EncumbranceMode, EncumbranceMode] = { + EncumbranceMode.NONE: EncumbranceMode.BASIC, + EncumbranceMode.BASIC: EncumbranceMode.BASIC, + EncumbranceMode.DETAILED: EncumbranceMode.DETAILED, +} +"""The weight the haul spread compares loads by. `none` tracks nothing at all, so the +spread compares treasure weight anyway (one member hoarding the loot is still the wrong +answer) and simply never refuses a load.""" + + +def _carry_load(member, ruleset) -> int: + """Return the carried weight in coins the haul spread compares carriers by.""" + return tracked_weight_coins(member.inventory, _SPREAD_MODES[ruleset.encumbrance]) + + +def _within_max_load(member, ruleset) -> bool: + """Return whether the member is still inside the 1,600-coin maximum load. + + Always true under the `none` encumbrance mode, which tracks no weight and imposes + no cap. + """ + if ruleset.encumbrance is EncumbranceMode.NONE: + return True + return tracked_weight_coins(member.inventory, ruleset.encumbrance) <= MAX_LOAD_COINS + + +def _coin_headroom(member, ruleset, wanted: int) -> int: + """Return how many more coins of weight the member can take. + + Args: + member: The carrier. + ruleset: The ruleset whose encumbrance mode governs. + wanted: The most that could possibly be handed over; the answer under the + `none` mode, which caps nothing. + """ + if ruleset.encumbrance is EncumbranceMode.NONE: + return wanted + return max(0, MAX_LOAD_COINS - tracked_weight_coins(member.inventory, ruleset.encumbrance)) + + +def _uncarry(collection: list, instance) -> None: + """Remove exactly the object that was handed over — by identity, never by equality. + + Two like `ItemInstance`s (same template, same quantity) compare equal under + pydantic, so `list.remove` could take a torch the member already owned and leave + the newly offered one behind, aliased into two inventories at once. + + Args: + collection: The inventory list the item was appended to. + instance: The exact object to take back out. + """ + for index, entry in enumerate(collection): + if entry is instance: + del collection[index] + return + + +def _can_use(member, instance) -> bool: + """Return whether the member's class may use this item — the loot-sorting preference. + + Only class policy counts, so the equipped-state checks are skipped (no inventory + is passed): the question is whether the plate mail belongs with the fighter, not + whether their hands are full this instant. A magic item passes its own `usable_by` + on top, which is what steers an arcane scroll to the magic-user. + + Args: + member (osrlib.core.character.Character): The candidate carrier. + instance: The [`ItemInstance`][osrlib.core.items.ItemInstance] or + [`MagicItemInstance`][osrlib.core.items.MagicItemInstance] being sorted. + + Returns: + True when nothing in the class's policies objects. + """ + if any(rejection.code in _CLASS_USE_CODES for rejection in validate_equip(member.definition, instance, None)): + return False + if isinstance(instance, MagicItemInstance): + return usable_by_class(magic_item_template(instance), member.definition) + return True + + +def _hand_item(carriers, ruleset, instance, put, take_back): + """Hand one item to a carrier who can use it, preferring the least loaded. + + The two best-effort rules in order: a carrier whose class may use the item wins + over one who may not, and among equals the lightest-laden takes it — so the loot + lands where it is useful without immobilising anyone. Ties break on marching + order, so the choice is deterministic and spends no RNG draw. `put` and + `take_back` place the item and undo that placement, which measures the marginal + weight against the real inventory instead of re-deriving it — the encumbrance + rules stay in one place. + + Args: + carriers: The candidate carriers, in marching order. + ruleset: The ruleset whose encumbrance mode governs. + instance: The item being handed out, for the usability preference. + put: Callable placing the item on a member. + take_back: Callable undoing `put` on a member. + + Returns: + The index of the carrier who took it, or `None` when every carrier is + already at maximum load. + """ + order = sorted( + range(len(carriers)), + key=lambda index: (not _can_use(carriers[index], instance), _carry_load(carriers[index], ruleset), index), + ) + for index in order: + member = carriers[index] + put(member) + if _within_max_load(member, ruleset): + return index + take_back(member) + return None + + +def _hand_valuable(carriers, ruleset, valuable): + """Hand one gem or piece of jewellery to the carrier holding the least treasure by worth. + + Gems and jewellery divide by *value*, not by count: three 50 gp gems against one + 300 gp ring is even in objects and lopsided in worth, and worth is what a share of + the treasure means. Value is cheap to move — a 1,000 gp gem weighs one coin — so + balancing it costs the party nothing in mobility. Ties break on marching order. + + Args: + carriers: The candidate carriers, in marching order. + ruleset: The ruleset whose encumbrance mode governs. + valuable: The [`ValuableInstance`][osrlib.core.items.ValuableInstance] to place. + + Returns: + The index of the carrier who took it, or `None` when every carrier is + already at maximum load. + """ + + def worth(member) -> int: + return sum(held.value_gp for held in member.inventory.valuables) + + order = sorted(range(len(carriers)), key=lambda index: (worth(carriers[index]), index)) + for index in order: + member = carriers[index] + member.inventory.valuables.append(valuable) + if _within_max_load(member, ruleset): + return index + _uncarry(member.inventory.valuables, valuable) + return None + + +def _even_shares(headrooms: list[int], total: int) -> list[int]: + """Split `total` coins as evenly across the carriers as their headroom allows. + + Every coin weighs one, whatever its metal, so an even split of a denomination is + even in worth *and* even in weight — the one place where fairness and mobility + want the same thing. A carrier who would pass their maximum load takes only what + fits and their surplus re-splits among the rest; the sub-coin remainder goes to + the front of the march. + + Args: + headrooms: Each carrier's remaining capacity in coins, in marching order. + total: The number of coins of one denomination to split. + + Returns: + Each carrier's share, in the same order; the shares sum to less than `total` + only when the party has run out of capacity. + """ + shares = [0] * len(headrooms) + remaining = total + while remaining > 0: + open_indices = [index for index in range(len(headrooms)) if headrooms[index] - shares[index] > 0] + if not open_indices: + break + base, extra = divmod(remaining, len(open_indices)) + if base == 0: # fewer coins left than carriers with room + for index in open_indices[:remaining]: + shares[index] += 1 + break + remaining = 0 + for position, index in enumerate(open_indices): + wanted = base + (1 if position < extra else 0) + given = min(wanted, headrooms[index] - shares[index]) + shares[index] += given + remaining += wanted - given + return shares + + +def _spread_coins(carriers, ruleset, coins: Coins) -> tuple[list[dict[str, int]], Coins]: + """Deal a haul's coins evenly across the carriers, richest denomination first. + + Each denomination splits on its own, so every carrier's share is equal in worth + and in weight. Richest-first is the order that matters only when the party runs + short of capacity: the platinum goes in the packs and the copper stays behind. + + Args: + carriers: The carriers, in marching order. + ruleset: The ruleset whose encumbrance mode governs. + coins: The pot to deal. + + Returns: + Each carrier's take by denomination (in carrier order) and the coins the + party could not carry. + """ + takes = [dict.fromkeys(_DENOMINATIONS, 0) for _ in carriers] + left = dict.fromkeys(_DENOMINATIONS, 0) + for denomination in _DENOMINATIONS: + total = getattr(coins, denomination) + if not total: + continue + headrooms = [_coin_headroom(member, ruleset, total) for member in carriers] + shares = _even_shares(headrooms, total) + for index, share in enumerate(shares): + if not share: + continue + purse = carriers[index].inventory.purse + setattr(purse, denomination, getattr(purse, denomination) + share) + takes[index][denomination] += share + left[denomination] = total - sum(shares) + return takes, Coins(**left) + + +def _distribute_haul(session, carriers, haul: DropPile) -> tuple[list[Event], DropPile, int, list]: + """Pack a haul into the carriers' packs: items where they are useful, wealth evenly. + + The order is items, then valuables, then coins — the lumpy, constrained goods + first while every carrier still has room to honour the constraint, then the wealth + that can be split to even things out. Items (magic items first, then gear) go to a + carrier whose class can use them, breaking ties toward the lightest load; gems and + jewellery divide by worth; coins divide evenly per denomination. Nothing is ever + placed past the 1,600-coin maximum load, so a take can never immobilise a carrier + that some other arrangement would have kept moving. + + Args: + session (osrlib.crawl.session.GameSession): The running session. + carriers: The carriers, in marching order. + haul: The goods to pack. Consumed, not mutated in place. + + Returns: + The per-carrier [`ItemAcquiredEvent`][osrlib.crawl.events.ItemAcquiredEvent]s + in marching order, the remainder nobody could carry, the coin value taken in + copper pieces, and the valuables taken (the XP award's exact inputs). + """ + ruleset = session.ruleset equipment = load_equipment() - item_ids: list[str] = [] - for dropped in pile.items: - taker.inventory.items.append(ItemInstance(template=equipment.get(dropped.item_id), quantity=dropped.quantity)) + taken_ids: list[list[str]] = [[] for _ in carriers] + taken_valuables: list = [] + leftovers = DropPile() + + for magic_item in haul.magic_items: + holder = _hand_item( + carriers, + ruleset, + magic_item, + lambda member, item=magic_item: member.inventory.items.append(item), + lambda member, item=magic_item: _uncarry(member.inventory.items, item), + ) + if holder is None: + leftovers.magic_items.append(magic_item) + continue + taken_ids[holder].append(magic_item.instance_id) + for dropped in haul.items: + instance = ItemInstance(template=equipment.get(dropped.item_id), quantity=dropped.quantity) + holder = _hand_item( + carriers, + ruleset, + instance, + lambda member, item=instance: member.inventory.items.append(item), + lambda member, item=instance: _uncarry(member.inventory.items, item), + ) + if holder is None: + leftovers.items.append(dropped) + continue + taken_ids[holder].extend([dropped.item_id] * dropped.quantity) + # Richest piece first: the greedy split of a set of values is at its evenest when + # the big lumps are placed while there is still room to balance them. + for valuable in sorted(haul.valuables, key=lambda piece: -piece.value_gp): + holder = _hand_valuable(carriers, ruleset, valuable) + if holder is None: + leftovers.valuables.append(valuable) + continue + taken_ids[holder].append(valuable.instance_id) + taken_valuables.append(valuable) + takes, leftovers.coins = _spread_coins(carriers, ruleset, haul.coins) + + events: list[Event] = [] + taken_cp = 0 + for index, member in enumerate(carriers): + taken_coins = Coins(**takes[index]) + if not taken_ids[index] and not taken_coins.total_coins: + continue + taken_cp += taken_coins.value_cp + events.append( + ItemAcquiredEvent( + character_id=member.id, item_ids=tuple(taken_ids[index]), coins_gp_value=taken_coins.value_gp + ) + ) + return events, leftovers, taken_cp, taken_valuables + + +def _leave_behind(session, leftovers: DropPile) -> list[Event]: + """Park what the party could not carry in the drop pile at its feet. + + Treasure is never destroyed: a party that drops gear (or comes back later) picks + the remainder up with another `TakeTreasure`. + """ + if not (leftovers.items or leftovers.valuables or leftovers.magic_items or leftovers.coins.total_coins): + return [] + pile = session.dungeon_state.piles.setdefault(_cell_ref(session), DropPile()) + for dropped in leftovers.items: + existing = next((entry for entry in pile.items if entry.item_id == dropped.item_id), None) + if existing is None: + pile.items.append(dropped) + else: + existing.quantity += dropped.quantity + pile.valuables.extend(leftovers.valuables) + pile.magic_items.extend(leftovers.magic_items) + pile.coins = Coins( + **{ + denomination: getattr(pile.coins, denomination) + getattr(leftovers.coins, denomination) + for denomination in _DENOMINATIONS + } + ) + item_ids = [magic_item.instance_id for magic_item in leftovers.magic_items] + item_ids += [valuable.instance_id for valuable in leftovers.valuables] + for dropped in leftovers.items: item_ids.extend([dropped.item_id] * dropped.quantity) - for valuable in pile.valuables: - taker.inventory.valuables.append(valuable) - item_ids.append(valuable.instance_id) - for magic_item in pile.magic_items: - taker.inventory.items.append(magic_item) - item_ids.append(magic_item.instance_id) - purse = taker.inventory.purse - for denomination in ("pp", "gp", "ep", "sp", "cp"): - setattr(purse, denomination, getattr(purse, denomination) + getattr(pile.coins, denomination)) - return item_ids + return [ItemsLeftBehindEvent(item_ids=tuple(item_ids), coins_gp_value=leftovers.coins.value_gp)] + + +def _take_haul(session, carriers, haul: DropPile) -> list[Event]: + """Distribute one haul, park the remainder, and award the immediate-timing XP.""" + events, leftovers, taken_cp, taken_valuables = _distribute_haul(session, carriers, haul) + events.extend(_leave_behind(session, leftovers)) + events.extend(_immediate_treasure_award(session, taken_cp, taken_valuables)) + return events # ---------------------------------------------------------------------- items and light diff --git a/src/osrlib/messages.py b/src/osrlib/messages.py index d0e689b..d76a42c 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.left_behind": lambda event: ( + "The party cannot carry it all — " + + (", ".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 "") + + " stays where it lies." + ), "exploration.item.given": lambda event: ( f"{event.character_id} gives " + (", ".join(event.item_ids) if event.item_ids else "") diff --git a/tests/crawl_fixtures.py b/tests/crawl_fixtures.py index ed84e20..5949f0f 100644 --- a/tests/crawl_fixtures.py +++ b/tests/crawl_fixtures.py @@ -44,6 +44,7 @@ from osrlib.data import load_classes __all__ = [ + "STOCK_ROSTER", "build_adventure", "build_party", "build_sightline_adventure", @@ -220,16 +221,18 @@ def _member(name: str, class_id: str) -> Character: ) -def build_party() -> Party: - """Build a stock four-member party (ids unassigned; the session assigns them).""" - return Party( - members=[ - _member("Brakk", "fighter"), - _member("Sable", "thief"), - _member("Wynn", "cleric"), - _member("Elara", "magic_user"), - ] - ) +STOCK_ROSTER = (("Brakk", "fighter"), ("Sable", "thief"), ("Wynn", "cleric"), ("Elara", "magic_user")) +"""The stock party's names and classes, in marching order.""" + + +def build_party(roster=STOCK_ROSTER) -> Party: + """Build the stock four-member party (ids unassigned; the session assigns them). + + Args: + roster: `(name, class_id)` pairs in marching order. Pass a narrower roster to + exercise class-gated behavior — loot only one class can use, say. + """ + return Party(members=[_member(name, class_id) for name, class_id in roster]) def build_milestone_adventure() -> Adventure: diff --git a/tests/goldens/phase4_delve.json b/tests/goldens/phase4_delve.json index 5bb2a23..59544ee 100644 --- a/tests/goldens/phase4_delve.json +++ b/tests/goldens/phase4_delve.json @@ -655,7 +655,8 @@ }, { "command_type": "take_treasure", - "feature_id": "coffer" + "feature_id": "coffer", + "recipient_id": null }, { "character_id": "character-0002", @@ -3253,7 +3254,39 @@ { "character_id": "character-0001", "code": "exploration.item.acquired", - "coins_gp_value": 400, + "coins_gp_value": 80, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "exploration.item.acquired", + "coins_gp_value": 80, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0003", + "code": "exploration.item.acquired", + "coins_gp_value": 80, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0004", + "code": "exploration.item.acquired", + "coins_gp_value": 80, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0005", + "code": "exploration.item.acquired", + "coins_gp_value": 80, "event_type": "item_acquired", "item_ids": [], "visibility": "player" @@ -8334,7 +8367,11 @@ "monster-0006 (goblin) is routed.", "The encounter ends (victory).", "The party moves to (4, 1), facing east.", - "character-0001 acquires 400 gp in coin.", + "character-0001 acquires 80 gp in coin.", + "character-0002 acquires 80 gp in coin.", + "character-0003 acquires 80 gp in coin.", + "character-0004 acquires 80 gp in coin.", + "character-0005 acquires 80 gp in coin.", "Detection roll (secret_doors, character-0002): 5 vs 2-in-6 — failure.", "character-0002 searches (secret_doors) and finds nothing.", "Wandering check: 3 vs 3-in-6 — an encounter!", diff --git a/tests/goldens/phase5_milestone.json b/tests/goldens/phase5_milestone.json index 62f66a3..a53d351 100644 --- a/tests/goldens/phase5_milestone.json +++ b/tests/goldens/phase5_milestone.json @@ -339,7 +339,8 @@ }, { "command_type": "take_treasure", - "feature_id": "cache-0001" + "feature_id": "cache-0001", + "recipient_id": null }, { "command_type": "move_party", @@ -351,10 +352,11 @@ }, { "command_type": "take_treasure", - "feature_id": "idol_shrine" + "feature_id": "idol_shrine", + "recipient_id": null }, { - "character_id": "character-0001", + "character_id": "character-0002", "coins": { "cp": 0, "ep": 0, @@ -377,7 +379,8 @@ }, { "command_type": "take_treasure", - "feature_id": "cache-0002" + "feature_id": "cache-0002", + "recipient_id": null }, { "command_type": "engage_battle" @@ -596,7 +599,8 @@ }, { "command_type": "take_treasure", - "feature_id": "pile" + "feature_id": "pile", + "recipient_id": null }, { "command_type": "resolve_battle_round", @@ -1643,13 +1647,37 @@ { "character_id": "character-0001", "code": "exploration.item.acquired", - "coins_gp_value": 50, + "coins_gp_value": 13, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "exploration.item.acquired", + "coins_gp_value": 13, "event_type": "item_acquired", "item_ids": [ "valuable-0002" ], "visibility": "player" }, + { + "character_id": "character-0003", + "code": "exploration.item.acquired", + "coins_gp_value": 12, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0004", + "code": "exploration.item.acquired", + "coins_gp_value": 12, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, { "chance": 0, "code": "exploration.wandering.checked", @@ -1659,7 +1687,7 @@ "visibility": "referee" }, { - "character_id": "character-0001", + "character_id": "character-0002", "code": "exploration.item.acquired", "coins_gp_value": 200, "event_type": "item_acquired", @@ -1713,7 +1741,31 @@ { "character_id": "character-0001", "code": "exploration.item.acquired", - "coins_gp_value": 210, + "coins_gp_value": 52, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "exploration.item.acquired", + "coins_gp_value": 52, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0003", + "code": "exploration.item.acquired", + "coins_gp_value": 52, + "event_type": "item_acquired", + "item_ids": [], + "visibility": "player" + }, + { + "character_id": "character-0004", + "code": "exploration.item.acquired", + "coins_gp_value": 52, "event_type": "item_acquired", "item_ids": [], "visibility": "player" @@ -3035,9 +3087,18 @@ "character_id": "character-0001", "code": "town.treasure.sold", "event_type": "treasure_sold", - "gp_value": 3800, + "gp_value": 1600, + "instance_ids": [ + "valuable-0001" + ], + "visibility": "player" + }, + { + "character_id": "character-0002", + "code": "town.treasure.sold", + "event_type": "treasure_sold", + "gp_value": 2200, "instance_ids": [ - "valuable-0001", "valuable-0002" ], "visibility": "player" @@ -3552,15 +3613,21 @@ "The party moves to (3, 0), facing east.", "The party moves to (4, 0), facing east.", "The party enters area shrine (level 1).", - "character-0001 acquires valuable-0002 and 50 gp in coin.", + "character-0001 acquires 13 gp in coin.", + "character-0002 acquires valuable-0002 and 13 gp in coin.", + "character-0003 acquires 12 gp in coin.", + "character-0004 acquires 12 gp in coin.", "Wandering check: skipped vs 0-in-6 — nothing comes.", - "character-0001 acquires 200 gp in coin.", + "character-0002 acquires 200 gp in coin.", "The party enters level barrow (level 2).", "The party moves to (1, 0), facing east.", "The party moves to (2, 0), facing east.", "The party enters area vault (level 2).", "Treasure generated at cache-0002 (unguarded): 210 gp in coin, 0 valuable(s), 0 magic item(s).", - "character-0001 acquires 210 gp in coin.", + "character-0001 acquires 52 gp in coin.", + "character-0002 acquires 52 gp in coin.", + "character-0003 acquires 52 gp in coin.", + "character-0004 acquires 52 gp in coin.", "Wandering check: 2 vs 6-in-6 — an encounter!", "An NPC party (basic, neutral) takes the field: npc-0001 (dwarf 1), npc-0002 (magic_user 2).", "Surprise (monsters): rolled 6, surprised on 1-2 — not surprised.", @@ -3676,7 +3743,8 @@ "character-0002 gains 480 XP (base 600), now level 1.", "character-0003 gains 660 XP (base 600), now level 2.", "character-0004 gains 660 XP (base 600), now level 1.", - "character-0001 sells 2 valuable(s) for 3800 gp.", + "character-0001 sells 1 valuable(s) for 1600 gp.", + "character-0002 sells 1 valuable(s) for 2200 gp.", "character-0001 purchases cure_light_wounds at the temple for 25 gp.", "town-cleric casts cure_light_wounds [heal] at character-0001.", "character-0001 regains 2 hit points (magical).", diff --git a/tests/test_events_kernel.py b/tests/test_events_kernel.py index acab307..5c9ea18 100644 --- a/tests/test_events_kernel.py +++ b/tests/test_events_kernel.py @@ -94,6 +94,7 @@ def sample_event(event_class, code): "ItemsGivenEvent": dict( character_id="pc-1", recipient_id="pc-2", item_ids=("rations_standard",), coins_gp_value=200 ), + "ItemsLeftBehindEvent": dict(item_ids=("valuable-0001",), coins_gp_value=40), "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 1f6b804..d80351e 100644 --- a/tests/test_exploration.py +++ b/tests/test_exploration.py @@ -7,13 +7,13 @@ import pytest -from crawl_fixtures import build_adventure, build_party +from crawl_fixtures import STOCK_ROSTER, build_adventure, build_party from osrlib.core.clock import ROUNDS_PER_TURN, TimeUnit -from osrlib.core.effects import Condition, has_condition +from osrlib.core.effects import Condition, has_condition, kill from osrlib.core.events import Visibility -from osrlib.core.items import Coins, ValuableInstance +from osrlib.core.items import Coins, ItemInstance, MagicItemInstance, ValuableInstance from osrlib.core.rng import RngStream -from osrlib.core.ruleset import Ruleset +from osrlib.core.ruleset import EncumbranceMode, Ruleset from osrlib.crawl import exploration from osrlib.crawl.commands import ( AdvanceTime, @@ -41,8 +41,17 @@ UseStairs, WedgeDoor, ) -from osrlib.crawl.dungeon import Direction, PartyLocation, TrapEffect, TrapSpec +from osrlib.crawl.dungeon import ( + Direction, + DroppedItem, + DropPile, + PartyLocation, + TrapEffect, + TrapSpec, + cell_ref, +) from osrlib.crawl.session import EXPLORATION_STREAM, GameSession +from osrlib.data import load_equipment pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") @@ -393,20 +402,257 @@ def test_remove_requires_a_found_trap(self): assert result.rejections[0].code == "exploration.trap.not_found" def test_take_treasure_runs_the_spring_check_and_fills_packs(self): + # Seed 9 springs the poison needle on the leader, who dies reaching in; the + # survivors split the 200 gp between them and the corpse takes no share. session = self.build_at_chest(seed=9) - first = session.party.living_members()[0] + before = session.party.living_members() result = session.execute(TakeTreasure(feature_id="chest")) assert result.accepted codes = [event.code for event in result.events] assert codes[0] == "exploration.detection.rolled" # the 2-in-6 spring check - acquired = next(event for event in result.events if event.code == "exploration.item.acquired") - assert acquired.coins_gp_value == 200 - assert first.inventory.purse.gp == 200 + acquired = [event for event in result.events if event.code == "exploration.item.acquired"] + survivors = session.party.living_members() + assert len(survivors) == len(before) - 1 + assert sorted(member.inventory.purse.gp for member in survivors) == [66, 67, 67] + assert sum(member.inventory.purse.gp for member in session.party.members) == 200 + assert sum(event.item_ids.count("holy_water") for event in acquired) == 1 assert "delve:1:chest" in session.dungeon_state.emptied_caches again = session.execute(TakeTreasure(feature_id="chest")) assert not again.accepted +class TestTreasureDistribution: + """`TakeTreasure`'s haul spread: usable items, even wealth, everybody still moving.""" + + def at_entrance(self, roster=STOCK_ROSTER, seed: int = 5, ruleset: Ruleset | None = None): + session = GameSession.new( + build_party(roster), build_adventure(wandering_chance=0), seed=seed, ruleset=ruleset or Ruleset() + ) + session.execute(EnterDungeon(dungeon_id="delve")) + place(session, (0, 0)) + return session + + def pile(self, session, **contents) -> None: + session.dungeon_state.piles[cell_ref("delve", 1, (0, 0))] = DropPile(**contents) + + def purses(self, session) -> list: + return [member.inventory.purse for member in session.party.living_members()] + + def test_a_haul_that_would_overload_one_member_spreads_instead(self): + # The reported failure: 600 sp + 900 cp on the member already holding coin + # tops the 1,600-coin maximum load, and the party moves at its slowest. + session = self.at_entrance() + session.execute(GrantCoins(character_id="character-0001", coins=Coins(gp=150))) + self.pile(session, coins=Coins(sp=600, cp=900)) + result = session.execute(TakeTreasure(feature_id="pile")) + assert result.accepted + acquired = [event for event in result.events if event.code == "exploration.item.acquired"] + assert len(acquired) == 4 # everyone shoulders a share + assert all(member.movement_rate(session.ruleset) > 0 for member in session.party.living_members()) + assert exploration.exploration_rate(session) > 0 + assert session.execute(MoveParty(direction=Direction.EAST)).accepted + + def test_coins_divide_evenly_denomination_by_denomination(self): + session = self.at_entrance() + self.pile(session, coins=Coins(gp=100, sp=50, cp=7)) + assert session.execute(TakeTreasure(feature_id="pile")).accepted + purses = self.purses(session) + assert [purse.gp for purse in purses] == [25, 25, 25, 25] + assert [purse.sp for purse in purses] == [13, 13, 12, 12] # the odd two to the front + assert [purse.cp for purse in purses] == [2, 2, 2, 1] + + def test_gems_and_jewellery_divide_by_worth_not_by_count(self): + session = self.at_entrance() + pieces = [300, 300, 50, 50, 50, 50, 50, 50] + self.pile( + session, + valuables=[ + ValuableInstance(instance_id=f"valuable-90{index:02d}", kind="gem", value_gp=value, weight_coins=1) + for index, value in enumerate(pieces) + ], + ) + assert session.execute(TakeTreasure(feature_id="pile")).accepted + worths = [sum(v.value_gp for v in member.inventory.valuables) for member in session.party.living_members()] + counts = [len(member.inventory.valuables) for member in session.party.living_members()] + assert sorted(worths) == [150, 150, 300, 300] # even in worth + assert sorted(counts) == [1, 1, 3, 3] # deliberately lopsided in objects + assert sum(worths) == sum(pieces) + + def test_items_go_to_a_character_whose_class_can_use_them(self): + session = self.at_entrance() + self.pile( + session, + items=[DroppedItem(item_id="plate_mail", quantity=1)], + magic_items=[ + MagicItemInstance(instance_id="magic-item-9001", template_id="wand_of_cold"), + MagicItemInstance(instance_id="magic-item-9002", template_id="staff_of_healing"), + ], + ) + assert session.execute(TakeTreasure(feature_id="pile")).accepted + holder = {} + for member in session.party.living_members(): + for instance in member.inventory.items: + key = instance.instance_id if isinstance(instance, MagicItemInstance) else instance.template.id + holder[key] = member.class_id + assert holder["magic-item-9001"] == "magic_user" # arcane-only wand + assert holder["magic-item-9002"] == "cleric" # divine-only staff + assert holder["plate_mail"] in ("fighter", "cleric") # never the thief or the magic-user + + def test_an_item_nobody_can_use_still_finds_a_carrier(self): + session = self.at_entrance(roster=(("Elara", "magic_user"), ("Ione", "magic_user"))) + self.pile(session, items=[DroppedItem(item_id="plate_mail", quantity=1)]) + result = session.execute(TakeTreasure(feature_id="pile")) + assert result.accepted + carried = [instance.template.id for member in session.party.members for instance in member.inventory.items] + assert carried.count("plate_mail") == 1 # nobody may wear it; somebody still hauls it + assert not session.dungeon_state.piles + + def test_an_item_that_will_not_fit_moves_on_without_disturbing_a_like_one(self): + # Detailed encumbrance weighs armour, and the fighter — the only member who + # may wear plate, so the first tried — is already too laden for a second + # suit, which falls back to the magic-user who merely hauls it. The suit the + # fighter already owned must stay put: two like `ItemInstance`s compare + # equal, so backing the offered one out by equality would take the wrong + # object and leave one suit aliased into two inventories. + session = self.at_entrance( + roster=(("Brakk", "fighter"), ("Elara", "magic_user")), + ruleset=Ruleset(encumbrance=EncumbranceMode.DETAILED), + ) + fighter, mage = session.party.members + owned = ItemInstance(template=load_equipment().get("plate_mail"), quantity=1) + fighter.inventory.items.append(owned) + session.execute(GrantCoins(character_id="character-0001", coins=Coins(gp=700))) + self.pile(session, items=[DroppedItem(item_id="plate_mail", quantity=1)]) + result = session.execute(TakeTreasure(feature_id="pile")) + assert result.accepted + assert [event.character_id for event in result.events if event.code == "exploration.item.acquired"] == [mage.id] + suits = [ + instance + for member in session.party.members + for instance in member.inventory.items + if not isinstance(instance, MagicItemInstance) and instance.template.id == "plate_mail" + ] + assert len(suits) == 2 and len({id(suit) for suit in suits}) == 2 # two suits, two objects + assert any(instance is owned for instance in fighter.inventory.items) + assert all(member.movement_rate(session.ruleset) > 0 for member in session.party.members) + + def test_a_haul_beyond_the_party_leaves_the_rest_and_loses_nothing(self): + session = self.at_entrance() + self.pile(session, coins=Coins(cp=10_000)) + result = session.execute(TakeTreasure(feature_id="pile")) + assert result.accepted + left = next(event for event in result.events if event.code == "exploration.item.left_behind") + carried = sum(purse.total_coins for purse in self.purses(session)) + remaining = session.dungeon_state.piles[cell_ref("delve", 1, (0, 0))].coins + assert carried == 4 * 1600 # every pack filled to the maximum load, no further + assert carried + remaining.total_coins == 10_000 # nothing destroyed + assert left.coins_gp_value == remaining.value_gp + assert all(member.movement_rate(session.ruleset) > 0 for member in session.party.living_members()) + + def test_the_richest_denomination_goes_in_the_packs_first(self): + session = self.at_entrance() + self.pile(session, coins=Coins(pp=4_000, cp=4_000)) + assert session.execute(TakeTreasure(feature_id="pile")).accepted + purses = self.purses(session) + remaining = session.dungeon_state.piles[cell_ref("delve", 1, (0, 0))].coins + assert sum(purse.pp for purse in purses) == 4_000 # platinum first + assert remaining.pp == 0 and remaining.cp == 1_600 # copper is what stays + assert sum(purse.cp for purse in purses) + remaining.cp == 4_000 + + def test_a_named_recipient_takes_the_lot(self): + session = self.at_entrance() + self.pile(session, coins=Coins(gp=100)) + result = session.execute(TakeTreasure(feature_id="pile", recipient_id="character-0003")) + assert result.accepted + assert [event.character_id for event in result.events if event.code == "exploration.item.acquired"] == [ + "character-0003" + ] + assert [purse.gp for purse in self.purses(session)] == [0, 0, 100, 0] + + def test_a_named_recipient_carries_only_what_fits_and_the_rest_stays(self): + session = self.at_entrance() + self.pile(session, coins=Coins(cp=2_000)) + result = session.execute(TakeTreasure(feature_id="pile", recipient_id="character-0002")) + assert result.accepted + assert session.member("character-0002").inventory.purse.cp == 1_600 + assert session.member("character-0002").movement_rate(session.ruleset) > 0 + assert session.dungeon_state.piles[cell_ref("delve", 1, (0, 0))].coins.cp == 400 + assert any(event.code == "exploration.item.left_behind" for event in result.events) + + def test_an_unknown_recipient_rejects_before_anything_moves(self): + session = self.at_entrance() + self.pile(session, coins=Coins(gp=100)) + before = session.clock.rounds + result = session.execute(TakeTreasure(feature_id="pile", recipient_id="character-9999")) + assert result.rejections[0].code == "session.command.unknown_member" + assert session.clock.rounds == before + assert session.dungeon_state.piles[cell_ref("delve", 1, (0, 0))].coins.gp == 100 + + def test_a_dead_recipient_rejects_and_the_survivors_still_split_the_haul(self): + session = self.at_entrance() + self.pile(session, coins=Coins(gp=99)) + kill(session.member("character-0002")) + refused = session.execute(TakeTreasure(feature_id="pile", recipient_id="character-0002")) + assert refused.rejections[0].code == "session.command.member_incapacitated" + assert session.execute(TakeTreasure(feature_id="pile")).accepted + assert [member.inventory.purse.gp for member in session.party.members] == [33, 0, 33, 33] + + def test_the_named_recipient_is_the_one_who_springs_the_cache_trap(self): + # Seed 9 rolls the 2-in-6 spring: the character who reaches in takes it. + session = quiet_session(seed=9) + entered(session) + place(session, (3, 2)) + result = session.execute(TakeTreasure(feature_id="chest", recipient_id="character-0003")) + assert result.accepted + sprung = next(event for event in result.events if event.code == "exploration.trap.sprung") + assert sprung.character_id == "character-0003" + + def test_the_spread_consumes_no_randomness(self): + session = self.at_entrance() + self.pile( + session, + coins=Coins(gp=137, sp=91), + valuables=[ValuableInstance(instance_id="valuable-9001", kind="gem", value_gp=500, weight_coins=1)], + items=[DroppedItem(item_id="plate_mail", quantity=1)], + ) + before = {key: state.model_dump() for key, state in session.streams.export_states().items()} + assert session.execute(TakeTreasure(feature_id="pile")).accepted + after = {key: state.model_dump() for key, state in session.streams.export_states().items()} + assert after == before + + def test_the_split_is_identical_across_two_seeded_runs(self): + def run(): + session = self.at_entrance(seed=17) + self.pile( + session, + coins=Coins(gp=137, sp=91, cp=13), + valuables=[ + ValuableInstance(instance_id=f"valuable-90{index:02d}", kind="gem", value_gp=value, weight_coins=1) + for index, value in enumerate((90, 40, 40, 10)) + ], + magic_items=[MagicItemInstance(instance_id="magic-item-9001", template_id="wand_of_cold")], + ) + result = session.execute(TakeTreasure(feature_id="pile")) + return [event.model_dump(mode="json") for event in result.events] + + assert run() == run() + + def test_the_party_total_is_conserved_across_a_take(self): + session = self.at_entrance() + session.execute(GrantCoins(character_id="character-0001", coins=Coins(gp=25))) + before = session.party_valuation_cp() + self.pile( + session, + coins=Coins(gp=100, ep=3), + valuables=[ + ValuableInstance(instance_id="valuable-9001", kind="jewellery", value_gp=700, weight_coins=10), + ValuableInstance(instance_id="valuable-9002", kind="gem", value_gp=250, weight_coins=1), + ], + ) + assert session.execute(TakeTreasure(feature_id="pile")).accepted + assert session.party_valuation_cp() == before + Coins(gp=100, ep=3).value_cp + (700 + 250) * 100 + + class TestTrapResolutionCensus: """The Designing_a_Dungeon example traps, resolved through the kernel."""