2026-04-14 17:41:39 +02:00

1419 lines
60 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- ============================================================
-- main.lua Core server logic
-- ============================================================
Players = {}
PlayerDefaultRoutings = {}
CachedPlayerNames = {}
JobGarageUsers = {}
function IsJobAllowed(jobName, action)
if not jobName then return false end
if not Config.AllowedJobs then return false end
for _, allowedJob in ipairs(Config.AllowedJobs) do
if allowedJob == jobName then
return true
end
end
return false
end
print("^2[GARAGES] Initializing server cache tables...^7")
local version = "5.1.2-FINAL"
print("^2[GARAGES] Server version " .. version .. " loaded!^7")
-- Wait for db module to be available
while not db do
Wait(1000)
Debug("Waiting for db", "your db")
end
-- [DB AUTO-FIX] Ensure necessary columns exist for the menu buttons to work
CreateThread(function()
Wait(5000) -- Wait for MySQL to be fully ready
print("^2[GARAGES] Checking database structure for tags and favorites...^7")
-- Ensure 'tag' column exists
MySQL.query("ALTER TABLE owned_vehicles ADD COLUMN IF NOT EXISTS tag VARCHAR(255) DEFAULT NULL")
-- Ensure 'favorite' column exists
MySQL.query("ALTER TABLE owned_vehicles ADD COLUMN IF NOT EXISTS favorite TINYINT(1) DEFAULT 0")
print("^2[GARAGES] Database structure is now ready!^7")
end)
-- ──────────────────────────────────────────────
-- GetImpoundOfType
-- Returns the name of the nearest impound garage
-- matching the given vehicle type (and optional coords).
-- ──────────────────────────────────────────────
local function ExtractCoordsFromGarageData(garageData)
if not garageData then return nil end
local function toVec3(obj)
local x = tonumber(obj.x)
local y = tonumber(obj.y)
local z = tonumber(obj.z)
if x and y and z then return vec3(x, y, z) end
end
if garageData.x then return toVec3(garageData) end
if garageData.menuCoords and garageData.menuCoords.x then return toVec3(garageData.menuCoords) end
if garageData.spawnCoords and garageData.spawnCoords.x then return toVec3(garageData.spawnCoords) end
return nil
end
function GetImpoundOfType(vehicleType, vehicleCoords)
if not vehicleCoords then
-- No coords: return the first matching impound
for garageName, garageData in pairs(Config.Garages) do
if garageData.type == vehicleType and garageData.isImpound then
return garageName
end
end
return false
end
-- Collect all impounds of that type with valid coords
local candidates = {}
for garageName, garageData in pairs(Config.Garages) do
if garageData.type == vehicleType and garageData.isImpound then
local coords = ExtractCoordsFromGarageData(garageData.coords)
if coords then
table.insert(candidates, { name = garageName, coords = coords })
end
end
end
if #candidates == 0 then return false end
-- Sort by distance and return the nearest
local refCoords = vehicleCoords
table.sort(candidates, function(a, b)
return #(a.coords - refCoords) < #(b.coords - refCoords)
end)
return candidates[1] and candidates[1].name or false
end
-- ──────────────────────────────────────────────
-- Load garages from DB into Config
-- ──────────────────────────────────────────────
local dbGarages = db.getGarages()
for _, garageData in pairs(dbGarages) do
Config.Garages[garageData.name] = garageData
end
-- ──────────────────────────────────────────────
-- Migration command
-- ──────────────────────────────────────────────
RegisterCommand("migratev5", function(sourcePlayer)
if sourcePlayer ~= 0 then
return print("This command can only be used by the server")
end
db.checkVehicleGarages()
print("Migrate v5")
end)
-- ──────────────────────────────────────────────
-- Load furniture data
-- ──────────────────────────────────────────────
Config.Furniture = db.getMergedFurnitureData()
-- ──────────────────────────────────────────────
-- Notification helper
-- ──────────────────────────────────────────────
function Notification(playerSource, message, notifType)
TriggerClientEvent("garages:notification", playerSource, message, notifType)
end
-- ──────────────────────────────────────────────
-- ClearGarage moves all vehicles in a garage to impound
-- ──────────────────────────────────────────────
function ClearGarage(garageId, playerIdentifier)
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE garage = ? AND %s = ?",
garageTable, storedColumn, garageIdentifierColumn
)
local selectQuery = string.format(
"SELECT type, %s FROM %s WHERE garage = ? AND %s = ? LIMIT 1",
garagePropsColumn, garageTable, garageIdentifierColumn
)
local vehicles = MySQL.Sync.fetchAll(selectQuery, { garageId, playerIdentifier })
for _, row in pairs(vehicles) do
local rawProps = row[garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
local impound = GetImpoundOfType(row.type, vehicleCoords)
if not impound then
Debug("Type:", row.type, "not found impound in [sellGarage], please create that type impound.")
return false
end
MySQL.Sync.execute(updateQuery, { impound, State.impounded, garageId, playerIdentifier })
end
ClearDecorations(garageId)
MySQL.prepare("DELETE FROM qs_garage_decorations WHERE insideId = ?", { garageId })
end
-- ──────────────────────────────────────────────
-- Player connected
-- ──────────────────────────────────────────────
RegisterNetEvent("garages:playerConnected")
AddEventHandler("garages:playerConnected", function()
local playerSource = source
table.insert(Players, playerSource)
UpdatePlayerCache(playerSource)
TriggerClientEvent("garages:setGarages", playerSource, Config.Garages)
TriggerClientEvent("garages:syncFurnitureData", playerSource, Config.Furniture)
local identifier = GetPlayerIdentifier(playerSource)
if identifier then
InitInsideShellGarage(playerSource)
end
Debug("Player connected", playerSource)
end)
-- ──────────────────────────────────────────────
-- ox-inventory stash registration
-- ──────────────────────────────────────────────
RegisterNetEvent("garages:ox:registerStash")
AddEventHandler("garages:ox:registerStash", function(stashId, slots, maxWeight)
exports.ox_inventory:RegisterStash(stashId, "stash" .. stashId, slots, maxWeight, false)
end)
-- ──────────────────────────────────────────────
-- qb-inventory open stash
-- ──────────────────────────────────────────────
RegisterNetEvent("garages:openStash")
AddEventHandler("garages:openStash", function(stashId, stashData)
local playerSource = source
exports["qb-inventory"]:OpenInventory(playerSource, stashId, stashData)
end)
-- ──────────────────────────────────────────────
-- GetBalance callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getBalance", function(sourcePlayer)
return GetAccountMoney(sourcePlayer, Config.MoneyType)
end)
-- ──────────────────────────────────────────────
-- Sell garage
-- ──────────────────────────────────────────────
local function SellGarage(garageId)
local playerSource = source
local playerIdentifier = GetPlayerIdentifier(playerSource)
local garageData = Config.Garages[garageId]
if not garageData then
return Notification(playerSource, i18n.t("garage_not_found"), "error")
end
local sellPrice = garageData.price * Config.GarageSellTax
ClearGarage(garageId, playerIdentifier)
MySQL.Sync.execute(
"UPDATE player_garages SET owner = ? WHERE name = ? AND owner = ? LIMIT 1",
{ "", garageId, playerIdentifier }
)
AddAccountMoney(playerSource, Config.MoneyType, sellPrice)
Notification(playerSource, i18n.t("sell_garage", { price = sellPrice }), "error")
Config.Garages[garageId].owner = nil
TriggerClientEvent("garages:updateGaragePartial", -1, { name = garageId, owner = false })
end
RegisterNetEvent("advancedgarages:sellGarage")
AddEventHandler("advancedgarages:sellGarage", SellGarage)
-- ──────────────────────────────────────────────
-- Buy garage
-- ──────────────────────────────────────────────
local function BuyGarage(garageId, price)
local playerSource = source
local playerIdentifier = GetPlayerIdentifier(playerSource)
local balance = GetAccountMoney(playerSource, Config.MoneyType)
if price > balance then
return Notification(playerSource, i18n.t("no_money", { price = price }), "error")
end
RemoveAccountMoney(playerSource, Config.MoneyType, price)
local existingRow = MySQL.Sync.fetchAll("SELECT 1 FROM player_garages WHERE name = ?", { garageId })
if not existingRow[1] then
local garageData = Config.Garages[garageId]
if not garageData then
return Debug(garageId .. " not found in Config...")
end
MySQL.Sync.execute(
"INSERT INTO player_garages (owner, name, price, coords, shell, type) VALUES (?, ?, ?, ?, ?, ?)",
{ playerIdentifier, garageId, garageData.price, json.encode(garageData.coords), json.encode(garageData.shell), garageData.type }
)
else
MySQL.Sync.execute(
"UPDATE player_garages SET owner = ? WHERE name = ?",
{ playerIdentifier, garageId }
)
end
Config.Garages[garageId].owner = playerIdentifier
Notification(playerSource, i18n.t("garage_purchase", { price = price }), "error")
TriggerClientEvent("garages:updateGaragePartial", -1, { name = garageId, owner = playerIdentifier })
end
RegisterNetEvent("advancedgarages:buyGarage")
AddEventHandler("advancedgarages:buyGarage", BuyGarage)
-- ──────────────────────────────────────────────
-- Startup: fix vehicles with NULL garage
-- ──────────────────────────────────────────────
CreateThread(function()
Wait(1250)
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE garage IS NULL",
garageTable, storedColumn
)
local selectQuery = string.format(
"SELECT type, %s FROM %s WHERE garage IS NULL",
garagePropsColumn, garageTable, garageIdentifierColumn
)
local nullGarageVehicles = MySQL.Sync.fetchAll(selectQuery)
for _, row in pairs(nullGarageVehicles) do
local rawProps = row[garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
local impound = GetImpoundOfType(row.type, vehicleCoords)
if not impound then
Debug("Type:", row.type, "not found impound in [First Thread], please create that type impound")
return
end
MySQL.Sync.execute(updateQuery, { impound, State.impounded })
Debug("Some vehicles without predetermined garage were sent to the nearest Impound")
end
end)
-- ──────────────────────────────────────────────
-- Key holder callbacks and events
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getGarageKeyHolders", function(sourcePlayer, garageId)
local garageData = Config.Garages[garageId]
if not garageData then return false end
local holders = garageData.holders
if not holders then return false end
local result = {}
for _, identifier in pairs(holders) do
local firstName, lastName = GetUserData(identifier)
table.insert(result, { firstname = firstName, lastname = lastName, identifier = identifier })
end
return result
end)
-- Track which player is currently in a job garage
local jobGarageUsers = JobGarageUsers
lib.callback.register("advancedgarages:isGarageAvailable", function(sourcePlayer, garageId)
return jobGarageUsers[garageId] == nil
end)
RegisterNetEvent("advancedgarages:setInJobGarage")
AddEventHandler("advancedgarages:setInJobGarage", function(garageId, isEntering)
local playerSource = source
if not garageId then return end
jobGarageUsers[garageId] = (isEntering and playerSource) or nil
end)
-- Take key event
RegisterNetEvent("advancedgarages:takeKey")
AddEventHandler("advancedgarages:takeKey", function(data)
local playerSource = source
local targetId = data.id
local garageId = data.garage
local garageData = Config.Garages[garageId]
if not garageData then
return Notification(playerSource, i18n.t("garage_not_found"), "error")
end
local holders = garageData.holders
if not holders then
return Notification(playerSource, i18n.t("garage_no_keyholders"), "info")
end
for idx, identifier in pairs(holders) do
if identifier == targetId then
table.remove(garageData.holders, idx)
MySQL.update.await(
"UPDATE player_garages SET holders = ? WHERE name = ?",
{ json.encode(garageData.holders), garageId }
)
TriggerClientEvent("garages:updateGaragePartial", -1, { name = garageId, holders = garageData.holders })
Notification(playerSource, i18n.t("garage_taked_keys"), "success")
return
end
end
end)
-- Give key event
RegisterNetEvent("advancedgarages:giveKey")
AddEventHandler("advancedgarages:giveKey", function(data)
local playerSource = source
local targetId = data.id
local garageId = data.garage
local targetIdent = GetPlayerIdentifier(targetId)
local garageHolders = Config.Garages[garageId].holders
if not targetIdent then
return Notification(playerSource, i18n.t("management.no_player"), "error")
end
local targetFirstName, targetLastName = GetUserData(targetIdent)
if garageHolders then
for _, identifier in pairs(garageHolders) do
if identifier == targetIdent then
Notification(playerSource, i18n.t("keyholders.already_keys"), "info")
return
end
end
table.insert(garageHolders, targetIdent)
else
garageHolders = { targetIdent }
end
MySQL.update.await(
"UPDATE player_garages SET holders = ? WHERE name = ?",
{ json.encode(garageHolders), garageId }
)
TriggerClientEvent("garages:updateGaragePartial", -1, { name = garageId, holders = garageHolders })
Notification(playerSource, i18n.t("keyholders.gave_keys", { garage = garageId, player = targetFirstName .. " " .. targetLastName }), "success")
Notification(targetId, i18n.t("keyholders.received_keys", { garage = garageId }), "success")
end)
-- ──────────────────────────────────────────────
-- Shell vehicle list callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:GetShellVehicleList", function(sourcePlayer, garageId)
local query
local args
local identifier = GetPlayerIdentifier(sourcePlayer)
if Config.GlobalGarageSync then
query = "SELECT * FROM " .. garageTable .. " WHERE " .. garageIdentifierColumn .. " = ?"
args = { identifier }
else
query = string.format("SELECT * FROM %s WHERE garage = ?", garageTable)
if Config.GarageSync then
args = { garageId }
else
query = query .. string.format(" AND %s = ?", garageIdentifierColumn)
args = { garageId, identifier }
end
end
local rows = MySQL.Sync.fetchAll(query, args)
if not rows[1] then return false end
local vehicles = {}
for _, row in pairs(rows) do
if Config.Framework == "qb" then
row.vehicle = row.mods
row.owner = row.citizenid
end
local props = json.decode(row.vehicle)
if props and props.model then
vehicles[#vehicles + 1] = row
else
Debug("Vehicle with plate:", row.plate, "has not `vehicle` data. We are skipping it. But this problem is not related to the script.")
end
end
return vehicles
end)
-- ──────────────────────────────────────────────
-- GetPlayerSqlName callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:GetPlayerSqlName", function(sourcePlayer, targetId)
local identifier = GetPlayerIdentifier(targetId)
local firstName, lastName = GetUserData(identifier)
return { firstName = firstName, lastName = lastName }
end)
-- ──────────────────────────────────────────────
-- getGarages callback (player garages list)
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getGarages", function(sourcePlayer)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format([[
SELECT player_garages.*, COUNT(%s.id) as vehicleCount
FROM player_garages
LEFT JOIN %s ON player_garages.name = %s.garage
WHERE player_garages.owner = ? GROUP BY player_garages.name
]], garageTable, garageTable, garageTable)
local garages = MySQL.Sync.fetchAll(query, { identifier })
for _, garage in pairs(garages) do
garage.coords = json.decode(garage.coords).menuCoords
if not garage.interior_type then
garage.interior_type = "ipl"
end
end
return garages
end)
-- ──────────────────────────────────────────────
-- GetVehicleFromPlate callback (DB lookup)
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:GetVehicleFromPlate", function(sourcePlayer, plate)
local identifier = GetPlayerIdentifier(sourcePlayer)
if not plate then
Error("advancedgarages:GetVehicleFromPlate ::: Plate not found", plate)
return false
end
local query = string.format(
"SELECT * FROM %s WHERE plate = ? AND %s = ? LIMIT 1",
garageTable, garageIdentifierColumn
)
local result = MySQL.Sync.fetchAll(query, { plate, identifier })
if Config.Framework == "qb" and result[1] then
result[1].vehicle = result[1].mods
result[1].owner = result[1].citizenid
end
return result[1]
end)
-- ──────────────────────────────────────────────
-- getGarageSlots callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getGarageSlots", function(sourcePlayer, garageId)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format(
"SELECT COUNT(*) as count FROM %s WHERE garage = ? AND %s = ?",
garageTable, garageIdentifierColumn
)
local result = MySQL.Sync.fetchAll(query, { garageId, identifier })
return result[1].count
end)
-- ──────────────────────────────────────────────
-- Money callbacks
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:existMoney", function(sourcePlayer, amount)
local balance = GetAccountMoney(sourcePlayer, Config.MoneyType)
return balance >= tonumber(amount)
end)
lib.callback.register("advancedgarages:removeMoney", function(sourcePlayer, amount)
return RemoveAccountMoney(sourcePlayer, Config.MoneyType, tonumber(amount))
end)
-- ──────────────────────────────────────────────
-- Startup: send "garage 1" (invalid) vehicles to impound
-- ──────────────────────────────────────────────
CreateThread(function()
Wait(1250)
local selectQuery = string.format("SELECT * FROM %s WHERE garage = '1'", garageTable)
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE plate = ?",
garageTable, storedColumn
)
local badVehicles = MySQL.Sync.fetchAll(selectQuery)
for _, row in pairs(badVehicles) do
if row.type == "car" or row.type == "automobile" then
row.type = "vehicle"
end
local rawProps = row[garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
local impound = GetImpoundOfType(row.type, vehicleCoords)
if not impound then
Debug("No impound for type:", row.type, "in [sentToImpound] debug")
else
MySQL.Sync.execute(updateQuery, { impound, State.impounded, row.plate })
Debug("Vehicle with plate:", row.plate, 'moved to impound because garage "1" does not exist')
end
end
end)
RegisterNetEvent("advancedgarages:server:sentToImpound")
AddEventHandler("advancedgarages:server:sentToImpound", function()
local selectQuery = string.format("SELECT * FROM %s WHERE garage = '1'", garageTable)
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE plate = ?",
garageTable, storedColumn
)
local badVehicles = MySQL.Sync.fetchAll(selectQuery)
for _, row in pairs(badVehicles) do
if row.type == "car" or row.type == "automobile" then
row.type = "vehicle"
end
local rawProps = row[garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
local impound = GetImpoundOfType(row.type, vehicleCoords)
if not impound then
Warning("No impound for type:", row.type, "in [sentToImpound] debug")
else
MySQL.Sync.execute(updateQuery, { impound, State.impounded, row.plate })
Debug("Vehicle with plate:", row.plate, 'moved to impound because garage "1" does not exist')
end
end
end)
-- ──────────────────────────────────────────────
-- getImpoundGarages export
-- ──────────────────────────────────────────────
local function GetImpoundGarages()
local result = {}
for garageName, garageData in pairs(Config.Garages) do
if garageData.isImpound then
result[garageName] = garageData
end
end
return result
end
exports("getImpoundGarages", GetImpoundGarages)
-- ──────────────────────────────────────────────
-- getGarageData callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getGarageData", function(sourcePlayer, garageId)
local identifier = GetPlayerIdentifier(sourcePlayer)
print("[GARAGES] Debug: Lecture du garage '" .. tostring(garageId) .. "' pour l'identifier: " .. tostring(identifier))
local vehicles
if Config.GlobalGarageSync then
local query = "SELECT * FROM " .. garageTable .. " WHERE " .. garageIdentifierColumn .. " = ?"
vehicles = MySQL.Sync.fetchAll(query, { identifier })
else
vehicles = MySQL.Sync.fetchAll(Query.SELECT_PLAYER_VEHICLES, { garageId, identifier })
end
print("[GARAGES] Debug: " .. #vehicles .. " vehicules trouves !")
local result = {}
for _, row in pairs(vehicles) do
if Config.Framework == "qb" then
row.vehicle = row.mods
row.owner = row.citizenid
end
if row.impound_data and row.impound_data ~= "" then
row.impound_data = json.decode(row.impound_data)
end
local props = json.decode(row.vehicle)
if props and props.model then
-- [AUTO-FIX] On force l'interface à savoir qu'on est le proprio
row.favorite = row.favorite or 0
row.tag = row.tag or ""
row.isOwner = true -- Force le dégrisage des boutons
row.owner = identifier -- Assure la correspondance d'ID
result[#result + 1] = row
else
Debug("Vehicle with plate:", row.plate, "has no `vehicle` data. Skipping.")
end
end
return result
end)
-- ──────────────────────────────────────────────
-- isVehicleOwned callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:isVehicleOwned", function(sourcePlayer, plate)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format(
"SELECT 1 FROM %s WHERE plate = ? AND %s = ? AND jobVehicle = ? LIMIT 1",
garageTable, garageIdentifierColumn
)
local result = MySQL.Sync.fetchAll(query, { plate, identifier, "" })
return result[1] ~= nil
end)
-- ──────────────────────────────────────────────
-- transferVehicle callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:transferVehicle", function(sourcePlayer, targetGarage, plate)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE plate = ? AND %s = ?",
garageTable, storedColumn, garageIdentifierColumn
)
local affected = MySQL.Sync.execute(query, { targetGarage, State.stored, plate, identifier })
RemoveAccountMoney(sourcePlayer, Config.MoneyType, Config.TransferGaragePrice)
return 1 == affected
end)
-- ──────────────────────────────────────────────
-- takeVehicleFromOut callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:takeVehicleFromOut", function(sourcePlayer, targetGarage, plate)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE `plate` = ? AND `%s` = ?",
garageTable, storedColumn, garageIdentifierColumn
)
local affected = MySQL.Sync.execute(query, { targetGarage, State.stored, plate, identifier })
RemoveAccountMoney(sourcePlayer, Config.MoneyType, Config.ImpoundPrice)
local vehicleKey = GetVehicleFromPlate(plate)
if vehicleKey then
local entity = PersistentVehicles[vehicleKey] and PersistentVehicles[vehicleKey].entity
if entity and DoesEntityExist(entity) then
DeleteEntity(entity)
end
PersistentVehicles[vehicleKey] = nil
end
return 1 == affected
end)
-- ──────────────────────────────────────────────
-- getOutVehicles callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getOutVehicles", function(sourcePlayer, garageId)
local identifier = GetPlayerIdentifier(sourcePlayer)
local garageData = Config.Garages[garageId]
if not garageData then
Error("advancedgarages:getOutVehicles ::: Garage not found:", garageId)
return {}
end
local typeFilter = ""
if garageData.type == "vehicle" then
typeFilter = 'AND type IN("vehicles", "car", "pdm", "vehicle", "automobile")'
elseif garageData.type == "plane" then
typeFilter = 'AND type IN("airplane", "air", "helicopter")'
elseif garageData.type == "boat" then
typeFilter = 'AND type IN("water")'
end
local query = string.format(
"SELECT * FROM %s WHERE garage != ? AND %s = ? AND impound_data = '' AND jobVehicle = '' %s",
garageTable, garageIdentifierColumn, typeFilter
)
local vehicles = MySQL.Sync.fetchAll(query, { garageId, identifier })
for _, row in pairs(vehicles) do
if Config.Framework == "qb" then
row.vehicle = row.mods
row.owner = row.citizenid
end
end
Debug("advancedgarages:getOutVehicles", #vehicles)
return vehicles
end)
-- ──────────────────────────────────────────────
-- transferVehicleToPlayer callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:transferVehicleToPlayer", function(sourcePlayer, targetId, plate)
targetId = tonumber(targetId)
local targetIdent = GetPlayerIdentifier(targetId)
if not targetIdent then
Error("advancedgarages:transferVehicleToPlayer ::: Target identifier not found for playerId:", targetId)
Notification(sourcePlayer, i18n.t("player_not_found"), "error")
return
end
local propsQuery = string.format(
"SELECT type, %s FROM %s WHERE plate = ? LIMIT 1",
garagePropsColumn, garageTable
)
local propsResult = MySQL.Sync.fetchAll(propsQuery, { plate })
if not propsResult[1] then return false end
-- Normalize vehicle type
local vehicleType = propsResult[1].type
local typeMap = { car = "vehicle", automobile = "vehicle", pdm = "vehicle", airplane = "plane", air = "plane", helicopter = "plane", heli = "plane", water = "boat" }
vehicleType = typeMap[vehicleType] or vehicleType
propsResult[1].type = vehicleType
local rawProps = propsResult[1][garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
-- Check transfer blacklist
if Config.TransferBlacklist and #Config.TransferBlacklist > 0 then
local model = props and props.model
for _, blacklistedModel in pairs(Config.TransferBlacklist) do
if joaat(blacklistedModel) == model then
return Notification(sourcePlayer, i18n.t("transfer_blacklisted"), "error")
end
end
end
local impound = GetImpoundOfType(vehicleType, vehicleCoords)
if not impound then
Debug("Type:", vehicleType, "not found impound in [transferVehicleToPlayer], please create that type impound")
return false
end
local sourceIdent = GetPlayerIdentifier(sourcePlayer)
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ?, `%s` = ? WHERE plate = ? AND %s = ?",
garageTable, storedColumn, garageIdentifierColumn, garageIdentifierColumn
)
local affected = MySQL.Sync.execute(updateQuery, { impound, State.impounded, targetIdent, plate, sourceIdent })
RemoveAccountMoney(sourcePlayer, Config.MoneyType, Config.TransferGaragePrice)
Notification(sourcePlayer, i18n.t("transfer_success_to", { garage = impound }), "info")
Notification(targetId, i18n.t("transfer_success", { garage = impound }), "info")
return 1 == affected
end)
-- ──────────────────────────────────────────────
-- RoutePlayer / RoutePlayerDefault events
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:RoutePlayer")
AddEventHandler("advancedgarages:RoutePlayer", function(data)
local playerSource = source
local currentBucket = GetPlayerRoutingBucket(playerSource)
PlayerDefaultRoutings[playerSource] = currentBucket
SetPlayerRoutingBucket(playerSource, math.random(1, 100000))
end)
RegisterNetEvent("advancedgarages:RoutePlayerDefault")
AddEventHandler("advancedgarages:RoutePlayerDefault", function(garageId)
local playerSource = source
SetPlayerRoutingBucket(playerSource, 0)
PlayerDefaultRoutings[playerSource] = nil
local identifier = GetPlayerIdentifier(playerSource)
LeavelShellGarage(garageId)
MySQL.Sync.execute(
string.format("UPDATE %s SET shell_garage = NULL WHERE %s = ?", userTable, identifierColumn),
{ identifier }
)
end)
-- ──────────────────────────────────────────────
-- GetUserData get player firstname/lastname
-- ──────────────────────────────────────────────
function GetUserData(identifier)
if Config.Framework == "qb" then
local player = GetPlayerFromIdentifier(identifier)
if not player then return false end
local charInfo = player.PlayerData.charinfo
return charInfo.firstname, charInfo.lastname
end
local query = string.format("SELECT firstname, lastname FROM %s WHERE %s = ?", userTable, identifierColumn)
local result = MySQL.Sync.fetchAll(query, { identifier })
if not result[1] then return false end
local firstName = result[1].firstname or "Player"
local lastName = result[1].lastname or "Player"
return firstName, lastName
end
-- ──────────────────────────────────────────────
-- savePlayer
-- ──────────────────────────────────────────────
function UpdatePlayerCache(playerSource)
local identifier = GetPlayerIdentifier(playerSource)
if not identifier then
return Wait(150)
end
local firstName, lastName = GetUserData(identifier)
CachedPlayerNames[playerSource] = firstName .. " " .. lastName
end
-- ──────────────────────────────────────────────
-- Initial player name cache thread
-- ──────────────────────────────────────────────
CreateThread(function()
for _, playerSource in ipairs(Players) do
UpdatePlayerCache(playerSource)
end
end)
-- ──────────────────────────────────────────────
-- playerDropped cleanup
-- ──────────────────────────────────────────────
AddEventHandler("playerDropped", function(reason)
local playerSource = source
-- Remove from job garage users
for garageId, user in pairs(JobGarageUsers or {}) do
if user == playerSource then
JobGarageUsers[garageId] = nil
end
end
-- Remove from Players list
for idx, player in pairs(Players) do
if player == playerSource then
table.remove(Players, idx)
end
end
-- Remove from name cache
if CachedPlayerNames and CachedPlayerNames[playerSource] then
CachedPlayerNames[playerSource] = nil
end
end)
-- ──────────────────────────────────────────────
-- GetPlayersName callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:GetPlayersName", function(sourcePlayer)
return CachedPlayerNames
end)
-- ──────────────────────────────────────────────
-- getJobVehicles callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getJobVehicles", function(sourcePlayer, garageId, jobName)
if not garageId then return false end
local query = string.format("SELECT * FROM %s WHERE garage = ? AND jobVehicle = ?", garageTable)
local results = MySQL.Sync.fetchAll(query, { garageId, jobName })
for _, row in pairs(results) do
if Config.Framework == "qb" then
row.vehicle = row.mods
row.owner = row.citizenid
end
row.vehicle = json.decode(row.vehicle)
end
return results
end)
-- ──────────────────────────────────────────────
-- addJobVehicle event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:addJobVehicle")
AddEventHandler("advancedgarages:addJobVehicle", function(jobName, plate, garageData)
local playerSource = source
if not jobName or not plate or not garageData then return end
if GetJobName(playerSource) ~= jobName then return end
local identifier = GetPlayerIdentifier(playerSource)
local selectQuery = string.format(
"SELECT * FROM %s WHERE plate = ? AND %s = ? LIMIT 1",
garageTable, garageIdentifierColumn
)
local existing = MySQL.Sync.fetchAll(selectQuery, { plate, identifier })
if not existing[1] then return end
local updateQuery = string.format(
"UPDATE %s SET jobVehicle = ?, garage = ? WHERE plate = ? AND %s = ?",
garageTable, garageIdentifierColumn
)
MySQL.Sync.execute(updateQuery, { jobName, garageData.name, plate, identifier })
local vehicleKey = GetVehicleFromPlate(plate)
if vehicleKey and PersistentVehicles[vehicleKey] then
local entity = PersistentVehicles[vehicleKey].entity
if DoesEntityExist(entity) then DeleteEntity(entity) end
PersistentVehicles[vehicleKey] = nil
end
end)
-- ──────────────────────────────────────────────
-- deleteJobVehicle event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:deleteJobVehicle")
AddEventHandler("advancedgarages:deleteJobVehicle", function(data)
local playerSource = source
local plate = data.plate
local garageId = data.garage
Debug("Work vehicle removed, info:", json.encode(plate), json.encode(garageId))
if not plate or not garageId then return end
local identifier = GetPlayerIdentifier(playerSource)
local selectQuery = string.format(
"SELECT * FROM %s WHERE plate = ? AND %s = ? LIMIT 1",
garageTable, garageIdentifierColumn
)
local existing = MySQL.Sync.fetchAll(selectQuery, { plate, identifier })
if not existing[1] then return end
local rawProps = existing[1][garagePropsColumn]
local props = json.decode(rawProps)
local vehicleCoords = (props and props.coords) or nil
local impound = GetImpoundOfType(existing[1].type, vehicleCoords)
if not impound then
Debug("Type:", existing[1].type, "not found impound in [deleteJobVehicle], please create that type impound")
return false
end
local updateQuery = string.format(
"UPDATE %s SET jobVehicle = ?, garage = ? WHERE plate = ? AND %s = ?",
garageTable, garageIdentifierColumn
)
MySQL.Sync.execute(updateQuery, { "", impound, plate, identifier })
Notification(playerSource, i18n.t("vehicle_impounded", { garage = impound }), "info")
end)
-- ──────────────────────────────────────────────
-- storeJobVehicle event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:storeJobVehicle")
AddEventHandler("advancedgarages:storeJobVehicle", function(garageId, plate, vehicleProps)
if not garageId or not plate then return end
local query = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ?, `%s` = ?, `jobGarage` = '' WHERE plate = ?",
garageTable, storedColumn, garagePropsColumn
)
MySQL.Sync.execute(query, { garageId, State.stored, json.encode(vehicleProps), plate })
end)
-- ──────────────────────────────────────────────
-- isVehicleOwnedByJob callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:isVehicleOwnedByJob", function(sourcePlayer, plate, jobName)
local query = string.format(
"SELECT 1 FROM %s WHERE plate = ? AND jobVehicle = ? AND garage = 'OUT'",
garageTable
)
local result = MySQL.Sync.fetchAll(query, { plate, jobName })
return result[1] ~= nil
end)
-- ──────────────────────────────────────────────
-- Startup: store all OUT job vehicles
-- ──────────────────────────────────────────────
CreateThread(function()
local query = string.format(
"UPDATE %s SET `garage` = %s.jobGarage, `%s` = ? WHERE garage = 'OUT' AND jobVehicle != ''",
garageTable, garageTable, storedColumn
)
MySQL.Sync.execute(query, { State.stored })
end)
-- ──────────────────────────────────────────────
-- takeoutJobVehicle event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:takeoutJobVehicle")
AddEventHandler("advancedgarages:takeoutJobVehicle", function(plate)
if not plate then return end
local selectQuery = string.format("SELECT * FROM %s WHERE plate = ? LIMIT 1", garageTable)
local existing = MySQL.Sync.fetchAll(selectQuery, { plate })
if not existing[1] then return end
local originalGarage = existing[1].garage
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ?, `jobGarage` = ? WHERE plate = ?",
garageTable, storedColumn
)
MySQL.Sync.execute(updateQuery, { "OUT", State.out, originalGarage, plate })
end)
-- ──────────────────────────────────────────────
-- saveVehicle callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:saveVehicle", function(sourcePlayer, garageId, vehicleProps, deformations)
if not deformations then deformations = {} end
local plate = MathTrim(vehicleProps.plate)
vehicleProps.deformations = deformations
local query = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ?, `%s` = ? WHERE plate = ?",
garageTable, storedColumn, garagePropsColumn
)
MySQL.Sync.execute(query, { garageId, State.stored, json.encode(vehicleProps), plate })
local vehicleKey = GetVehicleFromPlate(plate)
if not vehicleKey then return false end
local qsInventoryState = GetResourceState("qs-inventory")
if qsInventoryState and qsInventoryState:find("started") then
exports["qs-inventory"]:UpdateQuestProgress(sourcePlayer, "deposit_vehicle_garage", 100)
end
SendWebhook(
Webhooks.save_vehicle,
i18n.t("log.save_vehicle.title"),
7393279,
i18n.t("log.save_vehicle.description", {
player = GetPlayerName(sourcePlayer),
model = vehicleProps.model,
plate = plate,
garage = garageId,
})
)
PersistentVehicles[vehicleKey] = nil
return true
end)
-- ──────────────────────────────────────────────
-- checkTimeout callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:checkTimeout", function(sourcePlayer)
return false -- Pas de timeout par défaut pour débloquer le spawn
end)
-- ──────────────────────────────────────────────
-- DriveVehicle callback (spawn from garage)
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:DriveVehicle", function(sourcePlayer, vehicleProps, spawnCoords, impoundFee)
print("[GARAGES] Tentative de sortie: " .. tostring(vehicleProps.plate) .. " pour le joueur " .. tostring(sourcePlayer))
if SpawnTimeout then
local elapsed = os.time() - SpawnTimeout
if elapsed < 5 then
Debug("SpawnTimeout:", elapsed, "< 5")
return false
end
end
SpawnTimeout = os.time()
-- Mark vehicle as OUT in DB
local updateQuery = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ?, `impound_data` = ?, `%s` = ? WHERE plate = ?",
garageTable, storedColumn, garagePropsColumn
)
vehicleProps.coords = spawnCoords
local rowsChanged = MySQL.Sync.execute(updateQuery, { "OUT", State.out, "", json.encode(vehicleProps), vehicleProps.plate })
print("[GARAGES] Mise a jour SQL 'OUT' effectuee. Lignes affectees: " .. tostring(rowsChanged))
-- Deduct impound fee if any
if impoundFee then
local fee = tonumber(impoundFee)
if fee then
RemoveAccountMoney(sourcePlayer, Config.MoneyType, fee)
end
end
-- Fetch fresh DB row
local selectQuery = string.format("SELECT * FROM %s WHERE plate = ?", garageTable)
local dbResult = MySQL.Sync.fetchAll(selectQuery, { vehicleProps.plate })
if not dbResult[1] then
print("[GARAGES] ERREUR FATALE: Vehicule introuvable en base apres UPDATE ! Plaque: " .. tostring(vehicleProps.plate))
SpawnTimeout = nil
Error("Vehicle not found in [DriveVehicle]. Plate:", vehicleProps.plate)
return false
end
local row = dbResult[1]
if Config.Framework == "qb" then
row.vehicle = row.mods
end
local ownerIdent = row[garageIdentifierColumn]
if not row then
SpawnTimeout = nil
Error("2: Vehicle not found in [DriveVehicle]. Plate:", vehicleProps.plate)
return false
end
local props = row.vehicle and json.decode(row.vehicle) or {}
if not props then
SpawnTimeout = nil
Error("There was a problem loading the props for said vehicle...")
return false
end
local spawnedEntry = SpawnVehicle(row.id, ownerIdent, row.type, spawnCoords, props, sourcePlayer, true)
if not spawnedEntry then
print("[GARAGES] ERREUR : La voiture n'a pas pu etre creee physiquement (SpawnVehicle = nil). Type=" .. tostring(row.type))
SpawnTimeout = nil
return false
end
print("[GARAGES] VICTOIRE ! La voiture est dehors !")
SendWebhook(
Webhooks.take_vehicle,
i18n.t("log.take_vehicle.title"),
7393279,
i18n.t("log.take_vehicle.description", {
player = GetPlayerName(sourcePlayer),
model = props.model,
plate = props.plate,
})
)
SpawnTimeout = nil
return spawnedEntry
end)
-- ──────────────────────────────────────────────
-- SetVehicleTag event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:SetVehicleTag")
AddEventHandler("advancedgarages:SetVehicleTag", function(tag, plate)
local playerSource = source
local identifier = GetPlayerIdentifier(playerSource)
if not identifier or not tag or not plate then return end
local query = string.format("UPDATE %s SET tag = ? WHERE plate = ? AND %s = ?", garageTable, garageIdentifierColumn)
MySQL.Async.execute(query, { tostring(tag), plate, identifier }, function(rowsAffected)
if rowsAffected > 0 then
Debug("Tag updated successfully for plate", plate)
end
end)
end)
RegisterNetEvent("advancedgarages:setVehicleFav")
AddEventHandler("advancedgarages:setVehicleFav", function(isFavorite, plate)
local playerSource = source
local identifier = GetPlayerIdentifier(playerSource)
if not identifier or not plate then return end
-- Conversion booléen en int pour mysql
local favValue = isFavorite and 1 or 0
local query = string.format("UPDATE %s SET favorite = ? WHERE plate = ? AND %s = ?", garageTable, garageIdentifierColumn)
MySQL.Async.execute(query, { favValue, plate, identifier }, function(rowsAffected)
if rowsAffected > 0 then
Debug("Favorite status updated successfully for plate", plate)
end
end)
end)
-- ──────────────────────────────────────────────
-- garagemenu command
-- ──────────────────────────────────────────────
RegisterCommand("garagemenu", function(sourcePlayer, args)
TriggerClientEvent("advancedgarages:OpenGarageMenu", sourcePlayer)
end, false)
-- ──────────────────────────────────────────────
-- getRecoveryVehicles callback
-- ──────────────────────────────────────────────
lib.callback.register("advancedgarages:getRecoveryVehicles", function(sourcePlayer)
local identifier = GetPlayerIdentifier(sourcePlayer)
local query = string.format(
"SELECT * FROM %s WHERE garage = 'OUT' AND %s = ?",
garageTable, garageIdentifierColumn
)
return MySQL.Sync.fetchAll(query, { identifier })
end)
-- ──────────────────────────────────────────────
-- RecoveryVehicle event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:RecoveryVehicle")
AddEventHandler("advancedgarages:RecoveryVehicle", function(vehicleData)
local playerSource = source
local plate = vehicleData.plate
local vehicleType = vehicleData.type
local typeMap = { car = "vehicle", automobile = "vehicle", pdm = "vehicle", airplane = "plane", air = "plane", helicopter = "plane", heli = "plane", water = "boat" }
vehicleType = typeMap[vehicleType] or vehicleType
local balance = GetAccountMoney(playerSource, Config.MoneyType)
local recoveryPrice = Config.Recovery.price
if balance < recoveryPrice then
return Notification(playerSource, i18n.t("no_money", { price = recoveryPrice }), "error")
end
RemoveAccountMoney(playerSource, Config.MoneyType, recoveryPrice)
local identifier = GetPlayerIdentifier(playerSource)
local impound = GetImpoundOfType(vehicleType)
local query = string.format(
"UPDATE %s SET `garage` = ?, `%s` = ? WHERE plate = ? AND %s = ?",
garageTable, storedColumn, garageIdentifierColumn
)
MySQL.Sync.execute(query, { impound, State.impounded, plate, identifier })
SendWebhook(
Webhooks.recovery_vehicle,
i18n.t("log.recovery_vehicle.title"),
7393279,
i18n.t("log.recovery_vehicle.description", {
player = GetPlayerName(playerSource),
model = plate,
garage = impound,
price = recoveryPrice,
})
)
-- Remove from persistent vehicles if spawned
for key, persistentData in pairs(PersistentVehicles) do
if persistentData.vehicle.plate == plate then
if DoesEntityExist(persistentData.entity) then
DeleteEntity(persistentData.entity)
end
PersistentVehicles[key] = nil
Debug("Vehicle with plate:", plate, ", was removed from spawnedVehicles list!")
break
end
end
Notification(playerSource, i18n.t("give_recovery", { garage = impound, price = recoveryPrice }), "success")
end)
-- ──────────────────────────────────────────────
-- payVehiclePrice event
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:server:payVehiclePrice")
AddEventHandler("advancedgarages:server:payVehiclePrice", function(data)
local playerSource = source
local plate = data.vehicle.plate
local balance = GetAccountMoney(playerSource, Config.MoneyType)
local impoundDataRaw = MySQL.prepare.await(
string.format("SELECT impound_data FROM %s WHERE plate = ?", garageTable),
{ plate }
)
if not impoundDataRaw or impoundDataRaw == "" then
Error("Vehicle not in impound in [server:payVehiclePrice]. Plate:", plate)
return
end
Debug("impound_data", impoundDataRaw)
local impoundData = json.decode(impoundDataRaw)
local price = impoundData.price
if balance < price then
return Notification(playerSource, i18n.t("no_money", { price = price }), "info")
end
if not impoundData.getByPay then
local timeLeft = os.difftime(impoundData.time, os.time())
if timeLeft > 0 then
Error("getByPay is false and time is not expired but user trying to pay price. Probably trying to exploit the system. Plate:", plate, "source:", playerSource)
return
end
end
MySQL.Sync.execute(
string.format("UPDATE %s SET impound_data = '' WHERE plate = ?", garageTable),
{ plate }
)
RemoveAccountMoney(playerSource, Config.MoneyType, price)
Notification(playerSource, i18n.t("impound_pay", { price = price }), "info")
TriggerClientEvent("advancedgarages:client:payVehiclePrice", playerSource, plate)
end)
-- ──────────────────────────────────────────────
-- updateVehicleState event (QB-Core)
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:server:updateVehicleState")
AddEventHandler("advancedgarages:server:updateVehicleState", function(newState, plate)
MySQL.update(
"UPDATE player_vehicles SET state = ?, depotprice = ? WHERE plate = ?",
{ newState, 0, plate }
)
end)
-- ──────────────────────────────────────────────
-- AddGarage (from external resources)
-- ──────────────────────────────────────────────
RegisterNetEvent("advancedgarages:AddGarage")
AddEventHandler("advancedgarages:AddGarage", function(garageName, garageData)
if Config.Garages[garageName] then
local invoker = GetInvokingResource()
return Error("tried to create a garage with the name:", garageName, "but it already exists. Invoking resource:", invoker)
end
Config.Garages[garageName] = garageData
TriggerClientEvent("garages:updateGarage", -1, garageData)
end)
-- ──────────────────────────────────────────────
-- Resource name verification
-- ──────────────────────────────────────────────
CreateThread(function()
local resourceName = GetCurrentResourceName()
if resourceName == "qs-advancedgarages" then
verify = true
end
if verify ~= true then
repeat
Wait(3000)
Error("The console will close because ^4qs-advancedgarages ^0changed its name!")
Wait(5000)
os.exit()
until verify == true
end
end)