diff --git a/bridge/qb/client/functions.lua b/bridge/qb/client/functions.lua index 76c244645..237d7f9d5 100644 --- a/bridge/qb/client/functions.lua +++ b/bridge/qb/client/functions.lua @@ -8,6 +8,7 @@ local functions = {} ---@return PlayerData? playerData function functions.GetPlayerData(cb) if not cb then return QBX.PlayerData end + cb(QBX.PlayerData) end @@ -27,8 +28,10 @@ functions.HasItem = function(items, amount) return false end end + return true end + return count >= amount end @@ -137,6 +140,7 @@ local function getEntities(pool, ignoreList) -- luacheck: ignore entities[#entities + 1] = entity end end + return entities end @@ -175,6 +179,7 @@ local function getClosestEntity(entities, coords) -- luacheck: ignore closestDistance = distance end end + return closestEntity, closestDistance end @@ -233,11 +238,13 @@ functions.GetClosestBone = function(entity, list) distance = boneDistance end end + if not bone then bone = {id = GetEntityBoneIndexByName(entity, 'bodyshell'), type = 'remains', name = 'bodyshell'} coords = GetWorldPositionOfEntityBone(entity, bone.id) distance = #(coords - playerCoords) end + return bone, coords, distance end @@ -285,7 +292,9 @@ function functions.SpawnVehicle(model, cb, coords, isnetworked, teleportInto) SetVehRadioStation(veh, 'OFF') SetVehicleFuelLevel(veh, 100.0) SetModelAsNoLongerNeeded(model) + if teleportInto then TaskWarpPedIntoVehicle(cache.ped, veh, -1) end + if cb then cb(veh) end end @@ -295,12 +304,14 @@ functions.DeleteVehicle = qbx.deleteVehicle ---@deprecated use qbx.getVehiclePlate from modules/lib.lua functions.GetPlate = function(vehicle) if vehicle == 0 then return end + return qbx.getVehiclePlate(vehicle) end ---@deprecated use qbx.getVehicleDisplayName from modules/lib.lua functions.GetVehicleLabel = function(vehicle) if vehicle == nil or vehicle == 0 then return end + return qbx.getVehicleDisplayName(vehicle) end @@ -317,6 +328,7 @@ functions.SpawnClear = function(coords, radius) closeVeh[#closeVeh + 1] = vehicles[i] end end + return #closeVeh == 0 end @@ -375,6 +387,7 @@ function functions.SetVehicleProperties(vehicle, props) SetVehicleWheelHealth(vehicle, wheelIndex, health) end end + if props.headlightColor then SetVehicleHeadlightsColour(vehicle, props.headlightColor) end @@ -779,24 +792,30 @@ functions.StartParticleAtCoord = function(dict, ptName, looped, coords, rot, sca lib.requestNamedPtfxAsset(dict) UseParticleFxAssetNextCall(dict) SetPtfxAssetNextCall(dict) + local particleHandle if looped then particleHandle = StartParticleFxLoopedAtCoord(ptName, coords.x, coords.y, coords.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false, false) if color then SetParticleFxLoopedColour(particleHandle, color.r, color.g, color.b, false) end + SetParticleFxLoopedAlpha(particleHandle, alpha or 10.0) + if duration then Wait(duration) StopParticleFxLooped(particleHandle, false) end else SetParticleFxNonLoopedAlpha(alpha or 1.0) + if color then SetParticleFxNonLoopedColour(color.r, color.g, color.b) end + StartParticleFxNonLoopedAtCoord(ptName, coords.x, coords.y, coords.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false) end + return particleHandle end @@ -804,6 +823,7 @@ end functions.StartParticleOnEntity = function(dict, ptName, looped, entity, bone, offset, rot, scale, alpha, color, evolution, duration) -- luacheck: ignore lib.requestNamedPtfxAsset(dict) UseParticleFxAssetNextCall(dict) + local particleHandle = nil ---@cast bone number local pedBoneIndex = bone and GetPedBoneIndex(entity, bone) or 0 @@ -817,28 +837,35 @@ functions.StartParticleOnEntity = function(dict, ptName, looped, entity, bone, o else particleHandle = StartParticleFxLoopedOnEntity(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false) end + if evolution then SetParticleFxLoopedEvolution(particleHandle, evolution.name, evolution.amount, false) end + if color then SetParticleFxLoopedColour(particleHandle, color.r, color.g, color.b, false) end + SetParticleFxLoopedAlpha(particleHandle, alpha or 1.0) + if duration then Wait(duration) StopParticleFxLooped(particleHandle, false) end else SetParticleFxNonLoopedAlpha(alpha or 1.0) + if color then SetParticleFxNonLoopedColour(color.r, color.g, color.b) end + if boneID then StartParticleFxNonLoopedOnPedBone(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, boneID, scale or 1.0, false, false, false) else StartParticleFxNonLoopedOnEntity(ptName, entity, offset.x, offset.y, offset.z, rot.x, rot.y, rot.z, scale or 1.0, false, false, false) end end + return particleHandle end diff --git a/bridge/qb/server/commands.lua b/bridge/qb/server/commands.lua index c9d23b1ce..3943ceca5 100644 --- a/bridge/qb/server/commands.lua +++ b/bridge/qb/server/commands.lua @@ -7,6 +7,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission) restricted = permission and permission ~= 'user' and 'group.'..permission or false, params = {} } + for i = 1, #arguments do local argument = arguments[i] properties.params[i] = { @@ -16,6 +17,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission) optional = not argsrequired or argument?.optional } end + lib.addCommand(name, properties, function(source, args, raw) local _args = {} if #args > 0 then @@ -25,6 +27,7 @@ function commands.Add(name, help, arguments, argsrequired, callback, permission) _args[i] = args[arguments[i].name] end end + callback(source, _args, raw) end) end diff --git a/bridge/qb/server/events.lua b/bridge/qb/server/events.lua index d4d1e77b7..ba5d99da7 100644 --- a/bridge/qb/server/events.lua +++ b/bridge/qb/server/events.lua @@ -8,6 +8,7 @@ RegisterServerEvent('baseevents:enteringVehicle', function(veh, seat, modelName, netId = netId, event = 'Entering' } + TriggerClientEvent('QBCore:Client:VehicleInfo', src, data) end) @@ -20,6 +21,7 @@ RegisterServerEvent('baseevents:enteredVehicle', function(veh, seat, modelName, netId = netId, event = 'Entered' } + TriggerClientEvent('QBCore:Client:VehicleInfo', src, data) end) @@ -37,5 +39,6 @@ RegisterServerEvent('baseevents:leftVehicle', function(veh, seat, modelName, net netId = netId, event = 'Left' } + TriggerClientEvent('QBCore:Client:VehicleInfo', src, data) end) \ No newline at end of file diff --git a/bridge/qb/server/functions.lua b/bridge/qb/server/functions.lua index 3688da6ad..e90363cc1 100644 --- a/bridge/qb/server/functions.lua +++ b/bridge/qb/server/functions.lua @@ -31,26 +31,33 @@ end ---@return number? function functions.SpawnVehicle(source, model, coords, warp) local ped = GetPlayerPed(source) + model = type(model) == 'string' and joaat(model) or model + if not coords then coords = GetEntityCoords(ped) end + local heading = coords.w and coords.w or 0.0 local veh = CreateVehicle(model, coords.x, coords.y, coords.z, heading, true, true) + while not DoesEntityExist(veh) do Wait(0) end + if warp then while GetVehiclePedIsIn(ped, false) ~= veh do Wait(0) TaskWarpPedIntoVehicle(ped, veh, -1) end end + while NetworkGetEntityOwner(veh) ~= source do Wait(0) end + return veh end ---@deprecated use qbx.spawnVehicle from modules/lib.lua function functions.CreateVehicle(source, model, _, coords, warp) model = type(model) == 'string' and joaat(model) or (model --[[@as integer]]) - local ped = GetPlayerPed(source) + local ped = GetPlayerPed(source) local netId = qbx.spawnVehicle({ model = model, spawnSource = coords or ped, @@ -74,26 +81,32 @@ functions.Kick = function(source, reason, setKickReason, deferrals) if setKickReason then setKickReason(reason) end + CreateThread(function() if deferrals then deferrals.update(reason) Wait(2500) end + if source then DropPlayer(source --[[@as string]], reason) end + for _ = 0, 4 do while true do if source then if GetPlayerPing(source --[[@as string]]) >= 0 then break end + Wait(100) + CreateThread(function() DropPlayer(source --[[@as string]], reason) end) end end + Wait(5000) end end) @@ -126,8 +139,10 @@ functions.HasItem = function(source, items, amount) -- luacheck: ignore return false end end + return true end + return count >= amount end @@ -152,6 +167,7 @@ local function AddItem(itemName, item) TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, item) TriggerEvent('QBCore:Server:UpdateObject') + return true, 'success' end @@ -165,12 +181,15 @@ local function UpdateItem(itemName, item) if type(itemName) ~= 'string' then return false, 'invalid_item_name' end + if not qbCoreCompat.Shared.Items[itemName] then return false, 'item_not_exists' end + qbCoreCompat.Shared.Items[itemName] = item TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, item) TriggerEvent('QBCore:Server:UpdateObject') + return true, 'success' end @@ -184,7 +203,6 @@ local function AddItems(items) local shouldContinue = true local message = 'success' local errorItem = nil - for key, value in pairs(items) do if type(key) ~= 'string' then message = 'invalid_item_name' @@ -199,6 +217,7 @@ local function AddItems(items) errorItem = items[key] break end + lib.print.warn(('New item %s added but not found in ox_inventory. Printing item data'):format(key)) lib.print.warn(value) @@ -206,8 +225,10 @@ local function AddItems(items) end if not shouldContinue then return false, message, errorItem end + TriggerClientEvent('QBCore:Client:OnSharedUpdateMultiple', -1, 'Items', items) TriggerEvent('QBCore:Server:UpdateObject') + return true, message, nil end @@ -230,6 +251,7 @@ local function RemoveItem(itemName) TriggerClientEvent('QBCore:Client:OnSharedUpdate', -1, 'Items', itemName, nil) TriggerEvent('QBCore:Server:UpdateObject') + return true, 'success' end @@ -252,12 +274,14 @@ local function addJob(jobName, job) end CreateJobs({[jobName] = job}) + return true, 'success' end functions.AddJob = function(jobName, job) return exports['qb-core']:AddJob(jobName, job) end + createQbExport('AddJob', addJob) -- Multiple Add Jobs @@ -278,12 +302,14 @@ local function addJobs(jobs) end CreateJobs(jobs) + return true, 'success' end functions.AddJobs = function(jobs) return exports['qb-core']:AddJobs(jobs) end + createQbExport('AddJobs', addJobs) -- Single Update Job @@ -302,12 +328,14 @@ local function updateJob(jobName, job) end CreateJobs({[jobName] = job}) + return true, 'success' end functions.UpdateJob = function(jobName, job) return exports['qb-core']:UpdateJob(jobName, job) end + createQbExport('UpdateJob', updateJob) -- Single Add Gang @@ -326,12 +354,14 @@ local function addGang(gangName, gang) end CreateGangs({[gangName] = gang}) + return true, 'success' end functions.AddGang = function(gangName, gang) return exports['qb-core']:AddGang(gangName, gang) end + createQbExport('AddGang', addGang) -- Single Update Gang @@ -350,12 +380,14 @@ local function updateGang(gangName, gang) end CreateGangs({[gangName] = gang}) + return true, 'success' end functions.UpdateGang = function(gangName, gang) return exports['qb-core']:UpdateGang(gangName, gang) end + createQbExport('UpdateGang', updateGang) -- Multiple Add Gangs @@ -376,22 +408,26 @@ local function addGangs(gangs) end CreateGangs(gangs) + return true, 'success' end functions.AddGangs = function(gangs) return exports['qb-core']:AddGangs(gangs) end + createQbExport('AddGangs', addGangs) functions.RemoveJob = function(jobName) return exports.qbx_core:RemoveJob(jobName) end + createQbExport('RemoveJob', RemoveJob) functions.RemoveGang = function(gangName) return exports.qbx_core:RemoveGang(gangName) end + createQbExport('RemoveGang', RemoveGang) local function checkExistingMethod(method, methodName) @@ -401,8 +437,10 @@ local function checkExistingMethod(method, methodName) if not disableMethodOverrideWarning then lib.print.warn(warnMessage:format(methodName)) end + return allowMethodOverrides end + return true end @@ -430,6 +468,7 @@ function functions.AddPlayerMethod(ids, methodName, handler) end else if not QBX.Players[ids] then return end + if checkExistingMethod(QBX.Players[ids].Functions[methodName], methodName) then QBX.Players[ids].Functions[methodName] = handler end @@ -517,7 +556,7 @@ end functions.SetField = SetField exports('SetField', SetField) ----@param identifier Identifier +---@param identifier string ---@return integer source of the player with the matching identifier or 0 if no player found function functions.GetSource(identifier) return exports.qbx_core:GetSource(identifier) @@ -556,6 +595,7 @@ function functions.GetQBPlayers() for k, player in pairs(players) do deprecatedPlayers[k] = AddDeprecatedFunctions(player) end + return deprecatedPlayers end diff --git a/bridge/qb/server/main.lua b/bridge/qb/server/main.lua index ff69c4e5d..2f5d64216 100644 --- a/bridge/qb/server/main.lua +++ b/bridge/qb/server/main.lua @@ -63,11 +63,13 @@ RegisterNetEvent('QBCore:CallCommand', function(command, args) local src = source --[[@as Source]] local player = GetPlayer(src) if not player then return end + if IsPlayerAceAllowed(src --[[@as string]], ('command.%s'):format(command)) then local commandString = command for _, value in pairs(args) do commandString = ('%s %s'):format(commandString, value) end + TriggerClientEvent('QBCore:Command:CallCommand', src, commandString) end end) @@ -90,6 +92,7 @@ end ---@deprecated call a function instead function qbCoreCompat.Functions.TriggerCallback(name, source, cb, ...) if not qbCoreCompat.ServerCallbacks[name] then return end + qbCoreCompat.ServerCallbacks[name](source, cb, ...) end diff --git a/bridge/qb/shared/compat.lua b/bridge/qb/shared/compat.lua index c93d9e663..a3feb2bfa 100644 --- a/bridge/qb/shared/compat.lua +++ b/bridge/qb/shared/compat.lua @@ -28,6 +28,7 @@ return { return true end end + return false end @@ -70,7 +71,6 @@ return { ]] local fileSize = #file - for _, item in pairs(dump) do if not ItemList[item.name] then fileSize += 1 @@ -88,7 +88,7 @@ return { end end - file[fileSize+1] = '}' + file[fileSize + 1] = '}' SaveResourceFile('ox_inventory', 'data/items.lua', table.concat(file), -1) CreateThread(function() diff --git a/bridge/qb/shared/main.lua b/bridge/qb/shared/main.lua index 62053012f..859d1de7c 100644 --- a/bridge/qb/shared/main.lua +++ b/bridge/qb/shared/main.lua @@ -1,6 +1,7 @@ local qbShared = require 'shared.main' qbShared.Items = {} + local oxItems = require '@ox_inventory.data.items' for item, data in pairs(oxItems) do qbShared.Items[item] = { @@ -16,6 +17,7 @@ for item, data in pairs(oxItems) do description = data.description or nil } end + local oxWeapons = require '@ox_inventory.data.weapons' for weapon, data in pairs(oxWeapons.Weapons) do weapon = string.lower(weapon) @@ -33,6 +35,7 @@ for weapon, data in pairs(oxWeapons.Weapons) do description = nil } end + for component, data in pairs(oxWeapons.Components) do component = string.lower(component) qbShared.Items[component] = { @@ -48,6 +51,7 @@ for component, data in pairs(oxWeapons.Components) do description = data.description } end + for ammo, data in pairs(oxWeapons.Ammo) do ammo = string.lower(ammo) qbShared.Items[ammo] = { @@ -110,12 +114,14 @@ end ---@deprecated use qbx.string.trim from modules/lib.lua qbShared.Trim = function(str) if not str then return nil end + return qbx.string.trim(str) end ---@deprecated use qbx.string.capitalize from modules/lib.lua qbShared.FirstToUpper = function(str) if not str then return nil end + return qbx.string.capitalize(str) end diff --git a/client/character.lua b/client/character.lua index 159c2243f..fff49924a 100644 --- a/client/character.lua +++ b/client/character.lua @@ -284,6 +284,7 @@ local function spawnDefault() -- We use a callback to make the server wait on th while not IsScreenFadedIn() do Wait(0) end + TriggerEvent('qb-clothes:client:CreateFirstCharacter') end @@ -321,7 +322,6 @@ local function createCharacter(cid) :: noMatch :: local dialog = characterDialog() - if not dialog then return false end for input = 1, 3 do -- Run through first 3 inputs, aka first name, last name and nationality @@ -353,6 +353,7 @@ local function createCharacter(cid) end destroyPreviewCam() + return true end @@ -445,6 +446,7 @@ local function chooseCharacter() else spawnLastLocation() end + destroyPreviewCam() end }, @@ -459,6 +461,7 @@ local function chooseCharacter() centered = true, cancel = true }) + if alert == 'confirm' then TriggerServerEvent('qbx_core:server:deleteCharacter', character.citizenid) destroyPreviewCam() @@ -504,6 +507,7 @@ end) RegisterNetEvent('qbx_core:client:playerLoggedOut', function() if GetInvokingResource() then return end -- Make sure this can only be triggered from the server + chooseCharacter() end) diff --git a/client/events.lua b/client/events.lua index b100e75d0..f812c6fa9 100644 --- a/client/events.lua +++ b/client/events.lua @@ -24,6 +24,7 @@ end) RegisterNetEvent('QBCore:Player:SetPlayerData', function(val) local invokingResource = GetInvokingResource() if invokingResource and invokingResource ~= cache.resource then return end + QBX.PlayerData = val end) @@ -34,10 +35,10 @@ end) ---@param value boolean ---@diagnostic disable-next-line: param-type-mismatch AddStateBagChangeHandler('PVPEnabled', nil, function(bagName, _, value) - if bagName == 'global' then - SetCanAttackFriendly(cache.ped, value, false) - NetworkSetFriendlyFireOption(value) - end + if bagName ~= 'global' then return end + + SetCanAttackFriendly(cache.ped, value, false) + NetworkSetFriendlyFireOption(value) end) -- Teleport Commands @@ -56,12 +57,11 @@ RegisterNetEvent('QBCore:Command:TeleportToCoords', function(x, y, z, h) SetEntityHeading(cache.ped, h or GetEntityHeading(cache.ped)) end) ----@return 'marker'? error present if player did not place a blip RegisterNetEvent('QBCore:Command:GoToMarker', function() local blipMarker = GetFirstBlipInfoId(8) if not DoesBlipExist(blipMarker) then Notify(locale('error.no_waypoint'), 'error') - return 'marker' + return end -- Fade screen to hide how clients get teleported. @@ -96,8 +96,10 @@ RegisterNetEvent('QBCore:Command:GoToMarker', function() if GetGameTimer() - curTime > 1000 then break end + Wait(0) end + NewLoadSceneStop() SetPedCoordsKeepVehicle(ped, x, y, z) @@ -116,6 +118,7 @@ RegisterNetEvent('QBCore:Command:GoToMarker', function() SetPedCoordsKeepVehicle(ped, x, y, groundZ) break end + Wait(0) end @@ -204,6 +207,7 @@ RegisterNetEvent('QBCore:Client:OnSharedUpdateMultiple', function(tableName, val for key, value in pairs(values) do QBX.Shared[tableName][key] = value end + TriggerEvent('QBCore:Client:UpdateObject') end) @@ -212,6 +216,7 @@ end) ---@param props table RegisterNetEvent('qbx_core:client:setVehicleProperties', function(netId, props) if not props then return end + local timeOut = GetGameTimer() + 1000 local vehicle = NetworkGetEntityFromNetworkId(netId) while true do @@ -220,6 +225,7 @@ RegisterNetEvent('qbx_core:client:setVehicleProperties', function(netId, props) return end end + if GetGameTimer() > timeOut then return end diff --git a/client/functions.lua b/client/functions.lua index 544b22d01..370cc9550 100644 --- a/client/functions.lua +++ b/client/functions.lua @@ -20,8 +20,8 @@ function Notify(text, notifyType, duration, subTitle, notifyPosition, notifyStyl else description = text end - local position = notifyPosition or positionConfig + local position = notifyPosition or positionConfig lib.notify({ id = title, title = title, diff --git a/client/main.lua b/client/main.lua index 60647773f..0c64f30d8 100644 --- a/client/main.lua +++ b/client/main.lua @@ -58,6 +58,7 @@ end) local mapText = require 'config.client'.pauseMapText if mapText == '' or type(mapText) ~= 'string' then mapText = 'FiveM' end + AddTextEntry('FE_THDR_GTAO', mapText) CreateThread(function() @@ -81,5 +82,6 @@ lib.callback.register('qbx_core:client:getVehicleClasses', function() local class = GetVehicleClassFromName(model) classes[joaat(model)] = class end + return classes end) \ No newline at end of file diff --git a/client/vehicle-persistence.lua b/client/vehicle-persistence.lua index 790826a08..8f0c2c8d7 100644 --- a/client/vehicle-persistence.lua +++ b/client/vehicle-persistence.lua @@ -43,14 +43,17 @@ end local function sendPropsDiff() if not Entity(vehicle).state.persisted then return end + local newProps = lib.getVehicleProperties(vehicle) if not cachedProps then cachedProps = newProps return end + local diff, hasChanged = calculateDiff(cachedProps, newProps) cachedProps = newProps if not hasChanged then return end + TriggerServerEvent('qbx_core:server:vehiclePropsChanged', netId, diff) end diff --git a/config/server.lua b/config/server.lua index 21c903025..6429c9312 100644 --- a/config/server.lua +++ b/config/server.lua @@ -1,5 +1,6 @@ return { - updateInterval = 5, -- how often to update player data in minutes + updateInterval = 5, -- how often to update player thirst and hunger in minutes + dbUpdateInterval = 15, -- how often to update the database for player data in seconds money = { ---@alias MoneyType 'cash' | 'bank' | 'crypto' diff --git a/data/nationalities.lua b/data/nationalities.lua index 2ab062c24..9e010827b 100644 --- a/data/nationalities.lua +++ b/data/nationalities.lua @@ -1,4 +1,4 @@ ----@return table +---@return string[] return { 'Afghan', 'Albanian', diff --git a/modules/hooks.lua b/modules/hooks.lua index 11d98c62f..ce9f8307a 100644 --- a/modules/hooks.lua +++ b/modules/hooks.lua @@ -47,6 +47,7 @@ exports('registerHook', function(event, cb) cb.hookId = hookId eventHooks[event][#eventHooks[event] + 1] = cb + return hookId end) diff --git a/modules/lib.lua b/modules/lib.lua index 8706bdc87..0a4e33ee7 100644 --- a/modules/lib.lua +++ b/modules/lib.lua @@ -86,7 +86,7 @@ qbx.armsWithoutGloves = lib.table.freeze({ [157] = true, [161] = true, [165] = true - }), + }) }) ---Returns the given string with its trailing whitespaces removed. @@ -111,6 +111,7 @@ end ---@return number rounded integer if `decimalPlaces` isn't passed, number otherwise function qbx.math.round(num, decimalPlaces) if not decimalPlaces then return math.floor(num + 0.5) end + local power = 10 ^ decimalPlaces return math.floor((num * power) + 0.5) / power end @@ -123,6 +124,7 @@ function qbx.table.size(tbl) for _ in pairs(tbl) do size += 1 end + return size end @@ -132,10 +134,8 @@ end ---@return table function qbx.table.mapBySubfield(tble, subfield) local map = {} - for _, subTable in pairs(tble) do local subfieldValue = subTable[subfield] - if subfieldValue then if not map[subfieldValue] then map[subfieldValue] = {} @@ -169,6 +169,7 @@ end function qbx.getVehiclePlate(vehicle) local plate = GetVehicleNumberPlateText(vehicle) if not plate then return end + return qbx.string.trim(plate) end @@ -309,6 +310,7 @@ if isServer then local owner = NetworkGetEntityOwner(veh) state:set('initVehicle', true, true) netId = NetworkGetNetworkIdFromEntity(veh) + if props and type(props) == 'table' and props.plate then TriggerClientEvent('qbx_core:client:setVehicleProperties', owner, netId, props) local success = pcall(function() @@ -317,15 +319,18 @@ if isServer then if qbx.string.trim(GetVehicleNumberPlateText(veh)) == qbx.string.trim(props.plate) then local currentOwner = NetworkGetEntityOwner(veh) assert(currentOwner == owner, ('Owner changed during vehicle init. expected=%s, actual=%s'):format(owner, currentOwner)) + --- check that the plate matches twice, 100ms apart as a bug has been observed in which server side matches but plate is not observed by clients to match if plateMatched then return true end + plateMatched = true Wait(100) end end, 'Failed to set vehicle properties within 1 second', 1000) end) + if success then break else @@ -344,6 +349,7 @@ if isServer then --- prevent server from deleting a vehicle without an owner SetEntityOrphanMode(veh, 2) exports.qbx_core:EnablePersistence(veh) + return netId, veh end else @@ -376,9 +382,11 @@ else SetTextScale(scale, scale) SetTextFont(font) SetTextColour(math.floor(color.r), math.floor(color.g), math.floor(color.b), math.floor(color.a)) + if enableDropShadow then SetTextDropShadow() end + if enableOutline then SetTextOutline() end @@ -397,12 +405,11 @@ else ---Draws text onto the screen in 3D space for a single frame. ---@param params LibDrawText3DParams function qbx.drawText3d(params) -- luacheck: ignore - local isScaleparamANumber = type(params.scale) == "number" local text = params.text local coords = params.coords - local scale = (isScaleparamANumber and vec2(params.scale, params.scale)) - or params.scale - or vec2(0.35, 0.35) + local scale = type(params.scale) == 'number' and vec2(params.scale --[[@as number]], params.scale --[[@as number]]) + or params.scale + or vec2(0.35, 0.35) local font = params.font or 4 local color = params.color or vec4(255, 255, 255, 255) local enableDropShadow = params.enableDropShadow or false @@ -411,12 +418,15 @@ else SetTextScale(scale.x, scale.y) SetTextFont(font) SetTextColour(math.floor(color.r), math.floor(color.g), math.floor(color.b), math.floor(color.a)) + if enableDropShadow then SetTextDropShadow() end + if enableOutline then SetTextOutline() end + SetTextCentre(true) BeginTextCommandDisplayText('STRING') AddTextComponentSubstringPlayerName(text) @@ -427,6 +437,7 @@ else local factor = #text / 370 DrawRect(0.0, 0.0125, 0.017 + factor, 0.03, 0, 0, 0, 75) end + ClearDrawOrigin() end @@ -437,7 +448,6 @@ else ---@return integer entity, integer netId function qbx.getEntityAndNetIdFromBagName(bagName) local netId = tonumber(bagName:gsub('entity:', ''), 10) - local entity = lib.waitFor(function() if NetworkDoesEntityExistWithNetworkId(netId) then return NetworkGetEntityFromNetworkId(netId) @@ -472,6 +482,7 @@ else function qbx.deleteVehicle(vehicle) SetEntityAsMissionEntity(vehicle, true, true) DeleteVehicle(vehicle) + return not DoesEntityExist(vehicle) end @@ -527,6 +538,7 @@ else ---@param enable boolean function qbx.setVehicleExtra(vehicle, extra, enable) if not DoesExtraExist(vehicle, extra) then return end + SetVehicleExtra(vehicle, extra, not enable) end @@ -554,8 +566,9 @@ else function qbx.isWearingGloves() local armIndex = GetPedDrawableVariation(cache.ped, 3) local model = GetEntityModel(cache.ped) - local tble = qbx.armsWithoutGloves[model == `mp_m_freemode_01` and 'male' or 'female'] - return not tble[armIndex] + local tbl = qbx.armsWithoutGloves[model == `mp_m_freemode_01` and 'male' or 'female'] + + return not tbl[armIndex] end ---Attempts to load an audio bank and returns whether it was successful. @@ -589,10 +602,9 @@ else local returnSoundId = params.returnSoundId or false local source = params.audioSource local range = params.range or 5.0 - local soundId = GetSoundId() - local sourceType = type(source) + if sourceType == 'vector3' then local coords = source PlaySoundFromCoord(soundId, audioName, coords.x, coords.y, coords.z, audioRef, false, range, false) diff --git a/modules/logger.lua b/modules/logger.lua index bb76e1447..d24d6619d 100644 --- a/modules/logger.lua +++ b/modules/logger.lua @@ -25,7 +25,6 @@ local Colors = { -- https://www.spycolor.com/ local function applyRequestDelay() local currentTime = GetGameTimer() local timeDiff = currentTime - lastRequestTime - if timeDiff < requestDelay then local remainingDelay = requestDelay - timeDiff @@ -69,11 +68,9 @@ local function logPayload(payload) local remainingRequests = tonumber(headers['X-RateLimit-Remaining']) local resetTime = tonumber(headers['X-RateLimit-Reset']) - if remainingRequests and resetTime and remainingRequests == 0 then local currentTime = os.time() local resetDelay = resetTime - currentTime - if resetDelay > 0 then requestDelay = resetDelay * 1000 / 10 end @@ -147,6 +144,7 @@ local function createLog(log) ---@diagnostic disable-next-line: param-type-mismatch discordLog(log) end + lib.logger(log.source, log.event, log.message, log.oxLibTags) -- support for ox_lib: datadog, grafana loki logging, fivemanage end diff --git a/server/character.lua b/server/character.lua index 70a903aab..83f1d2c76 100644 --- a/server/character.lua +++ b/server/character.lua @@ -15,6 +15,7 @@ local function giveStarterItems(source) while not exports.ox_inventory:GetInventory(source) do Wait(100) end + for i = 1, #starterItems do local item = starterItems[i] if item.metadata and type(item.metadata) == 'function' then @@ -43,11 +44,12 @@ lib.callback.register('qbx_core:server:loadCharacter', function(source, citizenI logger.log({ source = 'qbx_core', - webhook = config.logging.webhook['joinleave'], + webhook = config.logging.webhook.joinleave, event = 'Loaded', color = 'green', message = ('**%s** (%s | ||%s|| | %s | %s | %s) loaded'):format(GetPlayerName(source), GetPlayerIdentifierByType(source, 'discord') or 'undefined', GetPlayerIdentifierByType(source, 'ip') or 'undefined', GetPlayerIdentifierByType(source, 'license2') or GetPlayerIdentifierByType(source, 'license') or 'undefined', citizenId, source) }) + lib.print.info(('%s (Citizen ID: %s ID: %s) has successfully loaded!'):format(GetPlayerName(source), citizenId, source)) end) @@ -63,6 +65,7 @@ lib.callback.register('qbx_core:server:createCharacter', function(source, data) giveStarterItems(source) lib.print.info(('%s has created a character'):format(GetPlayerName(source))) + return newData end) diff --git a/server/commands.lua b/server/commands.lua index 7c7e9047f..6b6677cc3 100644 --- a/server/commands.lua +++ b/server/commands.lua @@ -171,7 +171,6 @@ lib.addCommand('dv', { local ped = GetPlayerPed(source) local pedCars = {GetVehiclePedIsIn(ped, false)} local radius = args[locale('command.dv.params.radius.name')] - if pedCars[1] == 0 or radius then -- Only execute when player is not in a vehicle or radius is explicitly defined pedCars = lib.callback.await('qbx_core:client:getVehiclesInRadius', source, radius) else @@ -382,6 +381,7 @@ lib.addCommand('me', { args[1] = args[locale('command.me.params.message.name')] args[locale('command.me.params.message.name')] = nil if #args < 1 then Notify(source, locale('error.missing_args2'), 'error') return end + local msg = table.concat(args, ' '):gsub('[~<].-[>~]', '') local playerState = Player(source).state playerState:set('me', msg, true) diff --git a/server/events.lua b/server/events.lua index 942b81bb2..334a71d3f 100644 --- a/server/events.lua +++ b/server/events.lua @@ -21,10 +21,13 @@ AddEventHandler('playerJoining', function() local src = source --[[@as string]] local license = GetPlayerIdentifierByType(src, 'license2') or GetPlayerIdentifierByType(src, 'license') if not license then return end + if queue then queue.removePlayerJoining(license) end + if not serverConfig.checkDuplicateLicense then return end + if usedLicenses[license] then Wait(0) -- mandatory wait for the drop reason to show up DropPlayer(src, locale('error.duplicate_license')) @@ -38,31 +41,33 @@ AddEventHandler('playerDropped', function(reason) local src = source --[[@as string]] local license = GetPlayerIdentifierByType(src, 'license2') or GetPlayerIdentifierByType(src, 'license') if license then usedLicenses[license] = nil end + if not QBX.Players[src] then return end + GlobalState.PlayerCount = GetNumPlayerIndices() + local player = QBX.Players[src] - player.PlayerData.lastLoggedOut = os.time() + SetPlayerData(player.PlayerData.source, 'lastLoggedOut', os.time()) + logger.log({ source = 'qbx_core', - webhook = loggingConfig.webhook['joinleave'], + webhook = loggingConfig.webhook.joinleave, event = 'Dropped', color = 'red', message = ('**%s** (%s) left...\n **Reason:** %s'):format(GetPlayerName(src), player.PlayerData.license, reason), }) - player.Functions.Save() + QBX.Player_Buckets[player.PlayerData.license] = nil QBX.Players[src] = nil end) ----@param source Source|string +---@param source Source | string ---@return table local function getIdentifiers(source) local identifiers = {} - for i = 0, GetNumPlayerIdentifiers(source --[[@as string]]) - 1 do local identifier = GetPlayerIdentifier(source --[[@as string]], i) local prefix = identifier:match('([^:]+)') - if prefix ~= 'ip' then identifiers[prefix] = identifier end @@ -98,9 +103,7 @@ local function onPlayerConnecting(name, _, deferrals) if not userId then local identifiers = getIdentifiers(src) - identifiers.username = name - storage.createUser(identifiers) end @@ -131,6 +134,7 @@ local function onPlayerConnecting(name, _, deferrals) if not success then databasePromise:reject(err) end + databasePromise:resolve() end) @@ -225,6 +229,7 @@ RegisterNetEvent('QBCore:ToggleDuty', function() local src = source --[[@as Source]] local player = GetPlayer(src) if not player then return end + if player.PlayerData.job.onduty then player.Functions.SetJobDuty(false) Notify(src, locale('info.off_duty')) @@ -240,11 +245,13 @@ end) ---@param value number local function playerStateBagCheck(bagName, meta, value) if not value then return end + local plySrc = GetPlayerFromStateBagName(bagName) if not plySrc then return end + local player = QBX.Players[plySrc] - if not player then return end - if player.PlayerData.metadata[meta] == value then return end + if not player or player.PlayerData.metadata[meta] == value then return end + player.Functions.SetMetaData(meta, value) end @@ -261,4 +268,4 @@ end) ---@diagnostic disable-next-line: param-type-mismatch AddStateBagChangeHandler('stress', nil, function(bagName, _, value) playerStateBagCheck(bagName, 'stress', value) -end) +end) \ No newline at end of file diff --git a/server/functions.lua b/server/functions.lua index 5874397bb..299f1c9e4 100644 --- a/server/functions.lua +++ b/server/functions.lua @@ -9,45 +9,46 @@ local storage = require 'server.storage.main' -- ex: local player = GetPlayer(source) -- ex: local example = player.functionname(parameter) ----@alias Identifier 'steam'|'license'|'license2'|'xbl'|'ip'|'discord'|'live' - ----@param identifier Identifier +---@param identifier string ---@return integer source of the player with the matching identifier or 0 if no player found function GetSource(identifier) for src in pairs(QBX.Players) do local idens = GetPlayerIdentifiers(src) - for _, id in pairs(idens) do - if identifier == id then + for i = 1, #idens do + if identifier == idens[i] then return src end end end + return 0 end exports('GetSource', GetSource) ----@param identifier Identifier +---@param identifier string ---@return integer source of the player with the matching identifier or 0 if no player found function GetUserId(identifier) for src in pairs(QBX.Players) do local idens = GetPlayerIdentifiers(src) - for _, id in pairs(idens) do - if identifier == id then + for i = 1, #idens do + if identifier == idens[i] then return QBX.Players[src].PlayerData.userId end end end + return 0 end exports('GetUserId', GetUserId) ----@param source Source|string source or identifier of the player +---@param source Source | string source or identifier of the player ---@return Player function GetPlayer(source) - if tonumber(source) ~= nil then - return QBX.Players[tonumber(source)] + local numberSource = tonumber(source) + if numberSource then + return QBX.Players[numberSource] else return QBX.Players[GetSource(source --[[@as string]])] end @@ -108,13 +109,12 @@ function GetDutyCountJob(job) local players = {} local count = 0 for src, player in pairs(QBX.Players) do - if player.PlayerData.job.name == job then - if player.PlayerData.job.onduty then - players[#players + 1] = src - count += 1 - end + if player.PlayerData.job.name == job and player.PlayerData.job.onduty then + players[#players + 1] = src + count += 1 end end + return count, players end @@ -128,13 +128,12 @@ function GetDutyCountType(type) local players = {} local count = 0 for src, player in pairs(QBX.Players) do - if player.PlayerData.job.type == type then - if player.PlayerData.job.onduty then - players[#players + 1] = src - count += 1 - end + if player.PlayerData.job.type == type and player.PlayerData.job.onduty then + players[#players + 1] = src + count += 1 end end + return count, players end @@ -156,11 +155,12 @@ exports('GetBucketObjects', GetBucketObjects) ---@param bucket integer ---@return boolean function SetPlayerBucket(source, bucket) - if not (source or bucket) then return false end + if not source or not bucket then return false end Player(source).state:set('instance', bucket, true) SetPlayerRoutingBucket(source --[[@as string]], bucket) QBX.Player_Buckets[source] = bucket + return true end @@ -171,10 +171,11 @@ exports('SetPlayerBucket', SetPlayerBucket) ---@param bucket integer ---@return boolean function SetEntityBucket(entity, bucket) - if not (entity or bucket) then return false end + if not entity or not bucket then return false end SetEntityRoutingBucket(entity, bucket) QBX.Entity_Buckets[entity] = bucket + return true end @@ -185,7 +186,7 @@ exports('SetEntityBucket', SetEntityBucket) ---@return Source[]|boolean function GetPlayersInBucket(bucket) local curr_bucket_pool = {} - if not (QBX.Player_Buckets or next(QBX.Player_Buckets)) then + if not QBX.Player_Buckets or table.type(QBX.Player_Buckets) == 'empty' then return false end @@ -205,7 +206,7 @@ exports('GetPlayersInBucket', GetPlayersInBucket) ---@return boolean | integer[] function GetEntitiesInBucket(bucket) local curr_bucket_pool = {} - if not (QBX.Entity_Buckets or next(QBX.Entity_Buckets)) then + if not QBX.Entity_Buckets or table.type(QBX.Entity_Buckets) == 'empty' then return false end @@ -241,9 +242,7 @@ exports('CanUseItem', CanUseItem) ---@param source Source ---@return boolean function IsWhitelisted(source) - if not serverConfig.whitelist then return true end - if IsPlayerAceAllowed(source --[[@as string]], serverConfig.whitelistPermission) then return true end - return false + return not serverConfig.whitelist or IsPlayerAceAllowed(source --[[@as string]], serverConfig.whitelistPermission) end exports('IsWhitelisted', IsWhitelisted) @@ -254,12 +253,12 @@ exports('IsWhitelisted', IsWhitelisted) ---@param source Source ---@param permission string function AddPermission(source, permission) - if not IsPlayerAceAllowed(source --[[@as string]], permission) then - lib.addPrincipal('player.' .. source, 'group.' .. permission) - lib.addAce('player.' .. source, 'group.' .. permission) - TriggerClientEvent('QBCore:Client:OnPermissionUpdate', source) - TriggerEvent('QBCore:Server:OnPermissionUpdate', source) - end + if IsPlayerAceAllowed(source --[[@as string]], permission) then return end + + lib.addPrincipal('player.' .. source, 'group.' .. permission) + lib.addAce('player.' .. source, 'group.' .. permission) + TriggerClientEvent('QBCore:Client:OnPermissionUpdate', source) + TriggerEvent('QBCore:Server:OnPermissionUpdate', source) end ---@deprecated use cfg ACEs instead @@ -332,6 +331,7 @@ function GetPermission(source) perms[v] = true end end + return perms end @@ -345,7 +345,9 @@ exports('GetPermission', GetPermission) function IsOptin(source) local license = GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license') if not license or not IsPlayerAceAllowed(source --[[@as string]], 'admin') then return false end + local player = GetPlayer(source) + return player.PlayerData.optin end @@ -356,9 +358,10 @@ exports('IsOptin', IsOptin) function ToggleOptin(source) local license = GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license') if not license or not IsPlayerAceAllowed(source --[[@as string]], 'admin') then return end + local player = GetPlayer(source) player.PlayerData.optin = not player.PlayerData.optin - player.Functions.SetPlayerData('optin', player.PlayerData.optin) + SetPlayerData(player.PlayerData.source, 'optin', player.PlayerData.optin, nil, true) end exports('ToggleOptin', ToggleOptin) @@ -456,7 +459,7 @@ local function ExploitBan(playerId, origin) DropPlayer(playerId --[[@as string]], locale('info.exploit_banned', serverConfig.discord)) logger.log({ source = 'qbx_core', - webhook = loggingConfig.webhook['anticheat'], + webhook = loggingConfig.webhook.anticheat, event = 'Anti-Cheat', color = 'red', tags = loggingConfig.role, @@ -551,4 +554,4 @@ function DeleteVehicle(vehicle) end end -exports('DeleteVehicle', DeleteVehicle) +exports('DeleteVehicle', DeleteVehicle) \ No newline at end of file diff --git a/server/groups.lua b/server/groups.lua index 8ca4dced5..7dafba21a 100644 --- a/server/groups.lua +++ b/server/groups.lua @@ -50,6 +50,7 @@ function RemoveJob(jobName) jobs[jobName] = nil TriggerEvent('qbx_core:server:onJobUpdate', jobName, nil) TriggerClientEvent('qbx_core:client:onJobUpdate', -1, jobName, nil) + return true, 'success' end @@ -84,6 +85,7 @@ function RemoveGang(gangName) TriggerEvent('qbx_core:server:onGangUpdate', gangName, nil) TriggerClientEvent('qbx_core:client:onGangUpdate', -1, gangName, nil) + return true, 'success' end @@ -136,6 +138,7 @@ local function upsertJobData(name, data) grades = {}, } end + TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name]) TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name]) end @@ -153,6 +156,7 @@ local function upsertGangData(name, data) grades = {}, } end + TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name]) TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name]) end @@ -167,6 +171,7 @@ local function upsertJobGrade(name, grade, data) lib.print.error('Job must exist to edit grades. Not found:', name) return end + jobs[name].grades[grade] = data TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name]) TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name]) @@ -182,6 +187,7 @@ local function upsertGangGrade(name, grade, data) lib.print.error('Gang must exist to edit grades. Not found:', name) return end + gangs[name].grades[grade] = data TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name]) TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name]) @@ -196,6 +202,7 @@ local function removeJobGrade(name, grade) lib.print.error('Job must exist to edit grades. Not found:', name) return end + jobs[name].grades[grade] = nil TriggerEvent('qbx_core:server:onJobUpdate', name, jobs[name]) TriggerClientEvent('qbx_core:client:onJobUpdate', -1, name, jobs[name]) @@ -210,6 +217,7 @@ local function removeGangGrade(name, grade) lib.print.error('Gang must exist to edit grades. Not found:', name) return end + gangs[name].grades[grade] = nil TriggerEvent('qbx_core:server:onGangUpdate', name, gangs[name]) TriggerClientEvent('qbx_core:client:onGangUpdate', -1, name, gangs[name]) diff --git a/server/loops.lua b/server/loops.lua index eb64c5502..8621bdd47 100644 --- a/server/loops.lua +++ b/server/loops.lua @@ -1,49 +1,62 @@ local config = require 'config.server' +local storage = require 'server.storage.main' local function removeHungerAndThirst(src, player) local playerState = Player(src).state if not playerState.isLoggedIn then return end + local newHunger = playerState.hunger - config.player.hungerRate local newThirst = playerState.thirst - config.player.thirstRate player.Functions.SetMetaData('thirst', math.max(0, newThirst)) player.Functions.SetMetaData('hunger', math.max(0, newHunger)) - - player.Functions.Save() end -CreateThread(function() - local interval = 60000 * config.updateInterval - while true do - Wait(interval) - for src, player in pairs(QBX.Players) do - removeHungerAndThirst(src, player) - end - end -end) - local function pay(player) local job = player.PlayerData.job local payment = GetJob(job.name).grades[job.grade.level].payment or job.payment if payment <= 0 then return end + if not GetJob(job.name).offDutyPay and not job.onduty then return end + if not config.money.paycheckSociety then config.sendPaycheck(player, payment) return end + local account = config.getSocietyAccount(job.name) if not account then -- Checks if player is employed by a society config.sendPaycheck(player, payment) return end + if account < payment then -- Checks if company has enough money to pay society Notify(player.PlayerData.source, locale('error.company_too_poor'), 'error') return end + config.removeSocietyMoney(job.name, payment) config.sendPaycheck(player, payment) end +CreateThread(function() + local interval = 60000 * config.updateInterval + while true do + Wait(interval) + for src, player in pairs(QBX.Players) do + removeHungerAndThirst(src, player) + end + end +end) + +CreateThread(function() + local interval = 60 * config.dbUpdateInterval + while true do + Wait(interval) + storage.sendPlayerDataUpdates() + end +end) + CreateThread(function() local interval = 60000 * config.money.paycheckTimeout while true do diff --git a/server/main.lua b/server/main.lua index 283383b4a..68c0baf7d 100644 --- a/server/main.lua +++ b/server/main.lua @@ -9,16 +9,19 @@ elseif GetConvar('inventory:framework', '') ~= 'qbx' then elseif GetConvarInt('onesync_enableInfinity', 0) ~= 1 then startupErrors, errorMessage = true, 'OneSync Infinity is not enabled. You can do so in txAdmin settings or add +set onesync on to your server startup command line' end + if startupErrors then lib.print.error('Startup errors detected, shutting down server...') ExecuteCommand('quit immediately') + for _ = 1, 100 do lib.print.error(errorMessage) end + error(errorMessage) end ----@type 'strict'|'relaxed'|'inactive' +---@type 'strict' | 'relaxed' | 'inactive' local bucketLockDownMode = GetConvar('qbx:bucketlockdownmode', 'inactive') SetRoutingBucketEntityLockdownMode(0, bucketLockDownMode) @@ -51,6 +54,7 @@ local function createSessionId(entity) if existingSessionId then return existingSessionId end + currentSessionId += 1 local sessionId = currentSessionId Entity(entity).state:set('sessionId', sessionId, true) @@ -75,6 +79,7 @@ function GetVehicleClass(model) repeat local players = GetPlayers() if #players == 0 then break end + local playerId = players[math.random(#players)] -- this *may* fail, but we still need to resolve our promise pcall(function() @@ -92,6 +97,7 @@ function GetVehicleClass(model) vehicleClassesPromise:resolve() end end + return vehicleClasses[model] end diff --git a/server/motd.lua b/server/motd.lua index 2bb3f2b50..d46c43146 100644 --- a/server/motd.lua +++ b/server/motd.lua @@ -71,10 +71,8 @@ To turn this message off, set the ^3qbx:acknowledge^4 convar to true in your ser local serviceMessages = json.decode(Citizen.Await(requestPromise)) if type(serviceMessages) == 'table' then local hasServiceMessage = false - for i = 1, #serviceMessages do local message = serviceMessages[i] - if type(message) == 'table' and message.content and isResourceVersion(message.version) then if not hasServiceMessage then hasServiceMessage = true diff --git a/server/player.lua b/server/player.lua index c0efeda32..58803fc8a 100644 --- a/server/player.lua +++ b/server/player.lua @@ -34,6 +34,7 @@ function Login(source, citizenid, newData) tags = config.logging.role, message = ('%s [%s] Dropped for attempting to login twice'):format(GetPlayerName(tostring(source)), tostring(source)) }) + return false end @@ -43,6 +44,7 @@ function Login(source, citizenid, newData) lib.print.error('User does not exist. Licenses checked:', license2, license) return false end + if citizenid then local playerData = storage.fetchPlayerEntity(citizenid) if playerData and (playerData.license == license2 or playerData.license == license) then @@ -76,8 +78,10 @@ exports('Login', Login) ---@return Player? player if found in storage function GetOfflinePlayer(citizenid) if not citizenid then return end + local playerData = storage.fetchPlayerEntity(citizenid) if not playerData then return end + return CheckPlayerData(nil, playerData) end @@ -94,7 +98,6 @@ function SetJob(identifier, jobName, grade) grade = tonumber(grade) or 0 local job = GetJob(jobName) - if not job then lib.print.error(('cannot set job. Job %s does not exist'):format(jobName)) @@ -108,7 +111,6 @@ function SetJob(identifier, jobName, grade) end local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier) - if setJobReplaces and player.PlayerData.job.name ~= 'unemployed' then local success, errorResult = RemovePlayerFromJob(player.PlayerData.citizenid, player.PlayerData.job.name) @@ -135,16 +137,15 @@ exports('SetJob', SetJob) function SetJobDuty(identifier, onDuty) local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier) - if not player then return end - - player.PlayerData.job.onduty = not not onDuty - - if player.Offline then return end - - TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty) - TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty) + if not player then + lib.print.error(('SetJobDuty couldn\'t find player with identifier %s'):format(identifier)) + return + end - UpdatePlayerData(identifier) + SetPlayerData(identifier, {'job', 'onduty'}, not not onDuty, function() + TriggerEvent('QBCore:Server:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty) + TriggerClientEvent('QBCore:Client:SetDuty', player.PlayerData.source, player.PlayerData.job.onduty) + end) end exports('SetJobDuty', SetJobDuty) @@ -202,14 +203,10 @@ function SetPlayerPrimaryJob(citizenid, jobName) player.PlayerData.job = toPlayerJob(jobName, job, grade) - if player.Offline then - SaveOffline(player.PlayerData) - else - Save(player.PlayerData.source) - UpdatePlayerData(player.PlayerData.source) + SetPlayerData(citizenid, 'job', player.PlayerData.job, function() TriggerEvent('QBCore:Server:OnJobUpdate', player.PlayerData.source, player.PlayerData.job) TriggerClientEvent('QBCore:Client:OnJobUpdate', player.PlayerData.source, player.PlayerData.job) - end + end) return true end @@ -270,12 +267,11 @@ function AddPlayerToJob(citizenid, jobName, grade) storage.addPlayerToJob(citizenid, jobName, grade) - if not player.Offline then - player.PlayerData.jobs[jobName] = grade - SetPlayerData(player.PlayerData.source, 'jobs', player.PlayerData.jobs) + player.PlayerData.jobs[jobName] = grade + SetPlayerData(citizenid, 'jobs', player.PlayerData.jobs, function() TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName, grade) TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName, grade) - end + end, true) if player.PlayerData.job.name == jobName then SetPlayerPrimaryJob(citizenid, jobName) @@ -317,19 +313,14 @@ function RemovePlayerFromJob(citizenid, jobName) if player.PlayerData.job.name == jobName then local job = GetJob('unemployed') assert(job ~= nil, 'cannot find unemployed job. Does it exist in shared/jobs.lua?') - player.PlayerData.job = toPlayerJob('unemployed', job, 0) - if player.Offline then - SaveOffline(player.PlayerData) - else - Save(player.PlayerData.source) - end + + SetPlayerData(citizenid, 'job', toPlayerJob('unemployed', job, 0)) end - if not player.Offline then - SetPlayerData(player.PlayerData.source, 'jobs', player.PlayerData.jobs) + SetPlayerData(citizenid, 'jobs', player.PlayerData.jobs, function() TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, jobName) TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, jobName) - end + end, true) return true end @@ -347,7 +338,6 @@ function SetGang(identifier, gangName, grade) grade = tonumber(grade) or 0 local gang = GetGang(gangName) - if not gang then lib.print.error(('cannot set gang. Gang %s does not exist'):format(gangName)) @@ -364,7 +354,6 @@ function SetGang(identifier, gangName, grade) if setGangReplaces and player.PlayerData.gang.name ~= 'none' then local success, errorResult = RemovePlayerFromGang(player.PlayerData.citizenid, player.PlayerData.gang.name) - if not success then return false, errorResult end @@ -372,7 +361,6 @@ function SetGang(identifier, gangName, grade) if gangName ~= 'none' then local success, errorResult = AddPlayerToGang(player.PlayerData.citizenid, gangName, grade) - if not success then return false, errorResult end @@ -415,7 +403,7 @@ function SetPlayerPrimaryGang(citizenid, gangName) assert(gang.grades[grade] ~= nil, ('gang %s does not have grade %s'):format(gangName, grade)) - player.PlayerData.gang = { + SetPlayerData(citizenid, 'gang', { name = gangName, label = gang.label, isboss = gang.grades[grade].isboss, @@ -423,16 +411,10 @@ function SetPlayerPrimaryGang(citizenid, gangName) name = gang.grades[grade].name, level = grade } - } - - if player.Offline then - SaveOffline(player.PlayerData) - else - Save(player.PlayerData.source) - UpdatePlayerData(player.PlayerData.source) + }, function() TriggerEvent('QBCore:Server:OnGangUpdate', player.PlayerData.source, player.PlayerData.gang) TriggerClientEvent('QBCore:Client:OnGangUpdate', player.PlayerData.source, player.PlayerData.gang) - end + end) return true end @@ -492,12 +474,11 @@ function AddPlayerToGang(citizenid, gangName, grade) storage.addPlayerToGang(citizenid, gangName, grade) - if not player.Offline then - player.PlayerData.gangs[gangName] = grade - SetPlayerData(player.PlayerData.source, 'gangs', player.PlayerData.gangs) + player.PlayerData.gangs[gangName] = grade + SetPlayerData(citizenid, 'gangs', player.PlayerData.gangs, function() TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName, grade) TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName, grade) - end + end, true) if player.PlayerData.gang.name == gangName then SetPlayerPrimaryGang(citizenid, gangName) @@ -539,7 +520,8 @@ function RemovePlayerFromGang(citizenid, gangName) if player.PlayerData.gang.name == gangName then local gang = GetGang('none') assert(gang ~= nil, 'cannot find none gang. Does it exist in shared/gangs.lua?') - player.PlayerData.gang = { + + SetPlayerData(citizenid, 'gang', { name = 'none', label = gang.label, isboss = false, @@ -547,19 +529,13 @@ function RemovePlayerFromGang(citizenid, gangName) name = gang.grades[0].name, level = 0 } - } - if player.Offline then - SaveOffline(player.PlayerData) - else - Save(player.PlayerData.source) - end + }) end - if not player.Offline then - SetPlayerData(player.PlayerData.source, 'gangs', player.PlayerData.gangs) + SetPlayerData(citizenid, 'gangs', player.PlayerData.gangs, function() TriggerEvent('qbx_core:server:onGroupUpdate', player.PlayerData.source, gangName) TriggerClientEvent('qbx_core:client:onGroupUpdate', player.PlayerData.source, gangName) - end + end, true) return true end @@ -573,12 +549,13 @@ function CheckPlayerData(source, playerData) playerData = playerData or {} ---@diagnostic disable-next-line: param-type-mismatch local playerState = Player(source)?.state - local Offline = true + local isOffline = true + if source then playerData.source = source playerData.license = playerData.license or GetPlayerIdentifierByType(source --[[@as string]], 'license2') or GetPlayerIdentifierByType(source --[[@as string]], 'license') playerData.name = GetPlayerName(source) - Offline = false + isOffline = false end playerData.userId = playerData.userId or nil @@ -586,6 +563,7 @@ function CheckPlayerData(source, playerData) playerData.cid = playerData.charinfo?.cid or playerData.cid or 1 playerData.money = playerData.money or {} playerData.optin = playerData.optin or true + for moneytype, startamount in pairs(config.money.moneyTypes) do playerData.money[moneytype] = playerData.money[moneytype] or startamount end @@ -601,12 +579,14 @@ function CheckPlayerData(source, playerData) playerData.charinfo.phone = playerData.charinfo.phone or GenerateUniqueIdentifier('PhoneNumber') playerData.charinfo.account = playerData.charinfo.account or GenerateUniqueIdentifier('AccountNumber') playerData.charinfo.cid = playerData.charinfo.cid or playerData.cid + -- Metadata playerData.metadata = playerData.metadata or {} playerData.metadata.health = playerData.metadata.health or 200 playerData.metadata.hunger = playerData.metadata.hunger or 100 playerData.metadata.thirst = playerData.metadata.thirst or 100 playerData.metadata.stress = playerData.metadata.stress or 0 + if playerState then playerState:set('hunger', playerData.metadata.hunger, true) playerState:set('thirst', playerData.metadata.thirst, true) @@ -655,10 +635,11 @@ function CheckPlayerData(source, playerData) SerialNumber = GenerateUniqueIdentifier('SerialNumber'), InstalledApps = {}, } - local jobs, gangs = storage.fetchPlayerGroups(playerData.citizenid) + local jobs, gangs = storage.fetchPlayerGroups(playerData.citizenid) local job = GetJob(playerData.job?.name) or GetJob('unemployed') assert(job ~= nil, 'Unemployed job not found. Does it exist in shared/jobs.lua?') + local jobGrade = GetJob(playerData.job?.name) and playerData.job.grade.level or 0 playerData.job = { @@ -678,9 +659,12 @@ function CheckPlayerData(source, playerData) end playerData.jobs = jobs or {} + local gang = GetGang(playerData.gang?.name) or GetGang('none') assert(gang ~= nil, 'none gang not found. Does it exist in shared/gangs.lua?') + local gangGrade = GetGang(playerData.gang?.name) and playerData.gang.grade.level or 0 + playerData.gang = { name = playerData.gang?.name or 'none', label = gang.label, @@ -690,10 +674,12 @@ function CheckPlayerData(source, playerData) level = gangGrade } } + playerData.gangs = gangs or {} playerData.position = playerData.position or defaultSpawn playerData.items = {} - return CreatePlayer(playerData --[[@as PlayerData]], Offline) + + return CreatePlayer(playerData --[[@as PlayerData]], isOffline) end ---On player logout @@ -701,6 +687,7 @@ end function Logout(source) local player = GetPlayer(source) if not player then return end + local playerState = Player(source)?.state player.PlayerData.metadata.hunger = playerState?.hunger or player.PlayerData.metadata.hunger player.PlayerData.metadata.thirst = playerState?.thirst or player.PlayerData.metadata.thirst @@ -733,17 +720,18 @@ function CreatePlayer(playerData, Offline) self.PlayerData = playerData self.Offline = Offline - ---@deprecated use UpdatePlayerData instead + ---@deprecated exports.qbx_core:SetPlayerData calls these events automatically function self.Functions.UpdatePlayerData() if self.Offline then lib.print.warn('UpdatePlayerData is unsupported for offline players') return end - UpdatePlayerData(self.PlayerData.source) + TriggerEvent('QBCore:Player:SetPlayerData', self.PlayerData) + TriggerClientEvent('QBCore:Player:SetPlayerData', self.PlayerData.source, self.PlayerData) end - ---@deprecated use SetJob instead + ---@deprecated use exports.qbx_core:SetJob instead ---Overwrites current primary job with a new job. Removing the player from their current primary job ---@param jobName string name ---@param grade? integer defaults to 0 @@ -753,7 +741,7 @@ function CreatePlayer(playerData, Offline) return SetJob(self.PlayerData.source, jobName, grade) end - ---@deprecated use SetGang instead + ---@deprecated use exports.qbx_core:SetGang instead ---Removes the player from their current primary gang and adds the player to the new gang ---@param gangName string name ---@param grade? integer defaults to 0 @@ -763,46 +751,44 @@ function CreatePlayer(playerData, Offline) return SetGang(self.PlayerData.source, gangName, grade) end - ---@deprecated use SetJobDuty instead + ---@deprecated use exports.qbx_core:SetJobDuty instead ---@param onDuty boolean function self.Functions.SetJobDuty(onDuty) SetJobDuty(self.PlayerData.source, onDuty) end - ---@deprecated use SetPlayerData instead + ---@deprecated use exports.qbx_core:SetPlayerData instead ---@param key string ---@param val any function self.Functions.SetPlayerData(key, val) SetPlayerData(self.PlayerData.source, key, val) end - ---@deprecated use SetMetadata instead + ---@deprecated use exports.qbx_core:SetMetadata instead ---@param meta string ---@param val any function self.Functions.SetMetaData(meta, val) SetMetadata(self.PlayerData.source, meta, val) end - ---@deprecated use GetMetadata instead + ---@deprecated use exports.qbx_core:GetMetadata instead ---@param meta string ---@return any function self.Functions.GetMetaData(meta) return GetMetadata(self.PlayerData.source, meta) end - ---@deprecated use SetMetadata instead + ---@deprecated use exports.qbx_core:SetMetadata instead ---@param amount number function self.Functions.AddJobReputation(amount) if not amount then return end amount = tonumber(amount) --[[@as number]] - self.PlayerData.metadata[self.PlayerData.job.name].reputation += amount - - ---@diagnostic disable-next-line: param-type-mismatch - UpdatePlayerData(self.Offline and self.PlayerData.citizenid or self.PlayerData.source) + SetPlayerData(self.PlayerData.source, 'metadata', {self.PlayerData.job.name, 'reputation'}, self.PlayerData.metadata[self.PlayerData.job.name].reputation + amount) end + ---@deprecated Use exports.qbx_core:AddMoney or ox_inventory item exports ---@param moneytype MoneyType ---@param amount number ---@param reason? string @@ -811,6 +797,7 @@ function CreatePlayer(playerData, Offline) return AddMoney(self.PlayerData.source, moneytype, amount, reason) end + ---@deprecated Use exports.qbx_core:RemoveMoney or ox_inventory item exports ---@param moneytype MoneyType ---@param amount number ---@param reason? string @@ -819,6 +806,7 @@ function CreatePlayer(playerData, Offline) return RemoveMoney(self.PlayerData.source, moneytype, amount, reason) end + ---@deprecated Use exports.qbx_core:SetMoney or ox_inventory item exports ---@param moneytype MoneyType ---@param amount number ---@param reason? string @@ -827,6 +815,7 @@ function CreatePlayer(playerData, Offline) return SetMoney(self.PlayerData.source, moneytype, amount, reason) end + ---@deprecated Use exports.qbx_core:GetMoney or ox_inventory item checks ---@param moneytype MoneyType ---@return boolean | number amount or false if moneytype does not exist function self.Functions.GetMoney(moneytype) @@ -904,16 +893,13 @@ function CreatePlayer(playerData, Offline) error('Player.Functions.SetInventory is unsupported for ox_inventory. Try ClearInventory, then add the desired items.') end - ---@deprecated use SetCharInfo instead + ---@deprecated use exports.qbx_core:SetCharInfo instead ---@param cardNumber number function self.Functions.SetCreditCard(cardNumber) - self.PlayerData.charinfo.card = cardNumber - - ---@diagnostic disable-next-line: param-type-mismatch - UpdatePlayerData(self.Offline and self.PlayerData.citizenid or self.PlayerData.source) + SetPlayerData(self.PlayerData.source, {'charinfo', 'card'}, cardNumber) end - ---@deprecated use Save or SaveOffline instead + ---@deprecated use exports.qbx_core:Save or exports.qbx_core:SaveOffline instead function self.Functions.Save() if self.Offline then SaveOffline(self.PlayerData) @@ -963,11 +949,10 @@ function CreatePlayer(playerData, Offline) end end - if not self.Offline then - UpdatePlayerData(self.PlayerData.source) + SetPlayerData(self.PlayerData.source, 'job', self.PlayerData.job, function() TriggerEvent('QBCore:Server:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) TriggerClientEvent('QBCore:Client:OnJobUpdate', self.PlayerData.source, self.PlayerData.job) - end + end) end) AddEventHandler('qbx_core:server:onGangUpdate', function(gangName, gang) @@ -999,11 +984,10 @@ function CreatePlayer(playerData, Offline) end end - if not self.Offline then - UpdatePlayerData(self.PlayerData.source) + SetPlayerData(self.PlayerData.source, 'gang', self.PlayerData.gang, function() TriggerEvent('QBCore:Server:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang) TriggerClientEvent('QBCore:Client:OnGangUpdate', self.PlayerData.source, self.PlayerData.gang) - end + end) end) if not self.Offline then @@ -1013,7 +997,6 @@ function CreatePlayer(playerData, Offline) SetPedArmour(ped, self.PlayerData.metadata.armor) -- At this point we are safe to emit new instance to third party resource for load handling GlobalState.PlayerCount += 1 - UpdatePlayerData(self.PlayerData.source) Player(self.PlayerData.source).state:set('loadInventory', true, true) TriggerEvent('QBCore:Server:PlayerLoaded', self) end @@ -1029,11 +1012,13 @@ function Save(source) local ped = GetPlayerPed(source) local playerData = QBX.Players[source].PlayerData local playerState = Player(source)?.state - local pcoords = playerData.position + local playerCoords = playerData.position + if not playerState.inApartment and not playerState.inProperty then local coords = GetEntityCoords(ped) - pcoords = vec4(coords.x, coords.y, coords.z, GetEntityHeading(ped)) + playerCoords = vec4(coords.x, coords.y, coords.z, GetEntityHeading(ped)) end + if not playerData then lib.print.error('QBX.PLAYER.SAVE - PLAYERDATA IS EMPTY!') return @@ -1051,9 +1036,10 @@ function Save(source) CreateThread(function() storage.upsertPlayerEntity({ playerEntity = playerData, - position = pcoords, + position = playerCoords, }) end) + assert(GetResourceState('qb-inventory') ~= 'started', 'qb-inventory is not compatible with qbx_core. use ox_inventory instead') lib.print.verbose(('%s PLAYER SAVED!'):format(playerData.name)) end @@ -1073,6 +1059,7 @@ function SaveOffline(playerData) position = playerData.position.xyz }) end) + assert(GetResourceState('qb-inventory') ~= 'started', 'qb-inventory is not compatible with qbx_core. use ox_inventory instead') lib.print.verbose(('%s OFFLINE PLAYER SAVED!'):format(playerData.name)) end @@ -1080,30 +1067,63 @@ end exports('SaveOffline', SaveOffline) ---@param identifier Source | string ----@param key string +---@param key string | string[] ---@param value any -function SetPlayerData(identifier, key, value) - if type(key) ~= 'string' then return end +---@param cb? function A function that's called after the standard SetPlayerData events are triggered if the player is online +---@param cancelDbUpdate? boolean When true, makes sure the database doesn't get updated as a result of this change +function SetPlayerData(identifier, key, value, cb, cancelDbUpdate) + local hasSubKeys = type(key) == 'table' + if type(key) ~= 'string' or not hasSubKeys then return end local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier) - if not player then return end + if not player then + lib.print.error(('SetPlayerData couldn\'t find player with identifier %s'):format(identifier)) + return + end - player.PlayerData[key] = value + local oldValue = player.PlayerData[hasSubKeys and key[1] or key] + + if hasSubKeys then + local current = player.PlayerData[hasSubKeys and key[1] or key] + -- We don't check the last one because otherwise we lose the table reference + for i = 2, #key - 1 do + local newCurrent = current[key[i]] + if newCurrent then + current = newCurrent + elseif i ~= (#key - 1) then + -- if an invalid key is specified and we are not on the last one, stop trying to update + -- reason for allowing the last one to not exist is so we can insert new values + lib.print.error(('key %s doesn\'t exist in table player.PlayerData.%s'):format(key[i], key[1])) + return + end + end - UpdatePlayerData(identifier) -end + local lastIndex = #key + oldValue = current[key[lastIndex]] + current[key[lastIndex]] = value + else + player.PlayerData[key] = value + end ----@param identifier Source | string -function UpdatePlayerData(identifier) - local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier) + if not cancelDbUpdate then + storage.addPlayerDataUpdate(player.PlayerData.citizenid, key, value) + end - if not player or player.Offline then return end + if player.Offline then return end TriggerEvent('QBCore:Player:SetPlayerData', player.PlayerData) TriggerClientEvent('QBCore:Player:SetPlayerData', player.PlayerData.source, player.PlayerData) + TriggerEvent('qbx_core:server:setPlayerData', player.PlayerData.source, key, value, oldValue) + TriggerClientEvent('qbx_core:client:setPlayerData', player.PlayerData.source, key, value, oldValue) + + if not cb then return end + + cb() end +exports('SetPlayerData', SetPlayerData) + ---@param identifier Source | string ---@param metadata string ---@param value any @@ -1114,38 +1134,25 @@ function SetMetadata(identifier, metadata, value) if not player then return end - local oldValue = player.PlayerData.metadata[metadata] - - player.PlayerData.metadata[metadata] = value - - UpdatePlayerData(identifier) + if metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress' then + value = lib.math.clamp(value, 0, 100) + end - if not player.Offline then + local oldValue = player.PlayerData.metadata[metadata] + SetPlayerData(identifier, {'metadata', metadata}, value, function() local playerState = Player(player.PlayerData.source).state TriggerClientEvent('qbx_core:client:onSetMetaData', player.PlayerData.source, metadata, oldValue, value) - TriggerEvent('qbx_core:server:onSetMetaData', metadata, oldValue, value, player.PlayerData.source) - - if (metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress') then - value = lib.math.clamp(value, 0, 100) + TriggerEvent('qbx_core:server:onSetMetaData', metadata, oldValue, value, player.PlayerData.source) - if playerState[metadata] ~= value then - playerState:set(metadata, value, true) - end + if (metadata == 'hunger' or metadata == 'thirst' or metadata == 'stress') and playerState[metadata] ~= value then + playerState:set(metadata, value, true) end - if (metadata == 'dead' or metadata == 'inlaststand') then + if metadata == 'dead' or metadata == 'inlaststand' then playerState:set('canUseWeapons', not value, true) end - end - - if metadata == 'inlaststand' or metadata == 'isdead' then - if player.Offline then - SaveOffline(player.PlayerData) - else - Save(player.PlayerData.source) - end - end + end) end exports('SetMetadata', SetMetadata) @@ -1169,17 +1176,7 @@ exports('GetMetadata', GetMetadata) ---@param charInfo string ---@param value any function SetCharInfo(identifier, charInfo, value) - if type(charInfo) ~= 'string' then return end - - local player = type(identifier) == 'string' and (GetPlayerByCitizenId(identifier) or GetOfflinePlayer(identifier)) or GetPlayer(identifier) - - if not player then return end - - --local oldCharInfo = player.PlayerData.charinfo[charInfo] - - player.PlayerData.charinfo[charInfo] = value - - UpdatePlayerData(identifier) + SetPlayerData(identifier, 'charinfo', {charInfo}, value) end exports('SetCharInfo', SetCharInfo) @@ -1195,7 +1192,7 @@ local function emitMoneyEvents(source, playerMoney, moneyType, amount, actionTyp local isSet = actionType == 'set' local isRemove = actionType == 'remove' - TriggerClientEvent('hud:client:OnMoneyChange', source, moneyType, isSet and math.abs(difference) or amount, isSet and difference < 0 or isRemove, reason) + TriggerClientEvent('hud:client:OnMoneyChange', source, moneyType, isSet and difference and math.abs(difference) or amount, isSet and difference and difference < 0 or isRemove, reason) TriggerClientEvent('QBCore:Client:OnMoneyChange', source, moneyType, amount, actionType, reason) TriggerEvent('QBCore:Server:OnMoneyChange', source, moneyType, amount, actionType, reason) @@ -1231,17 +1228,13 @@ function AddMoney(identifier, moneyType, amount, reason) amount = amount }) then return false end - player.PlayerData.money[moneyType] += amount - - if not player.Offline then - UpdatePlayerData(identifier) - + SetPlayerData(identifier, {'money', moneyType}, player.PlayerData.money[moneyType] + amount, function() local tags = amount > 100000 and config.logging.role or nil local resource = GetInvokingResource() or cache.resource logger.log({ source = resource, - webhook = config.logging.webhook['playermoney'], + webhook = config.logging.webhook.playermoney, event = 'AddMoney', color = 'lightgreen', tags = tags, @@ -1250,7 +1243,7 @@ function AddMoney(identifier, moneyType, amount, reason) }) emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'add', reason) - end + end) return true end @@ -1286,17 +1279,13 @@ function RemoveMoney(identifier, moneyType, amount, reason) end end - player.PlayerData.money[moneyType] -= amount - - if not player.Offline then - UpdatePlayerData(identifier) - + SetPlayerData(identifier, {'money', moneyType}, player.PlayerData.money[moneyType] - amount, function() local tags = amount > 100000 and config.logging.role or nil local resource = GetInvokingResource() or cache.resource logger.log({ source = resource, - webhook = config.logging.webhook['playermoney'], + webhook = config.logging.webhook.playermoney, event = 'RemoveMoney', color = 'red', tags = tags, @@ -1305,7 +1294,7 @@ function RemoveMoney(identifier, moneyType, amount, reason) }) emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'remove', reason) - end + end) return true end @@ -1335,10 +1324,7 @@ function SetMoney(identifier, moneyType, amount, reason) }) then return false end player.PlayerData.money[moneyType] = amount - - if not player.Offline then - UpdatePlayerData(identifier) - + SetPlayerData(identifier, {'money', moneyType}, amount, function() local difference = amount - oldAmount local dirChange = difference < 0 and 'removed' or 'added' local absDifference = math.abs(difference) @@ -1347,7 +1333,7 @@ function SetMoney(identifier, moneyType, amount, reason) logger.log({ source = resource, - webhook = config.logging.webhook['playermoney'], + webhook = config.logging.webhook.playermoney, event = 'SetMoney', color = difference < 0 and 'red' or 'green', tags = tags, @@ -1355,7 +1341,7 @@ function SetMoney(identifier, moneyType, amount, reason) }) emitMoneyEvents(player.PlayerData.source, player.PlayerData.money, moneyType, amount, 'set', reason, difference) - end + end) return true end @@ -1388,7 +1374,7 @@ function DeleteCharacter(source, citizenid) if success then logger.log({ source = 'qbx_core', - webhook = config.logging.webhook['joinleave'], + webhook = config.logging.webhook.joinleave, event = 'Character Deleted', color = 'red', message = ('**%s** deleted **%s**...'):format(GetPlayerName(source), citizenid, source), @@ -1399,7 +1385,7 @@ function DeleteCharacter(source, citizenid) DropPlayer(tostring(source), locale('info.exploit_dropped')) logger.log({ source = 'qbx_core', - webhook = config.logging.webhook['anticheat'], + webhook = config.logging.webhook.anticheat, event = 'Anti-Cheat', color = 'white', tags = config.logging.role, @@ -1422,7 +1408,7 @@ function ForceDeleteCharacter(citizenid) if success then logger.log({ source = 'qbx_core', - webhook = config.logging.webhook['joinleave'], + webhook = config.logging.webhook.joinleave, event = 'Character Force Deleted', color = 'red', message = ('Character **%s** got deleted'):format(citizenid), @@ -1444,6 +1430,7 @@ function GenerateUniqueIdentifier(type) uniqueId = table.valueFunction() isUnique = storage.fetchIsUnique(type, uniqueId) until isUnique + return uniqueId end diff --git a/server/queue.lua b/server/queue.lua index 44e73856d..8d99ccada 100644 --- a/server/queue.lua +++ b/server/queue.lua @@ -4,10 +4,10 @@ if GetConvar('qbx:enablequeue', 'true') == 'false' then return false end ---@param resource string AddEventHandler('onResourceStarting', function(resource) - if resource == 'hardcap' then - lib.print.info('Preventing hardcap from starting...') - CancelEvent() - end + if resource ~= 'hardcap' then return end + + lib.print.info('Preventing hardcap from starting...') + CancelEvent() end) if GetResourceState('hardcap'):find('start') then @@ -146,6 +146,7 @@ local function updatePlayerJoining(source, license) if not joiningPlayers[license] then joiningPlayerCount += 1 end + joiningPlayers[license] = { source = source, timestamp = os.time() } end @@ -197,7 +198,6 @@ local function awaitPlayerQueue(source, license, deferrals) local playerTimingOut = isPlayerTimingOut(license) local data = playerDatas[license] - if data and not playerTimingOut then deferrals.done(locale('error.already_in_queue')) return @@ -228,7 +228,6 @@ local function awaitPlayerQueue(source, license, deferrals) -- wait until the player disconnected or until there are available slots and the player is first in queue while DoesPlayerExist(source --[[@as string]]) and ((GetNumPlayerIndices() + joiningPlayerCount) >= maxPlayers or data.globalPos > 1) do local displayTime = createDisplayTime(data.waitingSeconds, waitingEmojiIndex) - if useAdaptiveCard then deferrals.presentCard(generateCard({ subQueue = subQueue, @@ -255,6 +254,7 @@ local function awaitPlayerQueue(source, license, deferrals) if awaitPlayerTimeout(license) then dequeue(license) end + return end diff --git a/server/storage/players.lua b/server/storage/players.lua index 3a07f6552..6a8487a85 100644 --- a/server/storage/players.lua +++ b/server/storage/players.lua @@ -1,5 +1,32 @@ local defaultSpawn = require 'config.shared'.defaultSpawn local characterDataTables = require 'config.server'.characterDataTables +local playerDataUpdateQueue = {} +local collectedPlayerData = {} +local isUpdating = false + +local otherNamedPlayerFields = { + ['items'] = 'inventory', + ['lastLoggedOut'] = 'last_logged_out' +} + +local jsonPlayerFields = { + ['id'] = false, + ['userId'] = false, + ['citizenid'] = false, + ['cid'] = false, + ['license'] = false, + ['name'] = false, + ['money'] = true, + ['charinfo'] = true, + ['job'] = true, + ['gang'] = true, + ['position'] = true, + ['metadata'] = true, + ['inventory'] = true, + ['phone_number'] = false, + ['last_updated'] = false, + ['last_logged_out'] = false +} local function createUsersTable() MySQL.query([[ @@ -78,7 +105,7 @@ end ---@return BanEntity? local function fetchBan(request) local column, value = getBanId(request) - local result = MySQL.single.await('SELECT expire, reason FROM bans WHERE ' ..column.. ' = ?', { value }) + local result = MySQL.single.await('SELECT expire, reason FROM bans WHERE ' .. column .. ' = ?', { value }) return result and { expire = result.expire, reason = result.reason, @@ -88,7 +115,7 @@ end ---@param request GetBanRequest local function deleteBan(request) local column, value = getBanId(request) - MySQL.query.await('DELETE FROM bans WHERE ' ..column.. ' = ?', { value }) + MySQL.query.await('DELETE FROM bans WHERE ' .. column .. ' = ?', { value }) end ---@param request UpsertPlayerRequest @@ -166,22 +193,28 @@ local function fetchPlayerEntity(citizenId) end ---@param filters table +---@return string, any[] local function handleSearchFilters(filters) - if not (filters) then return '', {} end + if not filters then return '', {} end + local holders = {} local clauses = {} + if filters.license then clauses[#clauses + 1] = 'license = ?' holders[#holders + 1] = filters.license end + if filters.job then clauses[#clauses + 1] = 'JSON_EXTRACT(job, "$.name") = ?' holders[#holders + 1] = filters.job end + if filters.gang then clauses[#clauses + 1] = 'JSON_EXTRACT(gang, "$.name") = ?' holders[#holders + 1] = filters.gang end + if filters.metadata then local strict = filters.metadata.strict for key, value in pairs(filters.metadata) do @@ -192,6 +225,7 @@ local function handleSearchFilters(filters) else clauses[#clauses + 1] = 'JSON_EXTRACT(metadata, "$.' .. key .. '") >= ?' end + holders[#holders + 1] = value elseif type(value) == "boolean" then clauses[#clauses + 1] = 'JSON_EXTRACT(metadata, "$.' .. key .. '") = ?' @@ -203,6 +237,7 @@ local function handleSearchFilters(filters) end end end + return (' WHERE %s'):format(table.concat(clauses, ' AND ')), holders end @@ -317,6 +352,7 @@ local function fetchPlayerGroups(citizenid) gangs[group.group] = group.grade end end + return jobs, gangs end @@ -382,8 +418,98 @@ local function cleanPlayerGroups() lib.print.info('Removed invalid groups from player_groups table') end +---@param citizenid string +---@param key string | string[] +---@param value any +local function addPlayerDataUpdate(citizenid, key, value) + local hasSubKeys = type(key) == 'table' + + if hasSubKeys then + key[1] = otherNamedPlayerFields[key[1]] or key[1] + else + key = otherNamedPlayerFields[key] or key + end + + if jsonPlayerFields[hasSubKeys and key[1] or key] == nil then + lib.print.error(('Tried to update player data field %s when it doesn\'t exist. Value: %s'):format(hasSubKeys and key[1] or key, value)) + return + end + + if hasSubKeys and not jsonPlayerFields[key[1]] then + lib.print.error(('Tried to update player data field %s as a json object when it isn\'t one'):format(key[1])) + return + end + + value = type(value) == 'table' and json.encode(value) or value + + -- In sendPlayerDataUpdates we don't go more than 3 tables deep + if hasSubKeys and #key > 4 then + lib.print.error(('Cannot save field %s because data is too big.\nkeys: %s\nvalue: %s'):format(key[1], json.encode(key), value)) + return + end + + local currentTable = isUpdating and playerDataUpdateQueue or collectedPlayerData + if not currentTable[citizenid] then + currentTable[citizenid] = {} + end + + currentTable[citizenid][hasSubKeys and key[1] or key] = hasSubKeys and {} or value + + if hasSubKeys then + local current = currentTable[citizenid][key[1]] + -- We don't check the last one because otherwise we lose the table reference + for i = 2, #key - 1 do + if not current[key[i]] then + current[key[i]] = {} + end + end + + current[key[#key]] = value + end +end + +local function sendPlayerDataUpdates() + -- We implement this to ensure when updating no values are added to our updating sequence to prevent data loss by accidentally skipping over it + isUpdating = true + + for citizenid, playerData in pairs(collectedPlayerData) do + for key, data in pairs(playerData) do + if type(data) == 'table' then + -- We go a maximum of 3 tables deep into the current table to prevent misuse and qbox doesn't have more than 2 actually + -- If we were to make this variable to the amount of data there is, then enough tables can crash the server + for k, v in pairs(data) do + if type(v) == 'table' then + for k2, v2 in pairs(v) do + if type(v2) == 'table' then + for k3, v3 in pairs(v2) do + if type(v3) == 'table' then + v3 = json.encode(v3) + end + + MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2, k3), { v3, citizenid }) + end + else + MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s.%s", ?) WHERE citizenid = ?'):format(key, key, k, k2), { v2, citizenid }) + end + end + else + MySQL.prepare.await(('UPDATE players SET %s = JSON_SET(%s, "$.%s", ?) WHERE citizenid = ?'):format(key, key, k), { v, citizenid }) + end + end + else + MySQL.prepare.await(('UPDATE players SET %s = ? WHERE citizenid = ?'):format(key), { data, citizenid }) + end + end + end + + collectedPlayerData = playerDataUpdateQueue + playerDataUpdateQueue = {} + isUpdating = false +end + RegisterCommand('cleanplayergroups', function(source) if source ~= 0 then return warn('This command can only be executed using the server console.') end + cleanPlayerGroups() end, true) @@ -394,6 +520,7 @@ CreateThread(function() warn(('Table \'%s\' does not exist in database, please remove it from qbx_core/config/server.lua or create the table'):format(tableName)) end end + if GetConvar('qbx:cleanPlayerGroups', 'false') == 'true' then cleanPlayerGroups() end @@ -419,4 +546,6 @@ return { removePlayerFromJob = removePlayerFromJob, removePlayerFromGang = removePlayerFromGang, searchPlayerEntities = searchPlayerEntities, + sendPlayerDataUpdates = sendPlayerDataUpdates, + addPlayerDataUpdate = addPlayerDataUpdate } \ No newline at end of file diff --git a/server/vehicle-persistence.lua b/server/vehicle-persistence.lua index d25b4d160..6e2a7eeff 100644 --- a/server/vehicle-persistence.lua +++ b/server/vehicle-persistence.lua @@ -83,14 +83,17 @@ local function getPedsInVehicleSeats(vehicle) ped = ped, seat = i, } + occupantsI += 1 end end + return occupants end AddEventHandler('entityRemoved', function(entity) if not Entity(entity).state.persisted then return end + local sessionId = Entity(entity).state.sessionId local coords = GetEntityCoords(entity) local heading = GetEntityHeading(entity) diff --git a/shared/functions.lua b/shared/functions.lua index 43537e601..7dd231e6e 100644 --- a/shared/functions.lua +++ b/shared/functions.lua @@ -29,27 +29,24 @@ function HasPlayerGotGroup(filter, playerData, primary) local groups = not primary and GetPlayerGroups(playerData) if not filter then return false end - local _type = type(filter) + local _type = type(filter) if _type == 'string' then local job = playerData.job.name == filter local gang = playerData.gang.name == filter local group = groups and groups[filter] local citizenId = playerData.citizenid == filter - if job or gang or group or citizenId then return true end elseif _type == 'table' then local tabletype = table.type(filter) - if tabletype == 'hash' then for name, grade in pairs(filter) do local job = playerData.job.name == name local gang = playerData.gang.name == name local group = groups and groups[name] local citizenId = playerData.citizenid == name - if job and grade <= playerData.job.grade.level or gang and grade <= playerData.gang.grade.level or group and grade <= group or citizenId then return true end @@ -61,13 +58,13 @@ function HasPlayerGotGroup(filter, playerData, primary) local gang = playerData.gang.name == name local group = groups and groups[name] local citizenId = playerData.citizenid == name - if job or gang or group or citizenId then return true end end end end + return false end @@ -80,6 +77,7 @@ function GetPlayerGroups(playerData) groups[job] = data end end + for gang, data in pairs(playerData.gangs) do if not groups[gang] then groups[gang] = data @@ -87,5 +85,6 @@ function GetPlayerGroups(playerData) lib.print.warn(('Duplicate group name %s found in player_groups table, check job and gang shared lua.'):format(gang)) end end + return groups end \ No newline at end of file diff --git a/types.lua b/types.lua index 99698e98c..67d85e40a 100644 --- a/types.lua +++ b/types.lua @@ -102,6 +102,8 @@ ---@field fetchPlayerGroups fun(citizenid: string): table, table jobs, gangs ---@field removePlayerFromJob fun(citizenid: string, group: string) ---@field removePlayerFromGang fun(citizenid: string, group: string) +---@field sendPlayerDataUpdates fun() +---@field addPlayerDataUpdate fun(citizenid: string, key: string, subKeys?: string[], value: any) ---@class InsertBanRequest ---@field name string