Compare commits

..

3 Commits

Author SHA1 Message Date
DerEchteAlec bc53dbc8b5 ADD - check GitHub releases on startup
Compare the fxmanifest version against the latest published sky_phone release tag and report update, current, ahead, and failure states without blocking resource startup.

Document the startup check and cover the HTTP and version comparison behavior with a focused Lua test.
2026-08-19 20:22:23 +02:00
Dominik 734f4651cb ADD - add housing provider bridges (#6)
Co-authored-by: DerEchteAlec <alec.schitzkat@luwan.io>
2026-08-19 20:11:36 +02:00
DerEchteAlec 98373e8bd8 FIX - await setup persistence before completion (#9) 2026-08-19 20:08:33 +02:00
24 changed files with 3414 additions and 9 deletions
+7 -2
View File
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -249,6 +249,11 @@ When enabled, Sky Phone prints debug and informational messages. Warnings and er
The short LB Phone detection notice also remains visible when debug mode is disabled.
On every resource start, Sky Phone compares the `version` in `fxmanifest.lua` with the tag of the
latest published [GitHub release](https://github.com/sky-systems/sky_phone/releases/latest). The
server console reports whether the installed version is current and shows the release link when an
update is available. A failed GitHub request is reported but does not prevent the phone from starting.
## Security values
Sky Phone ships with stable generated defaults in `Config.Server`:
@@ -525,7 +530,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
+2 -1
View File
@@ -3,6 +3,7 @@ export type HousingAccess = 'owner' | 'keyholder'
export type HousingCapabilities = {
cctv: boolean
garageStatus: boolean
keyGrant?: boolean
keys: boolean
lock: boolean
waypoint: boolean
@@ -24,7 +25,7 @@ export type HousingProperty = {
access: HousingAccess
capabilities: HousingCapabilities
cctv: { enabled: boolean }
entrance: { x: number; y: number; z: number }
entrance?: { x: number; y: number; z: number }
garage: { enabled: boolean; storedVehicles: number } | null
id: string
keys?: HousingKey[]
@@ -44,6 +44,15 @@ describe('House app sheets', () => {
)
})
it('can expose key revocation without advertising key grants', () => {
expect(source).toContain(
'v-if="selectedProperty.capabilities.keyGrant !== false"',
)
expect(source).toContain(
'<section v-if="selectedProperty.capabilities.keys" class="house-keys">',
)
})
it('centers the resident icon above the Give a Key title', () => {
expect(source).toMatch(
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
+1
View File
@@ -464,6 +464,7 @@ onBeforeUnmount(() => {
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
>
<sky-button
v-if="selectedProperty.capabilities.keyGrant !== false"
rounded
small
inline
+2 -2
View File
@@ -91,7 +91,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ox_inventory, qb-inventory, lj-inventory, qs-inventory, codem-inventory, core_inventory, mf-inventory, smx-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
| **Languages** | English, German |
@@ -525,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
Select the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_properties` under `Config.Housing.System`. Automatic mode uses `Config.Housing.AutoPriority` and keeps the existing `esx_property` and `qbx_properties` defaults ahead of newly supported providers. Select a provider explicitly when multiple housing resources are running. Each bridge exposes only the capabilities supported by the documented provider API.
### Companies
+2 -2
View File
@@ -368,8 +368,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties" },
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+1
View File
@@ -55,6 +55,7 @@ server_scripts {
'config/media.lua',
'config/locales/en.lua',
'config/locales/de.lua',
'source/server/update_check.lua',
'source/bridge/server/database.lua',
'source/bridge/server/migrations.lua',
'source/bridge/server/callbacks.lua',
@@ -5,10 +5,67 @@ Bridge.Housing = {
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(
provider.enrich_overview == nil or type(provider.enrich_overview) == "function",
"Housing client provider enrich_overview must be a function"
)
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
Bridge.Housing.ClientProviders[name] = provider
end
function Bridge.Housing.EnrichOverview(provider_name, properties)
if type(properties) ~= "table" then
return nil, "invalid_overview"
end
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider or type(provider.enrich_overview) ~= "function" then
return properties
end
local success, enriched, error_code = pcall(provider.enrich_overview, properties)
if not success then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' overview enrichment failed: %s",
tostring(provider_name),
tostring(enriched)
)
return properties
end
if type(enriched) ~= "table" then
Bridge.Debug(
"error",
"[sky_phone] Housing provider '%s' returned invalid overview enrichment: %s",
tostring(provider_name),
tostring(error_code or enriched)
)
return properties
end
local entrances = {}
for _, property in ipairs(enriched) do
if type(property) == "table" and property.id ~= nil and entrances[property.id] == nil then
entrances[property.id] = Bridge.Normalize.Coordinates(property.entrance)
end
end
local result = {}
for _, property in ipairs(properties) do
local entrance = type(property) == "table" and entrances[property.id] or nil
if entrance then
local normalized = {}
for key, value in pairs(property) do
normalized[key] = value
end
normalized.entrance = entrance
result[#result + 1] = normalized
else
result[#result + 1] = property
end
end
return result
end
function Bridge.Housing.Execute(provider_name, action, data)
local provider = Bridge.Housing.ClientProviders[provider_name]
if not provider then
@@ -0,0 +1,27 @@
local resource_name = "nolag_properties"
Bridge.Housing.RegisterClientProvider("nolag", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" and action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:nolag:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
target = data.target,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,156 @@
local provider_name = "quasar"
local resource_name = "qs-housing"
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function property_tables(value, house)
value = decode_object(value)
if not value then
return {}
end
local result = { value }
local named = house and decode_object(field(value, house)) or nil
if named and named ~= value then
result[#result + 1] = named
end
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
local nested = decode_object(field(value, key))
if nested and nested ~= value and nested ~= named then
result[#result + 1] = nested
end
end
return result
end
local function entrance_for(value, house)
for _, candidate in ipairs(property_tables(value, house)) do
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "Enter" }) do
local coords = normalized_coords(field(candidate, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position", "points" }) do
local container = decode_object(field(candidate, key))
if container then
for _, nested_key in ipairs({
"entrance", "Entrance", "entry", "Entry", "enter", "Enter", "door", "frontDoor",
}) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
end
return nil
end
local function house_reference(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local house = tostring(value):match("^%s*(.-)%s*$")
return house ~= "" and house or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports["qs-housing"]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] qs-housing:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = entrance_for(house_data, house)
if not entrance then
return nil, "invalid_coordinates"
end
return entrance
end
local function enrich_overview(properties)
local result = {}
if GetResourceState(resource_name) ~= "started" or type(properties) ~= "table" then
return result
end
for _, property in ipairs(properties) do
local house = type(property) == "table" and house_reference(property.providerId) or nil
if house and property.id == provider_name .. ":" .. house then
local entrance = normalized_coords(property.entrance)
if not entrance then
entrance = house_entrance(house)
end
if entrance then
result[#result + 1] = {
id = property.id,
entrance = entrance,
}
end
end
end
return result
end
Bridge.Housing.RegisterClientProvider(provider_name, {
enrich_overview = enrich_overview,
execute = function(action, data)
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if action ~= "set_waypoint" then
return false, "capability_unavailable"
end
local house = type(data) == "table" and house_reference(data.providerId) or nil
if not house then
return false, "invalid_property"
end
local entrance, error_code = house_entrance(house)
if not entrance then
return false, error_code
end
SetNewWaypoint(entrance.x + 0.0, entrance.y + 0.0)
return true
end,
})
@@ -0,0 +1,25 @@
local provider_name = "rtx"
local resource_name = "rtx_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "toggle_lock" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" or type(data.providerId) ~= "string" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rtx:execute", {
action = action,
providerId = data.providerId,
})
if result and result.success then
return true
end
return false, result and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,27 @@
local provider_name = "rx"
local resource_name = "RxHousing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:rx: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,
})
@@ -0,0 +1,26 @@
local resource_name = "sn_properties"
Bridge.Housing.RegisterClientProvider("sn", {
execute = function(action, data)
if action ~= "grant_key" and action ~= "revoke_key" then
return false, "capability_unavailable"
end
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
if type(data) ~= "table" then
return false, "invalid_request"
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:sn:execute", {
action = action,
propertyId = data.propertyId,
providerId = data.providerId,
identifier = data.identifier,
})
if type(result) == "table" and result.success then
return true
end
return false, type(result) == "table" and result.error or "provider_rejected"
end,
})
@@ -0,0 +1,11 @@
local provider_name = "vms"
local resource_name = "vms_housing"
Bridge.Housing.RegisterClientProvider(provider_name, {
execute = function()
if GetResourceState(resource_name) ~= "started" then
return false, "provider_unavailable"
end
return false, "capability_unavailable"
end,
})
@@ -0,0 +1,510 @@
local provider_name = "nolag"
local resource_name = "nolag_properties"
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local normalized_coords = Bridge.Normalize.Coordinates
local function player_source(identifier)
if identifier == nil then
return nil
end
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
return source
end
end
return nil
end
local function player_name(source, fallback)
if source then
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
if type(name) == "string" then
name = name:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
end
end
return tostring(fallback)
end
local function get_properties(identifier)
local success, properties = pcall(function()
return exports[resource_name]:GetAllProperties(identifier, "user", true)
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetAllProperties failed: %s", tostring(properties))
return nil
end
return properties
end
local function get_property_data(property_id)
local success, property = pcall(function()
return exports[resource_name]:GetPropertyData(property_id)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPropertyData 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 get_keyholders(property_id)
local success, keyholders = pcall(function()
return exports[resource_name]:GetKeyHolders(property_id)
end)
if not success or type(keyholders) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetKeyHolders failed: %s", tostring(keyholders))
return nil
end
return keyholders
end
local function get_players_in_property(property_id)
local success, players = pcall(function()
return exports[resource_name]:GetPlayersInProperty(property_id)
end)
if not success or type(players) ~= "table" then
Bridge.Debug("error", "[sky_phone] nolag_properties:GetPlayersInProperty failed: %s", tostring(players))
return nil
end
return players
end
local function property_access(property, identifier)
if same_identifier(property.owner, identifier) then
return "owner"
end
if same_identifier(property.renter, identifier) then
return "keyholder"
end
return nil
end
local function keyholder_exists(keyholders, identifier)
if type(identifier) ~= "string" or identifier == "" then
return false
end
return keyholders[identifier] ~= nil
end
local function normalized_keys(property, keyholders)
local keys = {}
for identifier in pairs(keyholders) do
if (type(identifier) == "string" or type(identifier) == "number")
and not same_identifier(identifier, property.owner)
then
local normalized_identifier = tostring(identifier)
local source = player_source(normalized_identifier)
keys[#keys + 1] = {
identifier = normalized_identifier,
name = player_name(source, normalized_identifier),
online = source ~= 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, details, access, keyholders)
local property_id = tonumber(property.id)
local entrance = normalized_coords(property.coords)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
return nil
end
local manages_keys = access == "owner"
return {
id = ("nolag:%s"):format(property_id),
providerId = tostring(property_id),
name = tostring(property.label or details.label or ("Property %s"):format(property_id)),
access = access,
locked = property.doorLocked == true,
entrance = entrance,
capabilities = {
lock = property.hasKey == true,
keys = manages_keys,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = manages_keys and normalized_keys(details, keyholders) or nil,
}
end
local function find_property(properties, property_id)
for _, property in pairs(properties) do
if type(property) == "table" and tonumber(property.id) == property_id then
return property
end
end
return nil
end
local function parse_property_id(data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
local property_id = type(data.propertyId) == "string"
and tonumber(data.propertyId:match("^nolag:(%d+)$")) or nil
if not property_id then
property_id = tonumber(data.providerId)
end
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil, "invalid_property"
end
return property_id
end
local function resolve_property(source, data)
local property_id, error_code = parse_property_id(data)
if not property_id then
return nil, nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, nil, "housing_unavailable"
end
local properties = get_properties(identifier)
if not properties then
return nil, nil, nil, nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property or not normalized_coords(property.coords) then
return nil, nil, nil, nil, "property_access_denied"
end
local details, details_error = get_property_data(property_id)
if not details then
return nil, nil, nil, nil, details_error
end
local access = property_access(details, identifier)
if not access then
return nil, nil, nil, nil, "property_access_denied"
end
return property, details, property_id, access, nil, tostring(identifier)
end
local function require_current_owner(property_id, identifier)
local details, details_error = get_property_data(property_id)
if not details then
return nil, details_error
end
if property_access(details, identifier) ~= "owner" then
return nil, "owner_required"
end
return details
end
local function get_current_access_property(identifier, property_id)
local properties = get_properties(identifier)
if not properties then
return nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property then
return nil, "property_access_denied"
end
return property
end
local function key_candidates(source, property_id, details)
local players = get_players_in_property(property_id)
local keyholders = get_keyholders(property_id)
if not players or not keyholders then
return nil, "provider_error"
end
local candidates = {}
local seen = {}
for _, value in pairs(players) do
local target = tonumber(value)
local identifier = target and Bridge.Framework.GetIdentifier(target) or nil
if target and target ~= source and identifier and not seen[target]
and not same_identifier(identifier, details.owner)
and not keyholder_exists(keyholders, tostring(identifier))
then
seen[target] = true
candidates[#candidates + 1] = {
id = target,
name = player_name(target, identifier),
}
end
end
table.sort(candidates, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return candidates
end
local function validate_grant_target(source, property_id, details, target)
target = tonumber(target)
if not target or target == source then
return nil, "invalid_target"
end
local players = get_players_in_property(property_id)
local keyholders = get_keyholders(property_id)
if not players or not keyholders then
return nil, "provider_error"
end
local present = false
for _, value in pairs(players) do
if tonumber(value) == target then
present = true
break
end
end
if not present then
return nil, "target_not_in_property"
end
local identifier = Bridge.Framework.GetIdentifier(target)
if not identifier then
return nil, "invalid_target"
end
identifier = tostring(identifier)
if same_identifier(identifier, details.owner) or keyholder_exists(keyholders, identifier) then
return nil, "key_already_exists"
end
return identifier
end
local function execute_action(source, action, data)
if not SkyPhone.AllowOperation(source, "housing_nolag_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" and action ~= "toggle_lock" then
return { success = false, error = "invalid_action" }
end
local property, details, property_id, access, error_code, actor_identifier = resolve_property(source, data)
if not property then
return { success = false, error = error_code }
end
if (action == "grant_key" or action == "revoke_key") and access ~= "owner" then
return { success = false, error = "owner_required" }
end
if action == "toggle_lock" and property.hasKey ~= true then
return { success = false, error = "capability_unavailable" }
end
if action == "grant_key" then
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
if not target_identifier then
return { success = false, error = target_error }
end
local current_details, current_error = require_current_owner(property_id, actor_identifier)
if not current_details then
return { success = false, error = current_error }
end
local success, result, provider_error = pcall(function()
return exports[resource_name]:AddKey(source, property_id, target_identifier)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:AddKey failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local keyholders_after = get_keyholders(property_id)
if not keyholders_after then
return { success = false, error = "provider_error" }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return keyholder_exists(keyholders_after, target_identifier)
and { success = true }
or { success = false, error = "action_failed" }
end
if action == "revoke_key" then
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return { success = false, error = "invalid_target" }
end
local keyholders = get_keyholders(property_id)
if not keyholders then
return { success = false, error = "provider_error" }
end
if not keyholder_exists(keyholders, identifier) then
return { success = false, error = "key_not_found" }
end
local current_details, current_error = require_current_owner(property_id, actor_identifier)
if not current_details then
return { success = false, error = current_error }
end
local success, result, provider_error = pcall(function()
return exports[resource_name]:RemoveKey(source, property_id, identifier)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:RemoveKey failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local keyholders_after = get_keyholders(property_id)
if not keyholders_after then
return { success = false, error = "provider_error" }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return not keyholder_exists(keyholders_after, identifier)
and { success = true }
or { success = false, error = "action_failed" }
end
local current_property, current_error = get_current_access_property(actor_identifier, property_id)
if not current_property then
return { success = false, error = current_error }
end
if current_property.hasKey ~= true then
return { success = false, error = "capability_unavailable" }
end
local desired_state = current_property.doorLocked ~= true
local success, result, provider_error = pcall(function()
return exports[resource_name]:ToggleDoorlock(source, property_id, desired_state)
end)
if not success then
Bridge.Debug("error", "[sky_phone] nolag_properties:ToggleDoorlock failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local details_after, details_error = get_property_data(property_id)
if not details_after then
return { success = false, error = details_error }
end
if result ~= true then
return { success = false, error = provider_error or "provider_rejected" }
end
return type(details_after.doorLocked) == "boolean" and details_after.doorLocked == desired_state
and { success = true }
or { success = false, error = "action_failed" }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
identifier = tostring(identifier)
local properties = get_properties(identifier)
if not properties then
return nil, "provider_error"
end
local result = {}
for _, property in pairs(properties) do
local property_id = type(property) == "table" and tonumber(property.id) or nil
if property_id then
local details = get_property_data(property_id)
local access = details and property_access(details, identifier) or nil
local keyholders = access == "owner" and get_keyholders(property_id) or {}
local normalized = access and keyholders
and normalized_property(property, details, access, keyholders) or nil
if normalized then
result[#result + 1] = normalized
if #result >= Config.Housing.MaximumProperties then
break
end
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, details, property_id, access, error_code = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = normalized_coords(property.coords) }
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
local candidates, candidates_error = key_candidates(source, property_id, details)
return candidates and { candidates = candidates } or nil, candidates_error
end
if action == "grant_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local target_identifier, target_error = validate_grant_target(source, property_id, details, data.target)
if not target_identifier then
return nil, target_error
end
return { providerId = tostring(property_id), target = tonumber(data.target) }
end
if action == "revoke_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local identifier = data.identifier
local keyholders = get_keyholders(property_id)
if not keyholders then
return nil, "provider_error"
end
if type(identifier) ~= "string" or not keyholder_exists(keyholders, identifier) then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = identifier }
end
if action == "toggle_lock" then
if property.hasKey ~= true then
return nil, "capability_unavailable"
end
return { providerId = tostring(property_id) }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:nolag:execute", function(source, data)
if type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
return execute_action(source, data.action, data)
end)
@@ -0,0 +1,237 @@
local provider_name = "quasar"
local resource_name = "qs-housing"
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function property_tables(property)
if not table_like(property) then
return {}
end
local result = { property }
for _, key in ipairs({ "data", "houseData", "house_data", "propertyData", "property_data" }) do
local nested = decode_object(field(property, key))
if nested and nested ~= property then
result[#result + 1] = nested
end
end
return result
end
local function entrance_for(property)
for _, candidate in ipairs(property_tables(property)) do
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
local coords = normalized_coords(field(candidate, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
local container = decode_object(field(candidate, key))
if container then
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
end
return nil
end
local function non_empty_string(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local text = tostring(value):match("^%s*(.-)%s*$")
return text ~= "" and text or nil
end
local function house_reference(key, property)
local direct = non_empty_string(property)
if direct then
return direct
end
if table_like(property) then
for _, name in ipairs({
"house", "houseName", "house_name", "name", "identifier",
}) do
local value = non_empty_string(field(property, name))
if value then
return value
end
end
end
if type(key) == "string" and not tonumber(key) then
return non_empty_string(key)
end
if table_like(property) then
for _, name in ipairs({ "propertyId", "property_id", "id" }) do
local value = non_empty_string(field(property, name))
if value then
return value
end
end
end
return nil
end
local function property_name(property, house)
for _, candidate in ipairs(property_tables(property)) do
for _, key in ipairs({ "label", "address", "adress", "displayName", "display_name", "name" }) do
local value = non_empty_string(field(candidate, key))
if value then
return value
end
end
end
return house
end
local function get_player_properties(source)
local success, properties = pcall(function()
return exports[resource_name]:GetPlayerHouses(source)
end)
if success and type(properties) == "table" then
return properties
end
Bridge.Debug(
"error",
"[sky_phone] qs-housing:GetPlayerHouses failed for source %s: %s",
tostring(source),
tostring(properties)
)
return nil, "provider_error"
end
local function normalized_properties(source)
local properties, error_code = get_player_properties(source)
if not properties then
return nil, error_code
end
local result = {}
local seen = {}
for key, property in pairs(properties) do
local house = house_reference(key, property)
if house and not seen[house] then
local entrance = entrance_for(property)
seen[house] = true
result[#result + 1] = {
id = provider_name .. ":" .. house,
providerId = house,
name = property_name(property, house),
access = "owner",
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
while #result > maximum do
result[#result] = nil
end
return result
end
local function find_property(source, property_id)
if type(property_id) ~= "string" then
return nil, "invalid_property"
end
local house = property_id:match("^quasar:(.+)$")
if not house or house == "" then
return nil, "invalid_property"
end
local properties, error_code = normalized_properties(source)
if not properties then
return nil, error_code
end
for _, property in ipairs(properties) do
if property.providerId == house then
return property
end
end
return nil, "property_access_denied"
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = normalized_properties,
prepare = function(source, action, data)
if action == "toggle_lock" or action == "grant_key" or action == "revoke_key"
or action == "key_candidates"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action ~= "set_waypoint" then
return nil, "invalid_action"
end
local property, error_code = find_property(source, data and data.propertyId)
if not property then
return nil, error_code
end
return {
providerId = property.providerId,
coords = property.entrance,
}
end,
})
@@ -0,0 +1,547 @@
local provider_name = "rtx"
local resource_name = "rtx_housing"
local function provider_call(export_name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug(
"error",
"[sky_phone] %s:%s failed: %s",
resource_name,
export_name,
tostring(result)
)
return false, nil
end
return true, result
end
local function normalize_property_id(value)
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil, nil
end
local integer = math.tointeger(number)
if not integer then
return nil, nil
end
return integer, tostring(integer)
end
local function decode_table(value)
local value_type = type(value)
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
return value
end
if value_type ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function normalized_coords(value)
local coords = Bridge.Normalize.Coordinates(value)
if not coords then
return nil
end
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
return nil
end
return coords
end
local function nested_coords(value)
local data = decode_table(value)
if not data then
return nil
end
return normalized_coords(data.coords) or normalized_coords(data)
end
local function property_zone_coords(property)
local zone = decode_table(property.propertyzone)
local points = zone and decode_table(zone.polyzonedata) or nil
if not points then
return nil
end
local count = 0
local x_total = 0.0
local y_total = 0.0
local z_total = 0.0
local z_count = 0
for _, point in pairs(points) do
local point_type = type(point)
if point_type == "table" or point_type == "vector3" or point_type == "vector4" then
local x = Bridge.Normalize.FiniteNumber(point.x)
local y = Bridge.Normalize.FiniteNumber(point.y)
local z = Bridge.Normalize.FiniteNumber(point.z)
if x and y then
count = count + 1
x_total = x_total + x
y_total = y_total + y
if z then
z_count = z_count + 1
z_total = z_total + z
end
end
end
end
if count == 0 then
return nil
end
local z = z_count > 0 and z_total / z_count or nil
if not z then
local minimum_z = Bridge.Normalize.FiniteNumber(zone.minz)
local maximum_z = Bridge.Normalize.FiniteNumber(zone.maxz)
if minimum_z and maximum_z then
z = (minimum_z + maximum_z) / 2.0
end
end
return normalized_coords({
x = x_total / count,
y = y_total / count,
z = z,
})
end
local function property_entrance(property)
local coords = nested_coords(property.enter)
or nested_coords(property.sellsign)
if coords then
return coords
end
local doors = decode_table(property.doors)
if doors then
for _, door in pairs(doors) do
local door_data = decode_table(door)
coords = door_data and nested_coords(door_data.door1) or nil
if coords then
return coords
end
end
end
coords = nested_coords(property.garage)
return coords or property_zone_coords(property)
end
local function property_identifier(property, fallback)
local value = type(property) == "table"
and (property.houseid or property.id or property.propertyid)
or nil
return normalize_property_id(value or fallback)
end
local function get_all_properties()
local success, properties = provider_call("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_owned_properties(source)
local success, properties = provider_call("GetPlayerOwnedProperties", function()
return exports[resource_name]:GetPlayerOwnedProperties(source)
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPlayerOwnedProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_property(property_id)
local success, property = provider_call("GetPropertyData", function()
return exports[resource_name]:GetPropertyData(property_id)
end)
if not success then
return nil, "provider_error"
end
if property == nil then
return nil, "property_not_found"
end
if type(property) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPropertyData returned invalid data.", resource_name)
return nil, "provider_error"
end
return property
end
local function boolean_export(export_name, callback)
local success, value = provider_call(export_name, callback)
if not success then
return nil, "provider_error"
end
if type(value) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
return nil, "provider_error"
end
return value
end
local function lock_status(property_id)
return boolean_export("GetPropertyLockStatus", function()
return exports[resource_name]:GetPropertyLockStatus(property_id)
end)
end
local function collect_catalog(catalog, properties)
local is_array = #properties > 0
for key, property in pairs(properties) do
if type(property) == "table" then
local property_id, id_key = property_identifier(property, is_array and nil or key)
if property_id then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
end
local function collect_owned(properties, catalog)
local owned = {}
local is_array = #properties > 0
for key, value in pairs(properties) do
local property = type(value) == "table" and value or nil
local fallback = property and (is_array and nil or key)
or (is_array and value or key)
local property_id, id_key = property_identifier(property, fallback)
if property_id then
owned[id_key] = property_id
if catalog and property then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
return owned
end
local function property_access(source, property, property_id, id_key, owned, identifier)
if owned[id_key] then
return "owner"
end
local owner = property.owner or property.Owner
if identifier and owner ~= nil and tostring(owner) == tostring(identifier) then
return "owner"
end
local has_keys, error_code = boolean_export("CheckPropertyKeys", function()
return exports[resource_name]:CheckPropertyKeys(source, property_id)
end)
if has_keys == nil then
return nil, error_code
end
if has_keys then
return "keyholder"
end
local has_permissions
has_permissions, error_code = boolean_export("HasPlayerAnyPropertyPermissions", function()
return exports[resource_name]:HasPlayerAnyPropertyPermissions(source, property_id)
end)
if has_permissions == nil then
return nil, error_code
end
return has_permissions and "keyholder" or nil
end
local function can_control_lock(source, property_id, access)
if access == "owner" then
return true
end
return boolean_export("GetPropertyPermission", function()
return exports[resource_name]:GetPropertyPermission(source, property_id, "unlocking")
end)
end
local function property_name(property, id_key)
local name = property.propertyname or property.name
if type(name) == "string" and name ~= "" then
return name
end
local address = property.adress or property.address
if type(address) == "string" and address ~= "" then
return address
end
return ("Property %s"):format(id_key)
end
local function normalized_property(property, property_id, id_key, access, coords, locked, can_lock)
return {
id = ("%s:%s"):format(provider_name, id_key),
providerId = id_key,
name = property_name(property, id_key),
access = access,
locked = locked,
entrance = coords,
capabilities = {
lock = can_lock == true,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
local function actor_access(source, property, property_id, id_key)
local owned_properties, error_code = get_owned_properties(source)
if not owned_properties then
return nil, nil, error_code
end
local owned = collect_owned(owned_properties)
local identifier = Bridge.Framework.GetIdentifier(source)
local access
access, error_code = property_access(
source,
property,
property_id,
id_key,
owned,
identifier
)
if error_code then
return nil, nil, error_code
end
if not access then
return nil, nil, "property_access_denied"
end
local can_lock
can_lock, error_code = can_control_lock(source, property_id, access)
if can_lock == nil then
return nil, nil, error_code
end
return access, can_lock
end
local function resolve_property(source, data)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
return nil, nil, nil, nil, nil, "invalid_request"
end
local raw_id = data.propertyId:match("^rtx:(%d+)$")
local property_id, id_key = normalize_property_id(raw_id)
if not property_id then
return nil, nil, nil, nil, nil, "invalid_property"
end
local property, error_code = get_property(property_id)
if not property then
return nil, nil, nil, nil, nil, error_code
end
local access, can_lock
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
if not access then
return nil, nil, nil, nil, nil, error_code
end
local coords = property_entrance(property)
if not coords then
return nil, nil, nil, nil, nil, "invalid_coordinates"
end
return property, property_id, id_key, access, can_lock, nil, coords
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local owned_properties
owned_properties, error_code = get_owned_properties(source)
if not owned_properties then
return nil, error_code
end
local catalog = {}
collect_catalog(catalog, properties)
local owned = collect_owned(owned_properties, catalog)
for id_key, property_id in pairs(owned) do
if not catalog[id_key] then
local property
property, error_code = get_property(property_id)
if not property then
return nil, error_code
end
catalog[id_key] = { id = property_id, property = property }
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
local result = {}
for id_key, entry in pairs(catalog) do
local access
access, error_code = property_access(
source,
entry.property,
entry.id,
id_key,
owned,
identifier
)
if error_code then
return nil, error_code
end
if access then
local coords = property_entrance(entry.property)
if coords then
local locked
locked, error_code = lock_status(entry.id)
if locked == nil then
return nil, error_code
end
local can_lock
can_lock, error_code = can_control_lock(source, entry.id, access)
if can_lock == nil then
return nil, error_code
end
result[#result + 1] = normalized_property(
entry.property,
entry.id,
id_key,
access,
coords,
locked,
can_lock
)
else
Bridge.Debug(
"debug",
"[sky_phone] Ignored RTX property '%s' because no valid entrance was found.",
id_key
)
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)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
local limited = {}
for index = 1, math.min(#result, maximum) do
limited[index] = result[index]
end
return limited
end,
prepare = function(source, action, data)
local property, property_id, id_key, access, can_lock, error_code, coords =
resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = coords }
end
if action == "toggle_lock" then
if not can_lock then
return nil, "property_access_denied"
end
return { providerId = id_key }
end
if action == "key_candidates" or action == "grant_key" or action == "revoke_key" then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
return nil, "invalid_action"
end,
})
local function execute_lock_action(source, data)
if not SkyPhone.AllowOperation(source, "housing_rtx_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 data.action ~= "toggle_lock" then
return { success = false, error = "invalid_action" }
end
if GetResourceState(resource_name) ~= "started" then
return { success = false, error = "provider_unavailable" }
end
local property_id, id_key = normalize_property_id(data.providerId)
if not property_id then
return { success = false, error = "invalid_property" }
end
local property, error_code = get_property(property_id)
if not property then
return { success = false, error = error_code }
end
local access, can_lock
access, can_lock, error_code = actor_access(source, property, property_id, id_key)
if not access then
return { success = false, error = error_code }
end
if not can_lock then
return { success = false, error = "property_access_denied" }
end
local current
current, error_code = lock_status(property_id)
if current == nil then
return { success = false, error = error_code }
end
local requested = not current
local setter_success = provider_call("SetPropertyLockStatus", function()
return exports[resource_name]:SetPropertyLockStatus(property_id, requested)
end)
if not setter_success then
return { success = false, error = "provider_error" }
end
local verified
verified, error_code = lock_status(property_id)
if verified == nil then
return { success = false, error = error_code }
end
if verified ~= requested then
Bridge.Debug(
"error",
"[sky_phone] %s lock update verification failed for property %s.",
resource_name,
id_key
)
return { success = false, error = "action_failed" }
end
return { success = true }
end
Bridge.Callbacks.Register("sky_phone:housing:rtx:execute", function(source, data)
return execute_lock_action(source, data)
end)
@@ -0,0 +1,730 @@
local provider_name = "rx"
local resource_name = "RxHousing"
local function positive_integer(value)
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil
end
return number
end
local function table_like(value)
local value_type = type(value)
return value_type == "table" or value_type == "vector3" or value_type == "vector4"
end
local function field(value, key)
if not table_like(value) then
return nil
end
local success, result = pcall(function()
return value[key]
end)
return success and result or nil
end
local function decode_object(value)
if table_like(value) then
return value
end
if type(value) ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and table_like(decoded) and decoded or nil
end
local function non_empty_string(value)
if type(value) ~= "string" and type(value) ~= "number" then
return nil
end
local text = tostring(value):match("^%s*(.-)%s*$")
return text ~= "" and text or nil
end
local function normalized_coords(value)
value = decode_object(value)
return value and Bridge.Normalize.Coordinates(value) or nil
end
local function entrance_for(property)
if not table_like(property) then
return nil
end
for _, key in ipairs({ "entrance", "Entrance", "entry", "Entry" }) do
local coords = normalized_coords(field(property, key))
if coords then
return coords
end
end
for _, key in ipairs({ "coords", "coordinates", "location", "position" }) do
local container = decode_object(field(property, key))
if container then
for _, nested_key in ipairs({ "entrance", "Entrance", "entry", "Entry", "enter", "door" }) do
local coords = normalized_coords(field(container, nested_key))
if coords then
return coords
end
end
local coords = normalized_coords(container)
if coords then
return coords
end
end
end
return nil
end
local function owner_identifier(property)
local owner = field(property, "owner")
local direct = non_empty_string(owner)
if direct then
return direct
end
if not table_like(owner) then
return nil
end
for _, key in ipairs({ "identifier", "citizenid", "citizenId", "id" }) do
local value = non_empty_string(field(owner, key))
if value then
return value
end
end
return nil
end
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local function property_name(property, property_id)
for _, key in ipairs({ "label", "name", "address", "adress" }) do
local value = non_empty_string(field(property, key))
if value then
return value
end
end
return ("Property %s"):format(property_id)
end
local function call_export(name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug("error", "[sky_phone] RxHousing:%s failed: %s", name, tostring(result))
return nil, false
end
return result, true
end
local function get_all_properties()
local properties, success = call_export("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success or type(properties) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetAllProperties returned invalid data.")
end
return nil, "provider_error"
end
return properties
end
local function property_id_for(key, property)
if table_like(property) then
for _, name in ipairs({ "id", "propertyId", "property_id" }) do
local property_id = positive_integer(field(property, name))
if property_id then
return property_id
end
end
end
return positive_integer(key)
end
local function property_entries(properties)
local result = {}
local seen = {}
for key, value in pairs(properties) do
local property = decode_object(value)
local property_id = property and property_id_for(key, property) or nil
if property_id and not seen[property_id] then
seen[property_id] = true
result[#result + 1] = { id = property_id, data = property }
end
end
table.sort(result, function(left, right)
return left.id < right.id
end)
return result
end
local function get_owned_property_ids(identifier)
local properties, success = call_export("GetOwnedProperties", function()
return exports[resource_name]:GetOwnedProperties(identifier)
end)
if not success or type(properties) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetOwnedProperties returned invalid data.")
end
return nil, "provider_error"
end
local result = {}
local is_array = #properties > 0
local direct_id = property_id_for(nil, properties)
if direct_id then
result[direct_id] = true
return result
end
for key, value in pairs(properties) do
local property = decode_object(value)
local property_id = property and property_id_for(key, property)
or positive_integer(value)
if not property_id and (value == true or value == 1) then
property_id = positive_integer(key)
end
if not property_id and not is_array then
property_id = positive_integer(key)
end
if property_id then
result[property_id] = true
end
end
return result
end
local function get_property(property_id)
local property, success = call_export("GetProperty", function()
return exports[resource_name]:GetProperty(property_id)
end)
if not success then
return nil, "provider_error"
end
property = decode_object(property)
if not property then
return nil, "property_not_found"
end
return property
end
local function boolean_result(value)
return value == true or value == 1 or value == "true"
end
local function has_key(property_id, identifier)
local result, success = call_export("HasKey", function()
return exports[resource_name]:HasKey(property_id, identifier)
end)
if not success then
return nil, "provider_error"
end
return boolean_result(result)
end
local function online_source(identifier)
for _, player_source in ipairs(Bridge.Framework.GetPlayers() or {}) do
if same_identifier(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 keyholder_identifier(key, value)
if type(value) == "string" then
return non_empty_string(value)
end
if table_like(value) then
for _, name in ipairs({ "identifier", "citizenid", "citizenId", "playerIdentifier", "player_identifier" }) do
local identifier = non_empty_string(field(value, name))
if identifier then
return identifier
end
end
end
if type(key) == "string" and not tonumber(key) and (value == true or table_like(value)) then
return non_empty_string(key)
end
return nil
end
local function supplied_keyholder_name(value)
if not table_like(value) then
return nil
end
local direct = non_empty_string(field(value, "name") or field(value, "label"))
if direct then
return direct
end
local first_name = non_empty_string(field(value, "firstname") or field(value, "firstName")) or ""
local last_name = non_empty_string(field(value, "lastname") or field(value, "lastName")) or ""
local name = (first_name .. " " .. last_name):match("^%s*(.-)%s*$")
return name ~= "" and name or nil
end
local function get_keyholder_values(property_id)
local values, success = call_export("GetPropertyKeyholders", function()
return exports[resource_name]:GetPropertyKeyholders(property_id)
end)
if not success or type(values) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetPropertyKeyholders returned invalid data.")
end
return nil, "provider_error"
end
return values
end
local function normalized_keyholders(property_id, excluded_identifier)
local values, error_code = get_keyholder_values(property_id)
if not values then
return nil, error_code
end
local result = {}
local seen = {}
for key, value in pairs(values) do
local identifier = keyholder_identifier(key, value)
if identifier and not same_identifier(identifier, excluded_identifier) and not seen[identifier] then
seen[identifier] = true
local player_source = online_source(identifier)
result[#result + 1] = {
identifier = identifier,
name = player_source and player_name(player_source)
or supplied_keyholder_name(value)
or identifier,
online = player_source ~= nil,
revocable = true,
}
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return result
end
local function access_for(property_id, identifier, owned_properties)
if owned_properties[property_id] then
return "owner"
end
local allowed, error_code = has_key(property_id, identifier)
if allowed == nil then
return nil, error_code
end
return allowed and "keyholder" or nil
end
local function normalized_property(property_id, property, access, actor_identifier)
local entrance = entrance_for(property)
if not entrance then
return nil
end
local keys = nil
if access == "owner" then
local error_code
keys, error_code = normalized_keyholders(property_id, actor_identifier)
if not keys then
return nil, error_code
end
end
return {
id = ("rx:%s"):format(property_id),
providerId = tostring(property_id),
name = property_name(property, property_id),
access = access,
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keys = access == "owner",
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = keys,
}
end
local function get_overview(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return nil, owned_error
end
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local result = {}
for _, entry in ipairs(property_entries(properties)) do
local access, access_error = access_for(entry.id, identifier, owned_properties)
if access_error then
return nil, access_error
end
if access then
local property, normalize_error = normalized_property(entry.id, entry.data, access, identifier)
if normalize_error then
return nil, normalize_error
end
if property then
result[#result + 1] = property
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)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
while #result > maximum do
result[#result] = nil
end
return result
end
local function parse_property_id(value)
if type(value) ~= "string" then
return nil
end
return positive_integer(value:match("^rx:(%d+)$"))
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 = get_property(property_id)
if not property then
return nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, "housing_unavailable"
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return nil, nil, nil, owned_error
end
local access, access_error = access_for(property_id, identifier, owned_properties)
if access_error then
return nil, nil, nil, access_error
end
if not access then
return nil, nil, nil, "property_access_denied"
end
if not entrance_for(property) then
return nil, nil, nil, "invalid_coordinates"
end
return property, property_id, access
end
local function player_source_for(key, value)
local direct = positive_integer(value)
if direct then
return direct
end
if table_like(value) then
for _, name in ipairs({ "source", "playerId", "player_id", "serverId", "server_id", "id" }) do
local player_source = positive_integer(field(value, name))
if player_source then
return player_source
end
end
end
if value == true or table_like(value) then
return positive_integer(key)
end
return nil
end
local function players_in_property(property_id)
local values, success = call_export("GetPlayersInProperty", function()
return exports[resource_name]:GetPlayersInProperty(property_id)
end)
if not success or type(values) ~= "table" then
if success then
Bridge.Debug("error", "[sky_phone] RxHousing:GetPlayersInProperty returned invalid data.")
end
return nil, "provider_error"
end
local result = {}
local seen = {}
for key, value in pairs(values) do
local player_source = player_source_for(key, value)
if player_source and not seen[player_source] and GetPlayerName(player_source) then
seen[player_source] = true
result[#result + 1] = player_source
end
end
return result
end
local function key_candidates(source, property, property_id)
local players, error_code = players_in_property(property_id)
if not players then
return nil, error_code
end
local owner = Bridge.Framework.GetIdentifier(source) or owner_identifier(property)
local result = {}
for _, target in ipairs(players) do
local identifier = Bridge.Framework.GetIdentifier(target)
if target ~= source and identifier and not same_identifier(identifier, owner) then
local allowed, access_error = has_key(property_id, identifier)
if allowed == nil then
return nil, access_error
end
if not allowed then
result[#result + 1] = {
id = target,
name = player_name(target),
}
end
end
end
table.sort(result, function(left, right)
return string.lower(left.name) < string.lower(right.name)
end)
return result
end
local function has_keyholder(property_id, identifier)
local keyholders, error_code = normalized_keyholders(property_id)
if not keyholders then
return nil, error_code
end
for _, keyholder in ipairs(keyholders) do
if same_identifier(keyholder.identifier, identifier) then
return true
end
end
return false
end
local function execute_key_action(source, data)
if not SkyPhone.AllowOperation(source, "housing_rx_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 GetResourceState(resource_name) ~= "started" then
return { success = false, error = "provider_unavailable" }
end
if type(data) ~= "table" or (data.action ~= "grant_key" and data.action ~= "revoke_key") then
return { success = false, error = "invalid_action" }
end
local property_id = positive_integer(data.providerId)
if not property_id then
return { success = false, error = "invalid_property" }
end
local property, error_code = get_property(property_id)
if not property then
return { success = false, error = error_code }
end
if not entrance_for(property) then
return { success = false, error = "invalid_coordinates" }
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return { success = false, error = "owner_required" }
end
local owned_properties, owned_error = get_owned_property_ids(identifier)
if not owned_properties then
return { success = false, error = owned_error }
end
if not owned_properties[property_id] then
return { success = false, error = "owner_required" }
end
if data.action == "grant_key" then
local target = positive_integer(data.target)
local target_identifier = target and Bridge.Framework.GetIdentifier(target) or nil
if not target or target == source or not target_identifier or not GetPlayerName(target) then
return { success = false, error = "invalid_target" }
end
if same_identifier(target_identifier, identifier) then
return { success = false, error = "invalid_target" }
end
local players, players_error = players_in_property(property_id)
if not players then
return { success = false, error = players_error }
end
local inside = false
for _, player_source in ipairs(players) do
if player_source == target then
inside = true
break
end
end
if not inside then
return { success = false, error = "target_not_in_property" }
end
local allowed, access_error = has_key(property_id, target_identifier)
if allowed == nil then
return { success = false, error = access_error }
end
if allowed then
return { success = false, error = "key_already_exists" }
end
local current_owned, current_error = get_owned_property_ids(identifier)
if not current_owned then
return { success = false, error = current_error }
end
if not current_owned[property_id] then
return { success = false, error = "owner_required" }
end
local _, success = call_export("AddKeyholder", function()
return exports[resource_name]:AddKeyholder(property_id, target_identifier)
end)
if not success then
return { success = false, error = "provider_error" }
end
local allowed_after, verify_error = has_key(property_id, target_identifier)
if allowed_after == nil then
return { success = false, error = verify_error }
end
return allowed_after
and { success = true }
or { success = false, error = "action_failed" }
end
local target_identifier = non_empty_string(data.identifier)
if not target_identifier or same_identifier(target_identifier, identifier) then
return { success = false, error = "invalid_target" }
end
local exists, key_error = has_keyholder(property_id, target_identifier)
if exists == nil then
return { success = false, error = key_error }
end
if not exists then
return { success = false, error = "key_not_found" }
end
local current_owned, current_error = get_owned_property_ids(identifier)
if not current_owned then
return { success = false, error = current_error }
end
if not current_owned[property_id] then
return { success = false, error = "owner_required" }
end
local _, success = call_export("RemoveKeyholder", function()
return exports[resource_name]:RemoveKeyholder(property_id, target_identifier)
end)
if not success then
return { success = false, error = "provider_error" }
end
local exists_after, verify_error = has_keyholder(property_id, target_identifier)
if exists_after == nil then
return { success = false, error = verify_error }
end
return not exists_after
and { success = true }
or { success = false, error = "action_failed" }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = get_overview,
prepare = function(source, action, data)
if action == "toggle_lock" then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
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
return { coords = entrance_for(property) }
end
if access ~= "owner" then
return nil, "owner_required"
end
if action == "key_candidates" then
local candidates, candidates_error = key_candidates(source, property, property_id)
if not candidates then
return nil, candidates_error
end
return { candidates = candidates }
end
if action == "grant_key" then
local target = positive_integer(data and data.target)
if not target then
return nil, "invalid_target"
end
local candidates, candidates_error = key_candidates(source, property, property_id)
if not candidates then
return nil, candidates_error
end
for _, candidate in ipairs(candidates) do
if candidate.id == target then
return { providerId = tostring(property_id), target = target }
end
end
return nil, "target_not_in_property"
end
if action == "revoke_key" then
local target_identifier = non_empty_string(data and data.identifier)
local actor_identifier = Bridge.Framework.GetIdentifier(source)
if not target_identifier or same_identifier(target_identifier, actor_identifier) then
return nil, "invalid_target"
end
local exists, key_error = has_keyholder(property_id, target_identifier)
if exists == nil then
return nil, key_error
end
if not exists then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = target_identifier }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:rx:execute", function(source, data)
return execute_key_action(source, data)
end)
@@ -0,0 +1,393 @@
local provider_name = "sn"
local resource_name = "sn_properties"
local function same_identifier(left, right)
return left ~= nil and right ~= nil and tostring(left) == tostring(right)
end
local function normalized_property_id(value)
if type(value) == "table" then
value = value.id or value.propertyId or value.property_id
end
local property_id = tonumber(value)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil
end
return property_id
end
local normalized_coords = Bridge.Normalize.Coordinates
local function get_all_properties()
local success, properties = pcall(function()
return exports[resource_name]:getAllProperties()
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] sn_properties:getAllProperties failed: %s", tostring(properties))
return nil
end
return properties
end
local function collect_owned_property_ids(value, result)
local direct_id = normalized_property_id(value)
if direct_id then
result[direct_id] = true
return
end
if type(value) ~= "table" then
return
end
local array_length = #value
local is_dense_array = array_length > 0
if is_dense_array then
local entries = 0
for key in pairs(value) do
entries = entries + 1
if type(key) ~= "number" or key < 1 or key ~= math.floor(key) or key > array_length then
is_dense_array = false
end
end
is_dense_array = is_dense_array and entries == array_length
end
for key, entry in pairs(value) do
local entry_id = normalized_property_id(entry)
if entry_id then
result[entry_id] = true
else
local key_id = normalized_property_id(key)
local keyed_property = key_id and entry ~= nil and entry ~= false
and (entry == true or type(key) == "string" or not is_dense_array)
if keyed_property then
result[key_id] = true
end
end
end
end
local function get_owned_property_ids(source)
local success, properties = pcall(function()
return exports[resource_name]:getPlayerProperties(source)
end)
if not success then
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties failed: %s", tostring(properties))
return nil
end
local result = {}
if properties == nil or properties == false then
return result
end
if type(properties) ~= "table" and type(properties) ~= "number" and type(properties) ~= "string" then
Bridge.Debug("error", "[sky_phone] sn_properties:getPlayerProperties returned an invalid value")
return nil
end
collect_owned_property_ids(properties, result)
return result
end
local function player_source(identifier)
for _, source in ipairs(Bridge.Framework.GetPlayers()) do
if same_identifier(Bridge.Framework.GetIdentifier(source), identifier) then
return source
end
end
return nil
end
local function player_name(source, fallback)
if source then
local name = Bridge.Framework.GetCharacterName(source) or GetPlayerName(source)
if type(name) == "string" then
name = name:match("^%s*(.-)%s*$")
if name ~= "" then
return name
end
end
end
return tostring(fallback)
end
local function property_keys(property)
return type(property.keys) == "table" and property.keys or {}
end
local function property_access(property, identifier, owned_property_ids)
local property_id = normalized_property_id(property)
if property_id and owned_property_ids[property_id] then
return "owner"
end
if property_keys(property)[tostring(identifier)] ~= nil then
return "keyholder"
end
return nil
end
local function normalized_keys(property, actor_identifier)
local keys = {}
for identifier in pairs(property_keys(property)) do
if (type(identifier) == "string" or type(identifier) == "number")
and not same_identifier(identifier, actor_identifier)
then
local normalized_identifier = tostring(identifier)
local source = player_source(normalized_identifier)
keys[#keys + 1] = {
identifier = normalized_identifier,
name = player_name(source, normalized_identifier),
online = source ~= 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, actor_identifier)
local property_id = normalized_property_id(property)
local entrance = normalized_coords(property.coords)
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) or not entrance then
return nil
end
local owner = access == "owner"
return {
id = ("sn:%s"):format(property_id),
providerId = tostring(property_id),
name = tostring(property.label or ("Property %s"):format(property_id)),
access = access,
locked = false,
entrance = entrance,
capabilities = {
lock = false,
keyGrant = false,
keys = owner,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = owner and normalized_keys(property, actor_identifier) or nil,
}
end
local function find_property(properties, property_id)
if normalized_property_id(properties) == property_id then
return properties
end
for _, property in pairs(properties) do
if type(property) == "table" and tonumber(property.id) == property_id then
return property
end
end
return nil
end
local function parse_property_id(data)
if type(data) ~= "table" then
return nil, "invalid_request"
end
local property_id = type(data.propertyId) == "string"
and tonumber(data.propertyId:match("^sn:(%d+)$")) or nil
if not property_id then
property_id = tonumber(data.providerId)
end
if not property_id or property_id < 1 or property_id ~= math.floor(property_id) then
return nil, "invalid_property"
end
return property_id
end
local function resolve_property(source, data)
local property_id, error_code = parse_property_id(data)
if not property_id then
return nil, nil, nil, error_code
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, nil, nil, "housing_unavailable"
end
identifier = tostring(identifier)
local owned_property_ids = get_owned_property_ids(source)
if not owned_property_ids then
return nil, nil, nil, "provider_error"
end
local properties = get_all_properties()
if not properties then
return nil, nil, nil, "provider_error"
end
local property = find_property(properties, property_id)
if not property or not normalized_coords(property.coords) then
return nil, nil, nil, "property_not_found"
end
local access = property_access(property, identifier, owned_property_ids)
if not access then
return nil, nil, nil, "property_access_denied"
end
return property, property_id, access, nil, identifier, owned_property_ids
end
local function execute_action(source, action, data)
if not SkyPhone.AllowOperation(source, "housing_sn_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" then
return { success = false, error = "capability_unavailable" }
end
if action ~= "revoke_key" then
return { success = false, error = "invalid_action" }
end
local property, property_id, access, error_code, actor_identifier, owned_property_ids = resolve_property(source, data)
if not property then
return { success = false, error = error_code }
end
if access ~= "owner" or not owned_property_ids[property_id] then
return { success = false, error = "owner_required" }
end
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return { success = false, error = "invalid_target" }
end
if property_keys(property)[identifier] == nil then
return { success = false, error = "key_not_found" }
end
local current_owned_property_ids = get_owned_property_ids(source)
if not current_owned_property_ids then
return { success = false, error = "provider_error" }
end
if not current_owned_property_ids[property_id] then
return { success = false, error = "owner_required" }
end
local success, result = pcall(function()
return exports[resource_name]:removeKeyholder(identifier, property_id)
end)
if not success then
Bridge.Debug("error", "[sky_phone] sn_properties:removeKeyholder failed: %s", tostring(result))
return { success = false, error = "provider_error" }
end
local updated_properties = get_all_properties()
if not updated_properties then
return { success = false, error = "provider_error" }
end
local updated_property = find_property(updated_properties, property_id)
if not updated_property then
return { success = false, error = "property_not_found" }
end
if property_keys(updated_property)[identifier] ~= nil then
return { success = false, error = result == false and "provider_rejected" or "action_failed" }
end
return { success = true }
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
identifier = tostring(identifier)
local owned_property_ids = get_owned_property_ids(source)
if not owned_property_ids then
return nil, "provider_error"
end
local properties = get_all_properties()
if not properties then
return nil, "provider_error"
end
local result = {}
for _, property in pairs(properties) do
if type(property) == "table" then
local access = property_access(property, identifier, owned_property_ids)
local normalized = access and normalized_property(property, access, identifier) or nil
if normalized then
result[#result + 1] = normalized
if #result >= Config.Housing.MaximumProperties then
break
end
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, actor_identifier = resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = normalized_coords(property.coords) }
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
if action == "toggle_lock" then
return nil, "capability_unavailable"
end
if action == "key_candidates" then
if access ~= "owner" then
return nil, "owner_required"
end
return { candidates = {} }
end
if action == "grant_key" then
if access ~= "owner" then
return nil, "owner_required"
end
return nil, "capability_unavailable"
end
if action == "revoke_key" then
if access ~= "owner" then
return nil, "owner_required"
end
local identifier = data.identifier
if type(identifier) ~= "string" or identifier == "" or same_identifier(identifier, actor_identifier) then
return nil, "invalid_target"
end
if property_keys(property)[identifier] == nil then
return nil, "key_not_found"
end
return { providerId = tostring(property_id), identifier = identifier }
end
return nil, "invalid_action"
end,
})
Bridge.Callbacks.Register("sky_phone:housing:sn:execute", function(source, data)
if type(data) ~= "table" or type(data.action) ~= "string" then
return { success = false, error = "invalid_request" }
end
return execute_action(source, data.action, data)
end)
@@ -0,0 +1,428 @@
local provider_name = "vms"
local resource_name = "vms_housing"
local function provider_call(export_name, callback)
local success, result = pcall(callback)
if not success then
Bridge.Debug(
"error",
"[sky_phone] %s:%s failed: %s",
resource_name,
export_name,
tostring(result)
)
return false, nil
end
return true, result
end
local function normalize_property_id(value)
if type(value) == "number" then
local number = Bridge.Normalize.FiniteNumber(value)
if not number or number < 1 or number ~= math.floor(number) then
return nil, nil
end
local integer = math.tointeger(number)
return integer, integer and tostring(integer) or nil
end
if type(value) ~= "string" then
return nil, nil
end
local normalized = value:match("^%s*(.-)%s*$")
if normalized == "" or #normalized > 128 or normalized:find("%c") then
return nil, nil
end
return normalized, normalized
end
local function decode_table(value)
local value_type = type(value)
if value_type == "table" or value_type == "vector3" or value_type == "vector4" then
return value
end
if value_type ~= "string" or value == "" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function normalized_coords(value)
local coords = Bridge.Normalize.Coordinates(value)
if not coords then
return nil
end
if math.abs(coords.x) < 0.001 and math.abs(coords.y) < 0.001 and math.abs(coords.z) < 0.001 then
return nil
end
return coords
end
local function property_identifier(property, fallback)
local value = type(property) == "table"
and (property.id or property.propertyId or property.property_id)
or nil
return normalize_property_id(value or fallback)
end
local function get_all_properties()
local success, properties = provider_call("GetAllProperties", function()
return exports[resource_name]:GetAllProperties()
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetAllProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_player_properties(source)
local success, properties = provider_call("GetPlayerProperties", function()
return exports[resource_name]:GetPlayerProperties(source)
end)
if not success then
return nil, "provider_error"
end
if type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetPlayerProperties returned invalid data.", resource_name)
return nil, "provider_error"
end
return properties
end
local function get_property(property_id)
local success, property = provider_call("GetProperty", function()
return exports[resource_name]:GetProperty(property_id)
end)
if not success then
return nil, "provider_error"
end
if property == nil then
return nil, "property_not_found"
end
if type(property) ~= "table" then
Bridge.Debug("error", "[sky_phone] %s:GetProperty returned invalid data.", resource_name)
return nil, "provider_error"
end
return property
end
local function boolean_export(export_name, callback)
local success, value = provider_call(export_name, callback)
if not success then
return nil, "provider_error"
end
if type(value) ~= "boolean" then
Bridge.Debug("error", "[sky_phone] %s:%s returned a non-boolean value.", resource_name, export_name)
return nil, "provider_error"
end
return value
end
local function collect_catalog(catalog, properties)
local direct_id, direct_key = property_identifier(properties)
if direct_id then
catalog[direct_key] = { id = direct_id, property = properties }
return
end
local is_array = #properties > 0
for key, property in pairs(properties) do
if type(property) == "table" then
local property_id, id_key = property_identifier(property, is_array and nil or key)
if property_id then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
end
local function collect_player_properties(properties, catalog)
local direct = {}
local direct_id, direct_key = property_identifier(properties)
if direct_id then
direct[direct_key] = direct_id
if catalog then
catalog[direct_key] = { id = direct_id, property = properties }
end
return direct
end
local is_array = #properties > 0
for key, value in pairs(properties) do
local property = type(value) == "table" and value or nil
local fallback = property and (is_array and nil or key)
or (is_array and value or key)
local property_id, id_key = property_identifier(property, fallback)
if property_id then
direct[id_key] = property_id
if catalog and property then
catalog[id_key] = {
id = property_id,
property = property,
}
end
end
end
return direct
end
local function identifiers_match(value, identifier)
return identifier ~= nil
and value ~= nil
and tostring(value) ~= ""
and tostring(value) == tostring(identifier)
end
local function property_access(source, property, property_id, id_key, direct, identifier)
if identifiers_match(property.owner, identifier) then
return "owner"
end
if identifiers_match(property.renter, identifier) or direct[id_key] then
return "keyholder"
end
if not identifier then
return nil
end
local has_keys, error_code = boolean_export("HasKeys", function()
return exports[resource_name]:HasKeys(source, identifier, property_id)
end)
if has_keys == nil then
return nil, error_code
end
if has_keys then
return "keyholder"
end
local has_permissions
has_permissions, error_code = boolean_export("HasAnyPermission", function()
return exports[resource_name]:HasAnyPermission(property_id, identifier)
end)
if has_permissions == nil then
return nil, error_code
end
return has_permissions and "keyholder" or nil
end
local function property_metadata(property)
return decode_table(property.metadata) or {}
end
local function property_entrance(property, building_cache)
local object_id = property.object_id
if object_id ~= nil and tostring(object_id) ~= "" then
local normalized_object_id, object_key = normalize_property_id(object_id)
if normalized_object_id then
local building = nil
local cache_hit = building_cache and building_cache[object_key] ~= nil
if cache_hit then
building = building_cache[object_key]
else
local object, error_code = get_property(normalized_object_id)
if not object and error_code ~= "property_not_found" then
return nil, error_code
end
building = object or false
if building_cache then
building_cache[object_key] = building
end
end
if type(building) == "table" and building.type == "building" then
local coords = normalized_coords(property_metadata(building).enter)
if coords then
return coords
end
end
end
end
local metadata = property_metadata(property)
return normalized_coords(metadata.enter) or normalized_coords(metadata.menu)
end
local function property_name(property, id_key)
if type(property.name) == "string" and property.name ~= "" then
return property.name
end
if type(property.address) == "string" and property.address ~= "" then
return property.address
end
return ("Property %s"):format(id_key)
end
local function normalized_property(property, id_key, access, coords)
local metadata = property_metadata(property)
return {
id = ("%s:%s"):format(provider_name, id_key),
providerId = id_key,
name = property_name(property, id_key),
access = access,
locked = metadata.locked == true,
entrance = coords,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
end
local function resolve_property(source, data)
if type(data) ~= "table" or type(data.propertyId) ~= "string" then
return nil, nil, nil, nil, "invalid_request"
end
local raw_id = data.propertyId:match("^vms:(.+)$")
local property_id, id_key = normalize_property_id(raw_id)
if not property_id then
return nil, nil, nil, nil, "invalid_property"
end
local property, error_code = get_property(property_id)
if not property then
return nil, nil, nil, nil, error_code
end
local player_properties
player_properties, error_code = get_player_properties(source)
if not player_properties then
return nil, nil, nil, nil, error_code
end
local direct = collect_player_properties(player_properties)
local identifier = Bridge.Framework.GetIdentifier(source)
local access
access, error_code = property_access(
source,
property,
property_id,
id_key,
direct,
identifier
)
if error_code then
return nil, nil, nil, nil, error_code
end
if not access then
return nil, nil, nil, nil, "property_access_denied"
end
local coords
coords, error_code = property_entrance(property)
if not coords then
return nil, nil, nil, nil, error_code or "invalid_coordinates"
end
return property, property_id, id_key, coords
end
Bridge.Housing.RegisterProvider(provider_name, {
resource_name = resource_name,
is_available = function()
return GetResourceState(resource_name) == "started"
end,
get_overview = function(source)
local properties, error_code = get_all_properties()
if not properties then
return nil, error_code
end
local player_properties
player_properties, error_code = get_player_properties(source)
if not player_properties then
return nil, error_code
end
local catalog = {}
collect_catalog(catalog, properties)
local direct = collect_player_properties(player_properties, catalog)
for id_key, property_id in pairs(direct) do
if not catalog[id_key] then
local property
property, error_code = get_property(property_id)
if not property then
return nil, error_code
end
catalog[id_key] = { id = property_id, property = property }
end
end
local identifier = Bridge.Framework.GetIdentifier(source)
local building_cache = {}
local result = {}
for id_key, entry in pairs(catalog) do
local access
access, error_code = property_access(
source,
entry.property,
entry.id,
id_key,
direct,
identifier
)
if error_code then
return nil, error_code
end
if access then
local coords
coords, error_code = property_entrance(entry.property, building_cache)
if error_code then
return nil, error_code
end
if coords then
result[#result + 1] = normalized_property(
entry.property,
id_key,
access,
coords
)
else
Bridge.Debug(
"debug",
"[sky_phone] Ignored VMS property '%s' because no valid entrance was found.",
id_key
)
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)
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 50))
local limited = {}
for index = 1, math.min(#result, maximum) do
limited[index] = result[index]
end
return limited
end,
prepare = function(source, action, data)
local property, property_id, id_key, coords, error_code =
resolve_property(source, data)
if not property then
return nil, error_code
end
if action == "set_waypoint" then
return { coords = coords }
end
if action == "key_candidates" or action == "grant_key" or action == "revoke_key"
or action == "toggle_lock"
then
return nil, "capability_unavailable"
end
if action == "open_cctv" then
return nil, "cctv_unavailable"
end
return nil, "invalid_action"
end,
})
+30
View File
@@ -5,8 +5,38 @@ Bridge.Database = Bridge.Database or {}
Bridge.Framework = Bridge.Framework or {}
Bridge.Inventory = Bridge.Inventory or {}
Bridge.Radio = Bridge.Radio or {}
Bridge.Normalize = Bridge.Normalize or {}
Bridge.Speaker = Bridge.Speaker or {}
function Bridge.Normalize.FiniteNumber(value)
local number = tonumber(value)
if not number or number ~= number or number == math.huge or number == -math.huge then
return nil
end
return number
end
function Bridge.Normalize.Coordinates(value)
if value == nil then
return nil
end
local success, x, y, z = pcall(function()
return value.x or value[1], value.y or value[2], value.z or value[3]
end)
if not success then
return nil
end
x = Bridge.Normalize.FiniteNumber(x)
y = Bridge.Normalize.FiniteNumber(y)
z = Bridge.Normalize.FiniteNumber(z)
if not x or not y or not z then
return nil
end
return { x = x, y = y, z = z }
end
function Bridge.Speaker.IsEnabled()
return not Config.Speaker or Config.Speaker.Enabled ~= false
end
+26 -2
View File
@@ -169,6 +169,17 @@ RegisterNUICallback("housing:overview", function(data, cb)
return
end
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
if type(result) == "table" and result.success and type(result.data) == "table" then
local properties, error_code = Bridge.Housing.EnrichOverview(
result.data.provider,
result.data.properties
)
if not properties then
cb({ success = false, error = error_code or "provider_error" })
return
end
result.data.properties = properties
end
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
end)
@@ -211,11 +222,24 @@ RegisterNUICallback("housing:command", function(data, cb)
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
if coords == nil then
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 "invalid_coordinates" })
return
end
local x = type(coords) == "table" and camera_number(coords.x) or nil
local y = type(coords) == "table" and camera_number(coords.y) or nil
if not x or not y then
cb({ success = false, error = "invalid_coordinates" })
return
end
SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0)
SetNewWaypoint(x + 0.0, y + 0.0)
cb({ success = true })
return
end
+91
View File
@@ -0,0 +1,91 @@
local RELEASE_API_URL = "https://api.github.com/repos/sky-systems/sky_phone/releases/latest"
local RELEASE_PAGE_URL = "https://github.com/sky-systems/sky_phone/releases/latest"
local function parse_version(version)
if type(version) ~= "string" then
return nil
end
local major, minor, patch = version:match("^v?(%d+)%.(%d+)%.(%d+)$")
if not major then
return nil
end
return tonumber(major), tonumber(minor), tonumber(patch)
end
local function compare_versions(installed_version, release_version)
local installed_major, installed_minor, installed_patch = parse_version(installed_version)
local release_major, release_minor, release_patch = parse_version(release_version)
if not installed_major or not release_major then
return nil
end
if installed_major ~= release_major then
return installed_major < release_major and -1 or 1
end
if installed_minor ~= release_minor then
return installed_minor < release_minor and -1 or 1
end
if installed_patch ~= release_patch then
return installed_patch < release_patch and -1 or 1
end
return 0
end
local function print_update_notice(installed_version, release_version)
local border = "======================================================================"
print(([[
^1%s^0
^1 SKY PHONE UPDATE AVAILABLE ^0
^1%s^0
^3 Installed version: ^1%s^0
^3 Latest release: ^2%s^0
^5 Download: %s^0
^1%s^0]]):format(border, border, installed_version, release_version, RELEASE_PAGE_URL, border))
end
local function check_for_update()
local resource_name = GetCurrentResourceName()
local installed_version = GetResourceMetadata(resource_name, "version", 0)
if not parse_version(installed_version) then
print(("^3[sky_phone] Update check skipped because fxmanifest.lua has an invalid version: %s.^0")
:format(tostring(installed_version)))
return
end
PerformHttpRequest(RELEASE_API_URL, function(status_code, response_body)
if status_code ~= 200 then
print(("^3[sky_phone] GitHub update check failed with HTTP %s.^0"):format(tostring(status_code)))
return
end
local decoded, release = pcall(json.decode, response_body)
local release_version = decoded and type(release) == "table" and release.tag_name or nil
local comparison = compare_versions(installed_version, release_version)
if not comparison then
print(("^3[sky_phone] GitHub update check returned an invalid release tag: %s.^0")
:format(tostring(release_version)))
return
end
if comparison < 0 then
print_update_notice(installed_version, release_version)
elseif comparison > 0 then
print(("^3[sky_phone] Installed version %s is newer than the latest GitHub release %s.^0")
:format(installed_version, release_version))
else
print(("^2[sky_phone] Version %s is up to date.^0"):format(installed_version))
end
end, "GET", "", {
["Accept"] = "application/vnd.github+json",
["User-Agent"] = "sky_phone-update-check",
["X-GitHub-Api-Version"] = "2022-11-28",
})
end
CreateThread(check_for_update)
+69
View File
@@ -0,0 +1,69 @@
local source_path = "sky_phone/source/server/update_check.lua"
local original_print = print
local function run_check(installed_version, status_code, release_version)
local output = {}
local request
print = function(message)
output[#output + 1] = tostring(message)
end
GetCurrentResourceName = function()
return "sky_phone"
end
GetResourceMetadata = function(resource_name, key, index)
assert(resource_name == "sky_phone", "update check must read its own resource metadata")
assert(key == "version" and index == 0, "update check must read the fxmanifest version")
return installed_version
end
PerformHttpRequest = function(url, callback, method, body, headers)
request = {
url = url,
callback = callback,
method = method,
body = body,
headers = headers,
}
end
CreateThread = function(callback)
callback()
end
json = {
decode = function()
return { tag_name = release_version }
end,
}
dofile(source_path)
if request then
assert(request.url == "https://api.github.com/repos/sky-systems/sky_phone/releases/latest")
assert(request.method == "GET" and request.body == "", "update check must use a read-only GET request")
assert(request.headers["Accept"] == "application/vnd.github+json")
assert(request.headers["User-Agent"] == "sky_phone-update-check")
request.callback(status_code, "{}")
end
return table.concat(output, "\n"), request
end
local current_output = run_check("0.1.0", 200, "0.1.0")
assert(current_output:find("Version 0.1.0 is up to date", 1, true), "matching versions must report up to date")
local outdated_output = run_check("0.1.0", 200, "0.2.0")
assert(outdated_output:find("SKY PHONE UPDATE AVAILABLE", 1, true), "newer releases must show an update notice")
assert(outdated_output:find("Installed version: ^10.1.0", 1, true), "notice must show the manifest version")
assert(outdated_output:find("Latest release: ^20.2.0", 1, true), "notice must show the release tag")
local ahead_output = run_check("1.0.0", 200, "0.9.9")
assert(ahead_output:find("newer than the latest GitHub release", 1, true), "ahead versions must be distinguished")
local invalid_output, invalid_request = run_check("development", 200, "0.1.0")
assert(not invalid_request, "invalid manifest versions must not make an HTTP request")
assert(invalid_output:find("fxmanifest.lua has an invalid version", 1, true), "invalid manifests must be visible")
local failed_output = run_check("0.1.0", 429, "0.1.0")
assert(failed_output:find("GitHub update check failed with HTTP 429", 1, true), "HTTP failures must be visible")
print = original_print
io.write("Sky Phone update check tests passed\n")