Dit is een FiveM Framework Adapter – voor scripters. Lever één resource die draait op ESX, QBCore, en QBOX door frameworkspecifieke aanroepen te isoleren achter een dunne adapter. Plaats de shared/fw.lua en per‑framework adapters hieronder in elke resource, roep de stabiele interfacecontract (FW.Player, FW.Job, FW.Money, FW.Inv, FW.Events), en houd bedrijfslogica framework‑agnostisch. Een kleine testmatrix met stubs vangt mismatches voordat je implementeert.
Waarom een Adapter?
Frameworkverschillen clusteren rond dezelfde naden:
- Core toegang (ESX
getSharedObject, QBCoreGetCoreObject, QBOX exports only) - Playermodel (xPlayer vs Player/PlayerData)
- Identificatoren (licentie/steam vs citizenid)
- Geld & inventaris APIs
- Evenementnamen tijdens het laden/inloggen/baan-update
A unified interface houdt deze koppelingen buiten uw game logica. U wisselt de adapter, niet de codebase.
BTW: Je kunt hier onze geschreven adapter gratis gebruiken:
Hoe te gebruiken (Drop-in)
Boom (voorgesteld):
my-resource/ ├─ fxmanifest.lua ├─ shared/ │ ├─ adapters/ │ │ ├─ esx.lua │ │ ├─ qb.lua │ │ └─ qbox.lua │ └─ fw.lua ├─ server/ │ └─ main.lua └─ client/ └─ main.lua
fxmanifest.lua (laad eerst adapters, dan fw.lua zodat detectie kan binden):
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
shared_scripts {
'shared/adapters/*.lua',
'shared/fw.lua'
}
client_scripts {
'client/*.lua'
}
server_scripts {
'@oxmysql/lib/MySQL.lua', -- optional: if you use SQL
'server/*.lua'
}
In uw code (server of client):
-- use the stable interface everywhere local src = source local p = FW.Player.getBySrc(src) local job = FW.Job.getName(p) FW.Money.add(p, 'cash', 250, 'delivery-bonus') FW.Inv.addItem(p, 'water', 1) FW.Events.notify(src, 'Job bonus paid.', 'success')
The only symbol you depend on is
FW. Al het andere is intern aan de adapters.
Interface Contract (stabiel oppervlak)
Ontwerpdoel: Klein, expliciet, gedocumenteerd. Dit zijn de functies waarop u kunt vertrouwen binnen frameworks.
FW.meta
name() -> 'esx'|'qbcore'|'qbox'has(resourceName: string) -> boolean(resource gestart?)
FW.Player
getBySrc(src: number) -> any(framework speler handle)getStateId(p) -> string(ESX: identifier; QB/QBOX: citizenid)getServerId(p) -> number(numeriek id)getName(p) -> string
FW.Job
getName(p) -> stringgetGrade(p) -> number|stringonChange(handler(src, oldJob, newJob))(wordt geactiveerd wanneer de baan verandert, indien detecteerbaar)
FW.Money
get(p, account: 'cash'|'bank'|'black_money'?) -> numberadd(p, account, amount: number, reason?: string)remove(p, account, amount: number, reason?: string)
FW.Inv (best‑effort; zie Notities)
addItem(p, name: string, count: number, metadata?: table) -> booleanremoveItem(p, name: string, count: number, metadata?: table) -> boolean
Inventaris opmerking: servers variëren (qb-inventory, ox_inventory, qs‑inventory, etc.). De standaardimplementatie gebruikt de framework-inventaris indien beschikbaar en valt terug op
ox_inventoryindien gedetecteerd.
FW.Events
notify(target: number, msg: string, type?: 'info'|'success'|'error')onPlayerLoaded(handler(src))(best‑effort, met fallback viaplayerJoining)
Drop-in Adapters (kopiëren/plakken)
Dit zijn pragmatische standaardinstellingen. Als je fork verschilt (vooral voor QBOX), pas dan de paar gemarkeerde opmerkingen aan.
shared/fw.lua
-- framework bridge bootstrap
FW = FW or {}
local function started(name)
local st = GetResourceState(name)
return st == 'started' or st == 'starting'
end
local which
if started('qbx_core') then which = 'qbox'
elseif started('qb-core') then which = 'qbcore'
elseif started('es_extended') then which = 'esx' end
if which == 'qbcore' then
FW = Adapters.qb()
elseif which == 'qbox' then
FW = Adapters.qbox()
elseif which == 'esx' then
FW = Adapters.esx()
else
error('[FW] No supported framework found (es_extended / qb-core / qbx_core).')
end
-- tiny helpers common to all adapters
function FW.meta.has(res)
return started(res)
end
shared/adapters/esx.lua
Adapters = Adapters or {}
Adapters.esx = function()
local ESX = exports['es_extended']:getSharedObject()
local M = {
meta = { name = function() return 'esx' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return ESX.GetPlayerFromId(src) end
function M.Player.getStateId(p) return p.identifier end
function M.Player.getServerId(p) return p.source end
function M.Player.getName(p) return p.getName and p.getName() or GetPlayerName(p.source) end
-- Job
function M.Job.getName(p) return (p.getJob and p.getJob().name) or (p.job and p.job.name) end
function M.Job.getGrade(p)
local j = p.getJob and p.getJob() or p.job
return j and (j.grade or (j.grade and j.grade.grade))
end
function M.Job.onChange(handler)
-- ESX fires when job changes (commonly 'esx:setJob')
RegisterNetEvent('esx:setJob', function(job)
local src = source
handler(src, nil, job and job.name)
end)
end
-- Money
local function norm(account) return account == 'cash' and 'money' or account end
function M.Money.get(p, account)
account = norm(account)
if account == 'money' then return p.getMoney() end
local acc = p.getAccount and p.getAccount(account)
return acc and acc.money or 0
end
function M.Money.add(p, account, amount)
account = norm(account)
if account == 'money' then p.addMoney(amount) else p.addAccountMoney(account, amount) end
end
function M.Money.remove(p, account, amount)
account = norm(account)
if account == 'money' then p.removeMoney(amount) else p.removeAccountMoney(account, amount) end
end
-- Inventory (ESX native, with ox fallback)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count) p.addInventoryItem(name, count); return true end
function M.Inv.removeItem(p, name, count) p.removeInventoryItem(name, count); return true end
end
-- Events
function M.Events.notify(target, msg, kind)
kind = kind or 'info'
-- Implement your UI notify event here. Example placeholder:
TriggerClientEvent('fw:notify', target, msg, kind)
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('esx:playerLoaded', function(_)
handler(source)
end)
end
return M
end
shared/adapters/qb.lua (QBCore)
Adapters = Adapters or {}
Adapters.qb = function()
local QBCore = exports['qb-core']:GetCoreObject()
local M = {
meta = { name = function() return 'qbcore' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player
function M.Player.getBySrc(src) return QBCore.Functions.GetPlayer(src) end
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- QBCore client event relays job update; mirror serverside via simple relay if needed.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason) p.Functions.AddMoney(account, amount, reason or 'fw') end
function M.Money.remove(p, account, amount, reason) p.Functions.RemoveMoney(account, amount, reason or 'fw') end
-- Inventory (qb-inventory or ox)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
shared/adapters/qbox.lua (QBOX / qbx_core)
Adapters = Adapters or {}
Adapters.qbox = function()
-- QBOX typically exposes functions via exports only.
-- If your fork also ships a GetCoreObject, swap accordingly.
local QBX = exports['qbx_core']
local M = {
meta = { name = function() return 'qbox' end },
Player = {}, Job = {}, Money = {}, Inv = {}, Events = {}
}
-- Player (QBOX uses Player with PlayerData similar to QBCore)
function M.Player.getBySrc(src) return QBX:GetPlayer(src) end -- adjust if your API differs
function M.Player.getStateId(p) return p.PlayerData.citizenid end
function M.Player.getServerId(p) return p.PlayerData.source end
function M.Player.getName(p)
local pd = p.PlayerData
return (pd.charinfo and (pd.charinfo.firstname .. ' ' .. pd.charinfo.lastname)) or GetPlayerName(pd.source)
end
-- Job
function M.Job.getName(p) return p.PlayerData.job.name end
function M.Job.getGrade(p)
local g = p.PlayerData.job.grade
return type(g) == 'table' and (g.level or g.grade) or g
end
function M.Job.onChange(handler)
-- Some QBOX builds forward QBCore job events; if not, wire your own when setting jobs.
RegisterNetEvent('QBCore:Server:OnJobUpdate', function(job)
handler(source, nil, job and job.name)
end)
end
-- Money
function M.Money.get(p, account) return p.PlayerData.money[account] or 0 end
function M.Money.add(p, account, amount, reason)
if p.Functions and p.Functions.AddMoney then p.Functions.AddMoney(account, amount, reason or 'fw')
else QBX:AddMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
function M.Money.remove(p, account, amount, reason)
if p.Functions and p.Functions.RemoveMoney then p.Functions.RemoveMoney(account, amount, reason or 'fw')
else QBX:RemoveMoney(p.PlayerData.source, account, amount, reason or 'fw') end
end
-- Inventory (ox preferred on many QBOX servers)
local hasOX = GetResourceState('ox_inventory') == 'started'
if hasOX then
function M.Inv.addItem(p, name, count, meta) return exports.ox_inventory:AddItem(p.PlayerData.source, name, count, meta) end
function M.Inv.removeItem(p, name, count, meta) return exports.ox_inventory:RemoveItem(p.PlayerData.source, name, count, meta) end
else
-- fall back to qb-style if present
if p and p.Functions and p.Functions.AddItem then
function M.Inv.addItem(p, name, count, meta) return p.Functions.AddItem(name, count, false, meta) end
function M.Inv.removeItem(p, name, count) return p.Functions.RemoveItem(name, count) end
else
function M.Inv.addItem() return false end
function M.Inv.removeItem() return false end
end
end
-- Events
function M.Events.notify(target, msg, kind)
TriggerClientEvent('fw:notify', target, msg, kind or 'info')
end
function M.Events.onPlayerLoaded(handler)
-- Some QBOX builds reuse QBCore load events; if yours differs, relay from your login logic.
RegisterNetEvent('QBCore:Server:PlayerLoaded', function()
handler(source)
end)
end
return M
end
Gebruiksvoorbeelden
1) Een baanbonus betalen
RegisterNetEvent('myres:payBonus', function()
local src = source
local p = FW.Player.getBySrc(src)
if not p then return end
if FW.Job.getName(p) == 'delivery' then
FW.Money.add(p, 'cash', 250, 'delivery-bonus')
FW.Events.notify(src, 'Bonus paid (+$250).', 'success')
else
FW.Events.notify(src, 'You are not on duty as Delivery.', 'error')
end
end)
2) Inventarisverlening met ox fallback al afgehandeld
local function giveStarter(src) local p = FW.Player.getBySrc(src) if p then FW.Inv.addItem(p, 'water', 2) end end FW.Events.onPlayerLoaded(giveStarter)
Anti-Pattern Catalogus (en Oplossingen)
| Anti-patroon | Waarom het bijt | Fix met adapter |
|---|---|---|
Hard‑coderen van kernobjecten (ESX = exports['es_extended']:getSharedObject() verspreid) | Sluit je op ESX, omslachtig om te migreren | Alleen aanroepen FW.*. Core-resolutie bevindt zich in de adapter. |
Probleem met framework player handle op lange termijn (bijv. behouden xPlayer in een tabel voor altijd) | Handles kunnen verouderen; referenties verschillen per framework | Opnieuw ophalen via FW.Player.getBySrc(src) wanneer je handelt, of cache door getStateId sleutel en opnieuw oplossen. |
Aannemen van identifiers zijn hetzelfde (ESX identifier vs QB/QBOX citizenid) | Verbreekt DB-relaties/migraties | Gebruik FW.Player.getStateId(p) en een crosswalk-tabel tijdens migraties. |
Directe eventnamen in bedrijfslogica (esx:playerLoaded, QBCore:Server:PlayerLoaded) | Fragiel over forks | Abonneren via FW.Events.onPlayerLoaded. |
| Gemengde inventaris aannames | Servers wisselen vaak van inventaris | Gebruik FW.Inv.* dat detecteert ox_inventory eerst, dan framework. |
| SQL schema's bevroren op één framework | accounts, identifier, etc. wijken af | Gebruik neutrale kolommen (state_id, money_cash, money_bank) en migratiehulpmiddelen hieronder. |
SQL & Migratieopmerkingen voor identifiers (Snelreferentie)
- Primaire persoons-ID:
- ESX →
identifier(licentie\/steam) - QB/QBOX →
citizenid
- ESX →
- Neutrale sleutel in je tabellen:
state_id(string). OpslaanFW.Player.getStateId(p). - Geld:
- ESX:
money(contant geld),accounts.bank,accounts.black_money - QB/QBOX:
PlayerData.money.cash|bank
- ESX:
- Minimale kruispunt (eenmalige backfill):
-- Example: populate your neutral key from ESX users UPDATE my_table t JOIN users u ON u.identifier = t.identifier SET t.state_id = u.identifier WHERE t.state_id IS NULL; -- Example: migrate to QB/QBOX where you have a mapping table esx_identifier→citizenid UPDATE my_table t JOIN id_map m ON m.esx_identifier = t.state_id SET t.state_id = m.citizenid WHERE m.citizenid IS NOT NULL;
Houd de crosswalk (
id_map) alleen tijdens de overgang; toekomstige schrijfacties moeten altijd gebruikenstate_id.
Testmatrix & CI: Valideer een script over frameworks heen
Je hoeft geen volledige CFX-server te starten in CI om de meeste adapterproblemen op te vangen. Stub exports en voer unit tests uit voor het contractoppervlak.
1) Minimale test (Busted)
tests/fw_spec.lua
local function makeStub(framework)
_G.Adapters = {}
if framework == 'esx' then
_G.exports = { ['es_extended'] = { getSharedObject = function()
return {
GetPlayerFromId = function(src)
return {source = src, identifier = 'license:abc', getMoney = function() return 100 end,
addMoney = function() end, removeMoney = function() end,
getJob = function() return {name='mechanic', grade=2} end,
addAccountMoney=function() end, removeAccountMoney=function() end,
addInventoryItem=function() end, removeInventoryItem=function() end,
getName=function() return 'Alex ESX' end }
end
}
end } }
_G.GetResourceState = function(n) return n=='es_extended' and 'started' or 'missing' end
dofile('shared/adapters/esx.lua')
elseif framework == 'qbcore' then
_G.exports = { ['qb-core'] = { GetCoreObject = function()
return { Functions = { GetPlayer=function(src)
return { PlayerData={source=src,citizenid='CITZ123',job={name='mechanic',grade=2},
money={cash=100,bank=500},charinfo={firstname='Alex',lastname='QB'}},
Functions={AddMoney=function() end, RemoveMoney=function() end, AddItem=function() return true end, RemoveItem=function() return true end} }
end } }
end } }
_G.GetResourceState = function(n) return n=='qb-core' and 'started' or 'missing' end
dofile('shared/adapters/qb.lua')
elseif framework == 'qbox' then
_G.exports = { ['qbx_core'] = setmetatable({}, { __index = function()
return function(name) end
end }) }
_G.GetResourceState = function(n) return n=='qbx_core' and 'started' or 'missing' end
dofile('shared/adapters/qbox.lua')
end
dofile('shared/fw.lua')
end
describe('FW contract', function()
it('resolves player and money (esx)', function()
makeStub('esx')
assert.are.equal('esx', FW.meta.name())
local p = FW.Player.getBySrc(1)
assert.are.equal('license:abc', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
it('resolves player and money (qbcore)', function()
makeStub('qbcore')
assert.are.equal('qbcore', FW.meta.name())
local p = FW.Player.getBySrc(2)
assert.are.equal('CITZ123', FW.Player.getStateId(p))
assert.are.equal(100, FW.Money.get(p, 'cash'))
end)
end)
2) GitHub Acties (luacheck + busted)
.github/workflows/lua.yml
name: Lua CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
lua: [ '5.4' ]
steps:
- uses: actions/checkout@v4
- name: Install Lua & LuaRocks
uses: leafo/gh-actions-lua@v10
with: { luaVersion: ${{ matrix.lua }} }
- name: Install rocks
uses: leafo/gh-actions-luarocks@v4
- run: luarocks install luacheck
- run: luarocks install busted
- name: Lint
run: luacheck . --no-color --codes
- name: Test
run: busted -v
.luacheckrc (basislijn)
std = 'lua54'
unused_args = false
max_line_length = 140
ignore = { '211', '212' } -- adjust for your style
Voor volledige integratietests, start uw dev server één keer op en test met een kleine set commando's. CI stubs zijn voldoende om oppervlakkige fouten te detecteren.
Implementatie Checklist
- Plaats
shared/adapters/*.luaEnshared/fw.luain uw resource - Vervang alle directe ESX/QBCore/QBOX-aanroepen in je code met
FW.* - Alleen behouden een persistentiesleutel:
state_idin uw tabellen - Configureer inventaarvoorkeur (standaard eerst ox)
- Voeg CI (luacheck + busted) toe en een minimale test voor elke aanroep die je gebruikt
- Documenteer eventuele lokale afwijkingen (fork-specifieke gebeurtenissen) bovenaan uw adapterbestand
Frequently Extended Surface (optionele add-ons)
FW.Duty.set(p, true|false)– wikkel uw dienst-togglesFW.Permissions.has(src, aceOrGroup)– centraliseer admin/groepscontrolesFW.Vehicle.spawn(model, coords)– verberg framework spawn helpers
Behoud de core contract klein; plaats optionele helpers in een aparte module.
Tri-way Mapping Sneloverzicht
| Zorg | ESX | QBCore | QBOX (typisch) |
|---|---|---|---|
| Toegang tot de core | exports['es_extended']:getSharedObject() | exports['qb-core']:GetCoreObject() | Geen globaal; exports aan qbx_core |
| Speler via src | ESX.GetPlayerFromId(src) | QBCore.Functions.GetPlayer(src) | exports.qbx_core:GetPlayer(src) (pas aan indien geforkt) |
| Identifier | xPlayer.identifier | PlayerData.citizenid | PlayerData.citizenid |
| Geld toevoegen | addMoney / addAccountMoney | Functions.AddMoney | Functions.AddMoney of exports.qbx_core:AddMoney |
| Taaknaam | xPlayer.job.name | PlayerData.job.name | PlayerData.job.name |
| Speler geladen gebeurtenis | esx:playerLoaded | QBCore:Server:PlayerLoaded | Hergebruikt vaak QBCore events; fork-specifiek |
Bij twijfel over QBOX, inspecteer je fork's
qbx_coreexports en bedraad dienovereenkomstig.
Laatste opmerkingen
- Houd adapters saai: geen neveneffecten, geen databaseaanroepen.
- Behandel framework-handlers als ondoorzichtig; haal eruit wat je nodig hebt via het contract.
- Wanneer u moet afwijken voor een klant, Kopieer de adapter, niet uw bedrijfslogica.
Lees verder: FiveM Scripts converteren tussen ESX, QBCore & QBOX (Pillar Page)
