Compare commits

...

3 Commits

Author SHA1 Message Date
Alec Schitzkat efcc67334f BLD - Bump sky_phone version to 0.3.3 2026-08-26 18:47:33 +02:00
Leon.Schmidt 693c9bd6f9 FIX - allow configurable radio channel jobs (#46)
Mark locked-channel job maps mutable in the configurator schema and initialize new structured list rows from their schema template so required frequency ranges remain valid.
2026-08-26 18:46:51 +02:00
Leon.Schmidt a597e161ea ADD - integrate TGIANN House (#47)
Authorize owner properties from the documented TGIANN schema and enrich entrance waypoints through the published client export. Keep unsupported key, lock, CCTV, and garage capabilities disabled.
2026-08-26 18:46:39 +02:00
13 changed files with 364 additions and 12 deletions
+2 -2
View File
@@ -89,7 +89,7 @@ Sky Phone is built to be the **free FiveM phone you can choose without accepting
| **Inventories** | ak47_inventory, codem-inventory, core_inventory, jaksam_inventory, jpr-inventory, lj-inventory, mf-inventory, one_inventory, origen_inventory, ox_inventory, ps-inventory, qb-inventory, qs-inventory, smx-inventory, tgiann-inventory, hex_4_inventory, and native ESX inventory |
| **Calls** | YACA, PMA Voice, SaltyChat |
| **Radio** | YACA, PMA Voice, SaltyChat |
| **Housing** | RTX Housing, Quasar Housing, VMS Housing, RX Housing, NoLag Properties, SN Properties, ESX Property, qbx_properties |
| **Housing** | RTX Housing, Quasar Housing, TGIANN House, 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 |
@@ -650,7 +650,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
### Housing
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.
Select `rtx`, `quasar`, `tgiann`, `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. The TGIANN bridge expects `tgiann-core` to start before `tgiann-house`, lists server-authorized owner properties, and supports entrance waypoints; TGIANN does not publish stable external contracts for keyholders, lock controls, CCTV, or live garage status.
### Companies
@@ -380,10 +380,10 @@ function toggleOptionalString(event: Event): void {
function addListRow(): void {
const rows = Array.isArray(props.modelValue) ? [...props.modelValue] : []
const index = rows.length
const value = rows.length
? blankLike(rows[0])
: listTemplate.value
? blankFromConfiguratorStructure(listTemplate.value)
const value = listTemplate.value
? blankFromConfiguratorStructure(listTemplate.value)
: rows.length
? blankLike(rows[0])
: blankValue(newArrayKind.value)
rows.push(value)
emit('update:modelValue', rows)
@@ -440,6 +440,9 @@ describe('standalone admin panel contracts', () => {
expect(source).toContain("selectConfiguratorScope('media')")
expect(source).not.toContain('class="admin-panel-config-meta"')
expect(configuratorValueEditor).toContain('function addListRow()')
expect(configuratorValueEditor).toMatch(
/const value = listTemplate\.value\s+\? blankFromConfiguratorStructure\(listTemplate\.value\)\s+: rows\.length\s+\? blankLike\(rows\[0\]\)/,
)
expect(configuratorValueEditor).toContain('function addTableField()')
expect(configuratorValueEditor).toContain(
'const canExtendTable = computed(',
@@ -0,0 +1,63 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const readResourceFile = (path: string) =>
readFileSync(
new URL(`../../sky_phone/${path}`, import.meta.url),
'utf8',
).replace(/\r\n/g, '\n')
describe('housing provider contracts', () => {
it('registers TGIANN House with server-authorized owner properties', () => {
const adapter = readResourceFile('source/bridge/server/housing/tgiann.lua')
expect(adapter).toContain('local resource_name = "tgiann-house"')
expect(adapter).toContain('FROM `tgiann_house`')
expect(adapter).toContain('WHERE `owner` = ?')
expect(adapter).toContain('Bridge.Framework.GetIdentifier(source)')
expect(adapter).toContain(
'Bridge.Housing.RegisterProvider(provider_name, {',
)
expect(adapter).toContain('return nil, "property_access_denied"')
expect(adapter).not.toContain('`houseKeys`')
expect(adapter).not.toContain('tgiann-house:getPlayerHouses')
})
it('uses the published client export for entrance waypoints only', () => {
const adapter = readResourceFile('source/bridge/client/housing/tgiann.lua')
expect(adapter).toContain(
'return exports[resource_name]:getHouseData(house)',
)
expect(adapter).toContain(
'Bridge.Normalize.Coordinates(house_data.doorCoord)',
)
expect(adapter).toContain(
'Bridge.Housing.RegisterClientProvider(provider_name, {',
)
expect(adapter).toContain('SetNewWaypoint(')
expect(adapter).not.toContain('enterHouse(')
expect(adapter).not.toContain('forceOpenDoorHouse')
})
it('advertises TGIANN in housing configuration and documentation', () => {
const config = readResourceFile('config/config.lua')
const readme = readFileSync(
new URL('../../README.md', import.meta.url),
'utf8',
)
expect(config).toContain(
'"rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn"',
)
expect(readme).toContain('Quasar Housing, TGIANN House, VMS Housing')
expect(readme).toContain('`quasar`, `tgiann`, `vms`')
expect(readme).toContain(
'expects `tgiann-core` to start before `tgiann-house`',
)
expect(readme).toContain(
'lists server-authorized owner properties, and supports entrance waypoints',
)
})
})
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import type { AdminConfiguratorStructure } from '@/types/admin'
import { createMutableTableEntry } from '@/utils/adminConfiguratorDefaults'
import {
blankFromConfiguratorStructure,
createMutableTableEntry,
} from '@/utils/adminConfiguratorDefaults'
describe('admin configurator defaults', () => {
it('creates a usable company draft from its key and the server defaults', () => {
@@ -111,4 +114,33 @@ describe('admin configurator defaults', () => {
false,
)
})
it('preserves required nested list fields in schema-derived rows', () => {
const structure: AdminConfiguratorStructure = {
fields: {
jobs: {
fields: {
police: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
},
range: {
items: [
{ kind: 'value', valueType: 'number' },
{ kind: 'value', valueType: 'number' },
],
kind: 'list',
template: { kind: 'value', valueType: 'number' },
},
},
kind: 'table',
}
expect(blankFromConfiguratorStructure(structure)).toEqual({
jobs: { police: false },
range: [0, 0],
})
})
})
@@ -540,6 +540,25 @@ function buildStructure(value, scope, path) {
if (scope === 'config' && path === 'Phone.Keybind') {
return { kind: 'optionalString' }
}
if (
scope === 'config' &&
/^Radio\.LockedChannels\.\d+\.jobs$/.test(path) &&
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
return {
fields: Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
buildStructure(child, scope, `${path}.${key}`),
]),
),
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
}
if (
scope === 'config' &&
path === 'Companies.Definitions' &&
@@ -193,6 +193,29 @@ describe('admin configurator fixture', () => {
})
})
it('allows custom jobs in locked radio channel entries', () => {
const radio = loadConfiguratorSections()
.flatMap((section) => section.fields)
.find((field) => field.path === 'Radio')
const lockedChannels = radio?.structure?.fields?.LockedChannels
const jobs = lockedChannels?.items?.[0]?.fields?.jobs
expect(jobs).toMatchObject({
fields: {
ambulance: { kind: 'value', valueType: 'boolean' },
police: { kind: 'value', valueType: 'boolean' },
},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
expect(lockedChannels?.template?.fields?.jobs).toMatchObject({
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
})
})
it('publishes fixed schemas for every empty configurable collection', () => {
const fields = loadConfiguratorSections().flatMap(
(section) => section.fields,
+2 -2
View File
@@ -450,8 +450,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,
+1 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.3.2'
version '0.3.3'
provide 'lb-phone'
provide '17mov_Phone'
@@ -0,0 +1,73 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function house_reference(value)
if type(value) ~= "string" then
return nil
end
return value ~= "" and value or nil
end
local function house_entrance(house)
local success, house_data = pcall(function()
return exports[resource_name]:getHouseData(house)
end)
if not success then
Bridge.Debug(
"error",
"[sky_phone] tgiann-house:getHouseData failed for '%s': %s",
house,
tostring(house_data)
)
return nil, "provider_error"
end
local entrance = type(house_data) == "table"
and Bridge.Normalize.Coordinates(house_data.doorCoord) or nil
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 = house_entrance(house)
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,124 @@
local provider_name = "tgiann"
local resource_name = "tgiann-house"
local function query_owned_houses(identifier)
local success, properties = pcall(function()
return MySQL.query.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `owner` = ?
ORDER BY `name` ASC
]], { identifier })
end)
if not success or type(properties) ~= "table" then
Bridge.Debug("error", "[sky_phone] tgiann-house overview query failed: %s", tostring(properties))
return nil
end
return properties
end
local function owns_house(identifier, house)
local success, property = pcall(function()
return MySQL.single.await([[
SELECT `name`
FROM `tgiann_house`
WHERE `name` = ? AND `owner` = ?
LIMIT 1
]], { house, identifier })
end)
if not success then
Bridge.Debug("error", "[sky_phone] tgiann-house ownership query failed: %s", tostring(property))
return nil, "provider_error"
end
return type(property) == "table"
end
local function house_from_property_id(value)
if type(value) ~= "string" then
return nil
end
local house = value:match("^tgiann:(.+)$")
return house and house ~= "" and house or nil
end
local function normalized_property(house)
return {
id = provider_name .. ":" .. house,
providerId = house,
name = house,
access = "owner",
locked = false,
capabilities = {
lock = false,
keys = false,
waypoint = true,
cctv = false,
garageStatus = false,
},
cctv = { enabled = false },
garage = nil,
keys = nil,
}
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
local properties = query_owned_houses(identifier)
if not properties then
return nil, "provider_error"
end
local result = {}
local seen = {}
local maximum = math.max(0, math.floor(tonumber(Config.Housing.MaximumProperties) or 0))
for _, property in ipairs(properties) do
if #result >= maximum then
break
end
local house = type(property.name) == "string" and property.name or nil
if house and house ~= "" and not seen[house] then
seen[house] = true
result[#result + 1] = normalized_property(house)
end
end
return result
end,
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 house = house_from_property_id(data and data.propertyId)
if not house then
return nil, "invalid_property"
end
local identifier = Bridge.Framework.GetIdentifier(source)
if not identifier then
return nil, "housing_unavailable"
end
local owned, error_code = owns_house(identifier, house)
if owned == nil then
return nil, error_code
end
if not owned then
return nil, "property_access_denied"
end
return { providerId = house }
end,
})
@@ -637,6 +637,21 @@ local function build_structure(value, scope, path)
if scope == "config" and path == "Phone.Keybind" then
return { kind = "optionalString" }
end
if scope == "config"
and path:match("^Radio%.LockedChannels%.%d+%.jobs$")
and value_type == "table"
then
local fields = {}
for key, child in pairs(value) do
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
end
return {
fields = fields,
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = "boolean" },
}
end
if scope == "config" and path == "Companies.Definitions" and value_type == "table" then
local keys = {}
for key in pairs(value) do
+2 -2
View File
@@ -435,8 +435,8 @@ Config.Garage = {
}
Config.Housing = {
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
System = "auto", -- auto, rtx, quasar, tgiann, vms, rx, nolag, sn, esx_property, qbx_properties
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "tgiann", "vms", "rx", "nolag", "sn" },
MaximumProperties = 50,
OverviewRequestsPerMinute = 30,
ActionsPerMinute = 12,