Loot — Mental Model¶
Three ideas. Everything else in the system is a consequence of one of them.
1. Rolling and granting are different verbs¶
A roll answers what did you win. A grant answers where did it go. They are separate calls, separate failure modes, and separate concepts — and keeping them apart is what makes pickups possible at all.
A pickup rolls its award when it drops, then carries that result around the world until somebody walks into it, and grants it on collect. Those two moments can be minutes apart. If rolling and granting were one operation, a pickup would have to either roll at collect time (so the thing on the ground is not yet the thing you get) or hold a live reference to the table forever.
LootResult won = service.Roll(table, owner); // decided now
// … later, elsewhere …
service.Grant(won, collector); // delivered then
Grant never spawns pickups, whatever the service is configured to do. A spawned pickup granting through the normal path would spawn another pickup on collect, and that one would spawn another, for ever. That is not a special case bolted on; it falls out of taking the two verbs seriously.
2. The roller knows nothing about your game¶
LootRoller is static, takes an IRandomProvider, and returns a value. It cannot reach an inventory, a wallet, a scene or an owner, because it holds none of them.
This is the same shape as Crafting.Core and for the same reason: the part that decides is dependency-free, and the part that delivers is gated. RevFramework.Loot references neither Inventory nor Currency. Delete either and Loot still compiles; its awards for the missing system simply stop being delivered.
The practical consequence is that odds are testable as arithmetic. There is no "spin up a scene, grant a thousand times, count the inventory" test in this system, because there does not need to be.
3. An award is never half-delivered — it is somewhere, or it is reported¶
Three destinations, no fourth:
| Outcome | Where it went | How you find out |
|---|---|---|
| Delivered | The owner's container or wallet | Granted |
| Spawned | The world, at the owner's feet | Spawned — and Granted later, if they collect it |
| Lost | Nowhere | Undelivered |
The rule that makes this trustworthy: Granted carries what actually landed, not what was rolled. An earlier version fired it with the whole roll, so a "you received" toast announced items a full bag had silently eaten. If you drive UI from one event, drive it from this one.
Undelivered fires only when there is something to report, and only after both direct delivery and spawning have failed. Nothing is retried automatically, because where an undeliverable award should go — mailed, queued, converted to currency, dropped on the floor of the shop — is a design decision this component has no basis to make.
Weights are relative, and only within one table¶
A weight has no absolute meaning. 1 against 3 is identical to 25 against 75. This trips people who expect a weight to be a percentage, and it is why the debugger samples rather than reporting the authored numbers back at you.
Nested tables do not share a weight pool. A nested entry is chosen against its parent's weights, and then the nested table rolls its own — so an entry deep in a chain is not "weight 5 out of the total of everything". Reasoning about end-to-end odds by hand across nesting is exactly the thing sampling is for.
Modifiers adjust the result, not the odds¶
ILootModifier runs on the owner's parent chain, after the roll, against what was won. A modifier can double a currency award or swap an item; it cannot make a rare item more likely.
This is deliberate, and it is a real constraint. A table whose printed weights do not describe its behaviour is very hard to reason about — if a modifier could quietly re-weight entries, the asset would stop being the truth about the drop. The cost is that luck stats, magic find, difficulty scaling, level gating and pity counters are all odds features and none of them can be expressed today.
Know which of those you need before designing around this system.
The identity question, which decides what Loot can name¶
Loot refers to items by GUID string and currencies by id string, and validates neither.
That is what buys the decoupling — the roller does not need the Inventory assembly to hold a GUID. The price is that a typo'd GUID is not a compile error, not a warning, and not a runtime exception. It is a table that drops nothing, for ever, silently.
Nothing checks a table against your ItemDatabase. Until something does, the debugger is your validation: a table whose sampled output is missing an entry you authored is telling you the id is wrong.
The mental checklist before you ship a table¶
- Is every quantity what you meant? The roller treats anything below one as one, so a zeroed row still drops a single item rather than going quiet.
- If it is weighted, does it have a
Nothingentry, or is it meant to always drop? - Have you sampled it in the debugger, rather than read the weights?
- Do the GUIDs resolve — did every entry you authored actually appear in the sample?
- If the recipient's bag can be full, is a spawner bound, or are you handling
Undelivered?