Compare commits

..

6 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
Alec Schitzkat b0b8cfc550 BLD - Bump sky_phone version to 0.3.2 2026-08-25 21:27:14 +02:00
Leon.Schmidt 76484cfe05 PERF - bound crypto market history queries (#45) 2026-08-25 21:26:18 +02:00
Dominik9906 b406fa9443 FIX - UI and Bug Fix (#44) 2026-08-25 21:26:05 +02:00
26 changed files with 444 additions and 293 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
@@ -85,18 +85,6 @@ describe('browser development preview contract', () => {
)
})
it('replaces rectangular CEF focus outlines on the side hardware controls', () => {
expect(mainCss).toMatch(
/\.phone-hardware-button:focus\s*\{[^}]*outline:\s*none;/s,
)
expect(mainCss).toMatch(
/\.phone-hardware-button:focus-visible::after\s*\{[^}]*width:\s*3px;[^}]*height:\s*24px;[^}]*border-radius:\s*999px;[^}]*background:\s*#0a84ff;/s,
)
expect(mainCss).not.toMatch(
/\.phone-hardware-button:focus-visible\s*\{[^}]*outline:/s,
)
})
it('consumes Escape synchronously before FiveM can open the pause menu', () => {
expect(source).toContain("import { consumeEscape } from '@/utils/keyboard'")
expect(source).toContain(
+3 -21
View File
@@ -380,27 +380,9 @@ button {
.phone-hardware-button:disabled {
cursor: default;
}
.phone-hardware-button:focus {
outline: none;
}
.phone-hardware-button:focus-visible::after {
position: absolute;
top: 50%;
width: 3px;
height: 24px;
border-radius: 999px;
background: #0a84ff;
box-shadow: 0 0 0 1px rgb(255 255 255 / 90%);
content: '';
transform: translateY(-50%);
}
.phone-hardware-button--action:focus-visible::after,
.phone-hardware-button--volume-up:focus-visible::after,
.phone-hardware-button--volume-down:focus-visible::after {
right: 7px;
}
.phone-hardware-button--power:focus-visible::after {
left: 7px;
.phone-hardware-button:focus-visible {
outline: 2px solid #fff;
outline-offset: -6px;
}
.phone-hardware-button--action {
top: 176px;
@@ -18,7 +18,6 @@ import type { AdminConfiguratorDescribe } from '@/utils/adminConfiguratorDescrip
export type AdminConfigEditorLabels = {
addField: string
addJob: string
addRow: string
configuredSecret: string
convertToList: string
@@ -28,7 +27,6 @@ export type AdminConfigEditorLabels = {
emptyTable: string
entry: string
general: string
jobPlaceholder: string
keyPlaceholder: string
list: string
remove: string
@@ -151,11 +149,6 @@ const canExtendTable = computed(
!vectorType.value &&
(!tableStructure.value || tableStructure.value.mutableKeys === true),
)
const isJobTable = computed(
() =>
props.path === 'Radio.DisplayName.AllowedJobs' ||
/^Radio\.LockedChannels\[\d+\]\.jobs$/.test(props.path),
)
const usesFixedTableLayout = computed(
() =>
Boolean(tableStructure.value) && tableStructure.value?.mutableKeys !== true,
@@ -378,19 +371,6 @@ function updateOptionalString(event: Event): void {
}
}
function updateNewObjectKey(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
const value = isJobTable.value
? target.value
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '')
.slice(0, 64)
: target.value
newObjectKey.value = value
target.value = value
}
function toggleOptionalString(event: Event): void {
const target = event.target
if (!(target instanceof HTMLInputElement)) return
@@ -400,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)
@@ -1035,15 +1015,11 @@ function mapEntryStructure(
@submit.prevent="addTableField"
>
<input
:value="newObjectKey"
v-model="newObjectKey"
type="text"
:disabled="disabled"
:placeholder="
isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder
"
:aria-label="isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder"
autocomplete="off"
@input="updateNewObjectKey"
:placeholder="labels.keyPlaceholder"
:aria-label="labels.keyPlaceholder"
/>
<select
v-if="!tableStructure"
@@ -1063,7 +1039,7 @@ function mapEntryStructure(
{{ structureTypeLabel(tableStructure.template) }}
</span>
<button type="submit" :disabled="disabled || !canAddTableField">
<Plus :size="13" />{{ isJobTable ? labels.addJob : labels.addField }}
<Plus :size="13" />{{ labels.addField }}
</button>
</form>
</div>
@@ -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(',
-2
View File
@@ -400,7 +400,6 @@ function configuratorDescription(
const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
addField: t('configurator.table.addField'),
addJob: t('configurator.table.addJob'),
addRow: t('configurator.table.addRow'),
configuredSecret: t('configurator.secretConfigured'),
convertToList: t('configurator.table.convertToList'),
@@ -410,7 +409,6 @@ const configuratorEditorLabels = computed<AdminConfigEditorLabels>(() => ({
emptyTable: t('configurator.table.emptyTable'),
entry: t('configurator.table.entry'),
general: configuratorLocaleText('configurator.table.general', 'General'),
jobPlaceholder: t('configurator.table.jobPlaceholder'),
keyPlaceholder: t('configurator.table.keyPlaceholder'),
list: t('configurator.table.list'),
remove: t('configurator.table.remove'),
@@ -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,85 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const resourceSource = (path: string): string =>
readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8')
const configSource = resourceSource('config/config.lua')
const configuratorSource = resourceSource(
'source/server/phone_configurator.lua',
)
const radioSource = resourceSource('source/server/radio.lua')
const editorSource = readFileSync(
new URL('./components/AdminConfigValueEditor.vue', import.meta.url),
'utf8',
)
describe('radio configurator access contract', () => {
it('lets the server allow everyone or enforce configured job grades', () => {
expect(configSource).toMatch(
/DisplayName\s*=\s*\{[\s\S]*?AllowEveryone\s*=\s*false,[\s\S]*?AllowedJobs\s*=\s*\{/,
)
const start = radioSource.indexOf(
'local function can_set_display_name(source)',
)
const end = radioSource.indexOf(
'\nlocal function normalize_display_name',
start,
)
const permissionSource = radioSource.slice(start, end)
expect(permissionSource).toContain('if config.AllowEveryone == true then')
expect(permissionSource.indexOf('config.AllowEveryone')).toBeLessThan(
permissionSource.indexOf('Bridge.Framework.GetJob(source)'),
)
expect(permissionSource).toContain('config.AllowedJobs[job.name]')
expect(permissionSource).toContain('(tonumber(job.grade) or 0)')
})
it('keeps both radio job tables mutable and validates their value types', () => {
expect(configuratorSource).toContain(
'path == "Radio.DisplayName.AllowedJobs"',
)
expect(configuratorSource).toContain(
'path:match("^Radio%.LockedChannels%.%d+%.jobs$")',
)
expect(configuratorSource).toContain(
'path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil',
)
expect(configuratorSource).toMatch(
/entryDefault = radio_job_default,[\s\S]*?mutableKeys = true,[\s\S]*?valueType = type\(radio_job_default\)/,
)
expect(configuratorSource).toContain('not key:match("^[a-z0-9_-]+$")')
})
it('shows a compact job-name input for both radio job tables', () => {
expect(editorSource).toContain(
"props.path === 'Radio.DisplayName.AllowedJobs'",
)
expect(editorSource).toContain(
String.raw`/^Radio\.LockedChannels\[\d+\]\.jobs$/`,
)
expect(editorSource).toContain(
'isJobTable ? labels.jobPlaceholder : labels.keyPlaceholder',
)
expect(editorSource).toContain(
'isJobTable ? labels.addJob : labels.addField',
)
expect(editorSource).toContain('function updateNewObjectKey(event: Event)')
expect(editorSource).toContain('.toLowerCase()')
expect(editorSource).toContain(".replace(/[^a-z0-9_-]/g, '')")
expect(editorSource).toContain('.slice(0, 64)')
for (const locale of ['en', 'de', 'es']) {
const localeSource = resourceSource(`config/locales/${locale}.lua`)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.addJob`,
)
expect(localeSource).toContain(
`Locales["${locale}"].Nui.AdminPanel.configurator.table.jobPlaceholder`,
)
}
})
})
-2
View File
@@ -965,12 +965,10 @@ const adminPanelFallbackLocales = {
},
addRow: 'Add row',
addField: 'Add field',
addJob: 'Add job',
remove: 'Remove',
emptyList: 'No rows yet. Add the first row with plus.',
emptyTable: 'No fields yet. Add the first key below.',
keyPlaceholder: 'New key',
jobPlaceholder: 'Job name',
convertToList: 'Use list',
convertToMap: 'Use typed key table',
convertToTable: 'Use key table',
+2 -3
View File
@@ -1265,9 +1265,8 @@ label.sky-list-item__row {
opacity: 0;
}
.sky-field__select option {
background: var(--sky-native-select-option-background, #ffffff);
color: var(--sky-native-select-option-text, #000000);
.sky-field--floating-label .sky-field__select option {
color: var(--sky-text, #000000);
}
.sky-field--floating-label.sky-field--outline:not(
-17
View File
@@ -164,23 +164,6 @@ describe('SkyField', () => {
)
})
it('keeps native select options readable on the CEF popup surface', () => {
const controls = readFileSync(
new URL('../controls.css', import.meta.url),
'utf8',
)
const tokens = readFileSync(
new URL('../tokens.css', import.meta.url),
'utf8',
)
expect(controls).toMatch(
/\.sky-field__select option\s*\{[^}]*background:\s*var\(--sky-native-select-option-background, #ffffff\);[^}]*color:\s*var\(--sky-native-select-option-text, #000000\);/s,
)
expect(tokens).toContain('--sky-native-select-option-background: #ffffff;')
expect(tokens).toContain('--sky-native-select-option-text: #000000;')
})
it('raises floating labels only after the field has a value', async () => {
const emptyApp = createSSRApp(SkyField, {
floatingLabel: true,
-2
View File
@@ -188,8 +188,6 @@
--sky-hairline: rgba(0, 0, 0, 0.2);
--sky-field-outline: rgba(0, 0, 0, 0.3);
--sky-field-placeholder: rgba(0, 0, 0, 0.3);
--sky-native-select-option-background: #ffffff;
--sky-native-select-option-text: #000000;
--sky-pressed: rgba(0, 0, 0, 0.1);
--sky-tabbar-highlight-background: rgba(0, 0, 0, 0.1);
--sky-tabbar-thumb-background: rgba(0, 0, 0, 0.05);
@@ -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', () => {
@@ -112,35 +115,32 @@ describe('admin configurator defaults', () => {
)
})
it('uses the configured access value when adding radio jobs', () => {
const channelJobs: AdminConfiguratorStructure = {
entryDefault: true,
fields: {},
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',
mutableKeys: true,
template: { kind: 'value', valueType: 'boolean' },
}
const displayNameJobs: AdminConfiguratorStructure = {
entryDefault: 0,
fields: {},
kind: 'table',
mutableKeys: true,
template: { kind: 'value', valueType: 'number' },
}
expect(
createMutableTableEntry(
channelJobs,
'Radio.LockedChannels[1].jobs',
'mechanic',
),
).toBe(true)
expect(
createMutableTableEntry(
displayNameJobs,
'Radio.DisplayName.AllowedJobs',
'mechanic',
),
).toBe(0)
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,
+3 -4
View File
@@ -216,9 +216,8 @@ Config.Radio = {
AutoRejoin = false,
DisplayName = {
Enabled = true,
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
police = 0,
sheriff = 0,
fib = 0,
@@ -451,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,
-2
View File
@@ -2141,8 +2141,6 @@ Locales["de"] = {
},
}
Locales["de"].Nui.AdminPanel.configurator.table.addJob = "Job hinzufügen"
Locales["de"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Jobname"
Locales["de"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Animationsdateien",
Clips = "Clips",
-2
View File
@@ -2141,8 +2141,6 @@ Locales["en"] = {
},
}
Locales["en"].Nui.AdminPanel.configurator.table.addJob = "Add job"
Locales["en"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Job name"
Locales["en"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Dictionaries",
Clips = "Clips",
-2
View File
@@ -2141,8 +2141,6 @@ Locales["es"] = {
},
}
Locales["es"].Nui.AdminPanel.configurator.table.addJob = "Añadir trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.jobPlaceholder = "Nombre del trabajo"
Locales["es"].Nui.AdminPanel.configurator.table.subtabs = {
Dictionaries = "Diccionarios",
Clips = "Los clips",
+1 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems'
description 'Sky Phone'
version '0.3.1'
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,
})
+73 -35
View File
@@ -8,8 +8,10 @@ local market_order = {}
local market_dynamics = {}
local market_state = {}
local market_history = {}
local market_daily_buckets = {}
local market_cursor = 1
local market_persistence_interval = 5 * 60 * 1000
local market_daily_bucket_seconds = 5 * 60
local market_persistence_interval = market_daily_bucket_seconds * 1000
local global_market_trend = 0
local global_market_cycle = {
direction = 0,
@@ -373,6 +375,37 @@ local function initialize_markets()
market_cursor = math.min(market_cursor, math.max(#market_order, 1))
end
local function add_market_daily_price(buckets, price, timestamp)
local bucket_id = math.floor(timestamp / market_daily_bucket_seconds)
local bucket = buckets[#buckets]
if bucket and bucket.bucket_id == bucket_id then
bucket.low = math.min(bucket.low, price)
bucket.high = math.max(bucket.high, price)
return
end
buckets[#buckets + 1] = {
bucket_id = bucket_id,
low = price,
high = price,
}
end
local function market_daily_range(market_id, price, timestamp)
local buckets = market_daily_buckets[market_id]
local cutoff_bucket = math.floor((timestamp - 24 * 60 * 60) / market_daily_bucket_seconds)
while buckets[1] and buckets[1].bucket_id < cutoff_bucket do
table.remove(buckets, 1)
end
local low = price
local high = price
for index = 1, #buckets do
local bucket = buckets[index]
low = math.min(low, bucket.low)
high = math.max(high, bucket.high)
end
return low, high
end
local function load_market_cache()
local rows = Bridge.Database.Query([[
SELECT `id`,`price`,`version`,`status`, UNIX_TIMESTAMP(`updated_at`) AS `updated_at`
@@ -380,13 +413,16 @@ local function load_market_cache()
]], {})
local next_market_state = {}
local next_market_history = {}
local next_market_daily_buckets = {}
local history_limit = math.min(Config.Crypto.HistoryRetentionTicks, Config.Crypto.SparklinePoints)
local timestamp = os.time()
for _, row in ipairs(rows) do
if markets[row.id] then
next_market_state[row.id] = {
price = tonumber(row.price) or markets[row.id].InitialPrice,
version = tonumber(row.version) or 1,
status = row.status,
updated_at = tonumber(row.updated_at) or os.time(),
updated_at = tonumber(row.updated_at) or timestamp,
dirty = false,
}
end
@@ -397,30 +433,40 @@ local function load_market_cache()
error(("[sky_phone] Crypto market state is missing after initialization: %s"):format(market_id))
end
local rows_for_market = Bridge.Database.Query([[
SELECT `price`,`version`, UNIX_TIMESTAMP(`created_at`) AS `created_at`
SELECT `price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
]], { market_id, Config.Crypto.HistoryRetentionTicks })
WHERE `market_id` = ? ORDER BY `created_at` DESC, `id` DESC LIMIT ?
]], { market_id, history_limit })
local history = {}
for index = #rows_for_market, 1, -1 do
local tick = rows_for_market[index]
history[#history + 1] = {
price = tonumber(tick.price) or state.price,
version = tonumber(tick.version) or state.version,
created_at = tonumber(tick.created_at) or state.updated_at,
}
history[#history + 1] = tonumber(rows_for_market[index].price) or state.price
end
if #history == 0 then
history[1] = {
price = state.price,
version = state.version,
created_at = state.updated_at,
}
history[1] = state.price
end
next_market_history[market_id] = history
local daily_rows = Bridge.Database.Query([[
SELECT FLOOR(UNIX_TIMESTAMP(`created_at`) / ?) AS `bucket_id`,
MIN(`price`) AS `low_price`, MAX(`price`) AS `high_price`
FROM `sky_phone_crypto_market_ticks`
WHERE `market_id` = ?
AND `created_at` >= DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 24 HOUR)
GROUP BY `bucket_id` ORDER BY `bucket_id`
]], { market_daily_bucket_seconds, market_id })
local daily_buckets = {}
for _, daily_row in ipairs(daily_rows) do
daily_buckets[#daily_buckets + 1] = {
bucket_id = tonumber(daily_row.bucket_id),
low = tonumber(daily_row.low_price) or state.price,
high = tonumber(daily_row.high_price) or state.price,
}
end
add_market_daily_price(daily_buckets, state.price, timestamp)
next_market_daily_buckets[market_id] = daily_buckets
end
market_state = next_market_state
market_history = next_market_history
market_daily_buckets = next_market_daily_buckets
end
local function persist_market_cache()
@@ -557,6 +603,7 @@ local function market_dtos(selected_market_ids)
end
end
local result = {}
local timestamp = os.time()
for _, market_id in ipairs(market_order) do
if not selected or selected[market_id] then
local config = markets[market_id]
@@ -565,7 +612,7 @@ local function market_dtos(selected_market_ids)
local prices = {}
local first_history_index = math.max(1, #history - Config.Crypto.SparklinePoints + 1)
for index = first_history_index, #history do
prices[#prices + 1] = history[index].price
prices[#prices + 1] = history[index]
end
if #prices == 0 then
prices[1] = tonumber(row.price)
@@ -581,17 +628,7 @@ local function market_dtos(selected_market_ids)
end
local first = prices[1]
local price = tonumber(row.price) or config.InitialPrice
local daily_low = price
local daily_high = price
local daily_cutoff = os.time() - 24 * 60 * 60
for index = #history, 1, -1 do
local tick = history[index]
if tick.created_at < daily_cutoff then
break
end
daily_low = math.min(daily_low, tick.price)
daily_high = math.max(daily_high, tick.price)
end
local daily_low, daily_high = market_daily_range(market_id, price, timestamp)
result[#result + 1] = {
id = market_id,
symbol = config.Symbol,
@@ -607,7 +644,7 @@ local function market_dtos(selected_market_ids)
treasuryAvailable = decimal_string(balance("treasury", market_id), Config.Crypto.AssetScale),
priceHistory = price_history,
sparkline = sparkline,
updatedAt = (tonumber(row.updated_at) or os.time()) * 1000,
updatedAt = (tonumber(row.updated_at) or timestamp) * 1000,
}
end
end
@@ -1627,6 +1664,10 @@ local function start_crypto_schedulers()
)
advance_global_market_cycle()
local changed_markets = {}
local history_limit = math.min(
Config.Crypto.HistoryRetentionTicks,
Config.Crypto.SparklinePoints
)
market_count = math.min(market_count, #market_order)
for offset = 0, market_count - 1 do
@@ -1709,14 +1750,11 @@ local function start_crypto_schedulers()
row.updated_at = updated_at
row.dirty = true
local history = market_history[market_id]
history[#history + 1] = {
price = next_price,
version = next_version,
created_at = updated_at,
}
if #history > Config.Crypto.HistoryRetentionTicks then
history[#history + 1] = next_price
if #history > history_limit then
table.remove(history, 1)
end
add_market_daily_price(market_daily_buckets[market_id], next_price, updated_at)
changed_markets[#changed_markets + 1] = market_id
end
end
+16 -31
View File
@@ -274,22 +274,12 @@ local function upgrade_legacy_map(defaults, saved)
return { __skyType = "map", entries = entries }
end
local function radio_job_entry_default(path)
if path == "Radio.DisplayName.AllowedJobs" then
return 0
end
if type(path) == "string" and path:match("^Radio%.LockedChannels%.%d+%.jobs$") then
return true
end
return nil
end
local function merge_values(defaults, saved, path, excluded_paths)
path = path or ""
if type(defaults) ~= "table" or type(saved) ~= "table" then
return copy_value(saved)
end
if path == "Companies.Definitions" or radio_job_entry_default(path) ~= nil then
if path == "Companies.Definitions" then
return copy_value(saved)
end
if defaults.__skyType == "map" and not saved.__skyType then
@@ -502,16 +492,6 @@ local function empty_structure(scope, path)
if scope ~= "config" then
return nil
end
local radio_job_default = radio_job_entry_default(path)
if radio_job_default ~= nil then
return {
entryDefault = radio_job_default,
fields = {},
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = type(radio_job_default) },
}
end
if path == "Garage.VehicleImages.ModelNames" then
return {
entries = {},
@@ -657,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
@@ -732,16 +727,6 @@ local function build_structure(value, scope, path)
for key, child in pairs(value) do
fields[key] = build_structure(child, scope, path .. "." .. tostring(key))
end
local radio_job_default = scope == "config" and radio_job_entry_default(path) or nil
if radio_job_default ~= nil then
return {
entryDefault = radio_job_default,
fields = fields,
kind = "table",
mutableKeys = true,
template = { kind = "value", valueType = type(radio_job_default) },
}
end
return {
fields = fields,
kind = "table",
-6
View File
@@ -50,14 +50,8 @@ local function can_set_display_name(source)
if type(config) ~= "table" or not config.Enabled then
return false
end
if config.AllowEveryone == true then
return true
end
local job = Bridge.Framework.GetJob(source)
if type(job) ~= "table" or type(job.name) ~= "string" then
return false
end
local minimum_grade = type(config.AllowedJobs) == "table" and tonumber(config.AllowedJobs[job.name]) or nil
return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade
end
+3 -4
View File
@@ -201,9 +201,8 @@ Config.Radio = {
AutoRejoin = false,
DisplayName = {
Enabled = true,
AllowEveryone = false, -- true allows every job; false uses AllowedJobs and its minimum grades
MaxLength = 32,
AllowedJobs = { -- Job name = minimum grade. Used when AllowEveryone is false.
AllowedJobs = { -- Job name = minimum grade. Unlisted jobs cannot set a radio display name.
police = 0,
sheriff = 0,
fib = 0,
@@ -436,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,