Merge branch 'dev' into feature/funk

This commit is contained in:
Alec Schitzkat
2026-08-09 00:42:49 +02:00
45 changed files with 3743 additions and 477 deletions
+31
View File
@@ -140,6 +140,37 @@ Config.Banking = {
HistoryLimit = 50,
}
Config.Garage = {
System = "auto", -- auto, custom, esx, qb, qbox, ak47, bp, cd, codem, ds-servercreator, hex, jg, my, okok, op, quasar, rx, vms, ws, zyke_garages
MaximumVehicles = 250,
RequestsPerMinute = 30,
Custom = {
Table = "",
OwnerColumn = "",
},
Valet = {
Enabled = true,
Price = 750,
Account = "bank", -- bank or cash
RequestsPerMinute = 3,
CooldownSeconds = 60,
TimeoutSeconds = 180,
SpawnDistance = 110.0,
ArrivalDistance = 14.0,
DriveSpeed = 20.0,
DrivingStyle = 786603,
DriverModel = "s_m_m_autoshop_01",
-- The current valet driver route supports road vehicles. Keep non-road types disabled.
VehicleTypes = {
car = true,
bike = true,
boat = false,
plane = false,
helicopter = false,
},
},
}
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
+57 -3
View File
@@ -160,7 +160,8 @@ Locales["en"] = {
chartDaySummary = "{day}: {incoming} incoming, {outgoing} outgoing",
navigation = "Banking navigation", incoming = "Incoming", outgoing = "Outgoing",
refresh = "Refresh banking data", unavailable = "Banking unavailable", tryAgain = "Try Again",
playerId = "Player ID", playerIdPlaceholder = "Enter the recipient ID",
recipientPhone = "Phone number", recipientPhonePlaceholder = "Enter the recipient's number",
chooseContact = "Choose from contacts", noContacts = "No contacts saved yet.",
amount = "Amount", amountPlaceholder = "Enter an amount",
transactions = {
deposit = "Cash deposit", withdrawal = "Cash withdrawal",
@@ -170,13 +171,66 @@ Locales["en"] = {
transfer = { title = "Send money", body = "Transfer money from your bank account to an online player.", submit = "Send transfer" },
},
errors = {
invalid_request = "Enter a valid whole amount and player ID.", insufficient_funds = "There is not enough money in this account.",
invalid_request = "Enter a valid phone number and whole amount.", insufficient_funds = "There is not enough money in this account.",
target_not_found = "The recipient is not online.", self_transfer = "You cannot send money to yourself.",
rate_limited = "Please wait before making another transaction.", transfer_failed = "The transfer could not be completed.",
banking_unavailable = "Banking is currently unavailable.", request_failed = "The banking request failed.",
default = "The banking request failed.",
},
},
garage = {
name = "Garage", subtitle = "Your vehicle collection", myVehicles = "My Vehicles",
searchPlaceholder = "Search name, plate or garage",
tryAgain = "Try Again", unavailable = "Garage unavailable",
noVehicles = "No Vehicles", noVehiclesBody = "Vehicles owned by this character will appear here.",
noResults = "No Results", noResultsBody = "Try another vehicle, plate or location.",
unknownVehicle = "Unknown Vehicle", unknownLocation = "Location unavailable",
location = "Current location", fuel = "Fuel", engine = "Engine", body = "Body",
vin = "Vehicle Identification Number", notAvailable = "",
provider = "Vehicle data · {system}",
valetDelivered = "Your vehicle has arrived.",
valet = {
title = "Sky Valet", eyebrow = "Premium delivery",
body = "A professional driver brings this vehicle directly to your current location.",
deliver = "Deliver Vehicle", liveEyebrow = "Live delivery", tracked = "Live tracked",
onDemand = "On demand", cancel = "Cancel", confirmTitle = "Request Valet?",
confirmBody = "{vehicle} will be delivered to you for {price}.", confirm = "Request Valet",
ordering = "Requesting…", account = "The fee is charged to your {account} account.",
connecting = "Locating driver", arriving = "Arriving now", eta = "About {seconds}s away",
distance = "{distance} m away", unavailableStatus = "Only parked vehicles can be delivered.",
unsupported = "Valet delivery is disabled for this vehicle type.",
activeOrder = "A valet driver is already delivering another vehicle.",
status = { ordering = "Confirming order", preparing = "Preparing vehicle", en_route = "Driver en route",
arriving = "Arriving now", delivered = "Vehicle delivered", cancelled = "Delivery cancelled", failed = "Delivery failed" },
},
filters = { all = "All", garaged = "Parked", out = "Outside", impounded = "Impound" },
status = { garaged = "Parked", out = "Outside", impounded = "Impounded" },
kinds = { car = "Vehicle", bike = "Motorcycle", boat = "Boat", plane = "Aircraft", helicopter = "Helicopter" },
errors = {
garage_unavailable = "Vehicle data is unavailable for this character.",
valet_disabled = "Valet service is currently unavailable.",
valet_active = "A valet delivery is already active.",
valet_cooldown = "Please wait before requesting another vehicle.",
invalid_vehicle = "Select a valid vehicle.", vehicle_not_owned = "This vehicle does not belong to this character.",
vehicle_not_garaged = "This vehicle is not parked in a garage.",
valet_vehicle_type = "Valet supports cars and motorcycles only.",
insufficient_funds = "You do not have enough money for this delivery.",
valet_status_unsupported = "This garage system cannot release the vehicle.",
valet_not_found = "The valet order is no longer active.",
valet_not_cancellable = "The delivery can no longer be cancelled.",
invalid_vehicle_model = "The vehicle model could not be loaded.",
vehicle_already_out = "This vehicle is already nearby.",
valet_spawn_failed = "The driver could not prepare your vehicle.",
valet_no_road = "No suitable road was found near you.",
valet_interrupted = "The valet delivery was interrupted.",
valet_timeout = "The driver could not reach you in time. Your fee was refunded.",
valet_completion_failed = "The handover could not be confirmed.",
valet_vehicle_unverified = "The delivered vehicle could not be verified.",
rate_limited = "Please wait before refreshing again.",
device_not_open = "Open the phone again to view your vehicles.",
request_failed = "The garage request failed.", default = "The garage request failed.",
},
},
calculator = { name = "Calculator" },
snake = {
name = "Snake", backToMenu = "Back to game menu", board = "Snake game board",
@@ -449,7 +503,7 @@ Locales["en"] = {
weather = {
name = "Weather", now = "Now", today = "Today", refresh = "Refresh weather", details = "Weather details",
feelsLike = "Feels Like", wind = "Wind", humidity = "Humidity", rain = "Precipitation",
hourly = "24-Hour Forecast", daily = "7-Day Forecast", unavailable = "Weather is unavailable",
hourly = "Next Hours", unavailable = "Weather is unavailable",
stale = "Showing the last available weather update.", tryAgain = "Try Again",
regions = { los_santos = "Los Santos", blaine_county = "Blaine County", cayo_perico = "Cayo Perico" },
conditions = {
+2
View File
@@ -25,6 +25,7 @@ client_scripts {
'source/bridge/client/framework.lua',
'source/bridge/client/callbacks.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/bridge/client/radio.lua',
'source/client/main.lua',
'source/client/radio.lua',
@@ -51,6 +52,7 @@ server_scripts {
'source/server/notes.lua',
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/garage.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/calendar.lua',
+520
View File
@@ -0,0 +1,520 @@
local current_valet = nil
local vehicle_mods = {
modSpoilers = 0,
modFrontBumper = 1,
modRearBumper = 2,
modSideSkirt = 3,
modExhaust = 4,
modFrame = 5,
modGrille = 6,
modHood = 7,
modFender = 8,
modRightFender = 9,
modRoof = 10,
modEngine = 11,
modBrakes = 12,
modTransmission = 13,
modHorns = 14,
modSuspension = 15,
modArmor = 16,
modFrontWheels = 23,
modBackWheels = 24,
modPlateHolder = 25,
modVanityPlate = 26,
modTrimA = 27,
modOrnaments = 28,
modDashboard = 29,
modDial = 30,
modDoorSpeaker = 31,
modSeats = 32,
modSteeringWheel = 33,
modShifterLeavers = 34,
modAPlate = 35,
modSpeakers = 36,
modTrunk = 37,
modHydrolic = 38,
modEngineBlock = 39,
modAirFilter = 40,
modStruts = 41,
modArchCover = 42,
modAerials = 43,
modTrimB = 44,
modTank = 45,
modWindows = 46,
modLivery = 48,
}
local function garage_locale()
local locale = Locales[Config.Bridge.Locale] or Locales["en"]
return locale.Nui.Apps.garage
end
local function normalized_plate(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end
local function request_model(model)
RequestModel(model)
local timeout = GetGameTimer() + 10000
while not HasModelLoaded(model) and GetGameTimer() < timeout do
Wait(50)
end
return HasModelLoaded(model)
end
local function set_vehicle_color(vehicle, primary, secondary)
if type(primary) == "table" then
SetVehicleCustomPrimaryColour(
vehicle,
tonumber(primary[1]) or 0,
tonumber(primary[2]) or 0,
tonumber(primary[3]) or 0
)
end
if type(secondary) == "table" then
SetVehicleCustomSecondaryColour(
vehicle,
tonumber(secondary[1]) or 0,
tonumber(secondary[2]) or 0,
tonumber(secondary[3]) or 0
)
end
if type(primary) == "number" or type(secondary) == "number" then
local current_primary, current_secondary = GetVehicleColours(vehicle)
SetVehicleColours(
vehicle,
tonumber(primary) or current_primary,
tonumber(secondary) or current_secondary
)
end
end
local function apply_vehicle_properties(vehicle, properties, fallback)
properties = type(properties) == "table" and properties or {}
SetVehicleModKit(vehicle, 0)
set_vehicle_color(vehicle, properties.color1, properties.color2)
if properties.pearlescentColor or properties.wheelColor then
SetVehicleExtraColours(
vehicle,
tonumber(properties.pearlescentColor) or 0,
tonumber(properties.wheelColor) or 0
)
end
if properties.wheels then
SetVehicleWheelType(vehicle, tonumber(properties.wheels) or 0)
end
if properties.windowTint then
SetVehicleWindowTint(vehicle, tonumber(properties.windowTint) or 0)
end
if properties.xenonColor then
SetVehicleXenonLightsColor(vehicle, tonumber(properties.xenonColor) or -1)
end
for property, mod_type in pairs(vehicle_mods) do
local value = tonumber(properties[property])
if value then
SetVehicleMod(vehicle, mod_type, value, false)
end
end
ToggleVehicleMod(vehicle, 18, properties.modTurbo == true)
ToggleVehicleMod(vehicle, 20, properties.modSmokeEnabled == true)
ToggleVehicleMod(vehicle, 22, properties.modXenon == true)
if type(properties.neonEnabled) == "table" then
for index = 0, 3 do
SetVehicleNeonLightEnabled(vehicle, index, properties.neonEnabled[index + 1] == true)
end
end
if type(properties.neonColor) == "table" then
SetVehicleNeonLightsColour(
vehicle,
tonumber(properties.neonColor[1]) or 0,
tonumber(properties.neonColor[2]) or 0,
tonumber(properties.neonColor[3]) or 0
)
end
if type(properties.tyreSmokeColor) == "table" then
SetVehicleTyreSmokeColor(
vehicle,
tonumber(properties.tyreSmokeColor[1]) or 0,
tonumber(properties.tyreSmokeColor[2]) or 0,
tonumber(properties.tyreSmokeColor[3]) or 0
)
end
if type(properties.extras) == "table" then
for extra_id, enabled in pairs(properties.extras) do
local numeric_id = tonumber(extra_id)
if numeric_id and DoesExtraExist(vehicle, numeric_id) then
SetVehicleExtra(vehicle, numeric_id, not enabled)
end
end
end
local fuel = tonumber(properties.fuelLevel or properties.fuel or fallback.fuel)
local engine = tonumber(properties.engineHealth or properties.engine or fallback.engine and fallback.engine * 10)
local body = tonumber(properties.bodyHealth or properties.body or fallback.body and fallback.body * 10)
if fuel then
SetVehicleFuelLevel(vehicle, math.max(0.0, math.min(100.0, fuel)))
end
if engine then
SetVehicleEngineHealth(vehicle, math.max(-4000.0, math.min(1000.0, engine)))
end
if body then
SetVehicleBodyHealth(vehicle, math.max(0.0, math.min(1000.0, body)))
end
if properties.dirtLevel then
SetVehicleDirtLevel(vehicle, math.max(0.0, math.min(15.0, tonumber(properties.dirtLevel) or 0.0)))
end
SetVehicleNumberPlateText(vehicle, fallback.plate)
end
local function existing_vehicle(plate)
local normalized = string.upper(normalized_plate(plate))
for _, vehicle in ipairs(GetGamePool("CVehicle")) do
if string.upper(normalized_plate(GetVehicleNumberPlateText(vehicle))) == normalized then
return vehicle
end
end
return nil
end
local function public_valet_state()
if not current_valet then
return nil
end
return {
canCancel = current_valet.can_cancel,
cost = current_valet.cost,
distance = current_valet.distance,
etaSeconds = current_valet.eta_seconds,
orderId = current_valet.order_id,
plate = current_valet.plate,
status = current_valet.status,
vehicleName = current_valet.vehicle_name,
}
end
local function send_valet_state()
SendNUIMessage({
type = "garage:valet-status",
data = public_valet_state(),
})
end
local function remove_valet_blip()
if current_valet and current_valet.blip and DoesBlipExist(current_valet.blip) then
RemoveBlip(current_valet.blip)
current_valet.blip = nil
end
end
local function delete_valet_entities()
if not current_valet then
return
end
remove_valet_blip()
if current_valet.driver and DoesEntityExist(current_valet.driver) then
DeleteEntity(current_valet.driver)
end
if current_valet.vehicle and DoesEntityExist(current_valet.vehicle) then
DeleteEntity(current_valet.vehicle)
end
end
local function fail_valet(error_code, server_cancel)
if not current_valet then
return
end
local order_id = current_valet.order_id
current_valet.cancelled = true
delete_valet_entities()
if server_cancel then
Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
end
current_valet.status = "failed"
current_valet.can_cancel = false
current_valet.error = error_code
send_valet_state()
local locale = garage_locale()
Bridge.Framework.Notify(locale.name, locale.errors[error_code] or locale.errors.default, "error", 5000)
SetTimeout(8000, function()
if current_valet and current_valet.order_id == order_id then
current_valet = nil
send_valet_state()
end
end)
end
local function update_drive_target(driver, vehicle)
local coords = GetEntityCoords(PlayerPedId())
TaskVehicleDriveToCoordLongrange(
driver,
vehicle,
coords.x,
coords.y,
coords.z,
Config.Garage.Valet.DriveSpeed,
Config.Garage.Valet.DrivingStyle,
Config.Garage.Valet.ArrivalDistance
)
end
local function run_valet_delivery(order)
local model = tonumber(order.vehicle.model)
if not model and type(order.vehicle.model) == "string" then
model = joaat(order.vehicle.model)
end
local driver_model = joaat(order.driverModel)
if not model or not IsModelInCdimage(model) or not IsModelAVehicle(model) then
fail_valet("invalid_vehicle_model", true)
return
end
if existing_vehicle(order.vehicle.plate) then
fail_valet("vehicle_already_out", true)
return
end
current_valet.status = "preparing"
send_valet_state()
if not request_model(model) or not request_model(driver_model) then
SetModelAsNoLongerNeeded(model)
SetModelAsNoLongerNeeded(driver_model)
fail_valet("valet_spawn_failed", true)
return
end
local player_ped = PlayerPedId()
local player_coords = GetEntityCoords(player_ped)
local target = GetOffsetFromEntityInWorldCoords(player_ped, 0.0, -Config.Garage.Valet.SpawnDistance, 0.0)
local found, spawn_coords, spawn_heading = GetClosestVehicleNodeWithHeading(
target.x,
target.y,
target.z,
1,
3.0,
0
)
if not found then
SetModelAsNoLongerNeeded(model)
SetModelAsNoLongerNeeded(driver_model)
fail_valet("valet_no_road", true)
return
end
local vehicle = CreateVehicle(
model,
spawn_coords.x,
spawn_coords.y,
spawn_coords.z,
spawn_heading,
true,
true
)
if vehicle == 0 or not DoesEntityExist(vehicle) then
SetModelAsNoLongerNeeded(model)
SetModelAsNoLongerNeeded(driver_model)
fail_valet("valet_spawn_failed", true)
return
end
current_valet.vehicle = vehicle
SetEntityAsMissionEntity(vehicle, true, true)
SetVehicleOnGroundProperly(vehicle)
apply_vehicle_properties(vehicle, order.vehicle.properties, order.vehicle)
SetVehicleHasBeenOwnedByPlayer(vehicle, true)
SetVehicleDoorsLocked(vehicle, 2)
SetVehicleEngineOn(vehicle, true, true, false)
local network_id = NetworkGetNetworkIdFromEntity(vehicle)
SetNetworkIdCanMigrate(network_id, true)
local driver = CreatePedInsideVehicle(vehicle, 26, driver_model, -1, true, true)
if driver == 0 or not DoesEntityExist(driver) then
SetModelAsNoLongerNeeded(model)
SetModelAsNoLongerNeeded(driver_model)
fail_valet("valet_spawn_failed", true)
return
end
current_valet.driver = driver
SetEntityAsMissionEntity(driver, true, true)
SetBlockingOfNonTemporaryEvents(driver, true)
SetPedKeepTask(driver, true)
SetDriverAbility(driver, 1.0)
SetDriverAggressiveness(driver, 0.0)
SetModelAsNoLongerNeeded(model)
SetModelAsNoLongerNeeded(driver_model)
current_valet.blip = AddBlipForEntity(vehicle)
SetBlipSprite(current_valet.blip, 225)
SetBlipColour(current_valet.blip, 3)
SetBlipRoute(current_valet.blip, true)
current_valet.status = "en_route"
current_valet.can_cancel = true
update_drive_target(driver, vehicle)
send_valet_state()
local timeout = GetGameTimer() + (Config.Garage.Valet.TimeoutSeconds * 1000)
local next_retask = GetGameTimer() + 8000
while current_valet and not current_valet.cancelled and GetGameTimer() < timeout do
if not DoesEntityExist(vehicle)
or not DoesEntityExist(driver)
or IsEntityDead(driver)
or not IsVehicleDriveable(vehicle, false)
then
fail_valet("valet_interrupted", true)
return
end
player_coords = GetEntityCoords(PlayerPedId())
local vehicle_coords = GetEntityCoords(vehicle)
local distance = #(vehicle_coords - player_coords)
current_valet.distance = math.floor(distance + 0.5)
current_valet.eta_seconds = math.max(1, math.ceil(distance / math.max(1.0, Config.Garage.Valet.DriveSpeed)))
if distance <= Config.Garage.Valet.ArrivalDistance then
break
end
if GetGameTimer() >= next_retask then
update_drive_target(driver, vehicle)
next_retask = GetGameTimer() + 8000
end
send_valet_state()
Wait(750)
end
if not current_valet or current_valet.cancelled then
return
end
if GetGameTimer() >= timeout then
fail_valet("valet_timeout", true)
return
end
current_valet.status = "arriving"
current_valet.can_cancel = false
current_valet.distance = 0
current_valet.eta_seconds = 0
send_valet_state()
TaskVehicleTempAction(driver, vehicle, 27, 1800)
Wait(1800)
SetVehicleEngineOn(vehicle, false, true, true)
SetVehicleDoorsLocked(vehicle, 1)
TaskLeaveVehicle(driver, vehicle, 0)
local leave_timeout = GetGameTimer() + 5000
while IsPedInVehicle(driver, vehicle, false) and GetGameTimer() < leave_timeout do
Wait(100)
end
local driver_target = GetOffsetFromEntityInWorldCoords(vehicle, 4.0, 8.0, 0.0)
TaskGoStraightToCoord(
driver,
driver_target.x,
driver_target.y,
driver_target.z,
1.0,
10000,
GetEntityHeading(driver),
0.5
)
SetPedKeepTask(driver, true)
SetEntityAsNoLongerNeeded(driver)
current_valet.driver = nil
local completion = Bridge.Callbacks.Trigger(
"sky_phone:garage:valet-complete",
{
orderId = current_valet.order_id,
networkId = NetworkGetNetworkIdFromEntity(vehicle),
}
)
if not completion or not completion.success then
fail_valet("valet_completion_failed", false)
return
end
remove_valet_blip()
SetEntityAsNoLongerNeeded(vehicle)
current_valet.vehicle = nil
current_valet.status = "delivered"
current_valet.can_cancel = false
send_valet_state()
local locale = garage_locale()
Bridge.Framework.Notify(locale.name, locale.valetDelivered, "success", 5000)
local completed_order_id = current_valet.order_id
SetTimeout(12000, function()
if current_valet and current_valet.order_id == completed_order_id then
current_valet = nil
send_valet_state()
end
end)
end
RegisterNUICallback("garage:valet-request", function(data, cb)
if current_valet then
cb({ success = false, error = "valet_active" })
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-request", data)
if not result or not result.success or type(result.data) ~= "table" then
cb(result or { success = false, error = "request_failed" })
return
end
local vehicle = result.data.vehicle
local model_hash = tonumber(vehicle.model)
if not model_hash and type(vehicle.model) == "string" then
model_hash = joaat(vehicle.model)
end
local display_name = model_hash and GetDisplayNameFromVehicleModel(model_hash) or nil
local label = display_name and GetLabelText(display_name) or nil
current_valet = {
can_cancel = false,
cancelled = false,
cost = result.data.cost,
distance = nil,
eta_seconds = nil,
order_id = result.data.orderId,
plate = vehicle.plate,
status = "ordering",
vehicle_name = label and label ~= "NULL" and label ~= "CARNOTFOUND"
and label
or tostring(vehicle.model),
}
cb({ success = true, data = public_valet_state() })
send_valet_state()
CreateThread(function()
run_valet_delivery(result.data)
end)
end)
RegisterNUICallback("garage:valet-cancel", function(_, cb)
if not current_valet or not current_valet.can_cancel then
cb({ success = false, error = "valet_not_cancellable" })
return
end
local order_id = current_valet.order_id
local result = Bridge.Callbacks.Trigger("sky_phone:garage:valet-cancel", { orderId = order_id })
if not result or not result.success then
cb(result or { success = false, error = "request_failed" })
return
end
current_valet.cancelled = true
delete_valet_entities()
current_valet.status = "cancelled"
current_valet.can_cancel = false
send_valet_state()
cb({ success = true, data = public_valet_state() })
SetTimeout(5000, function()
if current_valet and current_valet.order_id == order_id then
current_valet = nil
send_valet_state()
end
end)
end)
RegisterNUICallback("garage:valet-state", function(_, cb)
cb({ success = true, data = public_valet_state() })
end)
RegisterNetEvent("sky_phone:garage:valet-aborted", function(error_code)
if current_valet then
fail_valet(error_code or "valet_timeout", false)
end
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name ~= GetCurrentResourceName() or not current_valet then
return
end
delete_valet_entities()
end)
+43
View File
@@ -258,10 +258,12 @@ end
RegisterNUICallback("weather:get", function(_, cb)
local coords = GetEntityCoords(PlayerPedId())
local weather_hash = GetPrevWeatherTypeHashName()
local next_weather_hash = GetNextWeatherTypeHashName()
cb({
success = true,
data = {
condition = weather_types[weather_hash] or "clear",
nextCondition = weather_types[next_weather_hash] or weather_types[weather_hash] or "clear",
region = weather_region(coords),
clock = {
year = GetClockYear(),
@@ -276,6 +278,47 @@ RegisterNUICallback("weather:get", function(_, cb)
})
end)
local function garage_vehicle_kind(model_hash, fallback)
if IsThisModelABoat(model_hash) then
return "boat"
end
if IsThisModelAPlane(model_hash) then
return "plane"
end
if IsThisModelAHeli(model_hash) then
return "helicopter"
end
if IsThisModelABike(model_hash) or IsThisModelABicycle(model_hash) then
return "bike"
end
return fallback
end
RegisterNUICallback("garage:vehicles", function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:garage:vehicles", data)
if not result or not result.success or type(result.data) ~= "table" then
cb(result or { success = false, error = "request_failed" })
return
end
for _, vehicle in ipairs(result.data.vehicles or {}) do
local model_hash = tonumber(vehicle.model)
if not model_hash and type(vehicle.model) == "string" and vehicle.model ~= "" then
model_hash = joaat(vehicle.model)
end
if model_hash then
local display_name = GetDisplayNameFromVehicleModel(model_hash)
local label = display_name and GetLabelText(display_name) or nil
if label and label ~= "NULL" and label ~= "CARNOTFOUND" then
vehicle.name = label
elseif type(vehicle.model) == "string" and vehicle.model ~= "" then
vehicle.name = vehicle.model
end
vehicle.kind = garage_vehicle_kind(model_hash, vehicle.kind)
end
end
cb(result)
end)
for _, callback_name in ipairs(server_callbacks) do
RegisterNUICallback(callback_name, function(data, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

-14
View File
@@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-qSN2Wmy5.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DnOTtSzB.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+31 -8
View File
@@ -83,6 +83,27 @@ local function notify_changed(source)
TriggerClientEvent("sky_phone:banking:changed", source)
end
local function online_source_for_phone(number)
local rows = Bridge.Database.Query([[
SELECT d.`imei`
FROM `sky_phone_sims` s
INNER JOIN `sky_phone_devices` d ON d.`sim_id` = s.`id`
WHERE s.`phone_number` = ?
LIMIT 1
]], { number })
local device = rows[1]
if not device or type(device.imei) ~= "string" then
return nil
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
local target = tonumber(player_source) or player_source
if SkyPhone.FindDeviceSlots(target, device.imei)[1] then
return target
end
end
return nil
end
Bridge.Callbacks.Register("sky_phone:banking:overview", function(source)
local identifier, error_response = require_banking_session(source)
if not identifier then
@@ -100,16 +121,18 @@ Bridge.Callbacks.Register("sky_phone:banking:transfer", function(source, data)
return error_response
end
local amount = valid_amount(data and data.amount)
local target_value = tonumber(data and data.target)
if not amount
or not target_value
or target_value ~= math.floor(target_value)
or target_value <= 0
or target_value > 65535
then
local number = SkyPhoneSimNumber.Normalize(
data and data.phoneNumber,
Config.Sim.NumberLength,
Config.Sim.NumberPrefix
)
if not amount or not number then
return { success = false, error = "invalid_request" }
end
local target = math.floor(target_value)
local target = online_source_for_phone(number)
if not target then
return { success = false, error = "target_not_found" }
end
if target == source then
return { success = false, error = "self_transfer" }
end
+438
View File
@@ -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)