You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

551 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Unity.BossRoom.Infrastructure;
using Unity.Services.Authentication;
using Unity.Services.Lobbies;
using Unity.Services.Lobbies.Models;
using Unity.Services.Wire.Internal;
using UnityEngine;
using VContainer;
using VContainer.Unity;
namespace Unity.BossRoom.UnityServices.Lobbies
{
/// <summary>
/// An abstraction layer between the direct calls into the Lobby API and the outcomes you actually want.
/// </summary>
public class LobbyServiceFacade : IDisposable, IStartable
{
[Inject] LifetimeScope m_ParentScope;
[Inject] UpdateRunner m_UpdateRunner;
[Inject] LocalLobby m_LocalLobby;
[Inject] LocalLobbyUser m_LocalUser;
[Inject] IPublisher<UnityServiceErrorMessage> m_UnityServiceErrorMessagePub;
[Inject] IPublisher<LobbyListFetchedMessage> m_LobbyListFetchedPub;
const float k_HeartbeatPeriod = 8; // The heartbeat must be rate-limited to 5 calls per 30 seconds. We'll aim for longer in case periods don't align.
float m_HeartbeatTime = 0;
LifetimeScope m_ServiceScope;
LobbyAPIInterface m_LobbyApiInterface;
RateLimitCooldown m_RateLimitQuery;
RateLimitCooldown m_RateLimitJoin;
RateLimitCooldown m_RateLimitQuickJoin;
RateLimitCooldown m_RateLimitHost;
public Lobby CurrentUnityLobby { get; private set; }
ILobbyEvents m_LobbyEvents;
bool m_IsTracking = false;
LobbyEventConnectionState m_LobbyEventConnectionState = LobbyEventConnectionState.Unknown;
public void Start()
{
m_ServiceScope = m_ParentScope.CreateChild(builder =>
{
builder.Register<LobbyAPIInterface>(Lifetime.Singleton);
});
m_LobbyApiInterface = m_ServiceScope.Container.Resolve<LobbyAPIInterface>();
//See https://docs.unity.com/lobby/rate-limits.html
m_RateLimitQuery = new RateLimitCooldown(1f);
m_RateLimitJoin = new RateLimitCooldown(3f);
m_RateLimitQuickJoin = new RateLimitCooldown(10f);
m_RateLimitHost = new RateLimitCooldown(3f);
}
public void Dispose()
{
EndTracking();
if (m_ServiceScope != null)
{
m_ServiceScope.Dispose();
}
}
public void SetRemoteLobby(Lobby lobby)
{
CurrentUnityLobby = lobby;
m_LocalLobby.ApplyRemoteData(lobby);
}
/// <summary>
/// Initiates tracking of joined lobby's events. The host also starts sending heartbeat pings here.
/// </summary>
public void BeginTracking()
{
if (!m_IsTracking)
{
m_IsTracking = true;
SubscribeToJoinedLobbyAsync();
// Only the host sends heartbeat pings to the service to keep the lobby alive
if (m_LocalUser.IsHost)
{
m_HeartbeatTime = 0;
m_UpdateRunner.Subscribe(DoLobbyHeartbeat, 1.5f);
}
}
}
/// <summary>
/// Ends tracking of joined lobby's events and leaves or deletes the lobby. The host also stops sending heartbeat pings here.
/// </summary>
public void EndTracking()
{
if (m_IsTracking)
{
m_IsTracking = false;
UnsubscribeToJoinedLobbyAsync();
// Only the host sends heartbeat pings to the service to keep the lobby alive
if (m_LocalUser.IsHost)
{
m_UpdateRunner.Unsubscribe(DoLobbyHeartbeat);
}
}
if (CurrentUnityLobby != null)
{
if (m_LocalUser.IsHost)
{
DeleteLobbyAsync();
}
else
{
LeaveLobbyAsync();
}
}
}
/// <summary>
/// Attempt to create a new lobby and then join it.
/// </summary>
public async Task<(bool Success, Lobby Lobby)> TryCreateLobbyAsync(string lobbyName, int maxPlayers, bool isPrivate)
{
if (!m_RateLimitHost.CanCall)
{
Debug.LogWarning("Create Lobby hit the rate limit.");
return (false, null);
}
try
{
var lobby = await m_LobbyApiInterface.CreateLobby(AuthenticationService.Instance.PlayerId, lobbyName, maxPlayers, isPrivate, m_LocalUser.GetDataForUnityServices(), null);
return (true, lobby);
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitHost.PutOnCooldown();
}
else
{
PublishError(e);
}
}
return (false, null);
}
/// <summary>
/// Attempt to join an existing lobby. Will try to join via code, if code is null - will try to join via ID.
/// </summary>
public async Task<(bool Success, Lobby Lobby)> TryJoinLobbyAsync(string lobbyId, string lobbyCode)
{
if (!m_RateLimitJoin.CanCall ||
(lobbyId == null && lobbyCode == null))
{
Debug.LogWarning("Join Lobby hit the rate limit.");
return (false, null);
}
try
{
if (!string.IsNullOrEmpty(lobbyCode))
{
var lobby = await m_LobbyApiInterface.JoinLobbyByCode(AuthenticationService.Instance.PlayerId, lobbyCode, m_LocalUser.GetDataForUnityServices());
return (true, lobby);
}
else
{
var lobby = await m_LobbyApiInterface.JoinLobbyById(AuthenticationService.Instance.PlayerId, lobbyId, m_LocalUser.GetDataForUnityServices());
return (true, lobby);
}
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitJoin.PutOnCooldown();
}
else
{
PublishError(e);
}
}
return (false, null);
}
/// <summary>
/// Attempt to join the first lobby among the available lobbies that match the filtered onlineMode.
/// </summary>
public async Task<(bool Success, Lobby Lobby)> TryQuickJoinLobbyAsync()
{
if (!m_RateLimitQuickJoin.CanCall)
{
Debug.LogWarning("Quick Join Lobby hit the rate limit.");
return (false, null);
}
try
{
var lobby = await m_LobbyApiInterface.QuickJoinLobby(AuthenticationService.Instance.PlayerId, m_LocalUser.GetDataForUnityServices());
return (true, lobby);
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitQuickJoin.PutOnCooldown();
}
else
{
PublishError(e);
}
}
return (false, null);
}
void ResetLobby()
{
CurrentUnityLobby = null;
if (m_LocalUser != null)
{
m_LocalUser.ResetState();
}
if (m_LocalLobby != null)
{
m_LocalLobby.Reset(m_LocalUser);
}
// no need to disconnect Netcode, it should already be handled by Netcode's callback to disconnect
}
void OnLobbyChanges(ILobbyChanges changes)
{
if (changes.LobbyDeleted)
{
Debug.Log("Lobby deleted");
ResetLobby();
EndTracking();
}
else
{
Debug.Log("Lobby updated");
changes.ApplyToLobby(CurrentUnityLobby);
m_LocalLobby.ApplyRemoteData(CurrentUnityLobby);
// as client, check if host is still in lobby
if (!m_LocalUser.IsHost)
{
foreach (var lobbyUser in m_LocalLobby.LobbyUsers)
{
if (lobbyUser.Value.IsHost)
{
return;
}
}
m_UnityServiceErrorMessagePub.Publish(new UnityServiceErrorMessage("Host left the lobby", "Disconnecting.", UnityServiceErrorMessage.Service.Lobby));
EndTracking();
// no need to disconnect Netcode, it should already be handled by Netcode's callback to disconnect
}
}
}
void OnKickedFromLobby()
{
Debug.Log("Kicked from Lobby");
ResetLobby();
EndTracking();
}
void OnLobbyEventConnectionStateChanged(LobbyEventConnectionState lobbyEventConnectionState)
{
m_LobbyEventConnectionState = lobbyEventConnectionState;
Debug.Log($"LobbyEventConnectionState changed to {lobbyEventConnectionState}");
}
async void SubscribeToJoinedLobbyAsync()
{
var lobbyEventCallbacks = new LobbyEventCallbacks();
lobbyEventCallbacks.LobbyChanged += OnLobbyChanges;
lobbyEventCallbacks.KickedFromLobby += OnKickedFromLobby;
lobbyEventCallbacks.LobbyEventConnectionStateChanged += OnLobbyEventConnectionStateChanged;
// The LobbyEventCallbacks object created here will now be managed by the Lobby SDK. The callbacks will be
// unsubscribed from when we call UnsubscribeAsync on the ILobbyEvents object we receive and store here.
m_LobbyEvents = await m_LobbyApiInterface.SubscribeToLobby(m_LocalLobby.LobbyID, lobbyEventCallbacks);
}
async void UnsubscribeToJoinedLobbyAsync()
{
if (m_LobbyEvents != null && m_LobbyEventConnectionState != LobbyEventConnectionState.Unsubscribed)
{
#if UNITY_EDITOR
try
{
await m_LobbyEvents.UnsubscribeAsync();
}
catch (WebSocketException e)
{
// This exception occurs in the editor when exiting play mode without first leaving the lobby.
// This is because Wire closes the websocket internally when exiting playmode in the editor.
Debug.Log(e.Message);
}
#else
await m_LobbyEvents.UnsubscribeAsync();
#endif
}
}
/// <summary>
/// Used for getting the list of all active lobbies, without needing full info for each.
/// </summary>
public async Task RetrieveAndPublishLobbyListAsync()
{
if (!m_RateLimitQuery.CanCall)
{
Debug.LogWarning("Retrieve Lobby list hit the rate limit. Will try again soon...");
return;
}
try
{
var response = await m_LobbyApiInterface.QueryAllLobbies();
m_LobbyListFetchedPub.Publish(new LobbyListFetchedMessage(LocalLobby.CreateLocalLobbies(response)));
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitQuery.PutOnCooldown();
}
else
{
PublishError(e);
}
}
}
public async Task<Lobby> ReconnectToLobbyAsync()
{
try
{
return await m_LobbyApiInterface.ReconnectToLobby(m_LocalLobby.LobbyID);
}
catch (LobbyServiceException e)
{
// If Lobby is not found and if we are not the host, it has already been deleted. No need to publish the error here.
if (e.Reason != LobbyExceptionReason.LobbyNotFound && !m_LocalUser.IsHost)
{
PublishError(e);
}
}
return null;
}
/// <summary>
/// Attempt to leave a lobby
/// </summary>
async void LeaveLobbyAsync()
{
string uasId = AuthenticationService.Instance.PlayerId;
try
{
await m_LobbyApiInterface.RemovePlayerFromLobby(uasId, m_LocalLobby.LobbyID);
}
catch (LobbyServiceException e)
{
// If Lobby is not found and if we are not the host, it has already been deleted. No need to publish the error here.
if (e.Reason != LobbyExceptionReason.LobbyNotFound && !m_LocalUser.IsHost)
{
PublishError(e);
}
}
finally
{
ResetLobby();
}
}
public async void RemovePlayerFromLobbyAsync(string uasId)
{
if (m_LocalUser.IsHost)
{
try
{
await m_LobbyApiInterface.RemovePlayerFromLobby(uasId, m_LocalLobby.LobbyID);
}
catch (LobbyServiceException e)
{
PublishError(e);
}
}
else
{
Debug.LogError("Only the host can remove other players from the lobby.");
}
}
async void DeleteLobbyAsync()
{
if (m_LocalUser.IsHost)
{
try
{
await m_LobbyApiInterface.DeleteLobby(m_LocalLobby.LobbyID);
}
catch (LobbyServiceException e)
{
PublishError(e);
}
finally
{
ResetLobby();
}
}
else
{
Debug.LogError("Only the host can delete a lobby.");
}
}
/// <summary>
/// Attempt to push a set of key-value pairs associated with the local player which will overwrite any existing
/// data for these keys. Lobby can be provided info about Relay (or any other remote allocation) so it can add
/// automatic disconnect handling.
/// </summary>
public async Task UpdatePlayerDataAsync(string allocationId, string connectionInfo)
{
if (!m_RateLimitQuery.CanCall)
{
return;
}
try
{
var result = await m_LobbyApiInterface.UpdatePlayer(CurrentUnityLobby.Id, AuthenticationService.Instance.PlayerId, m_LocalUser.GetDataForUnityServices(), allocationId, connectionInfo);
if (result != null)
{
CurrentUnityLobby = result; // Store the most up-to-date lobby now since we have it, instead of waiting for the next heartbeat.
}
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitQuery.PutOnCooldown();
}
else if (e.Reason != LobbyExceptionReason.LobbyNotFound && !m_LocalUser.IsHost) // If Lobby is not found and if we are not the host, it has already been deleted. No need to publish the error here.
{
PublishError(e);
}
}
}
/// <summary>
/// Attempt to update the set of key-value pairs associated with a given lobby and unlocks it so clients can see it.
/// </summary>
public async Task UpdateLobbyDataAndUnlockAsync()
{
if (!m_RateLimitQuery.CanCall)
{
return;
}
var localData = m_LocalLobby.GetDataForUnityServices();
var dataCurr = CurrentUnityLobby.Data;
if (dataCurr == null)
{
dataCurr = new Dictionary<string, DataObject>();
}
foreach (var dataNew in localData)
{
if (dataCurr.ContainsKey(dataNew.Key))
{
dataCurr[dataNew.Key] = dataNew.Value;
}
else
{
dataCurr.Add(dataNew.Key, dataNew.Value);
}
}
try
{
var result = await m_LobbyApiInterface.UpdateLobby(CurrentUnityLobby.Id, dataCurr, shouldLock: false);
if (result != null)
{
CurrentUnityLobby = result;
}
}
catch (LobbyServiceException e)
{
if (e.Reason == LobbyExceptionReason.RateLimited)
{
m_RateLimitQuery.PutOnCooldown();
}
else
{
PublishError(e);
}
}
}
/// <summary>
/// Lobby requires a periodic ping to detect rooms that are still active, in order to mitigate "zombie" lobbies.
/// </summary>
void DoLobbyHeartbeat(float dt)
{
m_HeartbeatTime += dt;
if (m_HeartbeatTime > k_HeartbeatPeriod)
{
m_HeartbeatTime -= k_HeartbeatPeriod;
try
{
m_LobbyApiInterface.SendHeartbeatPing(CurrentUnityLobby.Id);
}
catch (LobbyServiceException e)
{
// If Lobby is not found and if we are not the host, it has already been deleted. No need to publish the error here.
if (e.Reason != LobbyExceptionReason.LobbyNotFound && !m_LocalUser.IsHost)
{
PublishError(e);
}
}
}
}
void PublishError(LobbyServiceException e)
{
var reason = e.InnerException == null ? e.Message : $"{e.Message} ({e.InnerException.Message})"; // Lobby error type, then HTTP error type.
m_UnityServiceErrorMessagePub.Publish(new UnityServiceErrorMessage("Lobby Error", reason, UnityServiceErrorMessage.Service.Lobby, e));
}
}
}