ADD - integrate phone housing providers

This commit is contained in:
Type
2026-08-09 22:12:27 +02:00
parent 326a054cd5
commit 6702cd8933
11 changed files with 1187 additions and 1 deletions
+24 -1
View File
@@ -14,7 +14,7 @@ Config.Bridge = {
Config.Command = "phone"
Config.Phone = {
Item = "sky_phone",
Item = "phone",
DevelopmentCommand = true,
DeviceName = "iFruit Phone",
}
@@ -212,6 +212,29 @@ Config.Garage = {
},
}
Config.Housing = {
System = "auto", -- auto, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
Camera = {
HeightAboveEntrance = 2.5,
FieldOfView = 55.0,
MinimumFieldOfView = 20.0,
MaximumFieldOfView = 75.0,
RotateSpeed = 0.65,
ExitControl = 177, -- Backspace
NightVisionControl = 38, -- E
ZoomInControl = 241, -- Mouse wheel up
ZoomOutControl = 242, -- Mouse wheel down
LeftControl = 174,
RightControl = 175,
UpControl = 172,
DownControl = 173,
},
}
Config.Marketplace = {
PageSize = 20,
MessagePageSize = 50,
+31
View File
@@ -484,6 +484,37 @@ Locales["en"] = {
device_locked = "Unlock this phone to use SkyRide.",
default = "SkyRide could not complete the request.",
},
},
house = {
name = "House", subtitle = "Homes and access", myHomes = "My Homes",
properties = "Properties", owned = "Owned", shared = "Shared with you",
locked = "Locked", unlocked = "Unlocked", owner = "Owner", keyholder = "Key holder",
openDetails = "Open property details", tryAgain = "Try Again", refresh = "Refresh",
offline = "House is offline", offlineBody = "Start a supported housing resource or review Config.Housing.",
unavailable = "Homes unavailable", empty = "No Homes", emptyBody = "Owned homes and homes shared with you will appear here.",
provider = "Housing data · {system}", access = "Access", status = "Status",
actions = "Controls", setWaypoint = "Directions", viewCamera = "View Camera",
garage = "Garage", garageEnabled = "Enabled", garageDisabled = "Unavailable",
storedVehicles = "{count} stored", camera = "Camera", cameraAvailable = "Ready", cameraUnavailable = "Unavailable",
keys = "Keys", manageKeys = "Manage Keys", addKey = "Add Key", revokeKey = "Revoke",
noKeys = "No shared keys", noKeysBody = "Only you currently have access to this home.",
chooseResident = "Give a Key", chooseResidentBody = "Choose a person currently inside this property.",
noCandidates = "Nobody is waiting inside", noCandidatesBody = "A person must be inside the property before you can give them a key.",
revokeTitle = "Revoke Key?", revokeBody = "{name} will lose access to this property.",
offlineKey = "Offline", onlineKey = "Online", cancel = "Cancel", confirm = "Confirm",
lockSuccess = "Door status updated.", waypointSuccess = "Route set on your map.",
keyGranted = "Key granted.", keyRevoked = "Key revoked.", cameraStarting = "Opening camera…",
errors = {
provider_unavailable = "The housing provider is unavailable.", provider_error = "The housing provider could not be reached.",
housing_unavailable = "Housing data is unavailable for this character.", property_not_found = "This property no longer exists.",
property_access_denied = "You no longer have access to this property.", owner_required = "Only the property owner can do that.",
invalid_property = "Select a valid property.", invalid_action = "This housing action is unavailable.", capability_unavailable = "This property provider does not support that control.",
invalid_target = "Select a valid resident.", target_not_in_property = "That person is no longer inside the property.",
key_not_found = "That key no longer exists.", key_already_exists = "That person already has access to this property.", keyholder_offline = "Offline keys must be revoked through the property menu.",
cctv_unavailable = "The camera is unavailable for this property.", provider_rejected = "The housing provider rejected the request.",
action_failed = "The property change could not be saved.", rate_limited = "Please wait before using another housing control.", device_not_open = "Open the phone again to manage your homes.",
device_locked = "Unlock the phone to manage your homes.", request_failed = "The housing request failed.", default = "The housing request failed.",
},
},
calculator = { name = "Calculator" },
snake = {
+6
View File
@@ -24,10 +24,13 @@ client_scripts {
'config/locales/*.lua',
'source/bridge/client/framework.lua',
'source/bridge/client/callbacks.lua',
'source/bridge/client/housing.lua',
'source/bridge/client/housing/*.lua',
'source/client/animations.lua',
'source/client/camera.lua',
'source/client/garage.lua',
'source/client/skyride.lua',
'source/client/housing.lua',
'source/bridge/client/radio.lua',
'source/client/main.lua',
'source/client/radio.lua',
@@ -43,6 +46,8 @@ server_scripts {
'source/bridge/server/callbacks.lua',
'source/bridge/server/framework.lua',
'source/bridge/server/frameworks/*.lua',
'source/bridge/server/housing.lua',
'source/bridge/server/housing/*.lua',
'source/bridge/server/inventory.lua',
'source/bridge/server/inventory/*.lua',
'source/server/db_migrate.lua',
@@ -57,6 +62,7 @@ server_scripts {
'source/server/mail.lua',
'source/server/banking.lua',
'source/server/garage.lua',
'source/server/housing.lua',
'source/server/marketplace.lua',
'source/server/pages.lua',
'source/server/fliptok.lua',
@@ -0,0 +1,18 @@
Bridge.Housing = {
ClientProviders = {},
}
function Bridge.Housing.RegisterClientProvider(name, provider)
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
assert(type(provider) == "table" and type(provider.execute) == "function", "Housing client provider must implement execute")
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
Bridge.Housing.ClientProviders[name] = provider
end
function Bridge.Housing.Execute(provider_name, action, data)
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider then
return false, "provider_unavailable"
end
return provider.execute(action, data)
end
@@ -0,0 +1,63 @@
if Bridge.Framework.GetName() ~= "esx" then
return
end
local ESX = exports["es_extended"]:getSharedObject()
local callback_names = {
grant_key = "esx_property:GiveKey",
revoke_key = "esx_property:RemoveKey",
toggle_lock = "esx_property:toggleLock",
}
local function await_callback(name, arguments)
local request = promise.new()
local settled = false
ESX.TriggerServerCallback(name, function(success)
if settled then
return
end
settled = true
request:resolve(success and true or false)
end, table.unpack(arguments))
SetTimeout(Config.Bridge.CallbackTimeout, function()
if settled then
return
end
settled = true
Bridge.Debug("error", "[sky_phone] Housing provider callback '%s' timed out.", name)
request:resolve(false)
end)
return Citizen.Await(request)
end
Bridge.Housing.RegisterClientProvider("esx_property", {
execute = function(action, data)
local callback_name = callback_names[action]
if not callback_name or GetResourceState("esx_property") ~= "started" then
return false, "provider_unavailable"
end
local property_id = tonumber(data.providerId)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return false, "invalid_property"
end
local arguments = { property_id }
if action == "grant_key" then
arguments[2] = tonumber(data.target)
elseif action == "revoke_key" then
arguments[2] = data.identifier
end
if (action == "grant_key" and not arguments[2])
or (action == "revoke_key" and type(arguments[2]) ~= "string")
then
return false, "invalid_request"
end
if await_callback(callback_name, arguments) then
return true
end
return false, "provider_rejected"
end,
})
@@ -0,0 +1,24 @@
if Bridge.Framework.GetName() ~= "qbox" then
return
end
Bridge.Housing.RegisterClientProvider("qbx_properties", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState("qbx_properties") ~= "started" then
return false, "provider_unavailable"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:qbx_properties:execute", {
action = action,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
+113
View File
@@ -0,0 +1,113 @@
Bridge.Housing = {
Providers = {},
}
local selected_provider_name = nil
local last_provider_state = nil
function Bridge.Housing.RegisterProvider(name, provider)
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
assert(type(provider) == "table", "Housing provider must be a table")
assert(type(provider.is_available) == "function", "Housing provider must implement is_available")
assert(type(provider.get_overview) == "function", "Housing provider must implement get_overview")
assert(type(provider.prepare) == "function", "Housing provider must implement prepare")
assert(not Bridge.Housing.Providers[name], ("Housing provider '%s' is already registered"):format(name))
Bridge.Housing.Providers[name] = provider
end
local function provider_available(name)
local provider = Bridge.Housing.Providers[name]
return provider and provider.is_available()
end
local function report_provider_state(state, message)
if last_provider_state == state then
return
end
last_provider_state = state
Bridge.Debug(state == "online" and "debug" or "warn", message, { always = true })
end
function Bridge.Housing.ResolveProvider()
if selected_provider_name and provider_available(selected_provider_name) then
return selected_provider_name, Bridge.Housing.Providers[selected_provider_name]
end
selected_provider_name = nil
local configured = tostring(Config.Housing.System or "auto")
if configured ~= "auto" then
if provider_available(configured) then
selected_provider_name = configured
else
report_provider_state(
"offline:" .. configured,
("[sky_phone] Configured housing provider '%s' is unavailable."):format(configured)
)
return nil, nil
end
else
for _, name in ipairs(Config.Housing.AutoPriority or {}) do
if provider_available(name) then
selected_provider_name = name
break
end
end
if not selected_provider_name then
report_provider_state("offline:auto", "[sky_phone] No supported housing provider is running.")
return nil, nil
end
end
report_provider_state(
"online",
("[sky_phone] Housing provider '%s' is active."):format(selected_provider_name)
)
return selected_provider_name, Bridge.Housing.Providers[selected_provider_name]
end
function Bridge.Housing.GetOverview(source)
local name, provider = Bridge.Housing.ResolveProvider()
if not provider then
return {
available = false,
provider = nil,
properties = {},
}
end
local properties, error_code = provider.get_overview(source)
if not properties then
return nil, error_code or "provider_error"
end
return {
available = true,
provider = name,
properties = properties,
}
end
function Bridge.Housing.Prepare(source, action, data)
local name, provider = Bridge.Housing.ResolveProvider()
if not provider then
return nil, "provider_unavailable"
end
local prepared, error_code = provider.prepare(source, action, data)
if not prepared then
return nil, error_code or "action_failed"
end
prepared.provider = name
return prepared
end
local function reset_provider(resource_name)
for _, provider in pairs(Bridge.Housing.Providers) do
if provider.resource_name == resource_name then
selected_provider_name = nil
last_provider_state = nil
return
end
end
end
AddEventHandler("onResourceStart", reset_provider)
AddEventHandler("onResourceStop", reset_provider)
@@ -0,0 +1,272 @@
local resource_name = "esx_property"
local function property_list()
local success, properties = pcall(function()
return exports[resource_name]:GetProperties()
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] esx_property:GetProperties failed: %s", tostring(properties))
return nil
end
return properties
end
local function online_source(identifier)
if type(identifier) ~= "string" or identifier == "" then
return nil
end
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
if Bridge.Framework.GetIdentifier(player_source) == identifier then
return player_source
end
end
return nil
end
local function player_name(player_source)
local first_name = Bridge.Framework.GetFirstname(player_source)
local last_name = Bridge.Framework.GetLastname(player_source)
local name = table.concat({ tostring(first_name or ""), tostring(last_name or "") }, " ")
:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
return GetPlayerName(player_source) or tostring(player_source)
end
local function valid_coords(value)
return type(value) == "table"
and tonumber(value.x) ~= nil
and tonumber(value.y) ~= nil
and tonumber(value.z) ~= nil
end
local function valid_camera(property)
local cctv = property.cctv
return type(cctv) == "table"
and cctv.enabled == true
and valid_coords(property.Entrance)
and valid_coords(cctv.rot)
end
local function access_for(property, identifier)
if property.Owned ~= true then
return nil
end
if property.Owner == identifier then
return "owner"
end
if type(property.Keys) == "table" and property.Keys[identifier] then
return "keyholder"
end
return nil
end
local function normalized_keys(property)
local keys = {}
if type(property.Keys) ~= "table" then
return keys
end
for identifier, value in pairs(property.Keys) do
if type(identifier) == "string" and type(value) == "table" then
local online = online_source(identifier) ~= nil
keys[#keys + 1] = {
identifier = identifier,
name = tostring(value.name or identifier),
online = online,
revocable = online,
}
end
end
table.sort(keys, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return keys
end
local function normalized_property(index, property, access)
local entrance = property.Entrance
local garage = type(property.garage) == "table" and property.garage or nil
local stored_vehicles = garage and type(garage.StoredVehicles) == "table"
and #garage.StoredVehicles or 0
return {
id = ("esx_property:%s"):format(index),
providerId = tostring(index),
name = tostring(property.setName and property.setName ~= "" and property.setName or property.Name or ("Property %s"):format(index)),
access = access,
locked = property.Locked == true,
entrance = {
x = tonumber(entrance.x),
y = tonumber(entrance.y),
z = tonumber(entrance.z),
},
capabilities = {
lock = true,
keys = access == "owner",
waypoint = true,
cctv = valid_camera(property),
garageStatus = garage ~= nil,
},
cctv = {
enabled = valid_camera(property),
},
garage = garage and {
enabled = garage.enabled == true,
storedVehicles = stored_vehicles,
} or nil,
keys = access == "owner" and normalized_keys(property) or nil,
}
end
local function resolve_property(source, data)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
return nil, nil, nil, "invalid_request"
end
local provider_id = data.propertyId:match("^esx_property:(%d+)$")
local index = tonumber(provider_id)
if not index or index < 1 or index ~= math.floor(index) then
return nil, nil, nil, "invalid_property"
end
local properties = property_list()
local property = properties and properties[index]
if type(property) ~= "table" or not valid_coords(property.Entrance) then
return nil, nil, nil, "property_not_found"
end
local identifier = Bridge.Framework.GetIdentifier(source)
local access = identifier and access_for(property, identifier) or nil
if not access then
return nil, nil, nil, "property_access_denied"
end
return property, tostring(index), access
end
local function key_candidates(source, property)
local candidates = {}
local seen = {}
for _, value in pairs(property.plysinside or {}) do
local target = tonumber(value)
local target_identifier = target and Bridge.Framework.GetIdentifier(target) or nil
if target and target ~= source and target_identifier and not seen[target]
and target_identifier ~= property.Owner
and not (type(property.Keys) == "table" and property.Keys[target_identifier])
then
seen[target] = true
candidates[#candidates + 1] = {
id = target,
name = player_name(target),
}
end
end
table.sort(candidates, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return candidates
end
Bridge.Housing.RegisterProvider("esx_property", {
resource_name = resource_name,
is_available = function()
return Bridge.Framework.GetName() == "esx"
and GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local properties = property_list()
if not properties then
return nil, "provider_error"
end
local result = {}
for index, property in ipairs(properties) do
local access = type(property) == "table" and access_for(property, identifier) or nil
if access and valid_coords(property.Entrance) then
result[#result + 1] = normalized_property(index, property, access)
if #result >= Config.Housing.MaximumProperties then
break
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
return result
end,
prepare = function(source, action, data)
local property, provider_id, access, error_code = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
return { candidates = key_candidates(source, property) }
end
if action == "toggle_lock" then
return { providerId = provider_id }
end
if action == "set_waypoint" then
return {
coords = {
x = tonumber(property.Entrance.x),
y = tonumber(property.Entrance.y),
z = tonumber(property.Entrance.z),
},
}
end
if action == "open_cctv" then
if not valid_camera(property) then
return nil, "cctv_unavailable"
end
return {
camera = {
coords = {
x = tonumber(property.Entrance.x),
y = tonumber(property.Entrance.y),
z = tonumber(property.Entrance.z) + Config.Housing.Camera.HeightAboveEntrance,
},
rotation = {
x = tonumber(property.cctv.rot.x),
y = tonumber(property.cctv.rot.y),
z = tonumber(property.cctv.rot.z),
},
maximumLeft = tonumber(property.cctv.maxleft),
maximumRight = tonumber(property.cctv.maxright),
},
}
end
if access ~= "owner" then
return nil, "owner_required"
end
if action == "grant_key" then
local target = tonumber(data.target)
if not target then
return nil, "invalid_target"
end
local candidates = key_candidates(source, property)
for _, candidate in ipairs(candidates) do
if candidate.id == target then
return { providerId = provider_id, target = target }
end
end
return nil, "target_not_in_property"
end
if action == "revoke_key" then
local identifier = data.identifier
if type(identifier) ~= "string" or type(property.Keys) ~= "table" or not property.Keys[identifier] then
return nil, "key_not_found"
end
if not online_source(identifier) then
return nil, "keyholder_offline"
end
return { providerId = provider_id, identifier = identifier }
end
return nil, "invalid_action"
end,
})
@@ -0,0 +1,369 @@
local resource_name = "qbx_properties"
local function decode_array(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)
return success and type(decoded) == "table" and decoded or {}
end
local function query_properties(identifier)
local success, properties = pcall(function()
return MySQL.query.await([[
SELECT id, property_name, owner, keyholders, coords, garage
FROM properties
WHERE owner = ? OR JSON_CONTAINS(keyholders, JSON_QUOTE(?))
ORDER BY property_name ASC, id ASC
]], { identifier, identifier })
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] qbx_properties overview query failed: %s", tostring(properties))
return nil
end
return properties
end
local function query_property(property_id)
local success, property = pcall(function()
return MySQL.single.await([[
SELECT id, property_name, owner, keyholders, coords, garage
FROM properties
WHERE id = ?
]], { property_id })
end)
if not success then
Bridge.Debug("error", "[sky_phone] qbx_properties property query failed: %s", tostring(property))
return nil, "provider_error"
end
if type(property) ~= "table" then
return nil, "property_not_found"
end
return property
end
local function online_source(identifier)
for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do
if Bridge.Framework.GetIdentifier(player_source) == identifier then
return player_source
end
end
return nil
end
local function character_name(identifier)
local player = exports.qbx_core:GetPlayerByCitizenId(identifier)
or exports.qbx_core:GetOfflinePlayer(identifier)
local character = player and player.PlayerData and player.PlayerData.charinfo
if character then
local name = ("%s %s"):format(character.firstname or "", character.lastname or "")
:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
end
return identifier
end
local function access_for(property, identifier)
if property.owner == identifier then
return "owner"
end
for _, keyholder in ipairs(decode_array(property.keyholders)) do
if keyholder == identifier then
return "keyholder"
end
end
return nil
end
local function valid_coords(value)
return type(value) == "table"
and tonumber(value.x) ~= nil
and tonumber(value.y) ~= nil
and tonumber(value.z) ~= nil
end
local function garage_name(property_name)
return "property_" .. string.gsub(string.lower(property_name), " ", "_")
end
local function garage_counts(identifier)
if GetResourceState("qbx_garages") ~= "started" then
return {}
end
local success, rows = pcall(function()
return MySQL.query.await([[
SELECT garage, COUNT(*) AS stored_count
FROM player_vehicles
WHERE citizenid = ? AND state = 1 AND garage IS NOT NULL
GROUP BY garage
]], { identifier })
end)
if not success or type(rows) ~= "table" then
Bridge.Debug("error", "[sky_phone] qbx_properties garage status query failed: %s", tostring(rows))
return {}
end
local counts = {}
for _, row in ipairs(rows) do
if type(row.garage) == "string" then
counts[row.garage] = tonumber(row.stored_count) or 0
end
end
return counts
end
local function normalized_keys(property)
local keys = {}
for _, identifier in ipairs(decode_array(property.keyholders)) do
if type(identifier) == "string" and identifier ~= "" then
keys[#keys + 1] = {
identifier = identifier,
name = character_name(identifier),
online = online_source(identifier) ~= nil,
revocable = true,
}
end
end
table.sort(keys, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return keys
end
local function normalized_property(property, access, counts)
local coords = decode_array(property.coords)
local garage = property.garage and decode_array(property.garage) or nil
local garage_configured = garage and valid_coords(garage)
local garage_enabled = garage_configured
and GetResourceState("qbx_garages") == "started"
return {
id = ("qbx_properties:%s"):format(property.id),
providerId = tostring(property.id),
name = tostring(property.property_name or ("Property %s"):format(property.id)),
access = access,
locked = false,
entrance = {
x = tonumber(coords.x),
y = tonumber(coords.y),
z = tonumber(coords.z),
},
capabilities = {
lock = false,
keys = access == "owner",
waypoint = true,
cctv = false,
garageStatus = garage_configured and true or false,
},
cctv = { enabled = false },
garage = garage_configured and {
enabled = garage_enabled and true or false,
storedVehicles = counts[garage_name(property.property_name)] or 0,
} or nil,
keys = access == "owner" and normalized_keys(property) or nil,
}
end
local function parse_property_id(value)
if type(value) ~= "string" then
return nil
end
local property_id = tonumber(value:match("^qbx_properties:(%d+)$"))
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil
end
return property_id
end
local function resolve_property(source, data)
local property_id = parse_property_id(data and data.propertyId)
if not property_id then
return nil, nil, nil, "invalid_property"
end
local property, error_code = query_property(property_id)
if not property then
return nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
local access = identifier and access_for(property, identifier) or nil
local coords = decode_array(property.coords)
if not access then
return nil, nil, nil, "property_access_denied"
end
if not valid_coords(coords) then
return nil, nil, nil, "invalid_coordinates"
end
return property, property_id, access
end
local function player_property_id(player)
local metadata = player and player.PlayerData and player.PlayerData.metadata
return metadata and tonumber(metadata.currentPropertyId) or nil
end
local function key_candidates(source, property, property_id)
local candidates = {}
for _, target in ipairs(Bridge.Framework.GetPlayers()) do
local player = exports.qbx_core:GetPlayer(target)
local identifier = player and player.PlayerData and player.PlayerData.citizenid
if target ~= source and identifier and player_property_id(player) == property_id
and not access_for(property, identifier)
then
candidates[#candidates + 1] = {
id = target,
name = character_name(identifier),
}
end
end
table.sort(candidates, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return candidates
end
local function execute_key_action(source, action, data)
if not SkyPhone.AllowOperation(source, "housing_qbx_action", Config.Housing.ActionsPerMinute, 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 action ~= "grant_key" and action ~= "revoke_key" then
return { success = false, error = "invalid_action" }
end
local property_id = tonumber(data and data.providerId)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return { success = false, error = "invalid_property" }
end
local property, error_code = query_property(property_id)
if not property then
return { success = false, error = error_code }
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier or property.owner ~= identifier then
return { success = false, error = "owner_required" }
end
if action == "grant_key" then
local target = tonumber(data.target)
local target_player = target and exports.qbx_core:GetPlayer(target) or nil
local target_identifier = target_player and target_player.PlayerData
and target_player.PlayerData.citizenid or nil
if not target_identifier or target == source or player_property_id(target_player) ~= property_id then
return { success = false, error = "target_not_in_property" }
end
if access_for(property, target_identifier) then
return { success = false, error = "key_already_exists" }
end
local affected = MySQL.update.await([[
UPDATE properties
SET keyholders = JSON_ARRAY_APPEND(
CASE WHEN JSON_TYPE(keyholders) = 'ARRAY' THEN keyholders ELSE JSON_ARRAY() END,
'$', ?
)
WHERE id = ? AND owner = ?
AND NOT JSON_CONTAINS(
CASE WHEN JSON_TYPE(keyholders) = 'ARRAY' THEN keyholders ELSE JSON_ARRAY() END,
JSON_QUOTE(?)
)
]], { target_identifier, property_id, identifier, target_identifier })
return affected == 1 and { success = true } or { success = false, error = "action_failed" }
end
local target_identifier = data.identifier
if type(target_identifier) ~= "string" or target_identifier == "" then
return { success = false, error = "invalid_target" }
end
local affected = MySQL.update.await([[
UPDATE properties
SET keyholders = JSON_REMOVE(
keyholders,
JSON_UNQUOTE(JSON_SEARCH(keyholders, 'one', ?))
)
WHERE id = ? AND owner = ?
AND JSON_SEARCH(keyholders, 'one', ?) IS NOT NULL
]], { target_identifier, property_id, identifier, target_identifier })
return affected == 1 and { success = true } or { success = false, error = "key_not_found" }
end
Bridge.Housing.RegisterProvider("qbx_properties", {
resource_name = resource_name,
is_available = function()
return Bridge.Framework.GetName() == "qbox"
and GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local properties = query_properties(identifier)
if not properties then
return nil, "provider_error"
end
local counts = garage_counts(identifier)
local result = {}
for _, property in ipairs(properties) do
local access = access_for(property, identifier)
local coords = decode_array(property.coords)
if access and valid_coords(coords) then
result[#result + 1] = normalized_property(property, access, counts)
if #result >= Config.Housing.MaximumProperties then
break
end
end
end
table.sort(result, function(left, right)
if left.access ~= right.access then
return left.access == "owner"
end
return string.lower(left.name) < string.lower(right.name)
end)
return result
end,
prepare = function(source, action, data)
local property, property_id, access, error_code = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
local coords = decode_array(property.coords)
return { coords = { x = tonumber(coords.x), y = tonumber(coords.y), z = tonumber(coords.z) } }
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
return { candidates = key_candidates(source, property, property_id) }
end
if action == "grant_key" or action == "revoke_key" then
if access ~= "owner" then
return nil, "owner_required"
end
return {
providerId = tostring(property_id),
target = data.target,
identifier = data.identifier,
}
end
if action == "toggle_lock" then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:qbx_properties:execute", function(source, data)
if type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
return execute_key_action(source, data.action, data)
end)
+219
View File
@@ -0,0 +1,219 @@
local camera_state = nil
local function server_prepare(action, data)
local request = {
action = action,
propertyId = data and data.propertyId,
target = data and data.target,
identifier = data and data.identifier,
}
return Bridge.Callbacks.Trigger("sky_phone:housing:prepare", request)
end
local function suspend_phone()
SetNuiFocus(false, false)
TriggerEvent("sky_phone:animation:phone", false)
SendNUIMessage({ type = "app:suspend" })
end
local function resume_phone()
SendNUIMessage({ type = "app:resume" })
SetNuiFocus(true, true)
TriggerEvent("sky_phone:animation:phone", true)
end
local function stop_camera()
local state = camera_state
if not state then
return
end
camera_state = nil
RenderScriptCams(false, true, 350, true, true)
if state.camera and DoesCamExist(state.camera) then
DestroyCam(state.camera, false)
end
ClearFocus()
SetNightvision(state.night_vision)
if DoesEntityExist(state.ped) then
FreezeEntityPosition(state.ped, state.frozen)
end
resume_phone()
end
local function camera_number(value, fallback)
local number = tonumber(value)
if not number or number ~= number or number == math.huge or number == -math.huge then
return fallback
end
return number
end
local function run_camera(provider_name, camera_data)
if camera_state or type(camera_data) ~= "table"
or type(camera_data.coords) ~= "table"
or type(camera_data.rotation) ~= "table"
then
return
end
local coords = {
x = camera_number(camera_data.coords.x),
y = camera_number(camera_data.coords.y),
z = camera_number(camera_data.coords.z),
}
local rotation = {
x = camera_number(camera_data.rotation.x),
y = camera_number(camera_data.rotation.y, 0.0),
z = camera_number(camera_data.rotation.z),
}
if not coords.x or not coords.y or not coords.z or not rotation.x or not rotation.z then
Bridge.Debug("error", "[sky_phone] Rejected invalid housing camera data.")
return
end
local config = Config.Housing.Camera
local ped = PlayerPedId()
local camera = CreateCamWithParams(
"DEFAULT_SCRIPTED_CAMERA",
coords.x,
coords.y,
coords.z,
rotation.x,
rotation.y,
rotation.z,
config.FieldOfView,
true,
2
)
if not camera or camera == 0 then
Bridge.Debug("error", "[sky_phone] Failed to create housing camera.")
return
end
camera_state = {
camera = camera,
frozen = IsEntityPositionFrozen(ped),
night_vision = GetUsingnightvision(),
ped = ped,
provider = provider_name,
}
suspend_phone()
FreezeEntityPosition(ped, true)
SetFocusArea(coords.x, coords.y, coords.z, 0.0, 0.0, 0.0)
SetCamActive(camera, true)
RenderScriptCams(true, true, 350, true, true)
local maximum_left = camera_number(camera_data.maximumLeft)
local maximum_right = camera_number(camera_data.maximumRight)
local night_vision = camera_state.night_vision
while camera_state and camera_state.camera == camera do
Wait(0)
DisableAllControlActions(0)
if IsDisabledControlJustPressed(0, config.ExitControl)
or IsPedDeadOrDying(ped, true)
or GetResourceState(provider_name) ~= "started"
then
break
end
local camera_rotation = GetCamRot(camera, 2)
local next_x = camera_rotation.x
local next_z = camera_rotation.z
if IsDisabledControlPressed(0, config.LeftControl)
and (not maximum_left or next_z < maximum_left)
then
next_z = next_z + config.RotateSpeed
elseif IsDisabledControlPressed(0, config.RightControl)
and (not maximum_right or next_z > maximum_right)
then
next_z = next_z - config.RotateSpeed
end
if IsDisabledControlPressed(0, config.UpControl) then
next_x = math.min(85.0, next_x + config.RotateSpeed)
elseif IsDisabledControlPressed(0, config.DownControl) then
next_x = math.max(-85.0, next_x - config.RotateSpeed)
end
if next_x ~= camera_rotation.x or next_z ~= camera_rotation.z then
SetCamRot(camera, next_x, camera_rotation.y, next_z, 2)
end
local field_of_view = GetCamFov(camera)
if IsDisabledControlJustPressed(0, config.ZoomInControl) then
SetCamFov(camera, math.max(config.MinimumFieldOfView, field_of_view - 3.0))
elseif IsDisabledControlJustPressed(0, config.ZoomOutControl) then
SetCamFov(camera, math.min(config.MaximumFieldOfView, field_of_view + 3.0))
end
if IsDisabledControlJustPressed(0, config.NightVisionControl) then
night_vision = not night_vision
SetNightvision(night_vision)
end
end
stop_camera()
end
RegisterNUICallback("housing:overview", function(_, cb)
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("housing:key-candidates", function(data, cb)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
cb({ success = false, error = "invalid_request" })
return
end
local result = server_prepare("key_candidates", data)
cb(result or { success = false, error = "request_failed" })
end)
RegisterNUICallback("housing:command", function(data, cb)
if type(data) ~= "table" or type(data.action) ~= "string" or type(data.propertyId) ~= "string" then
cb({ success = false, error = "invalid_request" })
return
end
local actions = {
grant_key = true,
open_cctv = true,
revoke_key = true,
set_waypoint = true,
toggle_lock = true,
}
if not actions[data.action] then
cb({ success = false, error = "invalid_action" })
return
end
local prepared = server_prepare(data.action, data)
if not prepared or not prepared.success or type(prepared.data) ~= "table" then
cb(prepared or { success = false, error = "request_failed" })
return
end
if data.action == "set_waypoint" then
local coords = prepared.data.coords
if type(coords) ~= "table" or not tonumber(coords.x) or not tonumber(coords.y) then
cb({ success = false, error = "invalid_coordinates" })
return
end
SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0)
cb({ success = true })
return
end
if data.action == "open_cctv" then
if type(prepared.data.camera) ~= "table" or type(prepared.data.provider) ~= "string" then
cb({ success = false, error = "cctv_unavailable" })
return
end
cb({ success = true })
CreateThread(function()
run_camera(prepared.data.provider, prepared.data.camera)
end)
return
end
local success, error_code = Bridge.Housing.Execute(prepared.data.provider, data.action, prepared.data)
cb(success and { success = true } or { success = false, error = error_code or "provider_rejected" })
end)
AddEventHandler("onResourceStop", function(resource_name)
if resource_name == GetCurrentResourceName() and camera_state then
stop_camera()
end
end)
+48
View File
@@ -0,0 +1,48 @@
Bridge.Callbacks.Register("sky_phone:housing:overview", function(source)
if not SkyPhone.AllowOperation(
source,
"housing_overview",
Config.Housing.OverviewRequestsPerMinute,
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 overview, error_code = Bridge.Housing.GetOverview(source)
if not overview then
return { success = false, error = error_code or "request_failed" }
end
return { success = true, data = overview }
end)
Bridge.Callbacks.Register("sky_phone:housing:prepare", function(source, data)
if not SkyPhone.AllowOperation(source, "housing_action", Config.Housing.ActionsPerMinute, 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 type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
local allowed_actions = {
grant_key = true,
key_candidates = true,
open_cctv = true,
revoke_key = true,
set_waypoint = true,
toggle_lock = true,
}
if not allowed_actions[data.action] then
return { success = false, error = "invalid_action" }
end
local prepared, error_code = Bridge.Housing.Prepare(source, data.action, data)
if not prepared then
return { success = false, error = error_code or "action_failed" }
end
return { success = true, data = prepared }
end)