onClientResourceStop doesn't fire reliably for a resource's own client scripts when it stops itself, so props never got cleaned up. The server now broadcasts safe:despawn (the same path already used for pickup) for every known safe on onResourceStop, right before the resource actually goes down. Bump to 1.1.6.
368 lines
14 KiB
Lua
368 lines
14 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)
|
|
|
|
-------------------------------------------------------
|
|
-- 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
|