ADR-0006 — Instanced inventory¶
Status: ✅ Accepted Date: 2026-07-27 Supersedes: — (replaces legacy behaviour, not a prior ADR) Superseded by: —
Context¶
The original stores a character's inventory as a flat map from item identifier to an integer count:
player.cider._Inventory = { ["cake"] = 3, ["ak47"] = 1, ["small_pocket"] = 2 }
Two AK-47s are the integer 2. There is no item identity, no handle, and consequently
nowhere to store per-item state. This makes the following impossible by construction, not
merely unimplemented:
- durability, wear or condition;
- a partially loaded magazine;
- a container whose contents travel with it;
- a signed, written or named object;
- a unique or serial-numbered item;
- any distinction between two copies of the same thing.
The consequences are visible throughout the legacy design. Money could not be an item, so
items/misc/money.lua became a pseudo-item whose onUpdate intercepts every write and
returns true so the inventory never actually stores it
(D-12). Equipped weapons could not be represented, so
they are persisted separately in _Misc.EquippedWeapons and re-added on load — meaning the
save path mutates game state. Nested containers exist only as a one-level special case in
the packaging plugin.
Separately, world containers are a second, parallel implementation
(libraries/sv_container.lua) with the same capacity maths written twice, different mutation
paths, different networking and subtly different rules
(D-09). Every storage feature has to be built twice.
The master plan requires durability-bearing items, nested containers, splitting, combining, crafting quality, and a medical system with treatable injuries. None are expressible in the legacy model.
Decision¶
We will model inventory contents as item instances — each with a stable identity and its own state — held in a single inventory abstraction used identically by characters, world containers, vehicles and corpses.
/// <summary>
/// A specific item in the world or in an inventory. Distinct from its
/// <see cref="ItemDefinition"/>, which is the authored template shared by all instances.
/// </summary>
public sealed class ItemInstance
{
/// <summary>Stable identity, unique for the lifetime of the save.</summary>
public ItemId Id { get; init; }
/// <summary>The authored template this instance was created from.</summary>
public ItemDefinition Definition { get; init; }
/// <summary>
/// How many are represented. Always 1 unless <see cref="ItemDefinition.IsStackable"/>.
/// </summary>
public int Count { get; set; }
/// <summary>
/// Per-instance state, keyed by the component that owns it. Empty for simple items,
/// so the common case costs nothing.
/// </summary>
public ItemStateBag State { get; }
}
Stacking becomes explicit. ItemDefinition.IsStackable opts in; it is no longer an
accident of the storage shape. A stackable instance with Count = 3 is one entry. A
non-stackable item is always one instance per object, so an AK-47 with 12 rounds and an
AK-47 with a full magazine are different things.
One inventory model. IInventory is implemented once and used by every holder. Capacity
maths, transfer rules, validation and networking exist in exactly one place.
Nesting is free. A container item's contents are simply an IInventory in its state bag,
so a crate inside a locker works with no special case.
Capacity stays as space, not weight. The original's space budget — including negative-size items that grant capacity — is preserved unchanged; only the storage shape changes. See Legacy/03.
Alternatives considered¶
Keep the count map, add a side table for stateful items¶
Keep {definitionId: count} for ordinary items and maintain a separate collection for the
few that need state.
Genuinely attractive: it is a smaller change, and it keeps the common case as compact as the original. Rejected because it produces two models with a boundary that must be decided per-item, and every operation — transfer, split, drop, save, replicate — has to handle both and the transition between them. It is the legacy money pseudo-item pattern generalised, and the legacy container duplication says clearly what happens to a system with two parallel implementations of one concept.
Instances everywhere, no stacking at all¶
Every object is one instance; 500 pistol rounds are 500 instances. Conceptually cleanest and simplest to reason about.
Rejected on cost: ammunition, food and crafting materials are held in quantity, and per-round identity buys nothing while multiplying memory, save size, replication and UI work by three orders of magnitude.
Slot-based grid inventory¶
A fixed grid with positions, in the survival-game idiom.
Rejected as a gameplay change, not an implementation one. Applejack's inventory is a budget and its scarcity comes from the space total, not from spatial packing. Adopting grids would change how the game feels, which is out of scope for a decision about storage. Nothing here prevents a later grid UI over the same model.
Consequences¶
Positive¶
- Durability, ammunition, contents, signatures, uniqueness and quality all become expressible.
- One inventory implementation instead of two; storage features are built once.
- Nested containers work with no special case.
- Money can be modelled honestly — either a real item or plainly not one, but not a third thing that lies about being an item.
- Equipped weapons need no separate persistence path, so saving stops mutating game state.
- Splitting, combining and per-instance tooltips become natural operations.
Negative¶
- Larger saves and more replication traffic. An instance carries an id and possibly a state bag; the legacy format was a single integer per item type. Mitigated by an empty state bag costing nothing, by stacking, and by replicating inventories only to their owner.
- Item identity must be allocated and kept unique across a save, including across migration and server restarts.
- More complexity in the common case. A cake is now an object rather than a number.
- Duplication bugs become possible in a way they were not — an integer cannot be cloned by accident, an instance can. This is the main new risk and is why transfers are atomic and tested.
Neutral¶
- The legacy save format cannot be read. No migration is provided; there is no live server and no obligation to one.
- Item identifiers stay
snake_casematching the original, soDocumentation/Legacy/remains directly usable as a content specification.
Compliance¶
Unit tests, in UnitTests/, required before the module is considered done:
D10_TwoInstancesOfTheSameDefinitionAreDistinguishableD09_CharacterAndContainerInventoriesShareOneImplementation- Capacity maths including negative-size items and the over-encumbrance refusal on removal
- Stacking respects
IsStackableand the per-definition stack limit (D03_PerItemStackLimitIsEnforced) - Transfers are atomic: a failed transfer modifies neither side
- No transfer creates or destroys an instance id — the duplication guard
- Nested container contents round-trip through save and load
Notes¶
Revisit if profiling shows instance overhead is material at realistic player and item counts. The likely first mitigation is a compact representation for definitions that are stackable and stateless, applied inside the model rather than beside it — which is the rejected "side table" alternative done safely, as an optimisation behind a stable interface rather than as two models in the domain.