Skip to content

lumo-inventory — integration guide ​

Everything below is live in this build. Keys referenced here are defined in config/config.lua. Exports work the same on QBCore, Qbox, ESX and standalone.

Item exports (server) ​

The qb-inventory export set, plus a few additions. source is a player id; stash ids work where noted.

ExportWhat
AddItem(source, item, amount, slot?, info?, reason?)Add; false when it does not fit
AddItemOrDrop(source, item, amount, slot?, info?, reason?)Add, or drop at the player's feet when full
RemoveItem(source, item, amount, slot?, reason?)Remove
HasItem(source, item or {items}, amount?)Has it / all of them
GetItemCount(source, item or {items})Total amount
GetItemByName(source, item), GetItemsByName(source, item), GetItemBySlot(source, slot)Look up
CanAddItem(identifier, item, amount)Fits by weight and slots? (player or stash)
SetItemData(source, item, key, value, slot?)Change one metadata field
ClearInventory(source, keep?), SetInventory(source, items)Bulk
OpenInventory(source, stashId, {label, maxweight, slots})Open a stash
CreateShop({name, label, coords, items, groups?}), OpenShop(source, name)Shops (rules below)
CreateUsableItem(item, function(source, item) … end)What using an item does (any framework)
RefreshItems()Re-read the item list (ESX: after adding items to the items table)
UseItem(source, item)Use an item as if from the hotbar

Shop rules are checked when the shop opens (groups) and again on every purchase (the item fields), on every framework:

FieldOnMeaning
groupsshopOnly these jobs or gangs may open it: 'police', { 'police', 'sheriff' } or { police = 2 } (minimum grade)
requiredJob, requiredGangitemSame formats, job only / gang only
requiredGroupsitemJob or gang, same formats
requiredGradeitemMinimum job grade
requiredDutyitemMust be on duty
requiredLicenseitemA licence name, or a list where any one is enough. Read from metadata.licences (QBCore, Qbox, standalone) or esx_license's user_licenses (ESX, where any drive* licence counts as driver)

On ESX, scripts can also keep using xPlayer.addInventoryItem, removeInventoryItem, getInventoryItem, canCarryItem, addWeapon … — they are routed here (see INSTALLATION.md → ESX).

Experience / progression ​

XP lives on the character's metadata (metadata.experience): QBCore/Qbox players.metadata, ESX users.metadata, standalone the character (or licence). It saves with the character; no extra table. Categories are scored as the sum of their tracks, so levelling gunsmithing also advances trades. One-time milestone claims are stored in metadata.experience.claimed.

On first login after the standalone reputation system is retired, existing player_reputation points are imported once into matching Experience tracks. The marker is stored in metadata.experience.migrations, and the old SQL rows are retained as a rollback backup.

Awarding XP from another resource ​

Server side (preferred):

lua
exports['lumo-inventory']:AddExperience(src, 'gunsmithing', 25, 'weapon bench')

Returns the amount actually granted after the per-track daily cap (Config.Experience.dailyCap, 2500/day by default). Returns 0 if the track is unknown, XP is disabled, or the player has hit the cap.

Or, if you cannot call an export from where you are:

lua
TriggerEvent('qb-inventory:server:AddExperience', src, 'gunsmithing', 25, 'weapon bench')

That event refuses anything arriving from a client, so it is server-only.

Reading progression ​

lua
exports['lumo-inventory']:GetExperience(src, 'gunsmithing')     -- lifetime XP on a track
exports['lumo-inventory']:GetCategoryExperience(src, 'trades')  -- summed across its tracks
exports['lumo-inventory']:GetExperienceLevel(src, 'gunsmithing') -- current level (track or category)
exports['lumo-inventory']:GetExperiencePerk(src, 'crafting', 'craftSpeed')

GetExperiencePerk returns the best unlocked value for that effect (0 if none), which is how the crafting bench shortens its own timers. Gate content with it:

lua
if exports['lumo-inventory']:GetExperienceLevel(src, 'mechanic') < 5 then
    return QBCore.Functions.Notify(src, 'You are not skilled enough for this repair', 'error')
end

Awarding XP with no code changes at all ​

Config.Experience.itemXp maps an item name to a track. lumo-inventory sees every item that enters a player's inventory, so adding an entry there gives XP for gathering and loot without touching the resource that grants the item. reasonXp takes priority when the producing resource needs an exact discipline:

lua
itemXp = {
    metalscrap = { track = 'recycling', xp = 1 },
    goldbar = { track = 'heists', xp = 30 },
},

reasonXp = {
    { prefix = 'qb-bankrobbery:', track = 'heists', xp = 18 },
},

Internal shuffles (moving, splitting, stacking, dropping, rollbacks) are excluded, so you cannot farm XP by dragging one item back and forth.

Tracks owned by another resource ​

Some disciplines already run their own progression. Listing one in Config.Experience.externalTracks shows it in the Experience tab without the inventory re-deciding its numbers:

lua
externalTracks = {
    farming = { resource = 'lumo-farming', profile = 'GetFarmerProfile', ladder = 'GetExperienceLadder' },
},
  • profile(src) returns { xp = <lifetime XP> }; ladder() returns { levels = { { level, cumulative, title?, perk? }, ... } }.
  • Levels, XP-to-next and the level cap come from that ladder, so the tab always agrees with the owner's own menus. A category that is only one external track (Agriculture) shows that track's level, not a re-summed one.
  • The total is mirrored into metadata.experience.tracks, so GetExperience and GetExperienceLevel keep answering while the owner restarts; the last published ladder is cached for the same reason.
  • AddExperience refuses these tracks (returns 0), no daily cap applies and no milestone rewards are offered — the owner already pays for the work. Award the XP in the owner, and fire qb-inventory:client:experienceGained there if you want the inventory's toast and rank-up sound.

Client side ​

lua
exports['lumo-inventory']:IsEquipped('phone')          -- something in the PHONE bay?
exports['lumo-inventory']:IsEquipped('radio', 'radio') -- that specific item?
exports['lumo-inventory']:GetEquippedItem('armor')     -- the item table, or nil

Events you can listen for:

lua
AddEventHandler('qb-inventory:client:onExperienceGained', function(payload)
    -- payload = { track, label, amount, level, levelUp, reason }
end)

AddEventHandler('qb-inventory:client:equipSlotChanged', function(slotKey, item)
    -- item is nil when the bay was emptied
end)

Server side:

lua
AddEventHandler('qb-inventory:server:onLevelUp', function(src, trackKey, level) end)

Equip bays ​

Bays are reserved slot numbers above Config.MaxSlots (see Config.SpecialSlots) — ordinary inventory slots, just outside the visible grid. Order is gadget1, phone, backpack, gadget2, armor, radio.

What a bay accepts is decided in nui/src/types.ts (SPECIAL_SLOTS), matched by item name, because essentially every QBCore item is declared type = 'item'.

  • Phone / Radio — gate opening. qb-phone and qb-radio ask IsEquipped and fall back to their old behaviour if lumo-inventory is not running.
  • Armor — info.durability is the armour value. A vest carries 75 ped armour, heavy 100, scaled by condition. Damage is written back and persists; at 0 the plate is destroyed.
  • Backpack — capacity / capacityWeight on the shared item become the nested grid.

Crafting ​

Recipes live in Config.Crafting.Recipes. Ingredients are taken up front and rolled back if any removal fails; the product is granted on a server timer. Crafting XP is awarded on completion, and the craftSpeed perk shortens the timer.

lua
{ key = 'lockpick', item = 'lockpick', amount = 1, category = 'Tools', seconds = 6,
  ingredients = { { item = 'metalscrap', amount = 8 }, { item = 'plastic', amount = 3 } } },

The portable field menu exposes simple recipes. Placed benches open specialist recipes. The Underground Weapons Bench uses gunsmithing XP for recipe locks; all other benches use crafting XP.

Weapon components and finishes ​

Open a weapon's context menu and choose Attachments. The workshop asks qb-weapons for that exact weapon's GTA component map, so the same inventory item works on any compatible pistol, SMG, shotgun, AR, MG or sniper rifle.

  • Ammu-Nation (weapon licence): clip, flashlight, grip, small scope, luxury finish, and standard tint kits.
  • Underground Weapons Bench: suppressor, drum, every optic (medium through thermal), muzzle brakes, MK II barrels and all MK II tint kits.
  • Gunsmithing XP: earned by completing recipes at the Underground Weapons Bench. Higher-end optics, brakes, barrels and finishes unlock progressively.

Installed parts and tints are saved on the individual weapon item. Removing a part returns its item to the inventory; applying a finish consumes one matching tint kit. The server validates ownership, compatibility, weapon slot and serial for every install/remove request.

Full bags never swallow items ​

exports['lumo-inventory']:AddItem, the qb-inventory shim, Player.Functions.AddItem and the ox compat AddItem all go through AcquireItem: when a player's bag is full (weight or slots), the item is dropped at their feet as an ordinary ground drop that anyone can pick up, and the call still returns true. A player past Config.Overflow.maxDropsPerPlayer piles gets the item added to their nearest pile within mergeDistance (10 m). An explicit slot, a stash / trunk / drop target, and cash keep the strict result. Inside this resource, moves, splits, swaps, gives and rollbacks call the strict global AddItem, so a failed transfer still bounces back instead of landing on the floor. Ground drops are cleaned up after Config.CleanupDropTime minutes.

Big items are carried in the hands ​

Any item whose Config.DropSystem.Items entry has size = 'bulky' (TVs, microwaves, printers, crates...) is an ordinary inventory item. While a player has one anywhere in their inventory, their character carries it in both arms, and everyone nearby sees it. With several, the one in the lowest slot is shown. Carrying blocks sprint, jump, weapons, cover and entering vehicles. To free the hands, drop the item on the ground or move it into a trunk or stash through the inventory. Big items have no Hold / Throw / Place, and nothing else can be held while carrying one.

Other resources need nothing special: give the item with AddItem (a full bag drops it at the feet). To make a new item a carried one, add it to DropSystem.Items with a model and size = 'bulky'.

How it works: shared/carry.lua holds the rule. server/carry.lua publishes the carried item name on the player state bag lumoCarry, and client/carry.lua draws a local prop on every carrier and applies the pose and control limits to the local player.

Positioning a big item: /carrygrip. Carry the item and run /carrygrip. The item comes off your hand and you stand still. Place it with the mouse gizmo; the key hints show at the bottom centre:

KeyDoes
LMBDrag the arrows (move) or the rings (rotate)
W / RMove mode / rotate mode
QToggle item axes / world axes
GFree the mouse to look around, and back
HCycle which hand/chest bone it attaches to
Enter or ESave
BackspaceCancel

On save the placement is measured against the bone and the item is re-attached there. The gizmo keys are the engine's +gizmo* bindings (from object_gizmo, or registered here if it is absent). The first save on a client also measures which rotation order AttachEntityToEntity uses, and remembers it in that client's KVP.

Saving (needs the command ace or lumo.carrygrip) applies to that item for every player at once and is kept in the resource KVP across restarts. The config line is also printed to F8. /carrygrip reset puts the item back on the default two-hand box grip (rpemotes' box: left hand 60309, 0.025, 0.08, 0.255, -145, 290, 0). Priority: saved grip, then the item's carry config, then the box grip.

Admin commands ​

CommandPurpose
/testkitFills every equip bay plus loose gear for testing
/givexp [id] [track] [amount]Grant XP
/dropdebugLists live drops with distance and lock state

Database ​

Tables are created on first start (see INSTALLATION.md). On QBCore, migrations/2026-08-23-experience.sql optionally widens players.metadata from TEXT to LONGTEXT: protective only, since every resource calling SetMetaData shares that 64 KB budget and an overflow corrupts the whole character row.