diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index f8f12bc..c4ff094 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -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, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 03ecb67..9f61f80 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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 = { diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 50e24fb..69b2434 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/bridge/client/housing.lua b/sky_phone/source/bridge/client/housing.lua new file mode 100644 index 0000000..aed2c4a --- /dev/null +++ b/sky_phone/source/bridge/client/housing.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 diff --git a/sky_phone/source/bridge/client/housing/esx_property.lua b/sky_phone/source/bridge/client/housing/esx_property.lua new file mode 100644 index 0000000..08fe3f3 --- /dev/null +++ b/sky_phone/source/bridge/client/housing/esx_property.lua @@ -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, +}) diff --git a/sky_phone/source/bridge/client/housing/qbx_properties.lua b/sky_phone/source/bridge/client/housing/qbx_properties.lua new file mode 100644 index 0000000..968b93b --- /dev/null +++ b/sky_phone/source/bridge/client/housing/qbx_properties.lua @@ -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, +}) diff --git a/sky_phone/source/bridge/server/housing.lua b/sky_phone/source/bridge/server/housing.lua new file mode 100644 index 0000000..a0238da --- /dev/null +++ b/sky_phone/source/bridge/server/housing.lua @@ -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) diff --git a/sky_phone/source/bridge/server/housing/esx_property.lua b/sky_phone/source/bridge/server/housing/esx_property.lua new file mode 100644 index 0000000..8aecfed --- /dev/null +++ b/sky_phone/source/bridge/server/housing/esx_property.lua @@ -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, +}) diff --git a/sky_phone/source/bridge/server/housing/qbx_properties.lua b/sky_phone/source/bridge/server/housing/qbx_properties.lua new file mode 100644 index 0000000..3e547b2 --- /dev/null +++ b/sky_phone/source/bridge/server/housing/qbx_properties.lua @@ -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) diff --git a/sky_phone/source/client/housing.lua b/sky_phone/source/client/housing.lua new file mode 100644 index 0000000..d54d217 --- /dev/null +++ b/sky_phone/source/client/housing.lua @@ -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) diff --git a/sky_phone/source/server/housing.lua b/sky_phone/source/server/housing.lua new file mode 100644 index 0000000..d632d39 --- /dev/null +++ b/sky_phone/source/server/housing.lua @@ -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)