diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 095f0cf..f5ef846 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -530,8 +530,17 @@ function hydratePhone(payload: PhoneOpenPayload): void { clock.hydrate(payload.device?.data.alarms?.payload) games.hydrate(payload.device?.data.games?.payload) media.hydrate(payload.device?.data.media?.payload) - appStore.hydrate(payload.device?.data.apps?.payload) + appStore.hydrate(payload.device?.data.apps?.payload, payload.disabledApps) widgets.hydrate(payload.device?.data.widgets?.payload) + + const currentAppId = route.params.appId + if ( + typeof currentAppId === 'string' && + isPhoneAppId(currentAppId) && + !appStore.isInstalled(currentAppId) + ) { + void router.push('/') + } } function getInstalledNavigationAppIds(): string[] { diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index a0d391c..9a8e7ee 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -188,6 +188,24 @@ describe('standalone admin panel contracts', () => { ) }) + it('applies global bundled-app availability to phones and admin management', () => { + expect(config).toContain('Config.Apps = {') + expect(config).toContain('memory = true,') + expect(config).toContain('weather = true,') + expect(configDefault).toContain('Config.Apps = {') + expect(configuratorServer).toContain('Apps = true,') + expect(phoneServer).toContain('function SkyPhone.IsAppEnabled(app_id)') + expect(phoneServer).toContain('function SkyPhone.GetDisabledApps()') + expect(phoneServer).toContain( + 'disabledApps = SkyPhone.GetDisabledApps()', + ) + expect(server).toContain('if not SkyPhone.IsAppEnabled(app_id) then') + expect(server).toContain('disabledApps = SkyPhone.GetDisabledApps()') + expect(store).toContain('disabledApps: [] as string[]') + expect(store).toContain('disabledAppsFromConfigurator(response.data)') + expect(source).toContain('!admin.disabledApps.includes(app.id)') + }) + it('connects every operation through the standard NUI callback bridge', () => { expect(bridge).toContain('admin = [[') for (const endpoint of [ diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index c982409..8f1e65d 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -134,7 +134,9 @@ const manageableApps = computed(() => { const needle = appQuery.value.trim().toLocaleLowerCase(phone.lang) return PHONE_APPS.filter( (app): app is LaunchablePhoneAppDefinition => - isLaunchablePhoneApp(app) && !app.adminOnly, + isLaunchablePhoneApp(app) && + !app.adminOnly && + !admin.disabledApps.includes(app.id), ) .filter( (app) => diff --git a/frontend/src/components/PhoneSetupAssistant.vue b/frontend/src/components/PhoneSetupAssistant.vue index a4817c7..ffa5999 100644 --- a/frontend/src/components/PhoneSetupAssistant.vue +++ b/frontend/src/components/PhoneSetupAssistant.vue @@ -61,16 +61,22 @@ const passcodeError = ref('') const passcodeLength = ref<4 | 6>(phone.security.length === 4 ? 4 : 6) const notificationsEnabled = ref(true) const notificationSounds = ref(true) -const selectedApps = ref(['banking', 'garage', 'skyride']) +const selectedApps = ref( + (['banking', 'garage', 'skyride'] as const).filter((id) => + appStore.isAvailable(id), + ), +) const setupCompleteBusy = ref(false) const setupCompleteError = ref('') -const setupApps = ( - ['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const -).flatMap((id) => { - const app = getPhoneApp(id) - return app ? [app] : [] -}) +const setupApps = computed(() => + ( + ['banking', 'garage', 'skyride', 'citymarkt', 'picstagram', 'snake'] as const + ).flatMap((id) => { + const app = getPhoneApp(id) + return app && appStore.isAvailable(id) ? [app] : [] + }), +) const wallpaperChoices = WALLPAPER_IDS const progress = computed(() => `${((step.value + 1) / 10) * 100}%`) const displayName = computed(() => { diff --git a/frontend/src/phoneInventory.contract.test.ts b/frontend/src/phoneInventory.contract.test.ts index f084944..add39d8 100644 --- a/frontend/src/phoneInventory.contract.test.ts +++ b/frontend/src/phoneInventory.contract.test.ts @@ -52,6 +52,41 @@ describe('phone inventory contracts', () => { ) }) + it('uses One Inventory slot ids and authoritative slot reads', () => { + const adapter = readResourceFile( + 'source/bridge/server/inventory/one.lua', + ) + + expect(adapter).toContain('inventory:GetSlotIdsWithItem(') + expect(adapter).toContain( + 'local normalized = Bridge.Inventory.GetSlot(source, slot_id)', + ) + expect(adapter).not.toContain('inventory:SearchInventory(') + expect(adapter).toContain( + 'inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false', + ) + }) + + it('serializes and rate-limits phone bootstrap requests on both sides', () => { + const phoneClient = readResourceFile('source/client/main.lua') + const phoneServer = readResourceFile('source/server/phone.lua') + + expect(phoneClient).toContain('local function request_phone_open(') + expect(phoneClient).toMatch( + /request_phone_open\(callback_name\)[\s\S]*?open_requested = true[\s\S]*?Bridge\.Callbacks\.Trigger\(callback_name, \{\}\)/, + ) + expect(phoneClient).toMatch( + /RegisterNetEvent\("sky_phone:device:error"[\s\S]*?if not is_open then[\s\S]*?open_requested = false/, + ) + expect(phoneServer).toContain('local phone_open_in_progress = {}') + expect(phoneServer).toContain( + 'SkyPhone.AllowOperation(source, "phone_open", request_limit, 60)', + ) + expect(phoneServer).toContain( + 'pcall(perform_phone_open, source, used_item)', + ) + }) + it('auto-detects registered inventories and forces metadata-free adapters into compatible modes', () => { const inventoryBridge = readResourceFile( 'source/bridge/server/inventory.lua', @@ -166,10 +201,10 @@ describe('phone inventory contracts', () => { 'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "ToggleOpen"', ) expect(phoneClient).toContain( - 'local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})', + 'local result = Bridge.Callbacks.Trigger(callback_name, {})', ) expect(phoneClient).toMatch( - /result\.success ~= true[\s\S]*open_without_focus = false[\s\S]*return false/, + /result\.success == true[\s\S]*open_requested = false[\s\S]*open_without_focus = false[\s\S]*return false/, ) expect(phoneBridge).toContain( 'SkyPhoneCompatibility.RegisterExportAlias("lb-phone", "IsPhoneOnScreen"', @@ -265,7 +300,7 @@ describe('phone inventory contracts', () => { 'if active_key_mapping_command == command_name then', ) expect(phoneClient).toContain( - 'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})', + 'request_phone_open("sky_phone:device:open-request")', ) expect(phoneServer).toContain( 'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)', diff --git a/frontend/src/stores/admin.ts b/frontend/src/stores/admin.ts index 9888c04..e92a874 100644 --- a/frontend/src/stores/admin.ts +++ b/frontend/src/stores/admin.ts @@ -27,6 +27,19 @@ const EMPTY_STATS: AdminStats = { online: 0, simDevices: 0, } +function disabledAppsFromConfigurator( + configurator: AdminConfigurator, +): string[] { + const apps = configurator.sections + .flatMap((section) => section.fields) + .find((field) => field.scope === 'config' && field.path === 'Apps')?.value + if (!apps || typeof apps !== 'object' || Array.isArray(apps)) return [] + + return Object.entries(apps) + .filter(([, enabled]) => enabled === false) + .map(([appId]) => appId) + .sort() +} export const useAdminStore = defineStore('admin', { state: () => ({ @@ -36,6 +49,7 @@ export const useAdminStore = defineStore('admin', { configurator: null as AdminConfigurator | null, configuratorLoading: false, detailLoading: false, + disabledApps: [] as string[], error: '', initialized: false, loading: false, @@ -58,6 +72,9 @@ export const useAdminStore = defineStore('admin', { return false } this.players = response.data.players + this.disabledApps = Array.isArray(response.data.disabledApps) + ? response.data.disabledApps + : [] this.stats = response.data.stats this.audit = response.data.audit this.error = '' @@ -158,6 +175,7 @@ export const useAdminStore = defineStore('admin', { return false } this.configurator = response.data + this.disabledApps = disabledAppsFromConfigurator(response.data) this.error = '' return true }, @@ -178,6 +196,7 @@ export const useAdminStore = defineStore('admin', { this.actionKey = '' if (response.success && response.data) { this.configurator = response.data + this.disabledApps = disabledAppsFromConfigurator(response.data) this.error = '' } else { if (response.data) this.configurator = response.data diff --git a/frontend/src/stores/app-store.test.ts b/frontend/src/stores/app-store.test.ts index 649eaf3..159c1b0 100644 --- a/frontend/src/stores/app-store.test.ts +++ b/frontend/src/stores/app-store.test.ts @@ -75,6 +75,21 @@ describe('app store', () => { } }) + it('removes server-disabled apps without forgetting device claims', () => { + const apps = useAppStoreStore() + + apps.hydrate({ claimedApps: ['weather', 'snake'] }, ['weather']) + + expect(apps.isAvailable('weather')).toBe(false) + expect(apps.isInstalled('weather')).toBe(false) + expect(apps.homeLayout.grid).not.toContain('weather') + expect(apps.claimedApps).toContain('weather') + expect(apps.isInstalled('snake')).toBe(true) + + apps.hydrate({ claimedApps: apps.claimedApps }, []) + expect(apps.isInstalled('weather')).toBe(true) + }) + it('drops the retired admin app from persisted phone layouts', () => { const apps = useAppStoreStore() diff --git a/frontend/src/stores/app-store.ts b/frontend/src/stores/app-store.ts index 892b223..95acb5c 100644 --- a/frontend/src/stores/app-store.ts +++ b/frontend/src/stores/app-store.ts @@ -45,28 +45,49 @@ const pendingInstallations = new WeakMap< Map >() -function getDefaultGridIds(): LaunchablePhoneAppId[] { - return PHONE_APPS.filter((app) => app.dockOrder === null) +function isAppDisabled( + appId: LaunchablePhoneAppId, + disabledApps: readonly LaunchablePhoneAppId[], +): boolean { + return disabledApps.includes(appId) +} + +function getDefaultGridIds( + disabledApps: readonly LaunchablePhoneAppId[] = [], +): LaunchablePhoneAppId[] { + return PHONE_APPS.filter( + (app) => app.dockOrder === null && !isAppDisabled(app.id, disabledApps), + ) .sort((a, b) => a.gridOrder - b.gridOrder) .map((app) => app.id) } -function getDefaultDockIds(): LaunchablePhoneAppId[] { - return PHONE_APPS.filter((app) => app.dockOrder !== null) +function getDefaultDockIds( + disabledApps: readonly LaunchablePhoneAppId[] = [], +): LaunchablePhoneAppId[] { + return PHONE_APPS.filter( + (app) => app.dockOrder !== null && !isAppDisabled(app.id, disabledApps), + ) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) .map((app) => app.id) } -function getDefaultInstalledIds(): LaunchablePhoneAppId[] { +function getDefaultInstalledIds( + disabledApps: readonly LaunchablePhoneAppId[] = [], +): LaunchablePhoneAppId[] { return PHONE_APPS.filter((app) => { - if (app.adminOnly) return false + if (app.adminOnly || isAppDisabled(app.id, disabledApps)) return false return isExternalPhoneApp(app) ? app.defaultInstalled : DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) }).map((app) => app.id) } -function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean { +function isProtectedHomeApp( + appId: LaunchablePhoneAppId, + disabledApps: readonly LaunchablePhoneAppId[], +): boolean { + if (isAppDisabled(appId, disabledApps)) return false const app = getPhoneApp(appId) return app ? !isPhoneAppRemovable(app) @@ -104,6 +125,7 @@ function hasUninstalledBuiltinApp( export const useAppStoreStore = defineStore('app-store', { state: () => ({ claimedApps: [] as LaunchablePhoneAppId[], + disabledApps: [] as LaunchablePhoneAppId[], uninstalledApps: [] as LaunchablePhoneAppId[], homeLayout: createDefaultHomeLayout( getDefaultInstalledIds(), @@ -130,6 +152,7 @@ export const useAppStoreStore = defineStore('app-store', { return true }, claimApp(id: LaunchablePhoneAppId): void { + if (!this.isAvailable(id)) return this.uninstalledApps = this.uninstalledApps.filter( (appId) => appId !== id, ) @@ -150,6 +173,7 @@ export const useAppStoreStore = defineStore('app-store', { this.installingApps = {} }, installApp(id: LaunchablePhoneAppId): void { + if (!this.isAvailable(id)) return const installed = this.isInstalled(id) if ( this.installingApps[id] || @@ -191,6 +215,10 @@ export const useAppStoreStore = defineStore('app-store', { ) return } + if (!this.isAvailable(id)) { + delete this.installingApps[id] + return + } if (reportInstall && !isExternalPhoneApp(getPhoneApp(id))) { delete this.installingApps[id] console.error( @@ -220,8 +248,14 @@ export const useAppStoreStore = defineStore('app-store', { installations.set(id, { deviceImei, timer, token }) pendingInstallations.set(this, installations) }, - hydrate(payload: unknown): void { + hydrate(payload: unknown, disabledApps: unknown = []): void { this.cancelPendingInstalls() + this.disabledApps = Array.isArray(disabledApps) + ? disabledApps.filter( + (id): id is LaunchablePhoneAppId => + typeof id === 'string' && isPhoneAppId(id), + ) + : [] const data = payload as { claimedApps?: unknown homeLayout?: unknown @@ -256,16 +290,22 @@ export const useAppStoreStore = defineStore('app-store', { }) : [] const installedIds = [ - ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), - ].filter((id) => !this.uninstalledApps.includes(id)) + ...new Set([ + ...getDefaultInstalledIds(this.disabledApps), + ...this.claimedApps, + ]), + ].filter( + (id) => + !this.uninstalledApps.includes(id) && this.isAvailable(id), + ) const removedLegacyDefaults = hasUninstalledBuiltinApp( data?.homeLayout, installedIds, ) const defaults = createDefaultHomeLayout( installedIds, - getDefaultGridIds(), - getDefaultDockIds(), + getDefaultGridIds(this.disabledApps), + getDefaultDockIds(this.disabledApps), ) const parsedHomeLayout = parseHomeLayout( data?.homeLayout, @@ -277,8 +317,9 @@ export const useAppStoreStore = defineStore('app-store', { const removedDockGridDuplicates = normalizedHomeLayout !== parsedHomeLayout this.homeLayout = normalizedHomeLayout - const protectedHiddenAppIds = - this.homeLayout.hidden.filter(isProtectedHomeApp) + const protectedHiddenAppIds = this.homeLayout.hidden.filter((appId) => + isProtectedHomeApp(appId, this.disabledApps), + ) for (const appId of protectedHiddenAppIds) { this.homeLayout = restoreHomeApp(this.homeLayout, appId) } @@ -310,7 +351,11 @@ export const useAppStoreStore = defineStore('app-store', { this.persist() } }, + isAvailable(appId: LaunchablePhoneAppId): boolean { + return !isAppDisabled(appId, this.disabledApps) + }, isInstalled(appId: LaunchablePhoneAppId): boolean { + if (!this.isAvailable(appId)) return false const app = getPhoneApp(appId) if (app?.adminOnly) return false if (this.uninstalledApps.includes(appId)) return false @@ -322,12 +367,18 @@ export const useAppStoreStore = defineStore('app-store', { }, reconcileCatalog(): void { const installedIds = [ - ...new Set([...getDefaultInstalledIds(), ...this.claimedApps]), - ].filter((id) => !this.uninstalledApps.includes(id)) + ...new Set([ + ...getDefaultInstalledIds(this.disabledApps), + ...this.claimedApps, + ]), + ].filter( + (id) => + !this.uninstalledApps.includes(id) && this.isAvailable(id), + ) const defaults = createDefaultHomeLayout( installedIds, - getDefaultGridIds(), - getDefaultDockIds(), + getDefaultGridIds(this.disabledApps), + getDefaultDockIds(this.disabledApps), ) const previous = JSON.stringify(this.homeLayout) this.homeLayout = removeDockGridDuplicates( @@ -335,7 +386,7 @@ export const useAppStoreStore = defineStore('app-store', { ) for (const appId of [...this.homeLayout.hidden]) { - if (isProtectedHomeApp(appId)) { + if (isProtectedHomeApp(appId, this.disabledApps)) { this.homeLayout = restoreHomeApp(this.homeLayout, appId) } } @@ -345,6 +396,7 @@ export const useAppStoreStore = defineStore('app-store', { } }, recordLaunch(appId: LaunchablePhoneAppId): void { + if (!this.isAvailable(appId)) return this.launchCounts[appId] = (this.launchCounts[appId] ?? 0) + 1 this.persist() }, @@ -480,12 +532,13 @@ export const useAppStoreStore = defineStore('app-store', { return true }, removeHomeApp(appId: LaunchablePhoneAppId): void { - if (isProtectedHomeApp(appId)) return + if (isProtectedHomeApp(appId, this.disabledApps)) return this.homeLayout = removeHomeApp(this.homeLayout, appId) this.persist() }, restoreHomeApp(appId: LaunchablePhoneAppId): void { + if (!this.isAvailable(appId)) return this.homeLayout = restoreHomeApp(this.homeLayout, appId) this.persist() }, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 20ae494..e15996b 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -33,6 +33,7 @@ export type PasscodeResponseData = { export type PhoneOpenPayload = { account?: DeviceBootstrap['account'] device?: PhoneDevice + disabledApps?: string[] fallbackLocales?: LocaleTree lang?: string locales?: LocaleTree diff --git a/frontend/src/types/admin.ts b/frontend/src/types/admin.ts index 0b45a29..4c24c1b 100644 --- a/frontend/src/types/admin.ts +++ b/frontend/src/types/admin.ts @@ -85,6 +85,7 @@ export type AdminAuditEntry = { export type AdminBootstrap = { audit: AdminAuditEntry[] + disabledApps: string[] players: AdminPlayerSummary[] stats: AdminStats } diff --git a/frontend/src/utils/preferences.test.ts b/frontend/src/utils/preferences.test.ts index 0b19a15..046781b 100644 --- a/frontend/src/utils/preferences.test.ts +++ b/frontend/src/utils/preferences.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest' import type { LaunchablePhoneAppId } from '@/types/apps' import { DEFAULT_PHONE_PREFERENCES, + PHONE_SCALE_STEP, PHONE_SETUP_LAST_STEP, parsePhonePreferences, WALLPAPER_IDS, } from './preferences' describe('preferences', () => { + it('allows one-percent phone scale adjustments', () => { + expect(PHONE_SCALE_STEP).toBe(1) + }) + it('starts Setup Assistant for a phone without saved settings', () => { const value = parsePhonePreferences(null) diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 48b0358..a5c625a 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -38,7 +38,7 @@ export const WALLPAPER_IDS = [ ] as const export const PHONE_SCALE_MIN = 75 export const PHONE_SCALE_MAX = 150 -export const PHONE_SCALE_STEP = 5 +export const PHONE_SCALE_STEP = 1 export const PHONE_SETUP_LAST_STEP = 9 export type AppearanceMode = (typeof APPEARANCE_MODE_IDS)[number] diff --git a/frontend/src/views/apps/AppStoreApp.contract.test.ts b/frontend/src/views/apps/AppStoreApp.contract.test.ts index 013ab7f..f99a1bc 100644 --- a/frontend/src/views/apps/AppStoreApp.contract.test.ts +++ b/frontend/src/views/apps/AppStoreApp.contract.test.ts @@ -81,6 +81,7 @@ describe('AppStoreApp Sky navigation contract', () => { expect(source).toContain('@click="profileOpened = true"') expect(source).toContain('const installedApps = computed') expect(source).toContain('return !appStore.isInstalled(app.id)') + expect(source).toContain('if (!appStore.isAvailable(app.id)) return false') expect(source).toContain( 'class="store-account__apps phone-effect--expensive-shadow"', ) diff --git a/frontend/src/views/apps/AppStoreApp.vue b/frontend/src/views/apps/AppStoreApp.vue index eb9f4f6..9d7a087 100644 --- a/frontend/src/views/apps/AppStoreApp.vue +++ b/frontend/src/views/apps/AppStoreApp.vue @@ -122,6 +122,7 @@ const catalog = computed(() => if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) { return false } + if (!appStore.isAvailable(app.id)) return false return !appStore.isInstalled(app.id) }).sort((a, b) => a.gridOrder - b.gridOrder), @@ -129,7 +130,9 @@ const catalog = computed(() => const installedApps = computed(() => PHONE_APPS.filter( (app): app is LaunchablePhoneAppDefinition => - isLaunchablePhoneApp(app) && appStore.isInstalled(app.id), + isLaunchablePhoneApp(app) && + appStore.isAvailable(app.id) && + appStore.isInstalled(app.id), ).sort((a, b) => a.gridOrder - b.gridOrder), ) const installedGameCount = computed( @@ -143,6 +146,7 @@ const dailyCandidates = computed(() => !app.adminOnly && !isExternalPhoneApp(app) && app.id !== 'app-store' && + appStore.isAvailable(app.id) && !DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) && !appStore.isInstalled(app.id), ), diff --git a/frontend/src/views/apps/CryptoApp.contract.test.ts b/frontend/src/views/apps/CryptoApp.contract.test.ts index adf2e15..5565db5 100644 --- a/frontend/src/views/apps/CryptoApp.contract.test.ts +++ b/frontend/src/views/apps/CryptoApp.contract.test.ts @@ -369,6 +369,22 @@ describe('VaultX crypto app contracts', () => { expect(testServer).toContain('function advanceCryptoCycle(') }) + it('does not keep market and settlement workers active while disabled', () => { + expect(server).toContain('local function start_crypto_schedulers()') + expect(server).toMatch( + /start_crypto_schedulers\(\)[\s\S]*?if Config\.Crypto\.Enabled ~= true then\s+return/, + ) + expect(server).toContain( + 'while scheduler_generation == generation and Config.Crypto.Enabled == true do', + ) + expect(server).toContain( + 'AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)', + ) + expect(server).not.toMatch( + /CreateThread\(function\(\)\s+while true do\s+Wait\(5 \* 60 \* 1000\)/, + ) + }) + it('stores cash in price-scale minor units throughout the ledger', () => { expect(server).toContain( 'local ledger_amount = amount * Config.Crypto.PriceScale', diff --git a/frontend/src/views/apps/DarkChatApp.contract.test.ts b/frontend/src/views/apps/DarkChatApp.contract.test.ts index 13937a8..79121c7 100644 --- a/frontend/src/views/apps/DarkChatApp.contract.test.ts +++ b/frontend/src/views/apps/DarkChatApp.contract.test.ts @@ -6,6 +6,14 @@ const source = readFileSync( new URL('./DarkChatApp.vue', import.meta.url), 'utf8', ) +const server = readFileSync( + new URL('../../../../sky_phone/source/server/darkchat.lua', import.meta.url), + 'utf8', +) +const config = readFileSync( + new URL('../../../../sky_phone/config/config.lua', import.meta.url), + 'utf8', +) describe('DarkChatApp Sky UI contract', () => { it('uses first-party Sky UI without direct Konsta markup', () => { @@ -130,4 +138,13 @@ describe('DarkChatApp Sky UI contract', () => { /\.dc-inbox-navbar :deep\(\.sky-navbar__title-container > div\)\s*\{[^}]*translateY\(-14px\)/s, ) }) + + it('cleans expired messages in bounded indexed batches', () => { + expect(config).toContain('CleanupBatchSize = 250') + expect(server).toContain('Config.DarkChat.CleanupBatchSize') + expect(server).toMatch( + /DELETE FROM `sky_phone_darkchat_messages`[\s\S]*?ORDER BY `expires_at`, `id`[\s\S]*?LIMIT \?/, + ) + expect(server).toContain(']], { batch_size })') + }) }) diff --git a/frontend/src/views/apps/PhoneApp.contract.test.ts b/frontend/src/views/apps/PhoneApp.contract.test.ts index e169be7..36a2c24 100644 --- a/frontend/src/views/apps/PhoneApp.contract.test.ts +++ b/frontend/src/views/apps/PhoneApp.contract.test.ts @@ -16,6 +16,12 @@ describe('PhoneApp EasyShare contract', () => { it('uses the shared full-width Sky tab bar for phone sections', () => { expect(source).toContain(' { diff --git a/frontend/src/views/apps/PhoneApp.vue b/frontend/src/views/apps/PhoneApp.vue index e325f04..67c8ec9 100644 --- a/frontend/src/views/apps/PhoneApp.vue +++ b/frontend/src/views/apps/PhoneApp.vue @@ -2250,7 +2250,6 @@ onBeforeUnmount(() => { position: relative; padding-top: 6px; padding-right: 24px; - padding-bottom: 20px; } .phone-contacts-header { diff --git a/frontend/src/voiceProviders.contract.test.ts b/frontend/src/voiceProviders.contract.test.ts index 6f64358..01bbf28 100644 --- a/frontend/src/voiceProviders.contract.test.ts +++ b/frontend/src/voiceProviders.contract.test.ts @@ -116,6 +116,20 @@ describe('voice provider contracts', () => { expect(phoneApp).not.toContain('callMuted = !callMuted') }) + it('supports explicit automatic call-provider discovery on client and server', () => { + expect(config).toContain( + 'VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice)', + ) + expect(clientCalls).toContain('if configured == "auto" then') + expect(serverVoice).toContain('if configured == "auto" then') + expect(clientCalls).toContain( + 'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do', + ) + expect(serverVoice).toContain( + 'for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do', + ) + }) + it('passes Yaca radio volume arguments in the documented order', () => { expect(clientRadio).toContain( 'changeRadioChannelVolumeRaw(volume / 100, 1)', diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts index 3a46760..0bca2ce 100644 --- a/frontend/testserver/configurator-fixture.test.ts +++ b/frontend/testserver/configurator-fixture.test.ts @@ -75,7 +75,7 @@ describe('admin configurator fixture', () => { root !== 'CommandPermissions', ) - expect(sections).toHaveLength(45) + expect(sections).toHaveLength(46) expect( fields.reduce( (total, field) => total + countStructure(field.structure), diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index dbbe838..1941763 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -4726,6 +4726,7 @@ function adminMockBootstrap() { targetSource: 2, }, ], + disabledApps: [], players: [1, 2].map((source) => { const player = adminMockPlayerDetail(source) return { diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index d6b5ea7..1e50ed5 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -51,6 +51,7 @@ Config.Phone = { Item = "phone", Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories Keybind = "F1", -- false disables the configurable phone key mapping + OpenRequestsPerMinute = 20, AllowMovement = true, -- true: game input stays active while the mobile phone is open HoldToLook = { Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement @@ -60,6 +61,52 @@ Config.Phone = { DeviceName = "iFruit Phone", } +-- Server-wide availability for bundled apps. Set an entry to false to hide it +-- from every phone, the App Store and per-device app management. +Config.Apps = { + ["app-store"] = true, + banking = true, + billing = true, + calculator = true, + calendar = true, + camera = true, + citymarkt = true, + citywarn = true, + clock = true, + companies = true, + crewlink = true, + crypto = true, + darkchat = true, + feather = true, + flare = true, + fliptok = true, + garage = true, + health = true, + house = true, + ["local-pages"] = true, + mail = true, + map = true, + memory = true, + memos = true, + messages = true, + minesweeper = true, + music = true, + ["neon-drop"] = true, + notes = true, + ["number-merge"] = true, + phone = true, + photos = true, + picstagram = true, + radio = true, + settings = true, + ["sky-flappy"] = true, + skyride = true, + snake = true, + ["tower-stack"] = true, + weather = true, + ["weazel-news"] = true, +} + Config.TestData = { Enabled = false, -- development/test servers only; keep disabled in production Command = "phonetestdata", @@ -116,7 +163,7 @@ Config.Speaker = { } Config.Calls = { - VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty) + VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty) RingSeconds = 30, ContactNameMaxLength = 80, ContactNotesMaxLength = 500, @@ -301,6 +348,7 @@ Config.DarkChat = { VoiceMaxBase64Length = 360000, VoiceWaveformSamples = 48, CleanupIntervalSeconds = 30, + CleanupBatchSize = 250, AllowedDisappearTimers = { [0] = true, [-1] = true, -- after reading diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index c3753fc..81902e2 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -43,6 +43,7 @@ Locales["de"] = { invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.", phone_required = "Du benötigst ein Handy in deinem Inventar.", operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.", + rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.", voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.", default = "Das Handy konnte nicht geöffnet werden.", }, diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 78ca780..832c272 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -43,6 +43,7 @@ Locales["en"] = { invalid_sim = "This SIM card has invalid metadata.", phone_required = "You need a phone in your inventory.", operation_in_progress = "Another phone operation is already in progress.", + rate_limited = "The phone was opened too often. Try again in a moment.", voice_unavailable = "The configured phone voice service is unavailable.", default = "The phone could not be opened.", }, diff --git a/sky_phone/config/locales/es.lua b/sky_phone/config/locales/es.lua index 0a38a9d..c0fcf0a 100644 --- a/sky_phone/config/locales/es.lua +++ b/sky_phone/config/locales/es.lua @@ -43,6 +43,7 @@ Locales["es"] = { invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.", phone_required = "Necesitas un teléfono en tu inventario.", operation_in_progress = "Otra operación de teléfono ya está en progreso.", + rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.", voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.", default = "No se pudo abrir el teléfono.", }, diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 4567898..36657e2 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes' author 'Sky-Systems' description 'Sky Phone' -version '0.2.7' +version '0.2.8' provide 'lb-phone' provide '17mov_Phone' diff --git a/sky_phone/source/bridge/client/calls.lua b/sky_phone/source/bridge/client/calls.lua index e81b9d4..3418b91 100644 --- a/sky_phone/source/bridge/client/calls.lua +++ b/sky_phone/source/bridge/client/calls.lua @@ -11,6 +11,15 @@ local provider_aliases = { local function resolve_provider() local configured = tostring(Config.Calls.VoiceProvider or "") + if configured == "auto" then + for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do + if GetResourceState(provider_resources[candidate]) == "started" then + return candidate + end + end + return nil + end + local selected = provider_aliases[configured] or configured local resource_name = provider_resources[selected] if resource_name and GetResourceState(resource_name) == "started" then diff --git a/sky_phone/source/bridge/server/inventory/one.lua b/sky_phone/source/bridge/server/inventory/one.lua index 3431c4a..6322fc9 100644 --- a/sky_phone/source/bridge/server/inventory/one.lua +++ b/sky_phone/source/bridge/server/inventory/one.lua @@ -23,8 +23,8 @@ end function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata) local matches = {} - for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do - local normalized = normalize(item, tonumber(index) or index) + for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do + local normalized = Bridge.Inventory.GetSlot(source, slot_id) if normalized and normalized.name == item_name and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then matches[#matches + 1] = normalized @@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata) end local requested_metadata = type(metadata) == "table" and metadata or {} - if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then + if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then return false end local updated = Bridge.Inventory.GetSlot(source, slot.slot) diff --git a/sky_phone/source/bridge/server/voice.lua b/sky_phone/source/bridge/server/voice.lua index 6aee0cb..496806b 100644 --- a/sky_phone/source/bridge/server/voice.lua +++ b/sky_phone/source/bridge/server/voice.lua @@ -41,6 +41,15 @@ end local function resolve_call_provider() local configured = tostring(Config.Calls.VoiceProvider or "") + if configured == "auto" then + for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do + if GetResourceState(call_provider_resources[candidate]) == "started" then + return candidate + end + end + return nil + end + local selected = call_provider_aliases[configured] or configured local resource_name = call_provider_resources[selected] if resource_name and GetResourceState(resource_name) == "started" then diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index b175ad1..ca496c6 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -75,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale) +local function apply_disabled_apps(payload) + if type(payload) ~= "table" then + return + end + + local disabled = {} + for app_id, enabled in pairs(Config.Apps or {}) do + if type(app_id) == "string" and enabled == false then + disabled[#disabled + 1] = app_id + end + end + table.sort(disabled) + payload.disabledApps = disabled +end + local function send_admin_panel_open() SendNUIMessage({ type = "admin:open", @@ -136,6 +151,7 @@ AddEventHandler("sky_phone:configurator:updated", function() refresh_admin_command_suggestion() SkyPhoneApps.SendCatalog() if is_open and device_payload then + apply_disabled_apps(device_payload) device_payload.lang = locale_name device_payload.locales = locale.Nui device_payload.fallbackLocales = Locales.en.Nui @@ -175,6 +191,22 @@ local function close_phone(close_device_session) end end +local function request_phone_open(callback_name) + if is_open or open_requested then + return true + end + + open_requested = true + local result = Bridge.Callbacks.Trigger(callback_name, {}) + if type(result) == "table" and result.success == true then + return true + end + + open_requested = false + open_without_focus = false + return false +end + local function toggle_phone(open, no_focus) if open ~= nil and type(open) ~= "boolean" then Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.") @@ -190,6 +222,9 @@ local function toggle_phone(open, no_focus) should_open = not (is_open or open_requested) end if not should_open then + if open == nil and open_requested and not is_open then + return true + end close_phone() return true end @@ -202,12 +237,7 @@ local function toggle_phone(open, no_focus) return true end - local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) - if type(result) ~= "table" or result.success ~= true then - open_without_focus = false - return false - end - return true + return request_phone_open("sky_phone:device:open-request") end SkyPhoneClient.Toggle = toggle_phone @@ -217,13 +247,16 @@ AddEventHandler("sky_phone:client:forceClose", function() end) local function run_development_command() - if is_open or open_requested then + if is_open then close_phone() return end + if open_requested then + return + end open_without_focus = false - Bridge.Callbacks.Trigger("sky_phone:device:development-open", {}) + request_phone_open("sky_phone:device:development-open") end refresh_development_command = function() @@ -256,13 +289,16 @@ end refresh_development_command() local function run_phone_toggle() - if is_open or open_requested then + if is_open then close_phone() return end + if open_requested then + return + end open_without_focus = false - Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) + request_phone_open("sky_phone:device:open-request") end RegisterCommand("sky_phone_live_activity_open", function() @@ -271,8 +307,7 @@ RegisterCommand("sky_phone_live_activity_open", function() end open_home_requested = true - local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) - if not result or not result.success then + if not request_phone_open("sky_phone:device:open-request") then open_home_requested = false end end, false) @@ -459,6 +494,10 @@ end) RegisterNetEvent("sky_phone:device:open", function(data) if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.") + if not is_open then + open_requested = false + open_without_focus = false + end return end Bridge.Debug( @@ -468,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data) tostring(data.account ~= nil), { always = true } ) + apply_disabled_apps(data) device_payload = data update_equipped_phone_number(data) open_requested = true @@ -484,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data) Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.") return end + apply_disabled_apps(data) device_payload = data update_equipped_phone_number(data) SendNUIMessage({ type = "device:updated", data = data }) @@ -499,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function() end) RegisterNetEvent("sky_phone:device:error", function(error_code) - if not is_open and not open_requested then + if not is_open then + open_requested = false open_without_focus = false + open_home_requested = false end Bridge.Debug( "debug", diff --git a/sky_phone/source/server/admin.lua b/sky_phone/source/server/admin.lua index 38381e8..37188aa 100644 --- a/sky_phone/source/server/admin.lua +++ b/sky_phone/source/server/admin.lua @@ -373,6 +373,9 @@ end local function app_metadata(app_id) if BUILTIN_APPS[app_id] then + if not SkyPhone.IsAppEnabled(app_id) then + return nil + end return { defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true, removable = PROTECTED_APPS[app_id] ~= true, @@ -481,6 +484,7 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source) success = true, data = { players = players, + disabledApps = SkyPhone.GetDisabledApps(), stats = { online = #players, devices = tonumber(stats.devices) or 0, diff --git a/sky_phone/source/server/crypto.lua b/sky_phone/source/server/crypto.lua index 88a53c1..ddf94af 100644 --- a/sky_phone/source/server/crypto.lua +++ b/sky_phone/source/server/crypto.lua @@ -1368,8 +1368,6 @@ ensure_schema() migrate_crypto_keys() initialize_markets() -AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets) - local function reconcile_settlements(include_recent) local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)" local rows = Bridge.Database.Query([[ @@ -1416,8 +1414,6 @@ local function reconcile_settlements(include_recent) end end -reconcile_settlements(true) - local function crypto_random_int(minimum, maximum, failure_message) local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum) if type(value) ~= "number" then @@ -1500,134 +1496,160 @@ local function advance_market_cycle(config, dynamics) return dynamics.cycle_bias end -CreateThread(function() - while true do - Wait(5 * 60 * 1000) - reconcile_settlements(false) +local scheduler_generation = 0 + +local function start_crypto_schedulers() + scheduler_generation = scheduler_generation + 1 + if Config.Crypto.Enabled ~= true then + return end -end) -CreateThread(function() - while true do - local tick_seconds = crypto_random_int( - Config.Crypto.PriceTickMinimumSeconds, - Config.Crypto.PriceTickMaximumSeconds + 1, - "[sky_phone] Crypto entropy provider did not return a market tick interval." - ) - Wait(tick_seconds * 1000) - with_exchange_lock(function() - local market_count = crypto_random_int( - Config.Crypto.MarketsPerTickMinimum, - Config.Crypto.MarketsPerTickMaximum + 1, - "[sky_phone] Crypto entropy provider did not return a market count." + local generation = scheduler_generation + reconcile_settlements(true) + + CreateThread(function() + while scheduler_generation == generation and Config.Crypto.Enabled == true do + Wait(5 * 60 * 1000) + if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then + break + end + reconcile_settlements(false) + end + end) + + CreateThread(function() + while scheduler_generation == generation and Config.Crypto.Enabled == true do + local tick_seconds = crypto_random_int( + Config.Crypto.PriceTickMinimumSeconds, + Config.Crypto.PriceTickMaximumSeconds + 1, + "[sky_phone] Crypto entropy provider did not return a market tick interval." ) - advance_global_market_cycle() - local changed_markets = {} - market_count = math.min(market_count, #market_order) + Wait(tick_seconds * 1000) + if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then + break + end + with_exchange_lock(function() + local market_count = crypto_random_int( + Config.Crypto.MarketsPerTickMinimum, + Config.Crypto.MarketsPerTickMaximum + 1, + "[sky_phone] Crypto entropy provider did not return a market count." + ) + advance_global_market_cycle() + local changed_markets = {} + market_count = math.min(market_count, #market_order) - for offset = 0, market_count - 1 do - local order_index = ((market_cursor + offset - 1) % #market_order) + 1 - local market_id = market_order[order_index] - local config = markets[market_id] - local row = Bridge.Database.Query( - "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", - { market_id } - )[1] - if row and row.status == "active" then - local price = tonumber(row.price) or config.InitialPrice - local impulse = crypto_random_int( - -config.VolatilityBasisPoints, - config.VolatilityBasisPoints + 1, - "[sky_phone] Crypto entropy provider did not return a market movement." - ) - if impulse > 0 then - impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor) - elseif impulse < 0 then - impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor) - end - local shock_roll = crypto_random_int( - 0, - 10000, - "[sky_phone] Crypto entropy provider did not return a market shock roll." - ) - local dynamics = market_dynamics[market_id] or { - momentum = 0, - cycle_bias = 0, - cycle_direction = 0, - cycle_remaining_ticks = 0, - cycle_target = 0, - } - dynamics.momentum = truncate_integer(( - dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints - + impulse * Config.Crypto.MomentumImpulseBasisPoints - ) / 10000) - local cycle_bias = advance_market_cycle(config, dynamics) - market_dynamics[market_id] = dynamics - - local deviation = math.floor( - (config.InitialPrice - price) * 10000 / config.InitialPrice - ) - local reversion = truncate_integer( - deviation * Config.Crypto.MeanReversionBasisPoints / 10000 - ) - local shock = 0 - if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then - local multiplier = crypto_random_int( - Config.Crypto.MarketShockMinimumMultiplier, - Config.Crypto.MarketShockMaximumMultiplier + 1, - "[sky_phone] Crypto entropy provider did not return a market shock multiplier." + for offset = 0, market_count - 1 do + local order_index = ((market_cursor + offset - 1) % #market_order) + 1 + local market_id = market_order[order_index] + local config = markets[market_id] + local row = Bridge.Database.Query( + "SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1", + { market_id } + )[1] + if row and row.status == "active" then + local price = tonumber(row.price) or config.InitialPrice + local impulse = crypto_random_int( + -config.VolatilityBasisPoints, + config.VolatilityBasisPoints + 1, + "[sky_phone] Crypto entropy provider did not return a market movement." ) - local direction_roll = crypto_random_int( + if impulse > 0 then + impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor) + elseif impulse < 0 then + impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor) + end + local shock_roll = crypto_random_int( 0, - 2, - "[sky_phone] Crypto entropy provider did not return a market shock direction." + 10000, + "[sky_phone] Crypto entropy provider did not return a market shock roll." ) - local direction = direction_roll == 0 and -1 or 1 - shock = direction * config.VolatilityBasisPoints * multiplier - end + local dynamics = market_dynamics[market_id] or { + momentum = 0, + cycle_bias = 0, + cycle_direction = 0, + cycle_remaining_ticks = 0, + cycle_target = 0, + } + dynamics.momentum = truncate_integer(( + dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints + + impulse * Config.Crypto.MomentumImpulseBasisPoints + ) / 10000) + local cycle_bias = advance_market_cycle(config, dynamics) + market_dynamics[market_id] = dynamics - local maximum_movement = config.VolatilityBasisPoints - * Config.Crypto.MaximumMovementMultiplier - local movement = impulse + dynamics.momentum + cycle_bias - + global_market_trend + reversion + shock - movement = math.max(-maximum_movement, math.min(maximum_movement, movement)) - if movement > 0 then - movement = math.floor(movement / Config.Crypto.TickMovementDivisor) - elseif movement < 0 then - movement = math.ceil(movement / Config.Crypto.TickMovementDivisor) - end - local next_price = math.floor(price * (10000 + movement) / 10000) - if next_price == price and movement ~= 0 then - next_price = price + (movement > 0 and 1 or -1) - end - next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price)) - local next_version = (tonumber(row.version) or 0) + 1 - if Bridge.Database.Transaction({ - { query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } }, - { query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } }, - }) then - changed_markets[#changed_markets + 1] = market_id - Bridge.Database.Query([[ - DELETE FROM `sky_phone_crypto_market_ticks` - WHERE `market_id` = ? AND `id` NOT IN ( - SELECT `id` FROM ( - SELECT `id` FROM `sky_phone_crypto_market_ticks` - WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ? - ) retained + local deviation = math.floor( + (config.InitialPrice - price) * 10000 / config.InitialPrice + ) + local reversion = truncate_integer( + deviation * Config.Crypto.MeanReversionBasisPoints / 10000 + ) + local shock = 0 + if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then + local multiplier = crypto_random_int( + Config.Crypto.MarketShockMinimumMultiplier, + Config.Crypto.MarketShockMaximumMultiplier + 1, + "[sky_phone] Crypto entropy provider did not return a market shock multiplier." ) - ]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks }) + local direction_roll = crypto_random_int( + 0, + 2, + "[sky_phone] Crypto entropy provider did not return a market shock direction." + ) + local direction = direction_roll == 0 and -1 or 1 + shock = direction * config.VolatilityBasisPoints * multiplier + end + + local maximum_movement = config.VolatilityBasisPoints + * Config.Crypto.MaximumMovementMultiplier + local movement = impulse + dynamics.momentum + cycle_bias + + global_market_trend + reversion + shock + movement = math.max(-maximum_movement, math.min(maximum_movement, movement)) + if movement > 0 then + movement = math.floor(movement / Config.Crypto.TickMovementDivisor) + elseif movement < 0 then + movement = math.ceil(movement / Config.Crypto.TickMovementDivisor) + end + local next_price = math.floor(price * (10000 + movement) / 10000) + if next_price == price and movement ~= 0 then + next_price = price + (movement > 0 and 1 or -1) + end + next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price)) + local next_version = (tonumber(row.version) or 0) + 1 + if Bridge.Database.Transaction({ + { query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } }, + { query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } }, + }) then + changed_markets[#changed_markets + 1] = market_id + Bridge.Database.Query([[ + DELETE FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? AND `id` NOT IN ( + SELECT `id` FROM ( + SELECT `id` FROM `sky_phone_crypto_market_ticks` + WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ? + ) retained + ) + ]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks }) + end end end - end - market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1 - if #changed_markets > 0 then - TriggerClientEvent("sky_phone:crypto:changed", -1, { - markets = market_dtos(changed_markets), - updatedAt = os.time() * 1000, - }) - end - end) - end -end) + market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1 + if #changed_markets > 0 then + TriggerClientEvent("sky_phone:crypto:changed", -1, { + markets = market_dtos(changed_markets), + updatedAt = os.time() * 1000, + }) + end + end) + end + end) +end + +local function refresh_crypto_runtime() + initialize_markets() + start_crypto_schedulers() +end + +AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime) +start_crypto_schedulers() end) diff --git a/sky_phone/source/server/darkchat.lua b/sky_phone/source/server/darkchat.lua index 2c1d055..3851fc7 100644 --- a/sky_phone/source/server/darkchat.lua +++ b/sky_phone/source/server/darkchat.lua @@ -931,10 +931,16 @@ end) CreateThread(function() while true do Wait(Config.DarkChat.CleanupIntervalSeconds * 1000) + local batch_size = math.max( + 1, + math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250) + ) Bridge.Database.Query([[ DELETE FROM `sky_phone_darkchat_messages` WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP - ]], {}) + ORDER BY `expires_at`, `id` + LIMIT ? + ]], { batch_size }) end end) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 7d0406e..47db7d2 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database SkyPhone = {} +function SkyPhone.IsAppEnabled(app_id) + return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false +end + +function SkyPhone.GetDisabledApps() + local disabled = {} + for app_id, enabled in pairs(Config.Apps or {}) do + if type(app_id) == "string" and enabled == false then + disabled[#disabled + 1] = app_id + end + end + table.sort(disabled) + return disabled +end + local sessions = {} local preferred_device_imeis = {} local equipped_phone_numbers = {} local equipped_phone_identifiers = {} local equipped_phone_sources = {} local operation_attempts = {} +local phone_open_in_progress = {} local character_device_cache = {} local function trim(value) @@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded) return { token = session.token, + disabledApps = SkyPhone.GetDisabledApps(), phoneNumberFormat = { length = Config.Sim.NumberLength, groups = Config.Sim.NumberGroups, @@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei) end end -local function open_phone(source, used_item) +local function perform_phone_open(source, used_item) local opened_at = GetGameTimer() Bridge.Debug( "debug", @@ -926,6 +943,38 @@ local function open_phone(source, used_item) return true end +local function open_phone(source, used_item) + if phone_open_in_progress[source] then + TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress") + return false, "operation_in_progress" + end + + local request_limit = math.max( + 1, + math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20) + ) + if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then + TriggerClientEvent("sky_phone:device:error", source, "rate_limited") + return false, "rate_limited" + end + + phone_open_in_progress[source] = true + local completed, success, error_code = pcall(perform_phone_open, source, used_item) + phone_open_in_progress[source] = nil + if not completed then + Bridge.Debug( + "error", + "[sky_phone] Phone open failed unexpectedly for source %s: %s", + tostring(source), + tostring(success), + { always = true } + ) + TriggerClientEvent("sky_phone:device:error", source, "request_failed") + return false, "request_failed" + end + return success, error_code +end + phone_open_handler = open_phone flush_pending_phone_opens() @@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function() sessions[source] = nil discard_equipped_phone_number(source) operation_attempts[source] = nil + phone_open_in_progress[source] = nil character_device_cache[source] = nil preferred_device_imeis[source] = nil end) @@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name) if resource_name == GetCurrentResourceName() then sessions = {} operation_attempts = {} + phone_open_in_progress = {} character_device_cache = {} preferred_device_imeis = {} equipped_phone_numbers = {} diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 4fb4594..a824cdc 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -41,6 +41,7 @@ end local CLIENT_CONFIG_KEYS = { AdminPanel = true, Animations = true, + Apps = true, Banking = true, Billing = true, Bridge = true, diff --git a/sky_phone/source/shared/config_default.lua b/sky_phone/source/shared/config_default.lua index f72ecee..f484772 100644 --- a/sky_phone/source/shared/config_default.lua +++ b/sky_phone/source/shared/config_default.lua @@ -36,6 +36,7 @@ Config.Phone = { Item = "phone", Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories Keybind = "F1", -- false disables the configurable phone key mapping + OpenRequestsPerMinute = 20, AllowMovement = true, -- true: game input stays active while the mobile phone is open HoldToLook = { Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement @@ -45,6 +46,52 @@ Config.Phone = { DeviceName = "iFruit Phone", } +-- Server-wide availability for bundled apps. Set an entry to false to hide it +-- from every phone, the App Store and per-device app management. +Config.Apps = { + ["app-store"] = true, + banking = true, + billing = true, + calculator = true, + calendar = true, + camera = true, + citymarkt = true, + citywarn = true, + clock = true, + companies = true, + crewlink = true, + crypto = true, + darkchat = true, + feather = true, + flare = true, + fliptok = true, + garage = true, + health = true, + house = true, + ["local-pages"] = true, + mail = true, + map = true, + memory = true, + memos = true, + messages = true, + minesweeper = true, + music = true, + ["neon-drop"] = true, + notes = true, + ["number-merge"] = true, + phone = true, + photos = true, + picstagram = true, + radio = true, + settings = true, + ["sky-flappy"] = true, + skyride = true, + snake = true, + ["tower-stack"] = true, + weather = true, + ["weazel-news"] = true, +} + Config.TestData = { Enabled = false, -- development/test servers only; keep disabled in production Command = "phonetestdata", @@ -101,7 +148,7 @@ Config.Speaker = { } Config.Calls = { - VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty) + VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty) RingSeconds = 30, ContactNameMaxLength = 80, ContactNotesMaxLength = 500, @@ -286,6 +333,7 @@ Config.DarkChat = { VoiceMaxBase64Length = 360000, VoiceWaveformSamples = 48, CleanupIntervalSeconds = 30, + CleanupBatchSize = 250, AllowedDisappearTimers = { [0] = true, [-1] = true, -- after reading diff --git a/tests/inventory_extended_bridges.lua b/tests/inventory_extended_bridges.lua index dde982a..b4ce660 100644 --- a/tests/inventory_extended_bridges.lua +++ b/tests/inventory_extended_bridges.lua @@ -159,13 +159,16 @@ function one:GetSlot(_, slot) return one_items[slot] end -function one:SearchInventory() - return one_items +function one:GetSlotIdsWithItem(_, item_name, metadata) + assert(item_name == "phone") + if metadata and metadata.imei ~= one_items[7].metadata.imei then + return {} + end + return { 7 } end function one:SetItemMetadata(_, slot, metadata) one_items[slot].metadata = metadata - return true end function one:CanCarryItem()