mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 17:28:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f654d82b4b |
@@ -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 |
|
| **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 |
|
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||||
| **Radio** | 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** | ESX Property, qbx_properties |
|
||||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
| **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 |
|
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||||
| **Languages** | English, German |
|
| **Languages** | English, German |
|
||||||
@@ -249,11 +249,6 @@ 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.
|
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
|
## Security values
|
||||||
|
|
||||||
Sky Phone ships with stable generated defaults in `Config.Server`:
|
Sky Phone ships with stable generated defaults in `Config.Server`:
|
||||||
@@ -530,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
|||||||
|
|
||||||
### Housing
|
### 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 the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
|
||||||
|
|
||||||
### Companies
|
### Companies
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ describe('PhoneSetupAssistant contract', () => {
|
|||||||
expect(source).toContain('WALLPAPER_IDS')
|
expect(source).toContain('WALLPAPER_IDS')
|
||||||
expect(source).toContain('setAllAppNotifications')
|
expect(source).toContain('setAllAppNotifications')
|
||||||
expect(source).toContain('appStore.claimApp')
|
expect(source).toContain('appStore.claimApp')
|
||||||
expect(source).toContain('await phone.completeSetup()')
|
expect(source).toContain('phone.completeSetup()')
|
||||||
expect(source).toContain(':disabled="setupCompleteBusy"')
|
|
||||||
expect(source).toContain("phone.t('Setup.ready.saveFailed')")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists progress and supports resuming or moving backward', () => {
|
it('persists progress and supports resuming or moving backward', () => {
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6)
|
|||||||
const notificationsEnabled = ref(true)
|
const notificationsEnabled = ref(true)
|
||||||
const notificationSounds = ref(true)
|
const notificationSounds = ref(true)
|
||||||
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
const selectedApps = ref<BuiltinPhoneAppId[]>(['banking', 'garage', 'skyride'])
|
||||||
const setupCompleteBusy = ref(false)
|
|
||||||
const setupCompleteError = ref('')
|
|
||||||
|
|
||||||
const setupApps = (
|
const setupApps = (
|
||||||
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const
|
||||||
@@ -206,16 +204,8 @@ function choosePasscodeLength(length: 4 | 6): void {
|
|||||||
passcodeResetKey.value += 1
|
passcodeResetKey.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function finish(): Promise<void> {
|
function finish(): void {
|
||||||
if (setupCompleteBusy.value) return
|
phone.completeSetup()
|
||||||
setupCompleteBusy.value = true
|
|
||||||
setupCompleteError.value = ''
|
|
||||||
const completed = await phone.completeSetup()
|
|
||||||
setupCompleteBusy.value = false
|
|
||||||
if (!completed) {
|
|
||||||
setupCompleteError.value = phone.t('Setup.ready.saveFailed')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
emit('complete')
|
emit('complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,29 +794,12 @@ function skipSetupForDevelopment(): void {
|
|||||||
}}</b></span
|
}}</b></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<SkyButton
|
<SkyButton class="setup-assistant__primary" @click="finish">{{
|
||||||
class="setup-assistant__primary"
|
phone.t('Setup.ready.enter')
|
||||||
:disabled="setupCompleteBusy"
|
}}</SkyButton>
|
||||||
@click="finish"
|
|
||||||
>
|
|
||||||
<SkySpinner
|
|
||||||
v-if="setupCompleteBusy"
|
|
||||||
:label="phone.t('Setup.ready.saving')"
|
|
||||||
:size="18"
|
|
||||||
/>
|
|
||||||
<span v-else>{{ phone.t('Setup.ready.enter') }}</span>
|
|
||||||
</SkyButton>
|
|
||||||
<p
|
|
||||||
v-if="setupCompleteError"
|
|
||||||
class="setup-assistant__error"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
{{ setupCompleteError }}
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="setup-assistant__later"
|
class="setup-assistant__later"
|
||||||
:disabled="setupCompleteBusy"
|
|
||||||
@click="moveTo(0)"
|
@click="moveTo(0)"
|
||||||
>
|
>
|
||||||
{{ phone.t('Setup.ready.review') }}
|
{{ phone.t('Setup.ready.review') }}
|
||||||
|
|||||||
@@ -85,16 +85,4 @@ describe('phone inventory contracts', () => {
|
|||||||
)
|
)
|
||||||
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
|
expect(phoneServer).toContain('preferred_device_imeis[source] = imei')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps non-unique phones bound to one persistent device per character', () => {
|
|
||||||
const phoneServer = readResourceFile('source/server/phone.lua')
|
|
||||||
const migration = readResourceFile('source/server/db_migrate.lua')
|
|
||||||
|
|
||||||
expect(phoneServer).toContain('if not unique_phones then')
|
|
||||||
expect(phoneServer).toContain('return map_character_device(source, slot)')
|
|
||||||
expect(phoneServer).toContain('FROM `sky_phone_character_devices`')
|
|
||||||
expect(phoneServer).toContain('WHERE `owner_identifier` = ?')
|
|
||||||
expect(migration).toContain('name = "sky_phone_character_devices"')
|
|
||||||
expect(migration).toContain('primaryKey = "owner_identifier"')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -167,66 +167,4 @@ describe('phone device persistence scope', () => {
|
|||||||
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
expect(mockNuiCall).toHaveBeenCalledTimes(2)
|
||||||
expect(phone.deviceRevisions.widgets).toBe(1)
|
expect(phone.deviceRevisions.widgets).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks setup complete only after the settings save is acknowledged', async () => {
|
|
||||||
const completion = deferredResponse<{ revision: number }>()
|
|
||||||
mockNuiCall.mockReturnValueOnce(completion.promise)
|
|
||||||
const phone = usePhoneStore()
|
|
||||||
phone.open({
|
|
||||||
device: {
|
|
||||||
data: {
|
|
||||||
settings: {
|
|
||||||
payload: {
|
|
||||||
settings: { setupCompleted: false, setupStep: 9 },
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
revision: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
imei: '111',
|
|
||||||
name: 'Phone 111',
|
|
||||||
sim: null,
|
|
||||||
},
|
|
||||||
token: 'session-a',
|
|
||||||
})
|
|
||||||
|
|
||||||
const completed = phone.completeSetup()
|
|
||||||
await Promise.resolve()
|
|
||||||
|
|
||||||
expect(phone.preferences.settings.setupCompleted).toBe(false)
|
|
||||||
completion.resolve({ data: { revision: 4 }, success: true })
|
|
||||||
|
|
||||||
await expect(completed).resolves.toBe(true)
|
|
||||||
expect(phone.preferences.settings.setupCompleted).toBe(true)
|
|
||||||
expect(phone.deviceRevisions.settings).toBe(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps setup open when the completion save is rejected', async () => {
|
|
||||||
mockNuiCall.mockResolvedValueOnce({
|
|
||||||
error: 'request_failed',
|
|
||||||
success: false,
|
|
||||||
})
|
|
||||||
const phone = usePhoneStore()
|
|
||||||
phone.open({
|
|
||||||
device: {
|
|
||||||
data: {
|
|
||||||
settings: {
|
|
||||||
payload: {
|
|
||||||
settings: { setupCompleted: false, setupStep: 9 },
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
revision: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
imei: '111',
|
|
||||||
name: 'Phone 111',
|
|
||||||
sim: null,
|
|
||||||
},
|
|
||||||
token: 'session-a',
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(phone.completeSetup()).resolves.toBe(false)
|
|
||||||
expect(phone.preferences.settings.setupCompleted).toBe(false)
|
|
||||||
expect(phone.deviceRevisions.settings).toBe(3)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export type PhoneOpenPayload = {
|
|||||||
token?: string
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const namespaceQueues = new Map<string, Promise<boolean>>()
|
const namespaceQueues = new Map<string, Promise<void>>()
|
||||||
let nextPersistenceSession = 0
|
let nextPersistenceSession = 0
|
||||||
|
|
||||||
const companiesFallbackLocales = {
|
const companiesFallbackLocales = {
|
||||||
@@ -4949,8 +4949,6 @@ const defaultLocales: LocaleTree = {
|
|||||||
localOnly: 'Stored on this phone',
|
localOnly: 'Stored on this phone',
|
||||||
enter: 'Enter Sky Phone',
|
enter: 'Enter Sky Phone',
|
||||||
review: 'Review Setup',
|
review: 'Review Setup',
|
||||||
saveFailed: 'Setup could not be saved. Try again.',
|
|
||||||
saving: 'Saving setup',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Common: {
|
Common: {
|
||||||
@@ -5225,13 +5223,13 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
JSON.stringify(device.data.settings?.payload ?? null),
|
JSON.stringify(device.data.settings?.payload ?? null),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
saveDeviceNamespace(namespace: string, payload: unknown): Promise<boolean> {
|
saveDeviceNamespace(namespace: string, payload: unknown): void {
|
||||||
const imei = this.device?.imei
|
const imei = this.device?.imei
|
||||||
if (!imei) {
|
if (!imei) {
|
||||||
console.error(
|
console.error(
|
||||||
`[Phone persistence] Could not save ${namespace} without an active device.`,
|
`[Phone persistence] Could not save ${namespace} without an active device.`,
|
||||||
)
|
)
|
||||||
return Promise.resolve(false)
|
return
|
||||||
}
|
}
|
||||||
const generation = this.persistenceGeneration
|
const generation = this.persistenceGeneration
|
||||||
const session = this.persistenceSession
|
const session = this.persistenceSession
|
||||||
@@ -5245,7 +5243,7 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
this.deviceSessionToken === token
|
this.deviceSessionToken === token
|
||||||
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
|
const previous = namespaceQueues.get(queueKey) ?? Promise.resolve()
|
||||||
const queued = previous.then(async () => {
|
const queued = previous.then(async () => {
|
||||||
if (!isCurrentScope()) return false
|
if (!isCurrentScope()) return
|
||||||
const response = await nuiCall<{ revision: number }>('device:save', {
|
const response = await nuiCall<{ revision: number }>('device:save', {
|
||||||
imei,
|
imei,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -5260,21 +5258,13 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
Number(response.data?.revision) >= 0
|
Number(response.data?.revision) >= 0
|
||||||
) {
|
) {
|
||||||
this.deviceRevisions[namespace] = Number(response.data?.revision)
|
this.deviceRevisions[namespace] = Number(response.data?.revision)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
if (isCurrentScope()) {
|
|
||||||
console.error(
|
|
||||||
`[Phone persistence] Could not save ${namespace}: ${response.error ?? 'request_failed'}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
})
|
||||||
const tracked = queued.finally(() => {
|
const tracked = queued.finally(() => {
|
||||||
if (namespaceQueues.get(queueKey) === tracked)
|
if (namespaceQueues.get(queueKey) === tracked)
|
||||||
namespaceQueues.delete(queueKey)
|
namespaceQueues.delete(queueKey)
|
||||||
})
|
})
|
||||||
namespaceQueues.set(queueKey, tracked)
|
namespaceQueues.set(queueKey, tracked)
|
||||||
return tracked
|
|
||||||
},
|
},
|
||||||
async flushDevicePersistence(): Promise<void> {
|
async flushDevicePersistence(): Promise<void> {
|
||||||
const imei = this.device?.imei
|
const imei = this.device?.imei
|
||||||
@@ -5339,16 +5329,10 @@ export const usePhoneStore = defineStore('phone', {
|
|||||||
)
|
)
|
||||||
this.saveDeviceNamespace('settings', this.preferences)
|
this.saveDeviceNamespace('settings', this.preferences)
|
||||||
},
|
},
|
||||||
async completeSetup(): Promise<boolean> {
|
completeSetup(): void {
|
||||||
const completedPreferences = cloneJsonData(this.preferences)
|
this.preferences.settings.setupCompleted = true
|
||||||
completedPreferences.settings.setupCompleted = true
|
this.preferences.settings.setupStep = PHONE_SETUP_LAST_STEP
|
||||||
completedPreferences.settings.setupStep = PHONE_SETUP_LAST_STEP
|
this.saveDeviceNamespace('settings', this.preferences)
|
||||||
const saved = await this.saveDeviceNamespace(
|
|
||||||
'settings',
|
|
||||||
completedPreferences,
|
|
||||||
)
|
|
||||||
if (saved) this.preferences = completedPreferences
|
|
||||||
return saved
|
|
||||||
},
|
},
|
||||||
resetAfterFactoryReset(): void {
|
resetAfterFactoryReset(): void {
|
||||||
this.persistenceGeneration += 1
|
this.persistenceGeneration += 1
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export type HousingAccess = 'owner' | 'keyholder'
|
|||||||
export type HousingCapabilities = {
|
export type HousingCapabilities = {
|
||||||
cctv: boolean
|
cctv: boolean
|
||||||
garageStatus: boolean
|
garageStatus: boolean
|
||||||
keyGrant?: boolean
|
|
||||||
keys: boolean
|
keys: boolean
|
||||||
lock: boolean
|
lock: boolean
|
||||||
waypoint: boolean
|
waypoint: boolean
|
||||||
@@ -25,7 +24,7 @@ export type HousingProperty = {
|
|||||||
access: HousingAccess
|
access: HousingAccess
|
||||||
capabilities: HousingCapabilities
|
capabilities: HousingCapabilities
|
||||||
cctv: { enabled: boolean }
|
cctv: { enabled: boolean }
|
||||||
entrance?: { x: number; y: number; z: number }
|
entrance: { x: number; y: number; z: number }
|
||||||
garage: { enabled: boolean; storedVehicles: number } | null
|
garage: { enabled: boolean; storedVehicles: number } | null
|
||||||
id: string
|
id: string
|
||||||
keys?: HousingKey[]
|
keys?: HousingKey[]
|
||||||
|
|||||||
@@ -44,15 +44,6 @@ 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', () => {
|
it('centers the resident icon above the Give a Key title', () => {
|
||||||
expect(source).toMatch(
|
expect(source).toMatch(
|
||||||
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
|
/\.house-candidates > svg\s*\{[^}]*display:\s*block;[^}]*margin:\s*0 auto;/s,
|
||||||
|
|||||||
@@ -464,7 +464,6 @@ onBeforeUnmount(() => {
|
|||||||
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
|
><strong>{{ phone.t('Apps.house.keys') }}</strong></span
|
||||||
>
|
>
|
||||||
<sky-button
|
<sky-button
|
||||||
v-if="selectedProperty.capabilities.keyGrant !== false"
|
|
||||||
rounded
|
rounded
|
||||||
small
|
small
|
||||||
inline
|
inline
|
||||||
|
|||||||
+2
-2
@@ -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 |
|
| **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 |
|
| **Calls** | YACA, PMA Voice, SaltyChat |
|
||||||
| **Radio** | 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** | ESX Property, qbx_properties |
|
||||||
| **Garages** | Built-in/custom data and a broad set of popular garage providers configured through the bridge |
|
| **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 |
|
| **Custom app contracts** | Sky Phone, LB Phone, 17Movement, High Phone, Quasar Smartphone, YSeries |
|
||||||
| **Languages** | English, German |
|
| **Languages** | English, German |
|
||||||
@@ -525,7 +525,7 @@ Select the provider under `Config.Garage.System`. Vehicle images use the configu
|
|||||||
|
|
||||||
### Housing
|
### 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 the provider under `Config.Housing.System`. Automatic mode supports the configured provider priority.
|
||||||
|
|
||||||
### Companies
|
### Companies
|
||||||
|
|
||||||
|
|||||||
@@ -368,8 +368,8 @@ Config.Garage = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Config.Housing = {
|
Config.Housing = {
|
||||||
System = "auto", -- auto, rtx, quasar, vms, rx, nolag, sn, esx_property, qbx_properties
|
System = "auto", -- auto, esx_property, qbx_properties
|
||||||
AutoPriority = { "esx_property", "qbx_properties", "rtx", "quasar", "vms", "rx", "nolag", "sn" },
|
AutoPriority = { "esx_property", "qbx_properties" },
|
||||||
MaximumProperties = 50,
|
MaximumProperties = 50,
|
||||||
OverviewRequestsPerMinute = 30,
|
OverviewRequestsPerMinute = 30,
|
||||||
ActionsPerMinute = 12,
|
ActionsPerMinute = 12,
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ Locales["de"] = {
|
|||||||
ready = {
|
ready = {
|
||||||
eyebrow = "Einrichtung abgeschlossen", title = "Willkommen, {name}", body = "Dein Sky Phone ist eingerichtet und bereit. Du kannst alles später in den Einstellungen anpassen.",
|
eyebrow = "Einrichtung abgeschlossen", title = "Willkommen, {name}", body = "Dein Sky Phone ist eingerichtet und bereit. Du kannst alles später in den Einstellungen anpassen.",
|
||||||
localOnly = "Auf diesem Handy gespeichert", enter = "Sky Phone öffnen", review = "Einrichtung prüfen",
|
localOnly = "Auf diesem Handy gespeichert", enter = "Sky Phone öffnen", review = "Einrichtung prüfen",
|
||||||
saving = "Einrichtung wird gespeichert", saveFailed = "Die Einrichtung konnte nicht gespeichert werden. Versuche es erneut.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Common = {
|
Common = {
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ Locales["en"] = {
|
|||||||
ready = {
|
ready = {
|
||||||
eyebrow = "Setup Complete", title = "Welcome, {name}", body = "Your Sky Phone is configured and ready. Your choices can always be refined in Settings.",
|
eyebrow = "Setup Complete", title = "Welcome, {name}", body = "Your Sky Phone is configured and ready. Your choices can always be refined in Settings.",
|
||||||
localOnly = "Stored on this phone", enter = "Enter Sky Phone", review = "Review Setup",
|
localOnly = "Stored on this phone", enter = "Enter Sky Phone", review = "Review Setup",
|
||||||
saving = "Saving setup", saveFailed = "Setup could not be saved. Try again.",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Common = {
|
Common = {
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ server_scripts {
|
|||||||
'config/media.lua',
|
'config/media.lua',
|
||||||
'config/locales/en.lua',
|
'config/locales/en.lua',
|
||||||
'config/locales/de.lua',
|
'config/locales/de.lua',
|
||||||
'source/server/update_check.lua',
|
|
||||||
'source/bridge/server/database.lua',
|
'source/bridge/server/database.lua',
|
||||||
'source/bridge/server/migrations.lua',
|
'source/bridge/server/migrations.lua',
|
||||||
'source/bridge/server/callbacks.lua',
|
'source/bridge/server/callbacks.lua',
|
||||||
|
|||||||
@@ -5,67 +5,10 @@ Bridge.Housing = {
|
|||||||
function Bridge.Housing.RegisterClientProvider(name, provider)
|
function Bridge.Housing.RegisterClientProvider(name, provider)
|
||||||
assert(type(name) == "string" and name ~= "", "Housing provider name must be a non-empty string")
|
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(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))
|
assert(not Bridge.Housing.ClientProviders[name], ("Housing client provider '%s' is already registered"):format(name))
|
||||||
Bridge.Housing.ClientProviders[name] = provider
|
Bridge.Housing.ClientProviders[name] = provider
|
||||||
end
|
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)
|
function Bridge.Housing.Execute(provider_name, action, data)
|
||||||
local provider = Bridge.Housing.ClientProviders[provider_name]
|
local provider = Bridge.Housing.ClientProviders[provider_name]
|
||||||
if not provider then
|
if not provider then
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,510 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,730 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
@@ -5,38 +5,8 @@ Bridge.Database = Bridge.Database or {}
|
|||||||
Bridge.Framework = Bridge.Framework or {}
|
Bridge.Framework = Bridge.Framework or {}
|
||||||
Bridge.Inventory = Bridge.Inventory or {}
|
Bridge.Inventory = Bridge.Inventory or {}
|
||||||
Bridge.Radio = Bridge.Radio or {}
|
Bridge.Radio = Bridge.Radio or {}
|
||||||
Bridge.Normalize = Bridge.Normalize or {}
|
|
||||||
Bridge.Speaker = Bridge.Speaker 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()
|
function Bridge.Speaker.IsEnabled()
|
||||||
return not Config.Speaker or Config.Speaker.Enabled ~= false
|
return not Config.Speaker or Config.Speaker.Enabled ~= false
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -169,17 +169,6 @@ RegisterNUICallback("housing:overview", function(data, cb)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local result = Bridge.Callbacks.Trigger("sky_phone:housing:overview", {})
|
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" })
|
cb(type(result) == "table" and result or { success = false, error = "request_failed" })
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -222,24 +211,11 @@ RegisterNUICallback("housing:command", function(data, cb)
|
|||||||
end
|
end
|
||||||
if data.action == "set_waypoint" then
|
if data.action == "set_waypoint" then
|
||||||
local coords = prepared.data.coords
|
local coords = prepared.data.coords
|
||||||
if coords == nil then
|
if type(coords) ~= "table" or not tonumber(coords.x) or not tonumber(coords.y) 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" })
|
cb({ success = false, error = "invalid_coordinates" })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
SetNewWaypoint(x + 0.0, y + 0.0)
|
SetNewWaypoint(tonumber(coords.x) + 0.0, tonumber(coords.y) + 0.0)
|
||||||
cb({ success = true })
|
cb({ success = true })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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")
|
|
||||||
Reference in New Issue
Block a user