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:
- Reject unknown item ID.
- Unless
force, oramount < 1: reject ifcanFit(item.Size × amount)fails. - 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). - Apply the per-item cap — this code is dead, see D-03.
math.Clamp(total, 0, 2147483647); a count reaching zero deletes the key.- Send one
cider_Inventory_Itemusermessage 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 — cbox → packaging → item.
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.
- Already holding it → just select it, consume nothing.
- Requires ammo and you have none → refuse with a message.
- No
WeaponTypeconfigured → give immediately (the trivial path). _NextDeploycooldown still running → refuse, reporting the remaining time.- Holster slot full (
_GunCounts[WeaponType] >= GM.Config[WeaponType]) → refuse. Defaults: 1 large weapon, 2 small weapons (sh_config.lua). - Otherwise: set
_Equipping, emote a start message, and start a conditional timer —timer.Conditional, from the customgamemode/timer.lua— whose predicate isply:GetPos() == startPos. - Success (stood still for the duration): emote, give and select the weapon,
decrement the inventory, fire
OnEquip. - 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.