From 02397cfc5a2d4132a9bbc1a1abd07fa9045820d3 Mon Sep 17 00:00:00 2001 From: Kakarot Date: Tue, 12 May 2026 23:40:29 -0500 Subject: [PATCH] Refactor player system and API Large refactor of server/player.lua to modernize the player system and public API. Introduces a Player metatable with cleaner Player.Functions interface, offline player support, and centralized data validation/merge logic (CheckPlayerData, applyDynamicDefaults, merge/prune helpers). Adds unique ID generators, improved money logging/notifications, and safer save/delete flows (including ForceDeleteCharacter). Replaces old Login/Logout/CreatePlayer flows, exposes exports (GetPlayer, GetPlayerByCitizenId, GetOfflinePlayerByCitizenId, GetPlayerByLicense, GetOfflinePlayerByLicense, AddPlayerMethod, AddPlayerField) for runtime integration, and improves inventory and job/gang handling. Overall aims: clearer runtime API, offline lookups, and more maintainable player persistence and event synchronization. --- server/player.lua | 1057 ++++++++++++++++++++++++++------------------- 1 file changed, 616 insertions(+), 441 deletions(-) diff --git a/server/player.lua b/server/player.lua index 6710368..fe508c5 100644 --- a/server/player.lua +++ b/server/player.lua @@ -1,357 +1,220 @@ +--[[ + Getting a player: + + local Player = exports['qb-core']:GetPlayer(source) -- online, by server id + local Player = exports['qb-core']:GetPlayerByCitizenId(cid) -- online, by citizenid + local Player = exports['qb-core']:GetOfflinePlayerByCitizenId(cid) -- offline, DB lookup + + Calling functions: + + Player.AddMoney('cash', 500, 'reason') + Player.RemoveMoney('bank', 200, 'reason') + Player.SetJob('police', 2) + Player.SetMetaData('hunger', 100) + Player.Save() + + -- Old style (also still works): + Player.Functions.AddMoney('cash', 500, 'reason') +]] + +QBCore.Players = {} +QBCore.Player = {} local resourceName = GetCurrentResourceName() -function QBCore.Player.Login(source, citizenid, newData) - if not source or source == '' then - QBCore.ShowError(resourceName, 'ERROR QBCORE.PLAYER.LOGIN - NO SOURCE GIVEN!') - return false - end +-- ============================================================ +-- Player Metatable +-- ============================================================ - if citizenid then - local license = QBCore.Functions.GetIdentifier(source, 'license') - local row = MySQL.prepare.await('SELECT * FROM players WHERE citizenid = ?', { citizenid }) - if not row or license ~= row.license then - DropPlayer(source, Lang:t('info.exploit_dropped')) - TriggerEvent('qb-log:server:CreateLog', 'anticheat', 'Anti-Cheat', 'white', GetPlayerName(source) .. ' exploit attempt', false) - return false - end - row.money = json.decode(row.money) or {} - row.job = json.decode(row.job) or {} - row.gang = json.decode(row.gang) or {} - row.position = json.decode(row.position) or {} - row.metadata = json.decode(row.metadata) or {} - row.charinfo = json.decode(row.charinfo) or {} - QBCore.Player.CheckPlayerData(source, row) - else - QBCore.Player.CheckPlayerData(source, newData) - end - return true -end - -function QBCore.Player.Logout(source) - TriggerClientEvent('QBCore:Client:OnPlayerUnload', source) - TriggerEvent('QBCore:Server:OnPlayerUnload', source) - TriggerClientEvent('QBCore:Player:UpdatePlayerData', source) - Wait(200) - QBCore.Players[source] = nil -end - --- JSON DEFAULTS - -local DynamicDefaults = { - citizenid = QBCore.Functions.CreateCitizenId, - phone = QBCore.Functions.CreatePhoneNumber, - account = QBCore.Functions.CreateAccountNumber, - fingerprint = QBCore.Functions.CreateFingerId, - walletid = QBCore.Functions.CreateWalletId, - SerialNumber = QBCore.Functions.CreateSerialNumber, - bloodtype = function() - return QBCore.Shared.GetRandomElement(QBCore.Config.Player.Bloodtypes) - end +local varargMethods = { + 'GetPlayerData', 'UpdateClient', 'SetJob', 'SetGang', 'Notify', 'HasItem', + 'SetJobDuty', 'SetPlayerData', 'SetMetaData', 'GetMetaData', + 'AddRep', 'RemoveRep', 'GetRep', 'AddMoney', 'RemoveMoney', 'SetMoney', 'GetMoney', + 'AddMethod', 'AddField', } +local noargMethods = { 'GetName', 'Save', 'Logout' } -local function applyDynamicDefaults(tbl) - for k, v in pairs(tbl) do - if type(v) == 'table' then - applyDynamicDefaults(v) - elseif v == '__GENERATED__' then - local gen = DynamicDefaults[k] - if gen then - tbl[k] = gen() - end - end +local function buildMethodTable(player) + local t = {} + for _, name in ipairs(varargMethods) do + local fn = player[name] + t[name] = function(...) return fn(player, ...) end end -end - -local function mergeDefaults(dest, src) - for k, v in pairs(src) do - if type(v) == 'table' then - if type(dest[k]) ~= 'table' then - dest[k] = {} - end - mergeDefaults(dest[k], v) - else - if dest[k] == nil then - dest[k] = v - end - end + for _, name in ipairs(noargMethods) do + local fn = player[name] + t[name] = function() return fn(player) end end -end - -local function mergeWithGenerators(dest, src) - for k, v in pairs(src) do - if type(v) == 'table' then - if type(dest[k]) ~= 'table' then - dest[k] = {} - end - mergeWithGenerators(dest[k], v) - else - -- only act if dest is missing - if dest[k] == nil then - if v == '__GENERATED__' then - local gen = DynamicDefaults[k] - if gen then - dest[k] = gen() - else - -- fallback to literal, or warn - dest[k] = v - print(("Warning: no generator for '%s'"):format(k)) - end - else - -- static default - dest[k] = v - end - end - end - end -end - -local function pruneExtras(dest, src) - for k, v in pairs(dest) do - if src[k] == nil then - dest[k] = nil - elseif type(v) == 'table' and type(src[k]) == 'table' then - pruneExtras(v, src[k]) - end - end -end - -local function LogAndSyncMoneyChange(self, moneytype, amount, changeType, reason, diff) - local source = self.PlayerData.source - local citizenid = self.PlayerData.citizenid - local balance = self.PlayerData.money[moneytype] - local name = GetPlayerName(source) - local colorMap = { add = 'lightgreen', remove = 'red', set = 'green' } - local color = colorMap[changeType] or 'white' - local action = ({ - add = 'added', - remove = 'removed', - set = 'set' - })[changeType] - - local logMessage = ('**%s (citizenid: %s | id: %s)** $%s (%s) %s, new %s balance: %s reason: %s'):format( - name, citizenid, source, amount, moneytype, action, moneytype, balance, reason - ) - - local logLabel = ({ - add = 'AddMoney', - remove = 'RemoveMoney', - set = 'SetMoney' - })[changeType] - - local notifyAmount = 100000 - TriggerEvent('qb-log:server:CreateLog', 'playermoney', logLabel, color, logMessage, amount > notifyAmount) - - -- HUD & Client events - local isRemoval = changeType == 'remove' - local showAmount = changeType == 'set' and math.abs(diff or 0) or amount - - TriggerClientEvent('hud:client:OnMoneyChange', source, moneytype, showAmount, isRemoval) - TriggerClientEvent('QBCore:Client:OnMoneyChange', source, moneytype, amount, changeType, reason) - TriggerEvent('QBCore:Server:OnMoneyChange', source, moneytype, amount, changeType, reason) - - if isRemoval and moneytype == 'bank' then - TriggerClientEvent('qb-phone:client:RemoveBankMoney', source, amount) - end -end - -function QBCore.Player.CheckPlayerData(source, PlayerData) - PlayerData = PlayerData or {} - local Offline = not source - - local defaults = json.decode(LoadResourceFile(resourceName, 'shared/player_defaults.json')) - - if not PlayerData.citizenid then - applyDynamicDefaults(defaults) - mergeDefaults(PlayerData, defaults) - else - pruneExtras(PlayerData, defaults) - mergeWithGenerators(PlayerData, defaults) - end - - if source then - PlayerData.source = source - PlayerData.license = PlayerData.license or GetPlayerIdentifierByType(source, 'license') - PlayerData.name = GetPlayerName(source) - end - - if GetResourceState('qb-inventory') ~= 'missing' then - PlayerData.items = exports['qb-inventory']:LoadInventory(PlayerData.source, PlayerData.citizenid) - end - - return QBCore.Player.CreatePlayer(PlayerData, Offline) + return t end local Player = {} Player.__index = Player --- Add/Edit Player Functions Below +function Player.new(PlayerData, Offline) + local self = setmetatable({}, Player) + self.PlayerData = PlayerData + self.Offline = Offline + self.Functions = buildMethodTable(self) + self.Functions.UpdatePlayerData = self.Functions.UpdateClient + return self +end --- Player Data Getters +function Player:GetPlayerData() + return self.PlayerData +end + +function Player:UpdateClient(key, val) + if self.Offline then return end + TriggerEvent('QBCore:Player:SetPlayerData', self.PlayerData) + if key and val then + TriggerClientEvent('QBCore:Player:UpdatePlayerDataField', self.PlayerData.source, key, val) + else + TriggerClientEvent('QBCore:Player:SetPlayerData', self.PlayerData.source, self.PlayerData) + end +end + +function Player:SetJob(job, grade) + job = job:lower() + grade = grade or '0' + if not QBCore.Shared.Jobs[job] then return false end + self.PlayerData.job = { + name = job, + label = QBCore.Shared.Jobs[job].label, + onduty = QBCore.Shared.Jobs[job].defaultDuty, + type = QBCore.Shared.Jobs[job].type or 'none', + grade = { + name = 'No Grades', + level = 0, + payment = 30, + isboss = false + } + } + local gradeKey = tostring(grade) + local jobGradeInfo = QBCore.Shared.Jobs[job].grades[gradeKey] + if jobGradeInfo then + self.PlayerData.job.grade.name = jobGradeInfo.name + self.PlayerData.job.grade.level = tonumber(gradeKey) + self.PlayerData.job.grade.payment = jobGradeInfo.payment + self.PlayerData.job.grade.isboss = jobGradeInfo.isboss or false + self.PlayerData.job.isboss = jobGradeInfo.isboss or false + end + if not self.Offline then + self:UpdateClient('job', self.PlayerData.job) + TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) + TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) + end + return true +end + +function Player:SetGang(gang, grade) + gang = gang:lower() + grade = grade or '0' + if not QBCore.Shared.Gangs[gang] then return false end + self.PlayerData.gang = { + name = gang, + label = QBCore.Shared.Gangs[gang].label, + grade = { + name = 'No Grades', + level = 0, + isboss = false + } + } + local gradeKey = tostring(grade) + local gangGradeInfo = QBCore.Shared.Gangs[gang].grades[gradeKey] + if gangGradeInfo then + self.PlayerData.gang.grade.name = gangGradeInfo.name + self.PlayerData.gang.grade.level = tonumber(gradeKey) + self.PlayerData.gang.grade.isboss = gangGradeInfo.isboss or false + self.PlayerData.gang.isboss = gangGradeInfo.isboss or false + end + if not self.Offline then + self:UpdateClient('gang', self.PlayerData.gang) + TriggerEvent('QBCore:Server:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang) + TriggerClientEvent('QBCore:Client:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang) + end + return true +end + +function Player:Notify(text, notifyType, length) + TriggerClientEvent('QBCore:Notify', self.PlayerData.source, text, notifyType, length) +end + +function Player:HasItem(items, amount) + return QBCore.Functions.HasItem(self.PlayerData.source, items, amount) +end function Player:GetName() local charinfo = self.PlayerData.charinfo return charinfo.firstname .. ' ' .. charinfo.lastname end +function Player:SetJobDuty(onDuty) + self.PlayerData.job.onduty = not not onDuty + TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) + TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) + self:UpdateClient('job', self.PlayerData.job) +end + +function Player:SetPlayerData(key, val) + if not key or type(key) ~= 'string' then return end + self.PlayerData[key] = val + self:UpdateClient(key, val) +end + +function Player:SetMetaData(meta, val) + if not meta or type(meta) ~= 'string' then return end + if meta == 'hunger' or meta == 'thirst' then + val = val > 100 and 100 or val + end + self.PlayerData.metadata[meta] = val + self:UpdateClient('metadata', self.PlayerData.metadata) +end + function Player:GetMetaData(meta) if not meta or type(meta) ~= 'string' then return end return self.PlayerData.metadata[meta] end +function Player:AddRep(rep, amount) + if not rep or not amount then return end + local addAmount = tonumber(amount) + local currentRep = self.PlayerData.metadata['rep'][rep] or 0 + self.PlayerData.metadata['rep'][rep] = currentRep + addAmount + self:UpdateClient('metadata', self.PlayerData.metadata) +end + +function Player:RemoveRep(rep, amount) + if not rep or not amount then return end + local removeAmount = tonumber(amount) + local currentRep = self.PlayerData.metadata['rep'][rep] or 0 + self.PlayerData.metadata['rep'][rep] = math.max(0, currentRep - removeAmount) + self:UpdateClient('metadata', self.PlayerData.metadata) +end + function Player:GetRep(rep) if not rep then return end return self.PlayerData.metadata['rep'][rep] or 0 end -function Player:GetMoney(moneytype) - if not moneytype then return false end - moneytype = moneytype:lower() - return self.PlayerData.money[moneytype] -end - --- Job and Gang Functions - -function Player:SetJobDuty(onDuty) - if not self.PlayerData.job then return false end - local newDuty = onDuty == true - if self.PlayerData.job.onduty == newDuty then return false end - self.PlayerData.job.onduty = newDuty - self:UpdatePlayerData() - TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) - TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) - return true -end - -function Player:SetJob(job, grade) - if type(job) ~= 'string' then return false end - - job = job:lower() - grade = tostring(grade or '0') - - local jobData = QBCore.Shared.Jobs[job] - local gradeData = jobData and jobData.grades[grade] - - if not jobData then return false end - - local jobInfo = { - name = job, - label = jobData.label, - onduty = jobData.defaultDuty, - type = jobData.type or 'none', - grade = { - name = gradeData and gradeData.name or 'No Grades', - level = gradeData and tonumber(grade) or 0, - payment = gradeData and gradeData.payment or 30, - isboss = gradeData and (gradeData.isboss or false) or false - }, - isboss = gradeData and (gradeData.isboss or false) or false - } - - self.PlayerData.job = jobInfo - - if not self.Offline then - self:UpdatePlayerData() - TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, jobInfo) - TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, jobInfo) - end - - return true -end - -function Player:SetGang(gang, grade) - if type(gang) ~= 'string' then return false end - - gang = gang:lower() - grade = tostring(grade or '0') - - local gangData = QBCore.Shared.Gangs[gang] - local gradeData = gangData and gangData.grades[grade] - - if not gangData then return false end - - local gangInfo = { - name = gang, - label = gangData.label, - grade = { - name = gradeData and gradeData.name or 'No Grades', - level = gradeData and tonumber(grade) or 0, - isboss = gradeData and (gradeData.isboss or false) or false - }, - isboss = gradeData and (gradeData.isboss or false) or false - } - - self.PlayerData.gang = gangInfo - - if not self.Offline then - self:UpdatePlayerData() - TriggerEvent('QBCore:Server:OnGangUpdate', self.PlayerData.source, gangInfo) - TriggerClientEvent('QBCore:Client:OnGangUpdate', self.PlayerData.source, gangInfo) - end - - return true -end - --- Player Data Functions - -function Player:SetPlayerData(key, val) - if not key or type(key) ~= 'string' then return false end - if self.PlayerData[key] == nil then return false end - self.PlayerData[key] = val - self:UpdatePlayerData() - return true -end - -function Player:SetMetaData(meta, val) - if not meta or type(meta) ~= 'string' then return false end - if self.PlayerData.metadata[meta] == nil then return false end - if meta == 'hunger' or meta == 'thirst' then - val = math.min(math.max(val, 0), 100) - end - self.PlayerData.metadata[meta] = val - self:UpdatePlayerData() - return true -end - --- Reputation Functions - -function Player:AddRep(rep, amount) - if not rep then return false end - local addAmount = tonumber(amount) or 1 - local currentRep = self.PlayerData.metadata['rep'][rep] or 0 - self.PlayerData.metadata['rep'][rep] = currentRep + addAmount - self:UpdatePlayerData() - return true -end - -function Player:RemoveRep(rep, amount) - if not rep then return false end - local removeAmount = tonumber(amount) or 1 - local currentRep = self.PlayerData.metadata['rep'][rep] or 0 - self.PlayerData.metadata['rep'][rep] = math.max(currentRep - removeAmount, 0) - self:UpdatePlayerData() - return true -end - -function Player:SetRep(rep, amount) - if not rep then return false end - local setAmount = math.max(tonumber(amount) or 0, 0) - self.PlayerData.metadata['rep'][rep] = setAmount - self:UpdatePlayerData() - return true -end - --- Money Functions - function Player:AddMoney(moneytype, amount, reason) reason = reason or 'unknown' moneytype = moneytype:lower() amount = tonumber(amount) - if not amount or amount < 0 then return end + if amount < 0 then return end if not self.PlayerData.money[moneytype] then return false end self.PlayerData.money[moneytype] = self.PlayerData.money[moneytype] + amount if not self.Offline then - self:UpdatePlayerData() - LogAndSyncMoneyChange(self, moneytype, amount, 'add', reason) + self:UpdateClient('money', self.PlayerData.money) + local logExtra = amount > 100000 + TriggerEvent('qb-log:server:CreateLog', 'playermoney', 'AddMoney', 'lightgreen', + '**' .. GetPlayerName(self.PlayerData.source) .. + ' (citizenid: ' .. self.PlayerData.citizenid .. + ' | id: ' .. self.PlayerData.source .. ')** $' .. amount .. + ' (' .. moneytype .. ') added, new ' .. moneytype .. + ' balance: ' .. self.PlayerData.money[moneytype] .. + ' reason: ' .. reason, logExtra) + TriggerClientEvent('hud:client:OnMoneyChange', self.PlayerData.source, moneytype, amount, false) + TriggerClientEvent('QBCore:Client:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'add', reason) + TriggerEvent('QBCore:Server:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'add', reason) end return true end @@ -360,7 +223,7 @@ function Player:RemoveMoney(moneytype, amount, reason) reason = reason or 'unknown' moneytype = moneytype:lower() amount = tonumber(amount) - if not amount or amount < 0 then return end + if amount < 0 then return end if not self.PlayerData.money[moneytype] then return false end for _, mtype in pairs(QBCore.Config.Money.DontAllowMinus) do if mtype == moneytype and (self.PlayerData.money[moneytype] - amount) < 0 then @@ -370,8 +233,21 @@ function Player:RemoveMoney(moneytype, amount, reason) if self.PlayerData.money[moneytype] - amount < QBCore.Config.Money.MinusLimit then return false end self.PlayerData.money[moneytype] = self.PlayerData.money[moneytype] - amount if not self.Offline then - self:UpdatePlayerData() - LogAndSyncMoneyChange(self, moneytype, amount, 'remove', reason) + self:UpdateClient('money', self.PlayerData.money) + local logExtra = amount > 100000 + TriggerEvent('qb-log:server:CreateLog', 'playermoney', 'RemoveMoney', 'red', + '**' .. GetPlayerName(self.PlayerData.source) .. + ' (citizenid: ' .. self.PlayerData.citizenid .. + ' | id: ' .. self.PlayerData.source .. ')** $' .. amount .. + ' (' .. moneytype .. ') removed, new ' .. moneytype .. + ' balance: ' .. self.PlayerData.money[moneytype] .. + ' reason: ' .. reason, logExtra) + TriggerClientEvent('hud:client:OnMoneyChange', self.PlayerData.source, moneytype, amount, true) + if moneytype == 'bank' then + TriggerClientEvent('qb-phone:client:RemoveBankMoney', self.PlayerData.source, amount) + end + TriggerClientEvent('QBCore:Client:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'remove', reason) + TriggerEvent('QBCore:Server:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'remove', reason) end return true end @@ -380,64 +256,29 @@ function Player:SetMoney(moneytype, amount, reason) reason = reason or 'unknown' moneytype = moneytype:lower() amount = tonumber(amount) - if not amount or amount < 0 then return end + if amount < 0 then return false end if not self.PlayerData.money[moneytype] then return false end local difference = amount - self.PlayerData.money[moneytype] self.PlayerData.money[moneytype] = amount if not self.Offline then - self:UpdatePlayerData() - LogAndSyncMoneyChange(self, moneytype, amount, 'set', reason, difference) + self:UpdateClient('money', self.PlayerData.money) + TriggerEvent('qb-log:server:CreateLog', 'playermoney', 'SetMoney', 'green', + '**' .. GetPlayerName(self.PlayerData.source) .. + ' (citizenid: ' .. self.PlayerData.citizenid .. + ' | id: ' .. self.PlayerData.source .. ')** $' .. amount .. + ' (' .. moneytype .. ') set, new ' .. moneytype .. + ' balance: ' .. self.PlayerData.money[moneytype] .. + ' reason: ' .. reason) + TriggerClientEvent('hud:client:OnMoneyChange', self.PlayerData.source, moneytype, math.abs(difference), difference < 0) + TriggerClientEvent('QBCore:Client:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'set', reason) + TriggerEvent('QBCore:Server:OnMoneyChange', self.PlayerData.source, moneytype, amount, 'set', reason) end return true end --- Utility Functions - -function Player:Notify(text, type, length) - TriggerClientEvent('QBCore:Notify', self.PlayerData.source, text, type, length) -end - -function Player:AddMethod(methodName, handler) - if Player[methodName] then - return false - end - if type(handler) ~= 'function' then - print(('Error: Player:AddMethod - %s is not a function'):format(methodName)) - return false - end - Player[methodName] = handler - return true -end - -function Player:AddField(fieldName, data) - if Player[fieldName] then - return false - end - if type(data) == 'function' then - print(('Error: Player:AddField - %s is a function, not a field'):format(fieldName)) - return false - end - Player[fieldName] = data - return true -end - -function Player:AddPlayerData(key, value) - if type(key) ~= 'string' then - error('Player:AddPlayerData - key must be a string') - end - if self.PlayerData[key] then - return false - end - self.PlayerData[key] = value - return true -end - --- Player Handler Functions - -function Player:UpdatePlayerData() - if self.Offline then return end - TriggerEvent('QBCore:Player:SetPlayerData', self.PlayerData) - TriggerClientEvent('QBCore:Player:SetPlayerData', self.PlayerData.source, self.PlayerData) +function Player:GetMoney(moneytype) + if not moneytype then return false end + return self.PlayerData.money[moneytype:lower()] end function Player:Save() @@ -453,48 +294,324 @@ function Player:Logout() QBCore.Player.Logout(self.PlayerData.source) end -function Player.new(PlayerData, Offline) - if type(PlayerData) ~= 'table' then - error('Player.new param PlayerData must be a table') - end - if type(Offline) ~= 'boolean' then - error('Player.new param Offline must be a boolean') - end +function Player:AddMethod(methodName, handler) + if type(handler) ~= 'function' then return end + self[methodName] = handler + self.Functions[methodName] = handler +end - local self = setmetatable({}, Player) - self.PlayerData = PlayerData - self.Offline = Offline - self.Functions = setmetatable({}, { - __index = function(_, key) - if type(key) ~= 'string' or key:match('^__') then - error('Invalid function name: ' .. tostring(key)) +function Player:AddField(fieldName, data) + if type(data) == 'function' then return end + self[fieldName] = data +end + +-- ============================================================ +-- Login / Logout +-- ============================================================ + +local function decodePlayerFields(PlayerData) + PlayerData.money = json.decode(PlayerData.money) + PlayerData.job = json.decode(PlayerData.job) + PlayerData.gang = json.decode(PlayerData.gang) + PlayerData.position = json.decode(PlayerData.position) + PlayerData.metadata = json.decode(PlayerData.metadata) + PlayerData.charinfo = json.decode(PlayerData.charinfo) +end + +function QBCore.Player.Login(source, citizenid, newData) + if source and source ~= '' then + if citizenid then + local license = QBCore.Functions.GetIdentifier(source, 'license') + local PlayerData = MySQL.prepare.await('SELECT * FROM players where citizenid = ?', { citizenid }) + if PlayerData and license == PlayerData.license then + decodePlayerFields(PlayerData) + QBCore.Player.CheckPlayerData(source, PlayerData) + else + DropPlayer(source, Lang:t('info.exploit_dropped')) + TriggerEvent('qb-log:server:CreateLog', 'anticheat', 'Anti-Cheat', 'white', GetPlayerName(source) .. ' Has Been Dropped For Character Joining Exploit', false) end - if Player[key] and type(Player[key]) == 'function' then - return function(...) - return Player[key](self, ...) + else + QBCore.Player.CheckPlayerData(source, newData) + end + return true + else + QBCore.ShowError(resourceName, 'ERROR QBCORE.PLAYER.LOGIN - NO SOURCE GIVEN!') + return false + end +end + +function QBCore.Player.Logout(source) + TriggerClientEvent('QBCore:Client:OnPlayerUnload', source) + TriggerEvent('QBCore:Server:OnPlayerUnload', source) + TriggerClientEvent('QBCore:Player:UpdatePlayerData', source) + Wait(200) + QBCore.Players[source] = nil +end + +-- ============================================================ +-- Offline Player Lookups +-- ============================================================ + +function QBCore.Player.GetOfflinePlayer(citizenid) + if citizenid then + local PlayerData = MySQL.prepare.await('SELECT * FROM players where citizenid = ?', { citizenid }) + if PlayerData then + decodePlayerFields(PlayerData) + return QBCore.Player.CheckPlayerData(nil, PlayerData) + end + end + return nil +end + +function QBCore.Player.GetPlayerByLicense(license) + if license then + local source = QBCore.Functions.GetSource(license) + if source > 0 then + return QBCore.Players[source] + else + return QBCore.Player.GetOfflinePlayerByLicense(license) + end + end + return nil +end + +function QBCore.Player.GetOfflinePlayerByLicense(license) + if license then + local PlayerData = MySQL.prepare.await('SELECT * FROM players where license = ?', { license }) + if PlayerData then + decodePlayerFields(PlayerData) + return QBCore.Player.CheckPlayerData(nil, PlayerData) + end + end + return nil +end + +-- ============================================================ +-- Unique ID Generators +-- ============================================================ + +local function createUniqueId(generator, query) + local value = generator() + local result = MySQL.prepare.await(query, { value }) + if result == 0 then return value end + return createUniqueId(generator, query) +end + +function QBCore.Player.CreateCitizenId() + return createUniqueId( + function() return tostring(QBCore.Shared.RandomStr(3) .. QBCore.Shared.RandomInt(5)):upper() end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE citizenid = ?) AS uniqueCheck' + ) +end + +function QBCore.Functions.CreateAccountNumber() + return createUniqueId( + function() return 'US0' .. math.random(1, 9) .. 'QBCore' .. math.random(1111, 9999) .. math.random(1111, 9999) .. math.random(11, 99) end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE JSON_UNQUOTE(JSON_EXTRACT(charinfo, "$.account")) = ?) AS uniqueCheck' + ) +end + +function QBCore.Functions.CreatePhoneNumber() + return createUniqueId( + function() return math.random(100, 999) .. math.random(1000000, 9999999) end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE JSON_UNQUOTE(JSON_EXTRACT(charinfo, "$.phone")) = ?) AS uniqueCheck' + ) +end + +function QBCore.Player.CreateFingerId() + return createUniqueId( + function() return tostring(QBCore.Shared.RandomStr(2) .. QBCore.Shared.RandomInt(3) .. QBCore.Shared.RandomStr(1) .. QBCore.Shared.RandomInt(2) .. QBCore.Shared.RandomStr(3) .. QBCore.Shared.RandomInt(4)) end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata, "$.fingerprint")) = ?) AS uniqueCheck' + ) +end + +function QBCore.Player.CreateWalletId() + return createUniqueId( + function() return 'QB-' .. math.random(11111111, 99999999) end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata, "$.walletid")) = ?) AS uniqueCheck' + ) +end + +function QBCore.Player.CreateSerialNumber() + return createUniqueId( + function() return math.random(11111111, 99999999) end, + 'SELECT EXISTS(SELECT 1 FROM players WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata, "$.phonedata.SerialNumber")) = ?) AS uniqueCheck' + ) +end + +-- ============================================================ +-- Data Validation / Construction +-- ============================================================ + +local DynamicDefaults = { + citizenid = QBCore.Player.CreateCitizenId, + phone = QBCore.Functions.CreatePhoneNumber, + account = QBCore.Functions.CreateAccountNumber, + fingerprint = QBCore.Player.CreateFingerId, + walletid = QBCore.Player.CreateWalletId, + SerialNumber = QBCore.Player.CreateSerialNumber, + bloodtype = function() + return QBCore.Shared.GetRandomElement(QBCore.Config.Player.Bloodtypes) + end +} + +local function applyDynamicDefaults(tbl) + for k, v in pairs(tbl) do + if type(v) == 'table' then + applyDynamicDefaults(v) + elseif v == '__GENERATED__' then + local gen = DynamicDefaults[k] + if gen then tbl[k] = gen() end + end + end +end + +local function mergeDefaults(dest, src) + for k, v in pairs(src) do + if type(v) == 'table' then + if type(dest[k]) ~= 'table' then dest[k] = {} end + mergeDefaults(dest[k], v) + elseif dest[k] == nil then + dest[k] = v + end + end +end + +local function mergeWithGenerators(dest, src) + for k, v in pairs(src) do + if type(v) == 'table' then + if type(dest[k]) ~= 'table' then dest[k] = {} end + mergeWithGenerators(dest[k], v) + elseif dest[k] == nil then + if v == '__GENERATED__' then + local gen = DynamicDefaults[k] + if gen then + dest[k] = gen() + else + dest[k] = v + print(("Warning: no generator for '%s'"):format(k)) end else - error("Attempt to call undefined function '" .. key .. "'") + dest[k] = v end end - }) - return self + end +end + +local function pruneExtras(dest, src) + for k, v in pairs(dest) do + if src[k] == nil then + dest[k] = nil + elseif type(v) == 'table' and type(src[k]) == 'table' then + pruneExtras(v, src[k]) + end + end +end + +function QBCore.Player.CheckPlayerData(source, PlayerData) + PlayerData = PlayerData or {} + local Offline = not source + + local validatedJob = false + if PlayerData.job and PlayerData.job.name ~= nil and PlayerData.job.grade and PlayerData.job.grade.level ~= nil then + local jobInfo = QBCore.Shared.Jobs[PlayerData.job.name] + if jobInfo then + local jobGradeInfo = jobInfo.grades[tostring(PlayerData.job.grade.level)] + if jobGradeInfo then + PlayerData.job.label = jobInfo.label + PlayerData.job.grade.name = jobGradeInfo.name + PlayerData.job.grade.payment = jobGradeInfo.payment + PlayerData.job.grade.isboss = jobGradeInfo.isboss or false + PlayerData.job.isboss = jobGradeInfo.isboss or false + validatedJob = true + end + end + end + if not validatedJob then PlayerData.job = nil end + + local validatedGang = false + if PlayerData.gang and PlayerData.gang.name ~= nil and PlayerData.gang.grade and PlayerData.gang.grade.level ~= nil then + local gangInfo = QBCore.Shared.Gangs[PlayerData.gang.name] + if gangInfo then + local gangGradeInfo = gangInfo.grades[tostring(PlayerData.gang.grade.level)] + if gangGradeInfo then + PlayerData.gang.label = gangInfo.label + PlayerData.gang.grade.name = gangGradeInfo.name + PlayerData.gang.grade.isboss = gangGradeInfo.isboss or false + PlayerData.gang.isboss = gangGradeInfo.isboss or false + validatedGang = true + end + end + end + if not validatedGang then PlayerData.gang = nil end + + local defaults = json.decode(LoadResourceFile(resourceName, 'shared/player_defaults.json')) + + if not PlayerData.citizenid then + applyDynamicDefaults(defaults) + mergeDefaults(PlayerData, defaults) + else + pruneExtras(PlayerData, defaults) + mergeWithGenerators(PlayerData, defaults) + end + + if source then + PlayerData.source = source + PlayerData.license = PlayerData.license or QBCore.Functions.GetIdentifier(source, 'license') + PlayerData.name = GetPlayerName(source) + end + + if PlayerData.job and QBCore.Shared.ForceJobDefaultDutyAtLogin then + local jobInfo = QBCore.Shared.Jobs[PlayerData.job.name] + if jobInfo then + PlayerData.job.onduty = jobInfo.defaultDuty + end + end + + if GetResourceState('qb-inventory') ~= 'missing' then + PlayerData.items = exports['qb-inventory']:LoadInventory(PlayerData.source, PlayerData.citizenid) + end + + return QBCore.Player.CreatePlayer(PlayerData, Offline) end function QBCore.Player.CreatePlayer(PlayerData, Offline) local player = Player.new(PlayerData, Offline) - QBCore.Debug(player) - if not Offline then - local src = PlayerData.source - QBCore.Players[src] = player - QBCore.Player.Save(src) - TriggerEvent('QBCore:Server:PlayerLoaded', player) - player:UpdatePlayerData() - end - return player + if Offline then return player end + QBCore.Players[PlayerData.source] = player + QBCore.Player.Save(PlayerData.source) + TriggerEvent('QBCore:Server:PlayerLoaded', player) + player:UpdateClient() end --- Save player info to database (make sure citizenid is the primary key in your database) +-- ============================================================ +-- Runtime Extension API +-- ============================================================ + +local function forEachPlayer(ids, fn) + local idType = type(ids) + if idType == 'number' then + if ids == -1 then + for _, v in pairs(QBCore.Players) do fn(v) end + elseif QBCore.Players[ids] then + fn(QBCore.Players[ids]) + end + elseif idType == 'table' and table.type(ids) == 'array' then + for i = 1, #ids do forEachPlayer(ids[i], fn) end + end +end + +function QBCore.Functions.AddPlayerMethod(ids, methodName, handler) + forEachPlayer(ids, function(v) v:AddMethod(methodName, handler) end) +end + +function QBCore.Functions.AddPlayerField(ids, fieldName, data) + forEachPlayer(ids, function(v) v:AddField(fieldName, data) end) +end + +-- ============================================================ +-- Save / Persistence +-- ============================================================ function QBCore.Player.Save(source) local ped = GetPlayerPed(source) @@ -503,15 +620,15 @@ function QBCore.Player.Save(source) if PlayerData then MySQL.insert('INSERT INTO players (citizenid, cid, license, name, money, charinfo, job, gang, position, metadata) VALUES (:citizenid, :cid, :license, :name, :money, :charinfo, :job, :gang, :position, :metadata) ON DUPLICATE KEY UPDATE cid = :cid, name = :name, money = :money, charinfo = :charinfo, job = :job, gang = :gang, position = :position, metadata = :metadata', { citizenid = PlayerData.citizenid, - cid = tonumber(PlayerData.cid), - license = PlayerData.license, - name = PlayerData.name, - money = json.encode(PlayerData.money), - charinfo = json.encode(PlayerData.charinfo), - job = json.encode(PlayerData.job), - gang = json.encode(PlayerData.gang), - position = json.encode(pcoords), - metadata = json.encode(PlayerData.metadata) + cid = tonumber(PlayerData.cid), + license = PlayerData.license, + name = PlayerData.name, + money = json.encode(PlayerData.money), + charinfo = json.encode(PlayerData.charinfo), + job = json.encode(PlayerData.job), + gang = json.encode(PlayerData.gang), + position = json.encode(pcoords), + metadata = json.encode(PlayerData.metadata) }) if GetResourceState('qb-inventory') ~= 'missing' then exports['qb-inventory']:SaveInventory(source) end QBCore.ShowSuccess(resourceName, PlayerData.name .. ' PLAYER SAVED!') @@ -524,15 +641,15 @@ function QBCore.Player.SaveOffline(PlayerData) if PlayerData then MySQL.insert('INSERT INTO players (citizenid, cid, license, name, money, charinfo, job, gang, position, metadata) VALUES (:citizenid, :cid, :license, :name, :money, :charinfo, :job, :gang, :position, :metadata) ON DUPLICATE KEY UPDATE cid = :cid, name = :name, money = :money, charinfo = :charinfo, job = :job, gang = :gang, position = :position, metadata = :metadata', { citizenid = PlayerData.citizenid, - cid = tonumber(PlayerData.cid), - license = PlayerData.license, - name = PlayerData.name, - money = json.encode(PlayerData.money), - charinfo = json.encode(PlayerData.charinfo), - job = json.encode(PlayerData.job), - gang = json.encode(PlayerData.gang), - position = json.encode(PlayerData.position), - metadata = json.encode(PlayerData.metadata) + cid = tonumber(PlayerData.cid), + license = PlayerData.license, + name = PlayerData.name, + money = json.encode(PlayerData.money), + charinfo = json.encode(PlayerData.charinfo), + job = json.encode(PlayerData.job), + gang = json.encode(PlayerData.gang), + position = json.encode(PlayerData.position), + metadata = json.encode(PlayerData.metadata) }) if GetResourceState('qb-inventory') ~= 'missing' then exports['qb-inventory']:SaveInventory(PlayerData, true) end QBCore.ShowSuccess(resourceName, PlayerData.name .. ' OFFLINE PLAYER SAVED!') @@ -541,9 +658,11 @@ function QBCore.Player.SaveOffline(PlayerData) end end --- Delete character +-- ============================================================ +-- Character Deletion +-- ============================================================ -local playertables = { -- Add tables as needed +local playertables = { { table = 'players' }, { table = 'apartments' }, { table = 'bank_accounts' }, @@ -558,32 +677,20 @@ local playertables = { -- Add tables as needed { table = 'player_vehicles' } } -function QBCore.Player.DeleteCharacter(source, citizenid, force) +function QBCore.Player.DeleteCharacter(source, citizenid) local license = QBCore.Functions.GetIdentifier(source, 'license') - local result = MySQL.scalar.await('SELECT license FROM players WHERE citizenid = ?', { citizenid }) - - if force or (license == result) then - local query = 'DELETE FROM %s WHERE citizenid = ?' + local result = MySQL.scalar.await('SELECT license FROM players where citizenid = ?', { citizenid }) + if license == result then + local query = 'DELETE FROM %s WHERE citizenid = ?' local tableCount = #playertables - local queries = table.create(tableCount, 0) - - local player = QBCore.Functions.GetPlayerByCitizenId(citizenid) - if force and player then - DropPlayer(player.PlayerData.source, 'An admin deleted the character which you are currently using') - end - + local queries = {} for i = 1, tableCount do - local v = playertables[i] - queries[i] = { query = query:format(v.table), values = { citizenid } } + queries[i] = { query = query:format(playertables[i].table), values = { citizenid } } end - MySQL.transaction(queries, function(result2) if result2 then - if force then - TriggerEvent('qb-log:server:CreateLog', 'joinleave', 'Character Force Deleted', 'red', 'Character **' .. citizenid .. '** got deleted') - else - TriggerEvent('qb-log:server:CreateLog', 'joinleave', 'Character Deleted', 'red', '**' .. GetPlayerName(source) .. '** ' .. license .. ' deleted **' .. citizenid .. '**..') - end + TriggerEvent('qb-log:server:CreateLog', 'joinleave', 'Character Deleted', 'red', + '**' .. GetPlayerName(source) .. '** ' .. license .. ' deleted **' .. citizenid .. '**..') end end) else @@ -591,3 +698,71 @@ function QBCore.Player.DeleteCharacter(source, citizenid, force) TriggerEvent('qb-log:server:CreateLog', 'anticheat', 'Anti-Cheat', 'white', GetPlayerName(source) .. ' Has Been Dropped For Character Deletion Exploit', true) end end + +function QBCore.Player.ForceDeleteCharacter(citizenid) + local result = MySQL.scalar.await('SELECT license FROM players where citizenid = ?', { citizenid }) + if result then + local query = 'DELETE FROM %s WHERE citizenid = ?' + local tableCount = #playertables + local queries = {} + local existing = QBCore.Functions.GetPlayerByCitizenId(citizenid) + if existing then + DropPlayer(existing.PlayerData.source, 'An admin deleted the character which you are currently using') + end + for i = 1, tableCount do + queries[i] = { query = query:format(playertables[i].table), values = { citizenid } } + end + MySQL.transaction(queries, function(result2) + if result2 then + TriggerEvent('qb-log:server:CreateLog', 'joinleave', 'Character Force Deleted', 'red', + 'Character **' .. citizenid .. '** got deleted') + end + end) + end +end + +-- ============================================================ +-- Export Bridge +-- ============================================================ + +local function buildInterface(internalPlayer) + if not internalPlayer then return nil end + local iface = buildMethodTable(internalPlayer) + iface.PlayerData = internalPlayer.PlayerData + return iface +end + +exports('GetPlayer', function(source) + return buildInterface(QBCore.Players[tonumber(source)]) +end) + +exports('GetPlayerByCitizenId', function(citizenid) + for _, player in pairs(QBCore.Players) do + if player.PlayerData.citizenid == citizenid then + return buildInterface(player) + end + end + return nil +end) + +exports('GetOfflinePlayerByCitizenId', function(citizenid) + return buildInterface(QBCore.Player.GetOfflinePlayer(citizenid)) +end) + +exports('GetPlayerByLicense', function(license) + return buildInterface(QBCore.Player.GetPlayerByLicense(license)) +end) + +exports('GetOfflinePlayerByLicense', function(license) + return buildInterface(QBCore.Player.GetOfflinePlayerByLicense(license)) +end) + +exports('AddPlayerMethod', function(ids, methodName, handler) + QBCore.Functions.AddPlayerMethod(ids, methodName, handler) +end) + +exports('AddPlayerField', function(ids, fieldName, data) + QBCore.Functions.AddPlayerField(ids, fieldName, data) +end) + +PaycheckInterval()