Skip to content

Inventory and Items

The largest legacy subsystem and the one whose redesign has the widest blast radius.

Sources: libraries/sv_inventory.lua, libraries/cl_inventory.lua, libraries/sv_container.lua, libraries/sh_item.lua, metatables/sh_item.lua, metatables/sv_item.lua, items/**.


1. The inventory model

1.1 Storage shape

player.cider._Inventory = { ["cake"] = 3, ["ak47"] = 1, ["small_pocket"] = 2 }

A flat map from item unique ID to an integer count. That is the entire model.

Consequences, all of which are load-bearing on the rest of the design:

  • Everything stacks, unconditionally. Stacking is not a property an item opts into; it is a consequence of the storage being a counter.
  • No item has identity. Two AK-47s are the integer 2. There is no item ID, no handle, no reference.
  • No item has state. No durability, no loaded ammunition, no contents, no signature, no custom name, no serial number. There is nowhere to put it.
  • Ordering is undefined. It is a Lua hash table; UI sorts it at render time.

Design note: this is defect D-10 and is replaced wholesale by ADR-0006.

1.2 Capacity — space, not weight

Applejack has no weight system. It has a single abstract space budget.

Value Default Source
Base inventory size 40 sh_config.lua, config["Inventory Size"]
usedSpace   = Σ  item.Size × count      for every item where Size > 0
maxSpace    = 40 + Σ (item.Size × -count)  for every item where Size < 0
canFit(n)   = usedSpace + n ≤ maxSpace   (or n ≤ 0)

(libraries/sv_inventory.lua, getSize / getMaximumSpace / canFit.)

Negative-size items increase capacity. items/misc/small_pocket.lua has Size = -5, so carrying two pockets raises the budget from 40 to 50. This is an elegant mechanic — the container is itself a carried object — and it is explicitly preserved.

Pockets guard against capacity loss on removal: small_pocket's onDrop and onDestroy both check canFit(5 × amount) first and refuse if removing the pocket would leave the player over-encumbered. Any new implementation must reproduce this check — shrinking capacity below current load is otherwise reachable.

1.3 The single mutation path

Everything goes through one function:

cider.inventory.update(player, id, amount, force)   -- amount may be negative

libraries/sv_inventory.lua. Order of operations:

  1. Reject unknown item ID.
  2. Unless force, or amount < 1: reject if canFit(item.Size × amount) fails.
  3. Call item:onUpdate(player, newTotal) if defined. If it returns non-nil, that value is returned and the write is abandoned. This escape hatch is how money works (D-12).
  4. Apply the per-item cap — this code is dead, see D-03.
  5. math.Clamp(total, 0, 2147483647); a count reaching zero deletes the key.
  6. Send one cider_Inventory_Item usermessage to the owner.

A single choke point for mutation is a genuinely good decision and is retained. The escape hatch at step 3 is not.

1.4 Client mirror

libraries/cl_inventory.lua keeps cider.inventory.stored, reconstructed purely from incremental messages, plus a one-shot replay on PlayerInitialized (which calls update(ply, k, 0, true) for every item to force a message per entry). There is no reconciliation — see D-05.

The client duplicates the capacity maths so the UI can grey out actions locally. The server recomputes independently, so this is advisory only — which is correct.


2. Containers — a second, parallel inventory

libraries/sv_container.lua. Any entity can be made a container:

cider.container.make(entity, size, name, initialContents)
-- entity._Inventory = { name = "Filing Cabinet", size = 20, contents = {} }

It sets NWBool "cider_Container", NWString "Name", SIMPLE_USE, and registers a CallOnRemove hook that dumps the contents into the world when the entity is destroyed.

Differences from character inventory — note they are gratuitous, not principled:

Character Container
Capacity 40, modified by negative-size items Fixed per container, default 30
Capacity maths getSize / getMaximumSpace getContentsSize / getSpaceLeft — reimplemented
Size accumulation item.Size × count item.Size × math.abs(count)
Permissions implicit (it's yours) CAN_TAKE = 1, CAN_PUT = 2 bit flags
Contents source the table overridable by the PlayerGetContainerContents hook

That last row is the interesting one. Because contents are hook-resolvable, a plugin can present a virtual container whose contents are computed per-player — this is how banking was intended to work: one physical vault entity, per-player contents.

Design note: virtual/computed containers are a good idea and survive, as an interface rather than a hook that returns three values. The duplication of the whole subsystem does not — see D-09.

Spawnable containers

sh_config.lua, config["Spawnable Containers"] maps a model path to {capacity, name}:

Model Capacity Name
props/de_train/lockers_long.mdl 100 A lot of lockers
props_wasteland/controlroom_storagecloset001a/b.mdl 60 Storage Closet
props/cs_office/file_cabinet1_group.mdl, props/de_nuke/file_cabinet1_group.mdl 50 Filing Cabinet
props_c17/furnituredrawer001a.mdl, props/de_inferno/furnituredrawer001a.mdl 30 Chest of Drawers
props_wasteland/controlroom_filecabinet002a.mdl 30 Filing Cabinet
props_c17/furnituredrawer003a.mdl, several file_cabinet variants 20 Filing Cabinet / Chest of Drawers
props/cs_office/file_cabinet3.mdl 15 Filing Cabinet
props_lab/partsbin01.mdl 10 Chest of Drawers

Storage capacity is thus a property of the map, not the player. Finding a locker bank is meaningful. This directly supports the scarcity theme (01_VISION.md) and is preserved as a concept — mapped onto S&box prefabs rather than a model-path lookup table.


3. Item definitions

3.1 How a definition is written

Each item is a .lua file executed against a pre-created global ITEM, already setmetatable'd to _R.Item. The file assigns fields; the loader does the rest.

items/food/cake.lua, verbatim and complete:

ITEM.Name           = "Delicious Cake";
ITEM.Cost           = 2500;
ITEM.Model          = "models/foodnhouseholditems/cake.mdl";
ITEM.Store          = true;
ITEM.Plural         = "Delicious Cake's";
ITEM.Description    = "WOW what a delicious cake! Great for parties!";
ITEM.Hunger         = 50;
ITEM.Base           = "food";

Two fields are implicit:

  • UniqueID ← the filename (cake.lua"cake")
  • Category ← the directory (items/food/food)

This convention is compact and pleasant to author. It is worth keeping the feel of it — a content author should still write roughly this much and no more — while making it an asset rather than executable code (ADR-0004).

3.2 Full field vocabulary

Every field used across the 139 item files:

Field Type Meaning
Name string Display name, singular
Plural string Display name, plural
Description string Tooltip / store text
Model string World model; precached at registration
Cost int Purchase price
Refund int Sale value; defaults to Cost / 2
Size int Space consumed. Negative = grants capacity
Batch int Quantity received per store purchase
Max int Intended carry cap — never enforced, see D-03
Store bool Purchasable from a vendor
Base string | string[] Inherited base item; array form is broken (D-04)
Category string Implicit from directory
NoShow bool Hide from menus
Weapon bool Item grants a SWEP on use
WeaponType enum TYPE_SMALL | TYPE_LARGE — holster slot class
Ammo string Ammo type required to equip
Equippable bool Shows an "equip" verb
Equipword string The verb, e.g. "eat"
NoVehicles bool Cannot be used inside a vehicle
AutoClose bool Close the container UI after use
Capacity int Storage capacity when the item is a container
Hunger int Hunger restored on consumption
Becomes string Item ID this turns into when dropped/packaged
RecursiveDestroy bool Destroying this also destroys its contents

Callbacks: onUse, onDrop, onDestroy, onSell, onPickup, onUpdate, canManufacture, onManufacture, canContainer, OnEquip.

3.3 Inheritance

metatables/sh_item.lua:Register(). Bases live in items/base/ and are registered first. Merge semantics: for each key in the base, copy it only if the subject has not set it (if (not self[k]) then self[k] = v end).

Note the trap: the test is falsiness, not presence. A subject that deliberately sets Store = false gets the base's value copied over it, because false is falsy in Lua. Any new implementation must distinguish "unset" from "set to a falsy value".

Bases are themselves entries in GM.Items, so they are addressable by ID, and a base can include() another base to chain — cboxpackagingitem.

3.4 The nine bases

items/base/:

Base Provides
item No-op onDrop / onDestroy — the root
food Batch=5, Size=1, Equippable, Equipword="eat"; onUse refuses if hunger < 25, else reduces hunger by Hunger
weapon Weapon=true, NoVehicles=true; the full equip flow (below)
ammo Grants ammunition on use
packaging Capacity=20, AutoClose, Size=2; delegates onUse to the packaging plugin
cbox Chains from packaging
contraband Spawns the contraband SENT; enforces per-player counts against GM.Config.Contraband[uid].maximum
vehicle Max=1 (unenforced); delegates entirely to the vehicles plugin
npc NPC-spawning items

3.5 The weapon equip flow

items/base/weapon.lua — worth reading in full because it is the best-designed interaction in the original and the model for the new interaction framework.

  1. Already holding it → just select it, consume nothing.
  2. Requires ammo and you have none → refuse with a message.
  3. No WeaponType configured → give immediately (the trivial path).
  4. _NextDeploy cooldown still running → refuse, reporting the remaining time.
  5. Holster slot full (_GunCounts[WeaponType] >= GM.Config[WeaponType]) → refuse. Defaults: 1 large weapon, 2 small weapons (sh_config.lua).
  6. Otherwise: set _Equipping, emote a start message, and start a conditional timertimer.Conditional, from the custom gamemode/timer.lua — whose predicate is ply:GetPos() == startPos.
  7. Success (stood still for the duration): emote, give and select the weapon, decrement the inventory, fire OnEquip.
  8. Failure (moved): emote an abort message, clear _Equipping, consume nothing.

Note the design: the item is removed from the inventory only on success, inside the timer. The onUse returns false so the generic machinery does not consume it. Drawing a gun is a visible, interruptible, ~seconds-long commitment.

Design note: "interaction with a duration, a cancellation predicate, and success/abort branches" is exactly the shape of the general interaction framework (Architecture/10_INTERACTION_FRAMEWORK.md). In the original this pattern exists only for weapons; in the rewrite it is the default for lockpicking, searching, crafting, treating injuries and tying.

3.6 Content inventory

139 files across 15 categories, each with an init.lua defining CAT.Name, CAT.Description, optional CAT.NoShow. The loader defines CATEGORY_<UPPERCASE> so jobs can whitelist or blacklist whole categories.

Category Files Category Files
food 33 ammo 6
misc 13 illegal_goods 6
alcohol 11 weapons 5
packaging 11 police_weapons 4
vehicles 11 contraband 3
drinks 9 explosives 3
base 9 drugs 7
illegal_weapons 8

Note drinks exists as a category but drinks derive from food and restore hunger — there is no thirst need in the original. See 08_NEEDS_AND_STATE.md.


4. Item lifecycle

metatables/sv_item.lua. Each method is a thin wrapper: bail unless the definition supplies the matching callback, then mutate the inventory and log to EVENT_ITEM.

Method Behaviour
Use(ply) Runs onUse; consumes one on a truthy return
Drop(ply, pos, amt) Runs onDrop, removes from inventory, spawns a cider_item entity
Destroy(ply) Runs onDestroy; honours RecursiveDestroy
Make(pos, amt) Spawns a cider_item entity carrying (definition, amount)
Sell(ply) Refunds Refund or Cost / 2
Pickup(ply) Adds to inventory if it fits, removes the world entity

World items are a single entity class, cider_item, parameterised by SetItem(definition, amount) — one entity type for all 139 items. That is a good decision and carries over directly to a prefab + component in S&box.


5. What survives

Survives: the space budget with negative-size capacity items; map-authored container capacities; the single mutation choke point; filename/directory-derived identity as an authoring feel; one world-item entity for all items; the conditional-timer equip flow; category-level job restrictions; virtual/computed container contents.

Does not survive: the count-map storage shape; the duplicate container subsystem; string- keyed field lookup; falsy-vs-unset merge semantics; the onUpdate escape hatch; delta-only client state.

The redesign is specified in Architecture/08_DATA_MODEL.md and Architecture/09_ITEM_FRAMEWORK.md.