0ixb-stashes/server.lua
Bxio cfe36cb3b2 Despawn safes via server onResourceStop instead of client-side stop
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.
2026-08-03 12:01:58 +00:00

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