Wzory adapterów: ESX↔QBCore↔QBOX (eksporty, zdarzenia i…

Wzorce adapterów: ESX↔QBCore↔QBOX (Eksporty, zdarzenia i…)

To jest adapter FiveM Framework – dla skrypterów. Dostarcz jeden zasób, który działa na ESX, QBCore, oraz QBOX poprzez izolowanie wywołań specyficznych dla frameworka za cienkim adapterem. Umieść plik shared/fw.lua oraz adaptery per‑framework poniżej w dowolnym zasobie, wywołaj stabilny kontrakt interfejsu (FW.Player, FW.Job, FW.Money, FW.Inv, FW.Events), a logika biznesowa pozostanie niezależna od frameworka. Mała macierz testowa z atrapami wyłapuje niezgodności przed wdrożeniem.


Dlaczego Adapter?

Różnice między frameworkami skupiają się wokół tych samych szwów:

  • Rdzeń dostęp (ESX getSharedObject, QBCore GetCoreObject, tylko eksporty QBOX)
  • Model gracza (xPlayer vs Player/PlayerData)
  • Identyfikatory (license/steam vs citizenid)
  • Pieniądze i ekwipunek API
  • Nazwy zdarzeń podczas ładowania/logowania/aktualizacji pracy

A ujednolicony interfejs utrzymuje te szwy poza logiką gry. Zamieniasz adapter, nie kod bazowy.

BTW: Możesz użyć naszego napisanego adaptera tutaj, za darmo:


Jak używać (Drop‑in)

Drzewo (zalecane):

my-resource/
├─ fxmanifest.lua
├─ shared/
│ ├─ adapters/
│ │ ├─ esx.lua
│ │ ├─ qb.lua
│ │ └─ qbox.lua
│ └─ fw.lua
├─ server/
│ └─ main.lua
└─ client/
 └─ main.lua

fxmanifest.lua (załaduj adaptery najpierw, potem fw.lua aby detekcja mogła się powiązać):

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'
}

W swoim kodzie (serwer lub klient):

-- 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')

Jedynym symbolem, od którego zależysz, jest FW. Wszystko inne jest wewnętrzne dla adaptery.


Kontrakt interfejsu (stabilna powierzchnia)

Cel projektu: Mały, jawny, udokumentowany. Są to funkcje, na których możesz polegać w różnych frameworkach.

FW.meta

  • name() -> 'esx'|'qbcore'|'qbox'
  • has(resourceName: string) -> boolean (zasób uruchomiony?)

FW.Player

  • getBySrc(src: number) -> any (uchwyt gracza frameworka)
  • getStateId(p) -> string (ESX: identyfikator; QB/QBOX: citizenid)
  • getServerId(p) -> number (identyfikator numeryczny)
  • getName(p) -> string

FW.Job

  • getName(p) -> string
  • getGrade(p) -> number|string
  • onChange(handler(src, oldJob, newJob)) (wywoływane, gdy zmienia się praca, jeśli jest wykrywalna)

FW.Money

  • get(p, account: 'cash'|'bank'|'black_money'?) -> number
  • add(p, account, amount: number, reason?: string)
  • remove(p, account, amount: number, reason?: string)

FW.Inv (najlepsza możliwa próba; patrz Uwagi)

  • addItem(p, name: string, count: number, metadata?: table) -> boolean
  • removeItem(p, name: string, count: number, metadata?: table) -> boolean

Uwaga do ekwipunku: serwery się różnią (qb-inventory, ox_inventory, qs‑inventory, itp.). Domyślna implementacja używa ekwipunku frameworka, gdy jest dostępny, i wraca do ox_inventory jeśli wykryto.

FW.Events

  • notify(target: number, msg: string, type?: 'info'|'success'|'error')
  • onPlayerLoaded(handler(src)) (najlepsza możliwa próba, z zastępczym rozwiązaniem przez playerJoining)

Adaptery typu "drop-in" (kopiuj/wklej)

Są to pragmatyczne ustawienia domyślne. Jeśli Twój fork się różni (szczególnie dla QBOX), dostosuj kilka oznaczonych komentarzy.

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

Przykłady użycia

1) Wypłacanie premii za pracę

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) Przyznanie ekwipunku z fallbackiem ox już obsłużone

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)

Katalog anty-wzorców (i poprawki)

AntywzorzecDlaczego to gryzieNapraw za pomocą adaptera
Wbudowane na stałe kluczowe obiekty (ESX = exports['es_extended']:getSharedObject() rozsiane wszędzie)Blokuje Cię w ESX, uciążliwe do migracjiWywołuj tylko FW.*. Rozwiązanie serwera znajduje się w adapterze.
Długoterminowe przechowywanie uchwytu gracza frameworka (np. zachowaj xPlayer na zawsze w tabeli)Uchwyty mogą się przeterminować; odniesienia różnią się w zależności od frameworkaPobierz ponownie przez FW.Player.getBySrc(src) kiedy działasz, lub buforuj przez getStateId klucz i ponowne rozwiązanie.
Zakładając identyfikatory są takie same (ESX identifier vs QB/QBOX citizenid)Niszczy relacje/migracje bazy danychUżywać FW.Player.getStateId(p) i tabela przejść podczas migracji.
Bezpośrednie nazwy zdarzeń w logice biznesowej (esx:playerLoaded, QBCore:Server:PlayerLoaded)Kruche na rozwidleniachSubskrybuj przez FW.Events.onPlayerLoaded.
Założenia dotyczące mieszanych zapasówSerwery często wymieniają ekwipunkiUżywać FW.Inv.* który wykrywa ox_inventory najpierw, potem framework.
SQL schematy zamrożone do jednej strukturyaccounts, identifier, itp. rozbiegają sięUżyj neutralnych kolumn (state_id, money_cash, money_bank) oraz pomocników migracji poniżej.

SQL & Notatki dotyczące migracji identyfikatorów (szybki skrót)

  • Klucz główny osoby:
    • ESX → identifier (licencja/Steam)
    • QB/QBOX → citizenid
  • Neutralny klucz w twoich tabelach: state_id (string). Sklep FW.Player.getStateId(p).
  • Pieniądze:
    • ESX: money (gotówka), accounts.bank, accounts.black_money
    • QB/QBOX: PlayerData.money.cash|bank
  • Minimalne przejście dla pieszych (jednorazowe uzupełnienie):
-- 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;

Zachowaj przejście (id_map) tylko podczas przejścia; przyszłe zapisy powinny zawsze używać state_id.


Macierz testowania i CI: Walidacja skryptu w różnych strukturach

Nie musisz uruchamiać pełnego serwera CFX w CI, aby wyłapać większość problemów z adapterami. Eksporty zastępcze i uruchom testy jednostkowe dla interfejsu kontraktu.

1) Minimalny test (Zatrzymany)

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 Akcje (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 (domyślny)

std = 'lua54'
unused_args = false
max_line_length = 140
ignore = { '211', '212' } -- adjust for your style

Aby przeprowadzić pełne testy integracyjne, uruchom swój serwer deweloperski raz i przetestuj go za pomocą niewielkiego zestawu poleceń. Stuby CI wystarczą, aby wyłapać powierzchowne błędy.


Lista kontrolna implementacji

  • Wrzuć shared/adapters/*.lua I shared/fw.lua do twojego zasobu
  • Zastąp wszystkie bezpośrednie wywołania ESX/QBCore/QBOX w swoim kodzie za pomocą FW.*
  • Zachowaj tylko jeden klucz trwałości: state_id w twoich tabelach
  • Skonfiguruj preferencje ekwipunku (domyślnie najpierw ox)
  • Dodaj CI (luacheck + busted) i minimalny test dla każdego wywołania, którego używasz
  • Dokumentuj wszelkie lokalne odchylenia (zdarzenia specyficzne dla forka) na górze pliku adaptera

Rozszerzona Powierzchnia (opcjonalne dodatki)

  • FW.Duty.set(p, true|false) – zawiń przełączniki dyżuru
  • FW.Permissions.has(src, aceOrGroup) – centralizacja sprawdzania administratorów/grup
  • FW.Vehicle.spawn(model, coords) – ukryj pomocników tworzenia obiektów frameworka

Zachowaj rdzeń contract tiny; umieść opcjonalne pomocniki w osobnym module.


Szybka Tabela Mapowania Tri-way

ZmartwienieESXQBCoreQBOX (typowe)
Dostęp do rdzeniaexports['es_extended']:getSharedObject()exports['qb-core']:GetCoreObject()Brak globalnych; eksporty włączone qbx_core
Gracz według srcESX.GetPlayerFromId(src)QBCore.Functions.GetPlayer(src)exports.qbx_core:GetPlayer(src) (dostosuj, jeśli forkowane)
IdentyfikatorxPlayer.identifierPlayerData.citizenidPlayerData.citizenid
Dodaj pieniądzeaddMoney / addAccountMoneyFunctions.AddMoneyFunctions.AddMoney Lub exports.qbx_core:AddMoney
Nazwa pracyxPlayer.job.namePlayerData.job.namePlayerData.job.name
Zdarzenie załadowania graczaesx:playerLoadedQBCore:Server:PlayerLoadedCzęsto ponownie wykorzystuje zdarzenia QBCore; specyficzne dla forka

W razie wątpliwości co do QBOX, sprawdź swój widelec qbx_core exports i odpowiednio okablować.


Uwagi końcowe

  • Zachowaj adaptery nudny: brak efektów ubocznych, brak wywołań bazy danych.
  • Traktuj frameworkowe uchwyty jako nieprzezroczysty; wyodrębnij to, czego potrzebujesz, poprzez umowę.
  • Kiedy musisz odejść od klienta, skopiuj adapter, nie twoja logika biznesowa.

Czytaj dalej: Konwersja skryptów FiveM między ESX, QBCore i QBOX (Strona filarowa)