mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 06:01:35 +00:00
465 lines
15 KiB
Lua
465 lines
15 KiB
Lua
SkyPhoneGarage = {}
|
|
|
|
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_image_url(model)
|
|
local image_config = Config.Garage.VehicleImages or {}
|
|
if image_config.Enabled ~= true then
|
|
return nil
|
|
end
|
|
|
|
local configured_names = image_config.ModelNames or {}
|
|
local model_name = configured_names[model] or configured_names[tostring(model or "")]
|
|
if not model_name and type(model) == "string" and not tonumber(model) then
|
|
model_name = model
|
|
end
|
|
|
|
model_name = string.lower(tostring(model_name or ""))
|
|
if model_name == "" or not model_name:match("^[%w_-]+$") then
|
|
return nil
|
|
end
|
|
|
|
local template = tostring(image_config.UrlTemplate or "")
|
|
if template == "" or not template:find("{model}", 1, true) then
|
|
return nil
|
|
end
|
|
return (template:gsub("{model}", model_name))
|
|
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,
|
|
imageUrl = vehicle_image_url(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 = {
|
|
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)
|