local ox_inventory = exports.ox_inventory local safes = {} -- Distância máxima (metros) entre o jogador e a posição enviada em safe:place local PLACE_MAX_DISTANCE = 5.0 -- Tentativas de PIN por jogador: { [src] = { count = 0, blockedUntil = 0 } } local pinAttempts = {} -- Debug helper local function dbg(...) if Config and Config.Debug then print('[0ixb-stashes SERVER]', ...) end end -- Verifica se o jogador está fisicamente perto do safe local function isNearSafe(src, safe, maxDist) local ped = GetPlayerPed(src) local playerCoords = GetEntityCoords(ped) local safeCoords = vector3(safe.coords.x, safe.coords.y, safe.coords.z) return #(playerCoords - safeCoords) <= (maxDist or Config.SafeInteractDistance) end ------------------------------------------------------- -- CARREGAR SAFES DO DB AO INICIAR O RECURSO ------------------------------------------------------- AddEventHandler('onResourceStart', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end dbg('Resource started, carregando safes do DB...') exports.oxmysql:fetch('SELECT * FROM safes', {}, function(result) for _, v in ipairs(result) do -- coords agora pode conter: x,y,z,h OU x,y,z,rx,ry,rz local decoded = json.decode(v.coords) local safeData = { id = v.id, owner = v.owner, type = v.type, coords = { x = decoded.x, y = decoded.y, z = decoded.z, h = decoded.h, -- fallback caso seja safe antigo rx = decoded.rx, -- rotação completa (se existir) ry = decoded.ry, rz = decoded.rz }, pin = v.pin } safes[v.id] = safeData -- Registrar stash no Ox local cfg = Config.SafeTypes[safeData.type] if cfg then exports.ox_inventory:RegisterStash( "safe_"..safeData.id, cfg.label, cfg.slots, cfg.weight, safeData.owner ) end end -- Spawnar para quem está online for _, pid in ipairs(GetPlayers()) do for _, data in pairs(safes) do TriggerClientEvent('safe:spawn', tonumber(pid), data) end end dbg('Safes carregados e spawnados:', #result) end) end) ------------------------------------------------------- -- DESPAWNAR PARA TODOS ANTES DO RESOURCE PARAR -- onClientResourceStop não é confiável para o próprio resource -- se limpar sozinho no cliente, então avisamos daqui. ------------------------------------------------------- AddEventHandler('onResourceStop', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end for id in pairs(safes) do TriggerClientEvent('safe:despawn', -1, id) end end) ------------------------------------------------------- -- SINCRONIZAR SAFE POR PEDIDO ------------------------------------------------------- RegisterNetEvent('safe:requestSync', function() local src = source dbg('Player', src, 'pedindo sync de safes') for _, data in pairs(safes) do TriggerClientEvent('safe:spawn', src, data) end end) ------------------------------------------------------- -- COLOCAR SAFE (AGORA COM ROTAÇÃO COMPLETA) ------------------------------------------------------- RegisterNetEvent('safe:place', function(type, coords, pin) local src = source local cfg = Config.SafeTypes[type] if not cfg then return end dbg('safe:place chamado por', src, 'type', type) -- validar distância (evita coords arbitrárias vindas de um cliente modificado) local ped = GetPlayerPed(src) local playerCoords = GetEntityCoords(ped) local placeCoords = vector3(coords.x, coords.y, coords.z) if #(playerCoords - placeCoords) > PLACE_MAX_DISTANCE then dbg('safe:place REJEITADO - distância suspeita', src, #(playerCoords - placeCoords)) return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você está muito longe para colocar o safe aqui.'}) end -- remover item local removed = exports.ox_inventory:RemoveItem(src, cfg.item, 1) if not removed then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você não tem esse item.'}) end -- obter identifier local identifier = GetPlayerIdentifier(src, 1) ----------------------------------------------------------------- -- PREPARA COORDENADAS PARA SALVAR NO DB -- AGORA SEMPRE SALVA: x,y,z,h,rx,ry,rz ----------------------------------------------------------------- local coordsToSave = json.encode({ x = coords.x, y = coords.y, z = coords.z, h = coords.h, -- fallback rx = coords.rx, ry = coords.ry, rz = coords.rz }) ----------------------------------------------------------------- -- SALVAR NO DB ----------------------------------------------------------------- exports.oxmysql:insert( 'INSERT INTO safes (owner, type, coords, pin) VALUES (?, ?, ?, ?)', { identifier, type, coordsToSave, pin }, function(id) if not id then exports.ox_inventory:AddItem(src, cfg.item, 1) return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Erro ao salvar o safe.'}) end ----------------------------------------------------------------- -- GUARDAR NA MEMÓRIA DO SERVIDOR ----------------------------------------------------------------- local safeData = { id = id, owner = identifier, type = type, coords = coords, pin = pin } safes[id] = safeData ----------------------------------------------------------------- -- REGISTRAR STASH ----------------------------------------------------------------- exports.ox_inventory:RegisterStash( "safe_"..id, cfg.label, cfg.slots, cfg.weight, identifier ) ----------------------------------------------------------------- -- ENVIAR PARA TODOS ----------------------------------------------------------------- TriggerClientEvent('safe:spawn', -1, safeData) TriggerClientEvent('ox_lib:notify', src, {type='success', description='Safe colocado.'}) dbg('Safe colocado id', id, 'por', identifier) end ) end) ------------------------------------------------------- -- ABRIR SAFE ------------------------------------------------------- RegisterNetEvent('safe:open', function(id, pin) local src = source local now = os.time() local attempt = pinAttempts[id] -- bloqueio é do cofre, não do jogador if attempt and now < attempt.blockedUntil then local remaining = math.ceil(attempt.blockedUntil - now) return TriggerClientEvent('ox_lib:notify', src, {type='error', description=('Este safe está bloqueado. Aguarde %d segundos.'):format(remaining)}) end local safe = safes[id] if not safe then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Safe inexistente.'}) end if not isNearSafe(src, safe) then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você está muito longe do safe.'}) end if tostring(pin) ~= tostring(safe.pin) then attempt = attempt or { count = 0, blockedUntil = 0 } attempt.count = attempt.count + 1 if attempt.count >= Config.PinMaxAttempts then attempt.blockedUntil = now + Config.PinBlockTime attempt.count = 0 pinAttempts[id] = attempt dbg('Safe', id, 'bloqueado por tentativas de PIN erradas') return TriggerClientEvent('ox_lib:notify', src, {type='error', description=('PIN incorreto. Safe bloqueado por %d segundos.'):format(Config.PinBlockTime)}) end pinAttempts[id] = attempt local remainingAttempts = Config.PinMaxAttempts - attempt.count return TriggerClientEvent('ox_lib:notify', src, {type='error', description=('PIN incorreto. Mais %d tentativa(s) antes do safe ser bloqueado.'):format(remainingAttempts)}) end pinAttempts[id] = nil local cfg = Config.SafeTypes[safe.type] TriggerClientEvent('safe:openInventory', src, {id=id, slots=cfg.slots, weight=cfg.weight}) end) ------------------------------------------------------- -- RECOLHER SAFE ------------------------------------------------------- RegisterNetEvent('safe:pickup', function(id) local src = source local safe = safes[id] if not safe then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Safe inexistente.'}) end local identifier = GetPlayerIdentifier(src, 1) if safe.owner ~= identifier then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você não é o dono.'}) end if not isNearSafe(src, safe) then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='Você está muito longe do safe.'}) end local cfg = Config.SafeTypes[safe.type] -- Bloqueia o recolhimento se ainda houver itens dentro (evita perda de itens) -- Nota: confirme se "GetInventoryItems" é o nome do export na sua versão do ox_inventory local items = exports.ox_inventory:GetInventoryItems("safe_"..id) if items and next(items) then return TriggerClientEvent('ox_lib:notify', src, {type='error', description='O safe ainda tem itens dentro.'}) end exports.oxmysql:execute('DELETE FROM ox_inventory WHERE name=?', {'safe_'..id}) exports.oxmysql:execute('DELETE FROM safes WHERE id=?', {id}) safes[id] = nil exports.ox_inventory:ClearInventory("safe_"..id) TriggerClientEvent('safe:despawn', -1, id) exports.ox_inventory:AddItem(src, cfg.item, 1) TriggerClientEvent('ox_lib:notify', src, {type='success', description='Safe recolhido e item devolvido.'}) end) ------------------------------------------------------- -- UPDATE CHECKER / UPGRADE (via Gitea) -- Só existe enquanto Config.Debug = true. Em produção, com -- Config.Debug = false, este bloco inteiro nem registra os comandos. ------------------------------------------------------- if Config.Debug and Config.Update then local function giteaRawUrl(file) return ('%s/api/v1/repos/%s/%s/raw/%s?ref=%s'):format( Config.Update.giteaUrl, Config.Update.owner, Config.Update.repo, file, Config.Update.branch ) end local function checkForUpdate(cb) PerformHttpRequest(giteaRawUrl('version.json'), function(status, body) if status ~= 200 or not body then return cb(false, ('não foi possível contatar o Gitea (status %s)'):format(tostring(status))) end local ok, remote = pcall(json.decode, body) if not ok or not remote or not remote.version then return cb(false, 'resposta inválida em version.json') end cb(true, remote.version) end, 'GET', '', { ['Content-Type'] = 'application/json' }) end RegisterCommand(Config.Update.checkCommand, function(src) local current = GetResourceMetadata(GetCurrentResourceName(), 'version', 0) checkForUpdate(function(ok, result) if not ok then dbg('Erro ao checar update:', result) return end if result ~= current then dbg(('Nova versão disponível: %s (atual: %s). Use /%s para atualizar.') :format(result, current, Config.Update.upgradeCommand)) else dbg('Já está na versão mais recente ('..current..').') end end) end, true) RegisterCommand(Config.Update.upgradeCommand, function(src) local resourceName = GetCurrentResourceName() local files = Config.Update.files local pending = #files local failed = false dbg('Iniciando upgrade, baixando', pending, 'arquivo(s)...') for _, file in ipairs(files) do PerformHttpRequest(giteaRawUrl(file), function(status, body) if status == 200 and body and #body > 0 then SaveResourceFile(resourceName, file, body, #body) dbg('Baixado:', file) else failed = true dbg(('Falha ao baixar %s (status %s)'):format(file, tostring(status))) end pending = pending - 1 if pending == 0 then if failed then dbg('Upgrade incompleto — algum arquivo falhou.') else dbg('Upgrade concluído. Reinicie manualmente quando quiser: restart '..resourceName) end end end, 'GET') end end, true) dbg('Update checker ativo (Config.Debug = true). Comandos: /'..Config.Update.checkCommand..' /'..Config.Update.upgradeCommand) end