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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 53 additions & 4 deletions docs/adaptations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 25 additions & 4 deletions src/osrlib/crawl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,21 +534,41 @@ 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`

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
Expand All @@ -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):
Expand Down
20 changes: 20 additions & 0 deletions src/osrlib/crawl/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"ItemUsedEvent",
"ItemsDroppedEvent",
"ItemsGivenEvent",
"ItemsLeftBehindEvent",
"LightEvent",
"ListenedEvent",
"LocationEnteredEvent",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -708,6 +727,7 @@ class DiceRolledEvent(Event):
TrapEvent,
ItemAcquiredEvent,
ItemsDroppedEvent,
ItemsLeftBehindEvent,
ItemsGivenEvent,
LightEvent,
RestedEvent,
Expand Down
Loading
Loading