From 494e82732f3756d2c5221fdef9e64079ca3095ac Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Sat, 8 Aug 2026 18:53:39 +0200 Subject: [PATCH] ADD - implement garage server integration --- sky_phone/source/server/garage.lua | 438 +++++++++++++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 sky_phone/source/server/garage.lua diff --git a/sky_phone/source/server/garage.lua b/sky_phone/source/server/garage.lua new file mode 100644 index 0000000..93a7e3c --- /dev/null +++ b/sky_phone/source/server/garage.lua @@ -0,0 +1,438 @@ +Bridge.Database.AfterMigration("sky_phone", function() + +local supported_systems = { + auto = true, + custom = true, + esx = true, + qb = true, + qbox = true, + ak47 = true, + bp = true, + cd = true, + codem = true, + ["ds-servercreator"] = true, + hex = true, + jg = true, + my = true, + okok = true, + op = true, + quasar = true, + rx = true, + vms = true, + ws = true, + zyke_garages = true, +} + +local function decode_object(value) + if type(value) == "table" then + return value + end + if type(value) ~= "string" or value == "" then + return {} + end + local success, decoded = pcall(json.decode, value) + if not success or type(decoded) ~= "table" then + return {} + end + return decoded +end + +local function first_value(...) + local values = { ... } + for index = 1, #values do + local value = values[index] + if value ~= nil and value ~= "" then + return value + end + end + return nil +end + +local function truthy_database_value(value) + return value == true or value == 1 or value == "1" or value == "true" +end + +local function normalized_health(value, maximum) + local number = tonumber(value) + if not number then + return nil + end + if maximum == 1000 then + number = number / 10 + end + return math.max(0, math.min(100, math.floor(number + 0.5))) +end + +local function vehicle_status(row, location, garage_system) + local state = tonumber(row.state) + local location_key = string.lower(tostring(location or "")) + if truthy_database_value(row.impound) + or truthy_database_value(row.impounded) + or truthy_database_value(row.pound) + or location_key:find("impound", 1, true) + or location_key:find("pound", 1, true) + then + return "impounded" + end + if garage_system == "jg" then + return truthy_database_value(row.in_garage) and "garaged" or "out" + end + if state == 2 then + return "impounded" + end + local stored = first_value(row.stored, row.in_garage, row.parked) + if truthy_database_value(stored) or state == 1 then + return "garaged" + end + return "out" +end + +local function vehicle_kind(value) + local kind = string.lower(tostring(value or "")) + if kind == "boat" then + return "boat" + end + if kind == "plane" or kind == "air" or kind == "airplane" then + return "plane" + end + if kind == "heli" or kind == "helicopter" then + return "helicopter" + end + if kind == "bike" or kind == "bicycle" or kind == "motorcycle" then + return "bike" + end + return "car" +end + +local function vehicle_properties(row) + local properties = decode_object(first_value(row.mods, row.vehicle_data, row.properties)) + local vehicle_object = decode_object(row.vehicle) + if next(vehicle_object) ~= nil then + for key, value in pairs(vehicle_object) do + if properties[key] == nil then + properties[key] = value + end + end + end + return properties +end + +local function vehicle_dto(row, garage_system) + local mods = vehicle_properties(row) + local vehicle_value = row.vehicle + + local model = first_value(row.model, row.hash, mods.model, mods.hash) + if type(vehicle_value) == "string" and vehicle_value:sub(1, 1) ~= "{" then + model = first_value(model, vehicle_value) + elseif type(vehicle_value) == "number" then + model = first_value(model, vehicle_value) + end + local numeric_model = tonumber(model) + if numeric_model then + model = numeric_model + end + + local location = first_value(row.garage_id, row.parking, row.garage, row.parked_at) + local plate = tostring(first_value(row.plate, mods.plate) or ""):match("^%s*(.-)%s*$") + return { + id = tostring(first_value(row.id, row.vin, plate)), + plate = plate, + vin = tostring(row.vin or ""), + nickname = tostring(row.nickname or ""), + model = model, + kind = vehicle_kind(first_value(row.garage_type, row.type, mods.type)), + status = vehicle_status(row, location, garage_system), + location = tostring(location or ""), + fuel = normalized_health(first_value(row.fuel, mods.fuelLevel, mods.fuel), 100), + engine = normalized_health(first_value(row.engine, mods.engineHealth, mods.engine), 1000), + body = normalized_health(first_value(row.body, mods.bodyHealth, mods.body), 1000), + } +end + +local function storage_config() + local system = tostring(Config.Garage.System or "auto") + if not supported_systems[system] then + error(("[sky_phone] Unsupported garage system '%s'."):format(system)) + end + if system == "custom" then + local table_name = tostring(Config.Garage.Custom.Table or "") + local owner_column = tostring(Config.Garage.Custom.OwnerColumn or "") + if not table_name:match("^[%w_]+$") or not owner_column:match("^[%w_]+$") then + error("[sky_phone] Custom garage table and owner column must be configured with SQL identifiers.") + end + return table_name, owner_column, system + end + if system == "esx" then + return "owned_vehicles", "owner", system + end + if system == "qb" or system == "qbox" then + return "player_vehicles", "citizenid", system + end + if system == "auto" and GetResourceState("jg-advancedgarages") == "started" then + system = "jg" + end + if Bridge.Framework.GetName() == "esx" then + return "owned_vehicles", "owner", system == "auto" and "esx" or system + end + return "player_vehicles", "citizenid", system == "auto" and Bridge.Framework.GetName() or system +end + +local active_valets = {} +local valet_cooldowns = {} + +local function normalized_plate(value) + if type(value) ~= "string" then + return nil + end + local plate = value:match("^%s*(.-)%s*$") + if plate == "" or #plate > 16 or plate:find("[%c]") then + return nil + end + return plate +end + +local function owned_vehicle_row(identifier, plate) + local table_name, owner_column, garage_system = storage_config() + local rows = Bridge.Database.Query( + ("SELECT * FROM %s WHERE %s = ? AND TRIM(plate) = ? LIMIT 1"):format(table_name, owner_column), + { identifier, plate } + ) + return rows[1], table_name, owner_column, garage_system +end + +local function status_snapshot(row) + local snapshot = {} + for _, column in ipairs({ "stored", "state", "in_garage", "parked" }) do + if row[column] ~= nil then + snapshot[column] = row[column] + end + end + return snapshot +end + +local function write_vehicle_status(order, restore) + local assignments = {} + local parameters = {} + for column, original_value in pairs(order.status) do + assignments[#assignments + 1] = ("%s = ?"):format(column) + parameters[#parameters + 1] = restore and original_value or 0 + end + if #assignments == 0 then + return false + end + parameters[#parameters + 1] = order.identifier + parameters[#parameters + 1] = order.plate + Bridge.Database.Query( + ("UPDATE %s SET %s WHERE %s = ? AND TRIM(plate) = ?") + :format(order.table_name, table.concat(assignments, ", "), order.owner_column), + parameters + ) + return true +end + +local function refund_valet(source, order) + if order.refunded then + return + end + order.refunded = true + if not Bridge.Framework.AddMoney(source, Config.Garage.Valet.Account, order.cost) then + Bridge.Debug("error", "[sky_phone] Failed to refund valet order '%s' for player %s.", tostring(order.id), tostring(source)) + return + end + TriggerClientEvent("sky_phone:banking:changed", source) +end + +local function cancel_valet(source, order) + write_vehicle_status(order, true) + refund_valet(source, order) + active_valets[source] = nil +end + +Bridge.Callbacks.Register("sky_phone:garage:valet-request", function(source, data) + local valet = Config.Garage.Valet + if not valet.Enabled then + return { success = false, error = "valet_disabled" } + end + if not SkyPhone.AllowOperation(source, "garage_valet", valet.RequestsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + if active_valets[source] then + return { success = false, error = "valet_active" } + end + local now = os.time() + if (valet_cooldowns[source] or 0) > now then + return { + success = false, + error = "valet_cooldown", + data = { retryAfter = valet_cooldowns[source] - now }, + } + end + local plate = normalized_plate(data and data.plate) + if not plate then + return { success = false, error = "invalid_vehicle" } + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return { success = false, error = "garage_unavailable" } + end + local row, table_name, owner_column, garage_system = owned_vehicle_row(identifier, plate) + if not row then + return { success = false, error = "vehicle_not_owned" } + end + local vehicle = vehicle_dto(row, garage_system) + if vehicle.status ~= "garaged" then + return { success = false, error = "vehicle_not_garaged" } + end + if not valet.VehicleTypes[vehicle.kind] then + return { success = false, error = "valet_vehicle_type" } + end + local price = math.max(0, math.floor(tonumber(valet.Price) or 0)) + local balance = tonumber(Bridge.Framework.GetMoney(source, valet.Account)) or 0 + if balance < price then + return { success = false, error = "insufficient_funds" } + end + local order = { + cost = price, + expires_at = now + valet.TimeoutSeconds, + identifier = identifier, + id = ("%s:%s:%s"):format(source, now, math.random(100000, 999999)), + owner_column = owner_column, + plate = plate, + status = status_snapshot(row), + table_name = table_name, + } + if not write_vehicle_status(order, false) then + return { success = false, error = "valet_status_unsupported" } + end + if price > 0 and not Bridge.Framework.RemoveMoney(source, valet.Account, price) then + write_vehicle_status(order, true) + return { success = false, error = "insufficient_funds" } + end + active_valets[source] = order + TriggerClientEvent("sky_phone:banking:changed", source) + return { + success = true, + data = { + cost = price, + driverModel = valet.DriverModel, + orderId = order.id, + vehicle = { + body = vehicle.body, + engine = vehicle.engine, + fuel = vehicle.fuel, + kind = vehicle.kind, + model = vehicle.model, + plate = vehicle.plate, + properties = vehicle_properties(row), + }, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:garage:valet-cancel", function(source, data) + local order = active_valets[source] + if not order or type(data) ~= "table" or data.orderId ~= order.id then + return { success = false, error = "valet_not_found" } + end + cancel_valet(source, order) + return { success = true, data = { refunded = order.cost } } +end) + +Bridge.Callbacks.Register("sky_phone:garage:valet-complete", function(source, data) + local order = active_valets[source] + if not order or type(data) ~= "table" or data.orderId ~= order.id then + return { success = false, error = "valet_not_found" } + end + + local network_id = tonumber(data.networkId) + if not network_id or network_id <= 0 or network_id ~= math.floor(network_id) then + return { success = false, error = "valet_vehicle_unverified" } + end + local entity = NetworkGetEntityFromNetworkId(network_id) + if entity == 0 or not DoesEntityExist(entity) or tonumber(NetworkGetEntityOwner(entity)) ~= source then + return { success = false, error = "valet_vehicle_unverified" } + end + + active_valets[source] = nil + valet_cooldowns[source] = os.time() + Config.Garage.Valet.CooldownSeconds + return { success = true } +end) + +AddEventHandler("playerDropped", function() + local source = source + local order = active_valets[source] + if order then + cancel_valet(source, order) + end + valet_cooldowns[source] = nil +end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name ~= GetCurrentResourceName() then + return + end + for source, order in pairs(active_valets) do + cancel_valet(source, order) + end +end) + +CreateThread(function() + while true do + Wait(5000) + local now = os.time() + for source, order in pairs(active_valets) do + if order.expires_at <= now then + cancel_valet(source, order) + TriggerClientEvent("sky_phone:garage:valet-aborted", source, "valet_timeout") + end + end + end +end) + +Bridge.Callbacks.Register("sky_phone:garage:vehicles", function(source) + if not SkyPhone.AllowOperation(source, "garage_vehicles", Config.Garage.RequestsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return error_response + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" then + return { success = false, error = "garage_unavailable" } + end + local table_name, owner_column, garage_system = storage_config() + local rows = Bridge.Database.Query( + ("SELECT * FROM `%s` WHERE `%s` = ? LIMIT ?"):format(table_name, owner_column), + { identifier, Config.Garage.MaximumVehicles } + ) + local vehicles = {} + for _, row in ipairs(rows) do + local vehicle = vehicle_dto(row, garage_system) + if vehicle.plate ~= "" then + vehicles[#vehicles + 1] = vehicle + end + end + table.sort(vehicles, function(left, right) + return left.plate < right.plate + end) + return { + success = true, + data = { + system = garage_system, + valet = { + account = Config.Garage.Valet.Account, + enabled = Config.Garage.Valet.Enabled, + price = math.max(0, math.floor(tonumber(Config.Garage.Valet.Price) or 0)), + vehicleTypes = Config.Garage.Valet.VehicleTypes, + }, + vehicles = vehicles, + }, + } +end) + +end)