CityRP Documentation Repository

Class-Based Item Registration

Last updated

Code: core/libraries/sh_itemcore.lua (the ITEM class), core/libraries/sh_item.lua (cityrp.item.new, cityrp.item.register, the registry) History: metatable-based item library introduced 2019-08-21 (efcf7cb22); ITEM split out of cityrp.item into its own file 2020-05-02 (d366624eb)

This document was written after the fact. It records what the class-based registration route is, why it replaced cityrp.item.register({}), the decisions baked into it, and the sharp edges those decisions left behind. The usage guide is items.md; the action hooks are in item-actions.md; metadata is in item-metadata-schema.md. This page is about why the route is shaped the way it is.

Problem

Before this route, every item was a plain table handed to cityrp.item.register(tbl, baseID). Two problems drove the redesign:

  1. Inheritance required a registered parent. register(tbl, "base_x") looks the base up in cityrp.item.stored, so the base had to be a registered — and therefore obtainable — item. Anything that wanted to share data (ten suits at the same price, a family of backpacks) had to either duplicate the fields or publish an abstract base that players could see in the market unless something explicitly hid it. This is AGENTS.md footgun #6.
  2. One item per file, lots of boilerplate. Each variant was a full table with its own name, cost, model, description, uniqueID assignments. Families of near-identical cosmetic items (the bulk of the catalogue) were the worst case.

Goals

  • Share data between variants without registering the shared part.
  • Define several related items in one file with minimal repetition.
  • Keep the runtime contract (cityrp.item.stored[id], the canX/onX hooks, cityrp.item.get) identical so every consumer — inventory, market, manufacturing, entities — keeps working unchanged.
  • Coexist with the legacy route indefinitely. Migrating the legacy files was never in scope; today 368 files use ITEM:New and 250 still call cityrp.item.register.

Non-goals

  • Replacing the registry. cityrp.item.stored is still a flat uniqueID -> table map.
  • Validating item definitions (required fields, model existence, category names). Registration still accepts whatever it is given.
  • Changing the hook naming. The UpperCamelCase/lowerCamelCase split described at the top of sh_itemcore.lua (SetCost vs canUse) is deliberate: construction-time API vs runtime hooks.

Design

Two layers

Layer Where Responsibility
ITEM class sh_itemcore.lua The global base table every item ultimately indexes into. Holds defaults (size = 1, cost = 0, batch = 5, store = true, category = "Base", model = "models/error.mdl"), the fluent setters, New/Clone/Merge/Register, and the default canX = true hooks.
cityrp.item sh_item.lua The registry and its lookup/category helpers. cityrp.item.new(base) builds the metatable link; cityrp.item.register(item) post-processes and stores.

ITEM:Register(id) is a thin wrapper: it sets uniqueID if given, then calls the legacy cityrp.item.register(self) with no base argument. The fluent route therefore reuses all the legacy normalisation (default canX, replaced/outdated handling, .rep to .reps fix-up, GetMetaName, the long-category warning) rather than duplicating it. There is exactly one registration code path.

Inheritance is a metatable chain, not a copy

ITEM  <--__index--  stored["base_accessory"]  <--__index--  (unregistered local BASE)  <--__index--  stored["backpacknavy_2"]
  • ITEM:New("base_id") → cityrp.item.new("base_id") → setmetatable({base = id, BaseClass = stored[id]}, {__index = stored[id]}).
  • ITEM:New() with no base → setmetatable({}, ITEM) (ITEM.__index = ITEM).
  • item:Clone() → setmetatable({base = rawget(self, "uniqueID") or "tmp_base_<file>"}, {__index = self}).

This is the decision that solves goal 1. A clone's parent is whatever table it was cloned from, registered or not, so an intermediate BASE can exist purely in the file's local scope. The chain terminates at a registered base (which in turn chains to ITEM), so every fluent setter and every default hook is reachable from the leaf.

Contrast with the legacy route, which does table.Inherit(item, baseTab) — a shallow copy of the base's fields onto the item at registration time — and sets the item's metatable to the base only as a fallback.

Consequence (intended): the shared part is never stored, so it can never be bought, spawned, or listed.

Consequence (accepted): fields are resolved lazily. Mutating a base after its children are registered changes the children too, because nothing was copied. uponLoad handlers, which run over every stored item after all item files have loaded, rely on this being safe.

The fluent API

Setters return self so a whole item is one expression. Most setters are generated by ITEM:AutoFunction(name, key) from a datas table (Cost/Price → cost, Description/Desc → description, Category/Cat → category, Team, Batch, Member → membershiplevel, REP/REPs → reps, CanUse, Size, LootName, LootPlural). The aliases exist so call sites read naturally; they are not separate fields.

Hand-written setters exist where one call sets more than one field or has true-means-copy semantics:

  • SetName(name, plural) also sets plural (default name .. "s"; true → same as name; "" → false).
  • SetModel(model, skin, autoStore) / SetSkin(skin, autoStore) / SetStoreModel / SetStoreSkin — true copies from the other side (inventory ↔ store), autoStore pushes to the other side. This exists for cosmetic items whose inventory model and market preview differ.
  • SetStore(val) — false/0 hides from the market; a number both enables the store and sets batch.
  • SetColor / SetMaterial — appearance overrides, consumed by cityrp.item.applyAppearance.
  • SetMetaSchema / AddMetaField / RemoveMetaField / SetMetaFieldOrder — see item-metadata-schema.md.
  • CanUse/OnUse, CanGive/OnGive, … CanPlace/onPlace — one pair per action in item-actions.md. CanX(nil) resets to the always-true default.
  • Replaced(by) / Replacement(new, old) — mark an ID as superseded. Replacement builds a stub that inherits from the new item and registers it under the old ID with the replaced flag, so the legacy onUse override tells the player what to buy instead.

SetData(key, value) and Merge(tbl) are the escape hatches for fields with no setter. ITEM:New also accepts a data table in either argument position (New("base", {...}), New({...}, "base"), New({...})), which is how table-style definitions are bolted onto the fluent route without a rewrite.

Load order

Registration order matters because cityrp.item.new(base) looks the base up at call time. sh_init.lua enforces it:

  1. core/items/<folder> in a fixed list, with base first, then ammunition, cars, clothing, …, collectables.
  2. cityrp.plugin.LoadSubResourceItems — plugin-owned items/ folders, after core so they can inherit from core bases.
  3. item:uponLoad() on every stored item.
  4. CityRP.Core.PostItemLoad.

Within a folder, files load in file.Find order. A base defined in the same folder as its children is only safe if it sorts first — which is why the real bases live in core/items/base/.

Alternatives considered

These are reconstructed from the shape of the code rather than from a record of the original discussion.

  • Keep register(tbl, base) but add a hidden flag to bases. Rejected in effect: it still pollutes the registry, still needs every consumer (market, /giveitem, loot tables, cityrp.item.get fuzzy matching) to honour the flag, and doesn't reduce boilerplate. store = false already exists and is exactly this half-measure; it hides from the market only.
  • Copy-on-register for the fluent route too (table.Inherit). Would make registration-order mistakes fail loudly (the base is fully materialised) and remove the late-mutation hazard, but breaks uponLoad-style post-processing of bases and costs memory across a catalogue of several hundred items. Lazy chaining was kept.
  • A separate new registry. Rejected; dual registries would have forced every consumer to check both. Reusing cityrp.item.register for post-processing is why the fluent route got the legacy fix-ups for free.

Known sharp edges

Things the design leaves open, documented so they are not rediscovered as bugs. None of these are new; they are consequences of the decisions above.

Edge Cause Effect
Misspelled or not-yet-loaded base is silent. cityrp.item.new(base) falls through to setmetatable({}, ITEM) when stored[base] is nil. ITEM:New("base_accesory") registers a working item with ITEM defaults (models/error.mdl, category Base) and no error. Same outcome if the base's file loads later.
Legacy bases do not expose ITEM methods. A table registered via cityrp.item.register with no base has no metatable, so a chain rooted at it never reaches ITEM. ITEM:New("legacy_base") returns a table on which :SetCost is nil. cityrp.item.applyAppearance is a plain function rather than a method for this reason. Fluent items must inherit from fluent or ITEM-rooted bases.
Setters called on the global ITEM change every item. All setters write to self; nothing stops self being ITEM itself. Always start a chain with New or Clone.
Clone base IDs are synthetic. Clone() names an unregistered parent tmp_base_<file>. item.base is not guaranteed to be a registered ID; consumers that walk base must use BaseClass/the metatable, not stored[item.base].
Registration re-runs on hot reload. stored[id] is overwritten, not guarded. Re-including an item file replaces the table; anything holding a reference to the old table keeps the stale one. Consistent with AGENTS.md footgun #7.
onPlace setter is lowercase. Preserved for compatibility. item:onPlace(fn) sets the hook; item:OnPlace is nil.
No validation at Register. By design (non-goal). A missing name errors inside register's log-string concatenation rather than with a useful message; a missing uniqueID stores the item under nil.

Consequences for contributors

  • New items use ITEM:New(<real base>) and chain setters; never cityrp.item.register for new code (modern-apis.md).
  • Shared data between variants goes on an unregistered local BASE = ITEM:New(...), and each variant is BASE:Clone():...:Register(id).
  • Do not create a registered item whose only job is inheritance. If a real base is missing, add it under core/items/base/ so the load order protects it.
  • :Register(id) must be the last call in the chain; the post-processing in cityrp.item.register runs once and does not see later direct mutations (only lazily-chained ones, see above).
  • When touching a legacy file, converting it to this route is welcome but must preserve its uniqueIDs — those are persisted in player inventories.