// ----------------------------------------------------------------------------
//
// Loadbalancing Framework for Photon - Copyright (C) 2018 Exit Games GmbH
//
//
// The RegionHandler class provides methods to ping a list of regions,
// to find the one with best ping.
//
// developer@photonengine.com
// ----------------------------------------------------------------------------
#if UNITY_4_7 || UNITY_5 || UNITY_5_3_OR_NEWER
#define SUPPORTED_UNITY
#endif
#if UNITY_WEBGL
#define PING_VIA_COROUTINE
#endif
namespace Photon.Realtime
{
using System;
using System.Text;
using System.Threading;
using System.Net;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using ExitGames.Client.Photon;
#if SUPPORTED_UNITY
using UnityEngine;
using Debug = UnityEngine.Debug;
#endif
#if SUPPORTED_UNITY || NETFX_CORE
using Hashtable = ExitGames.Client.Photon.Hashtable;
using SupportClass = ExitGames.Client.Photon.SupportClass;
#endif
///
/// Provides methods to work with Photon's regions (Photon Cloud) and can be use to find the one with best ping.
///
///
/// When a client uses a Name Server to fetch the list of available regions, the LoadBalancingClient will create a RegionHandler
/// and provide it via the OnRegionListReceived callback.
///
/// Your logic can decide to either connect to one of those regional servers, or it may use PingMinimumOfRegions to test
/// which region provides the best ping.
///
/// It makes sense to make clients "sticky" to a region when one gets selected.
/// This can be achieved by storing the SummaryToCache value, once pinging was done.
/// When the client connects again, the previous SummaryToCache helps limiting the number of regions to ping.
/// In best case, only the previously selected region gets re-pinged and if the current ping is not much worse, this one region is used again.
///
public class RegionHandler
{
/// The implementation of PhotonPing to use for region pinging (Best Region detection).
/// Defaults to null, which means the Type is set automatically.
public static Type PingImplementation;
/// A list of region names for the Photon Cloud. Set by the result of OpGetRegions().
///
/// Implement ILoadBalancingCallbacks and register for the callbacks to get OnRegionListReceived(RegionHandler regionHandler).
/// You can also put a "case OperationCode.GetRegions:" into your OnOperationResponse method to notice when the result is available.
///
public List EnabledRegions { get; protected internal set; }
private string availableRegionCodes;
private Region bestRegionCache;
///
/// When PingMinimumOfRegions was called and completed, the BestRegion is identified by best ping.
///
public Region BestRegion
{
get
{
if (this.EnabledRegions == null)
{
return null;
}
if (this.bestRegionCache != null)
{
return this.bestRegionCache;
}
this.EnabledRegions.Sort((a, b) => a.Ping.CompareTo(b.Ping) );
this.bestRegionCache = this.EnabledRegions[0];
return this.bestRegionCache;
}
}
///
/// This value summarizes the results of pinging currently available regions (after PingMinimumOfRegions finished).
///
///
/// This value should be stored in the client by the game logic.
/// When connecting again, use it as previous summary to speed up pinging regions and to make the best region sticky for the client.
///
public string SummaryToCache
{
get
{
if (this.BestRegion != null) {
return this.BestRegion.Code + ";" + this.BestRegion.Ping + ";" + this.availableRegionCodes;
}
return this.availableRegionCodes;
}
}
public string GetResults()
{
StringBuilder sb = new StringBuilder();
sb.AppendFormat("Region Pinging Result: {0}\n", this.BestRegion.ToString());
if (this.pingerList != null)
{
foreach (RegionPinger region in this.pingerList)
{
sb.AppendFormat(region.GetResults() + "\n");
}
}
sb.AppendFormat("Previous summary: {0}", this.previousSummaryProvided);
return sb.ToString();
}
public void SetRegions(OperationResponse opGetRegions)
{
if (opGetRegions.OperationCode != OperationCode.GetRegions)
{
return;
}
if (opGetRegions.ReturnCode != ErrorCode.Ok)
{
return;
}
string[] regions = opGetRegions[ParameterCode.Region] as string[];
string[] servers = opGetRegions[ParameterCode.Address] as string[];
if (regions == null || servers == null || regions.Length != servers.Length)
{
//TODO: log error
//Debug.LogError("The region arrays from Name Server are not ok. Must be non-null and same length. " + (regions == null) + " " + (servers == null) + "\n" + opGetRegions.ToStringFull());
return;
}
this.bestRegionCache = null;
this.EnabledRegions = new List(regions.Length);
for (int i = 0; i < regions.Length; i++)
{
Region tmp = new Region(regions[i], servers[i]);
if (string.IsNullOrEmpty(tmp.Code))
{
continue;
}
this.EnabledRegions.Add(tmp);
}
Array.Sort(regions);
this.availableRegionCodes = string.Join(",", regions);
}
private List pingerList;
private Action onCompleteCall;
private int previousPing;
public bool IsPinging { get; private set; }
private string previousSummaryProvided;
public bool PingMinimumOfRegions(Action onCompleteCallback, string previousSummary)
{
if (this.EnabledRegions == null || this.EnabledRegions.Count == 0)
{
//TODO: log error
//Debug.LogError("No regions available. Maybe all got filtered out or the AppId is not correctly configured.");
return false;
}
if (this.IsPinging)
{
//TODO: log warning
//Debug.LogWarning("PingMinimumOfRegions() skipped, because this RegionHandler is already pinging some regions.");
return false;
}
this.IsPinging = true;
this.onCompleteCall = onCompleteCallback;
this.previousSummaryProvided = previousSummary;
if (string.IsNullOrEmpty(previousSummary))
{
return this.PingEnabledRegions();
}
string[] values = previousSummary.Split(';');
if (values.Length < 3)
{
return this.PingEnabledRegions();
}
int prevBestRegionPing;
bool secondValueIsInt = Int32.TryParse(values[1], out prevBestRegionPing);
if (!secondValueIsInt)
{
return this.PingEnabledRegions();
}
string prevBestRegionCode = values[0];
string prevAvailableRegionCodes = values[2];
if (string.IsNullOrEmpty(prevBestRegionCode))
{
return this.PingEnabledRegions();
}
if (string.IsNullOrEmpty(prevAvailableRegionCodes))
{
return this.PingEnabledRegions();
}
if (!this.availableRegionCodes.Equals(prevAvailableRegionCodes) || !this.availableRegionCodes.Contains(prevBestRegionCode))
{
return this.PingEnabledRegions();
}
if (prevBestRegionPing >= RegionPinger.PingWhenFailed)
{
return this.PingEnabledRegions();
}
// let's check only the preferred region to detect if it's still "good enough"
this.previousPing = prevBestRegionPing;
Region preferred = this.EnabledRegions.Find(r => r.Code.Equals(prevBestRegionCode));
RegionPinger singlePinger = new RegionPinger(preferred, this.OnPreferredRegionPinged);
singlePinger.Start();
return true;
}
private void OnPreferredRegionPinged(Region preferredRegion)
{
if (preferredRegion.Ping > this.previousPing * 1.50f)
{
this.PingEnabledRegions();
}
else
{
this.IsPinging = false;
this.onCompleteCall(this);
#if PING_VIA_COROUTINE
MonoBehaviourEmpty.SelfDestroy();
#endif
}
}
private bool PingEnabledRegions()
{
if (this.EnabledRegions == null || this.EnabledRegions.Count == 0)
{
//TODO: log
//Debug.LogError("No regions available. Maybe all got filtered out or the AppId is not correctly configured.");
return false;
}
this.pingerList = new List();
foreach (Region region in this.EnabledRegions)
{
RegionPinger rp = new RegionPinger(region, this.OnRegionDone);
this.pingerList.Add(rp);
rp.Start(); // TODO: check return value
}
return true;
}
private void OnRegionDone(Region region)
{
this.bestRegionCache = null;
foreach (RegionPinger pinger in this.pingerList)
{
if (!pinger.Done)
{
return;
}
}
this.IsPinging = false;
this.onCompleteCall(this);
#if PING_VIA_COROUTINE
MonoBehaviourEmpty.SelfDestroy();
#endif
}
}
public class RegionPinger
{
public static int Attempts = 5;
public static bool IgnoreInitialAttempt = true;
public static int MaxMilliseconsPerPing = 800; // enter a value you're sure some server can beat (have a lower rtt)
public static int PingWhenFailed = Attempts * MaxMilliseconsPerPing;
private Region region;
private string regionAddress;
public int CurrentAttempt = 0;
public bool Done { get; private set; }
private Action onDoneCall;
private PhotonPing ping;
private List rttResults;
public RegionPinger(Region region, Action onDoneCallback)
{
this.region = region;
this.region.Ping = PingWhenFailed;
this.Done = false;
this.onDoneCall = onDoneCallback;
}
/// Selects the best fitting ping implementation or uses the one set in RegionHandler.PingImplementation.
/// PhotonPing instance to use.
private PhotonPing GetPingImplementation()
{
PhotonPing ping = null;
// using each type explicitly in the conditional code, makes sure Unity doesn't strip the class / constructor.
#if !UNITY_EDITOR && NETFX_CORE
if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingWindowsStore))
{
ping = new PingWindowsStore();
}
#elif NATIVE_SOCKETS || NO_SOCKET
if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingNativeDynamic))
{
ping = new PingNativeDynamic();
}
#elif UNITY_WEBGL
if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingHttp))
{
ping = new PingHttp();
}
#else
if (RegionHandler.PingImplementation == null || RegionHandler.PingImplementation == typeof(PingMono))
{
ping = new PingMono();
}
#endif
if (ping == null)
{
if (RegionHandler.PingImplementation != null)
{
ping = (PhotonPing)Activator.CreateInstance(RegionHandler.PingImplementation);
}
}
return ping;
}
///
/// Starts the ping routine for the assigned region.
///
///
/// Pinging runs in a ThreadPool worker item or (if needed) in a Thread.
/// WebGL runs pinging on the Main Thread as coroutine.
///
/// Always true.
public bool Start()
{
// all addresses for Photon region servers will contain a :port ending. this needs to be removed first.
// PhotonPing.StartPing() requires a plain (IP) address without port or protocol-prefix (on all but Windows 8.1 and WebGL platforms).
string address = this.region.HostAndPort;
int indexOfColon = address.LastIndexOf(':');
if (indexOfColon > 1)
{
address = address.Substring(0, indexOfColon);
}
this.regionAddress = ResolveHost(address);
this.ping = this.GetPingImplementation();
this.Done = false;
this.CurrentAttempt = 0;
this.rttResults = new List(Attempts);
#if PING_VIA_COROUTINE
MonoBehaviourEmpty.Instance.StartCoroutine(this.RegionPingCoroutine());
#else
bool queued = false;
#if !NETFX_CORE
try
{
queued = ThreadPool.QueueUserWorkItem(this.RegionPingPooled);
}
catch
{
queued = false;
}
#endif
if (!queued)
{
SupportClass.StartBackgroundCalls(this.RegionPingThreaded, 0, "RegionPing_" + this.region.Code + "_" + this.region.Cluster);
}
#endif
return true;
}
// wraps RegionPingThreaded() to get the signature compatible with ThreadPool.QueueUserWorkItem
protected internal void RegionPingPooled(object context)
{
this.RegionPingThreaded();
}
protected internal bool RegionPingThreaded()
{
this.region.Ping = PingWhenFailed;
float rttSum = 0.0f;
int replyCount = 0;
Stopwatch sw = new Stopwatch();
for (this.CurrentAttempt = 0; this.CurrentAttempt < Attempts; this.CurrentAttempt++)
{
bool overtime = false;
sw.Reset();
sw.Start();
try
{
this.ping.StartPing(this.regionAddress);
}
catch (Exception e)
{
System.Diagnostics.Debug.WriteLine("RegionPinger.RegionPingThreaded() catched an exception for ping.StartPing(). Exception: " + e + " Source: " + e.Source + " Message: " + e.Message);
break;
}
while (!this.ping.Done())
{
if (sw.ElapsedMilliseconds >= MaxMilliseconsPerPing)
{
overtime = true;
break;
}
#if !NETFX_CORE
System.Threading.Thread.Sleep(0);
#endif
}
sw.Stop();
int rtt = (int)sw.ElapsedMilliseconds;
this.rttResults.Add(rtt);
if (IgnoreInitialAttempt && this.CurrentAttempt == 0)
{
// do nothing.
}
else if (this.ping.Successful && !overtime)
{
rttSum += rtt;
replyCount++;
this.region.Ping = (int)((rttSum) / replyCount);
}
#if !NETFX_CORE
System.Threading.Thread.Sleep(10);
#endif
}
//Debug.Log("Done: "+ this.region.Code);
this.Done = true;
this.ping.Dispose();
this.onDoneCall(this.region);
return false;
}
#if SUPPORTED_UNITY
///
/// Affected by frame-rate of app, as this Coroutine checks the socket for a result once per frame.
///
protected internal IEnumerator RegionPingCoroutine()
{
this.region.Ping = PingWhenFailed;
float rttSum = 0.0f;
int replyCount = 0;
Stopwatch sw = new Stopwatch();
for (this.CurrentAttempt = 0; this.CurrentAttempt < Attempts; this.CurrentAttempt++)
{
bool overtime = false;
sw.Reset();
sw.Start();
try
{
this.ping.StartPing(this.regionAddress);
}
catch (Exception e)
{
Debug.Log("catched: " + e);
break;
}
while (!this.ping.Done())
{
if (sw.ElapsedMilliseconds >= MaxMilliseconsPerPing)
{
overtime = true;
break;
}
yield return 0; // keep this loop tight, to avoid adding local lag to rtt.
}
sw.Stop();
int rtt = (int)sw.ElapsedMilliseconds;
this.rttResults.Add(rtt);
if (IgnoreInitialAttempt && this.CurrentAttempt == 0)
{
// do nothing.
}
else if (this.ping.Successful && !overtime)
{
rttSum += rtt;
replyCount++;
this.region.Ping = (int)((rttSum) / replyCount);
}
yield return new WaitForSeconds(0.1f);
}
//Debug.Log("Done: "+ this.region.Code);
this.Done = true;
this.ping.Dispose();
this.onDoneCall(this.region);
yield return null;
}
#endif
public string GetResults()
{
return string.Format("{0}: {1} ({2})", this.region.Code, this.region.Ping, this.rttResults.ToStringFull());
}
///
/// Attempts to resolve a hostname into an IP string or returns empty string if that fails.
///
///
/// To be compatible with most platforms, the address family is checked like this:
/// if (ipAddress.AddressFamily.ToString().Contains("6")) // ipv6...
///
/// Hostname to resolve.
/// IP string or empty string if resolution fails
public static string ResolveHost(string hostName)
{
if (hostName.StartsWith("wss://"))
{
hostName = hostName.Substring(6);
}
if (hostName.StartsWith("ws://"))
{
hostName = hostName.Substring(5);
}
string ipv4Address = string.Empty;
try
{
#if UNITY_WSA || NETFX_CORE || UNITY_WEBGL
return hostName;
#else
IPAddress[] address = Dns.GetHostAddresses(hostName);
if (address.Length == 1)
{
return address[0].ToString();
}
// if we got more addresses, try to pick a IPv6 one
// checking ipAddress.ToString() means we don't have to import System.Net.Sockets, which is not available on some platforms (Metro)
for (int index = 0; index < address.Length; index++)
{
IPAddress ipAddress = address[index];
if (ipAddress != null)
{
if (ipAddress.ToString().Contains(":"))
{
return ipAddress.ToString();
}
if (string.IsNullOrEmpty(ipv4Address))
{
ipv4Address = address.ToString();
}
}
}
#endif
}
catch (System.Exception e)
{
System.Diagnostics.Debug.WriteLine("RegionPinger.ResolveHost() catched an exception for Dns.GetHostAddresses(). Exception: " + e + " Source: " + e.Source + " Message: " + e.Message);
}
return ipv4Address;
}
}
#if PING_VIA_COROUTINE
internal class MonoBehaviourEmpty : MonoBehaviour
{
private static bool instanceSet; // to avoid instance null check which may be incorrect
private static MonoBehaviourEmpty instance;
public static MonoBehaviourEmpty Instance
{
get
{
if (instanceSet)
{
return instance;
}
GameObject go = new GameObject();
DontDestroyOnLoad(go);
go.name = "RegionPinger";
instance = go.AddComponent();
instanceSet = true;
return instance;
}
}
public static void SelfDestroy()
{
if (instanceSet)
{
instanceSet = false;
Destroy(instance.gameObject);
}
}
}
#endif
}