Skip to content

Distribute recovered treasure across the party - #32

Merged
mmacy merged 1 commit into
mainfrom
fix-loot-distribution
Jul 25, 2026
Merged

Distribute recovered treasure across the party#32
mmacy merged 1 commit into
mainfrom
fix-loot-distribution

Conversation

@mmacy

@mmacy mmacy commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Fixes the case reported downstream as osr-web#8: "Party member in position 0 is bestowed all loot."

The bug

All three branches of _handle_take_treasure took living[0]. Battle and surrender spoils land in the cell's DropPile and are recovered through TakeTreasure, so this covered essentially every haul in play.

It can immobilise the party. RAW puts maximum load at 1,600 coins of weight, says a character carrying more cannot move, and sets the party's rate by its slowest member. Measured: 150 gp plus a 600 sp / 900 cp pile put 1,650 coins on member zero, dropped them to movement rate 0, and MoveParty then rejected with exploration.move.cannot_move for the whole party. One pickup, party stuck, unwindable only by hand through GiveItems.

Every write to a member's inventory or purse was swept; the other sites (GiveItems, the referee grant commands, selling, purchasing, evasion, NPC equipping, starting gold) already name their recipient and are correct.

The policy

Items → valuables → coins. Items are the lumpy, class-constrained part and go out while everyone still has room to honour the constraint; wealth follows as the fluid balancer.

  • Items to a character whose class can use them, via the existing equip policies and, for magic, usable_by_class — so plate mail stops landing on the magic-user. Least-loaded wins among usable carriers; an item nobody can use still finds a pack rather than vanishing.
  • Gems and jewellery balance by worth, not count, richest piece first to whoever holds the least value. A share means worth, and value is nearly free to move — a 1,000 gp gem weighs one coin. Balancing by count would call one 300 gp ring even with three 50 gp gems.
  • Coins divide evenly denomination by denomination — the one place fairness and mobility agree, since every coin weighs 1.
  • Rejected: compensating a big gem with coin. Matching a 1,000 gp gem in coin is 1,000 coins of weight, which is precisely the immobilisation being fixed.
  • Maximum load is a hard floor. Nothing is placed past it, so a take cannot immobilise anyone. What will not fit stays in the cell as a pile and emits a new exploration.item.left_behind; best-first packing means a capacity-starved party keeps the platinum and leaves the copper.
  • recipient_id is optional and additive — one named carrier, capped at their own load, and also the character who springs any trap on the cache.

RAW

Silent on who physically carries, and explicitly so — srd/Awarding_XP.md: "Awarded XP is always divided evenly, irrespective of how the players decide to divide the treasure." Registered as an engineering decision in the adaptations register, not as a rule. What RAW does pin is the consequence, in Time, Weight, Movement: the 1,600-coin ceiling and party-speed-is-slowest-member. XP is unaffected — it sums the whole party.

Determinism

Distribution consumes no randomness (asserted against exported stream state). Goldens regenerated for the event stream only; every RNG stream state, clock reading, XP award, and level-up line is byte-identical, and the phase-4 seed search selected the same seed. recipient_id is additive, so no schema_version bump.

An aliasing bug found in passing

Pydantic models compare by value, so removing an item by equality could remove an equal item the carrier already owned and leave the offered one aliased into two inventories at once. Removal is now by identity, with a regression test that fails 2 == 2 and 1 == 2 if reverted.

Verification

  • 1606 passed, 58 skipped (baseline 1590, +16)
  • ruff format --check, ruff check, pyright 0 errors, mkdocs build --strict clean
  • Downstream osr-web: 193 passed, 6 screenshot shots, gate exit 0, byte-stable across two captures

play-view.png now shows the party splitting a haul — the thief takes the silver dagger and holy water, everyone shares the coin — instead of one name hoovering the lot.

https://claude.ai/code/session_01PjCy2kE4R1Yj85rizkkXpV

Every treasure pickup landed entirely on the first living member: all three
branches of _handle_take_treasure took `living[0]`. Since battle and surrender
spoils funnel back through TakeTreasure, that covered essentially every haul
in play.

It is not only unfair. RAW puts maximum load at 1,600 coins of weight, states
that a character carrying more cannot move, and sets the party's rate by its
slowest member — so one haul on one character stops everyone. Measured: 150 gp
plus a 600 sp / 900 cp pile put 1,650 coins on member zero, dropped them to a
movement rate of 0, and had MoveParty reject with exploration.move.cannot_move
for the whole party.

A haul is now distributed: items first, then valuables, then coins.

Items go to a character whose class can actually use them, checked through the
existing equip policies and, for magic, usable_by_class — so the plate mail
stops landing on the magic-user. Least-loaded wins among usable carriers, and
an item nobody can use still finds a pack rather than vanishing.

Gems and jewellery balance by worth rather than count, richest piece first to
whoever holds the least value. A share means worth, and value is nearly free
to move — a thousand-gold gem weighs one coin. Coins then divide evenly
denomination by denomination, which is the one place fairness and mobility
agree, since every coin weighs the same.

Nothing is ever placed past a character's maximum load, so a take cannot
immobilise anyone. What will not fit stays in the cell as a pile and emits
exploration.item.left_behind; best-first packing means a capacity-starved
party keeps the platinum and leaves the copper. TakeTreasure gains an optional
recipient_id for callers that want one carrier, who is also the character who
springs any trap on the cache.

RAW is silent on who physically carries what — "Awarded XP is always divided
evenly, irrespective of how the players decide to divide the treasure" — so
this is registered as an engineering decision in the adaptations register, not
as a rule. XP is unaffected either way; it sums the whole party.

Distribution consumes no randomness. The phase-4 and phase-5 goldens are
regenerated for the event stream only: every RNG stream state, clock reading,
XP award, and level-up line is byte-identical.

Also fixes an aliasing bug found while writing the back-out path. Pydantic
models compare by value, so removing an item by equality could remove an equal
item the carrier already owned and leave the offered one in two inventories at
once. Removal is now by identity.

Claude-Session: https://claude.ai/code/session_01PjCy2kE4R1Yj85rizkkXpV
@mmacy
mmacy merged commit 8566d80 into main Jul 25, 2026
5 checks passed
@mmacy
mmacy deleted the fix-loot-distribution branch July 25, 2026 20:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant