Distance rejection now says exactly why, the PIN rate-limit shows remaining seconds, and the client no longer announces pickup success before the server validates it.
353 lines
13 KiB
Lua
353 lines
13 KiB
Lua
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)
|
|
|
|
|
|
|
|
-------------------------------------------------------
|
|
-- 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[src]
|
|
|
|
if attempt and now < attempt.blockedUntil then
|
|
local remaining = math.ceil(attempt.blockedUntil - now)
|
|
return TriggerClientEvent('ox_lib:notify', src, {type='error', description=('Muitas tentativas erradas. 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
|
|
dbg('Jogador', src, 'bloqueado por tentativas de PIN erradas')
|
|
end
|
|
|
|
pinAttempts[src] = attempt
|
|
return TriggerClientEvent('ox_lib:notify', src, {type='error', description='PIN incorreto.'})
|
|
end
|
|
|
|
pinAttempts[src] = 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. Resource NÃO foi reiniciado.')
|
|
else
|
|
dbg('Upgrade concluído. Reiniciando resource...')
|
|
ExecuteCommand('restart '..resourceName)
|
|
end
|
|
end
|
|
end, 'GET')
|
|
end
|
|
end, true)
|
|
|
|
dbg('Update checker ativo (Config.Debug = true). Comandos: /'..Config.Update.checkCommand..' /'..Config.Update.upgradeCommand)
|
|
end
|