Files

564 lines
18 KiB
C#
Raw Permalink Normal View History

using PCL.Core.App;
using PCL.Core.App.Localization;
using PCL.Core.Link.Natayark;
using PCL.Core.Link.Scaffolding;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.EasyTier;
using PCL.Core.Logging;
using PCL.Core.Utils;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using PCL.Core.App.IoC;
using PCL.Core.UI;
using PCL.Core.Link.McPing;
namespace PCL.Core.Link.Lobby;
/// <summary>
/// Lobby server. For auto-management
/// </summary>
[LifecycleService(LifecycleState.Loaded)]
public class LobbyService() : GeneralService("lobby", "LobbyService")
{
private static readonly LobbyController _LobbyController = new();
private static CancellationTokenSource _lobbyCts = new();
private static Task? _discoveringTask;
private static CancellationTokenSource _discoveringCts = new();
private static readonly Timer _ServerGameWatcher =
new(_CheckGameState, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
private static bool _isGameWatcherRunnable = false;
private static int _isLeaving;
/// <summary>
/// Current lobby state.
/// </summary>
public static LobbyState CurrentState { get; private set; } = LobbyState.Idle;
/// <summary>
/// Founded local Minecraft worlds.
/// </summary>
public static ObservableCollection<FoundWorld> DiscoveredWorlds { get; } = [];
/// <summary>
/// Current players in current lobby.
/// </summary>
public static ObservableCollection<PlayerProfile> Players { get; private set; } = [];
/// <summary>
/// Demonstrate whether the current user is the host of the lobby.
/// </summary>
public static bool IsHost => _LobbyController.IsHost;
/// <summary>
/// Current lobby full code.
/// </summary>
public static string? CurrentLobbyCode { get; private set; }
/// <summary>
/// Current lobby username.
/// </summary>
public static string? CurrentUserName { get; private set; }
#region UI Events
/// <summary>
/// Invoked when lobby state changed. (first arg is the old state; second arg is the new state.)
/// </summary>
public static event Action<LobbyState, LobbyState>? StateChanged;
/// <summary>
/// Invoked when need to download EasyTier core files.
/// </summary>
public static event Action? OnNeedDownloadEasyTier;
/// <summary>
/// Invoked when user stop the game in server mode.
/// </summary>
public static event Action? OnUserStopGame;
/// <summary>
/// Invoked when client ping happened.
/// </summary>
public static event Action<long>? OnClientPing;
/// <summary>
/// Invoked when server shut down.
/// </summary>
public static event Action? OnServerShutDown;
/// <summary>
/// Invoked when server started successfully.
/// </summary>
public static event Action? OnServerStarted;
public static event Action<Exception>? OnServerException;
#endregion
/// <inheritdoc />
public override void Stop()
{
_lobbyCts.Cancel();
_LobbyController.CloseAsync().GetAwaiter().GetResult();
_ServerGameWatcher.Dispose();
_lobbyCts.Dispose();
_discoveringCts.Cancel();
if (_discoveringTask is not null)
{
Task.WhenAll(_discoveringTask);
_discoveringTask.Dispose();
}
_discoveringCts.Dispose();
}
private static bool _IsEasyTierCoreFileNotExist() =>
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe")) &&
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "Packet.dll")) &&
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-cli.exe"));
public static async Task InitializeAsync()
{
if (CurrentState is not LobbyState.Idle && CurrentState is not LobbyState.Error)
{
return;
}
_SetState(LobbyState.Initializing);
try
{
if (_IsEasyTierCoreFileNotExist())
{
LogWrapper.Info("LobbyService", "EasyTier not found, starting download.");
OnNeedDownloadEasyTier?.Invoke();
}
else
{
LogWrapper.Info("LobbyService", "EasyTier files check completed.");
}
// refresh naid token
var naidRefreshToken = States.Link.NaidRefreshToken;
if (!string.IsNullOrWhiteSpace(naidRefreshToken))
{
var expTime = States.Link.NaidRefreshExpireTime;
if (!string.IsNullOrWhiteSpace(expTime) &&
Convert.ToDateTime(expTime).CompareTo(DateTime.Now) < 0)
{
States.Link.NaidRefreshToken = string.Empty;
HintWrapper.Show(Lang.Text("Tools.GameLink.Natayark.TokenExpired"), HintTheme.Error);
}
else
{
await NatayarkProfileManager.GetNaidDataAsync(naidRefreshToken, true).ConfigureAwait(false);
}
}
_SetState(LobbyState.Initialized);
LogWrapper.Info("LobbyService", "Lobby service initialized successfully.");
_ = DiscoverWorldAsync();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Lobby service initialization failed.");
HintWrapper.Show(Lang.Text("Link.Lobby.InitFailed"), HintTheme.Error);
_SetState(LobbyState.Error);
}
}
/// <summary>
/// Discover minecraft shared world.
/// </summary>
public static async Task DiscoverWorldAsync()
{
if (_discoveringCts.IsCancellationRequested)
{
return;
}
if (CurrentState is not LobbyState.Initialized && CurrentState is not LobbyState.Idle)
{
return;
}
_SetState(LobbyState.Discovering);
await _RunInUiAsync(() => DiscoveredWorlds.Clear()).ConfigureAwait(false);
_discoveringTask = Task.Run(async () =>
{
var recordedPorts = new ConcurrentSet<int>();
using var listener = new BroadcastListener();
var handler = new Action<BroadcastRecord, IPEndPoint>((info, _) => Task.Run(async () =>
{
if (!recordedPorts.TryAdd(info.Address.Port)) return;
using var pinger = McPingServiceFactory.CreateService(new IPEndPoint(IPAddress.Loopback, info.Address.Port));
using var cts = new CancellationTokenSource(2000);
try
{
var pingRes = await pinger.PingAsync(cts.Token).ConfigureAwait(false);
if (pingRes is null)
{
throw new ArgumentNullException(nameof(pingRes), "Failed to ping minecraft entity.");
}
var worldName = Lang.Text("Link.Lobby.WorldNameFormat",
pingRes.Description,
pingRes.Version.Name,
info.Address.Port);
await _RunInUiAsync(() => DiscoveredWorlds.Add(new FoundWorld(worldName, info.Address.Port)))
.ConfigureAwait(false);
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", $"Pinging port {info.Address.Port} failed.");
}
}));
listener.OnReceive += handler;
listener.Start();
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
listener.OnReceive -= handler;
}, _discoveringCts.Token);
_SetState(LobbyState.Initialized);
}
private static bool _NotHaveNaid() =>
LobbyInfoProvider.RequiresLogin &&
string.IsNullOrWhiteSpace(NatayarkProfileManager.NaidProfile.AccessToken);
/// <summary>
/// Create a new lobby.
/// </summary>
/// <param name="port">Minecraft share port.</param>
/// <param name="username">Player name.</param>
public static async Task<bool> CreateLobbyAsync(int port, string username)
{
if (_NotHaveNaid())
{
HintWrapper.Show(Lang.Text("Link.Lobby.LoginRequired"), HintTheme.Error);
return false;
}
await _discoveringCts.CancelAsync().ConfigureAwait(false);
_SetState(LobbyState.Creating);
try
{
CurrentUserName = username;
var serverEntity = await _LobbyController.LaunchServerAsync(username, port).ConfigureAwait(false);
if (serverEntity is null)
{
HintWrapper.Show(Lang.Text("Link.Lobby.CreateRoomFailed"), HintTheme.Error);
return false;
}
CurrentLobbyCode = serverEntity.EasyTier.Lobby.FullCode;
serverEntity.Server.ServerStopped += () => OnServerShutDown?.Invoke();
serverEntity.Server.PlayerProfilePing += _ServerOnPlayerPing;
serverEntity.Server.ServerStarted += _ServerOnServerStarted;
serverEntity.Server.ServerException += _ServerOnServerException;
//serverEntity.EasyTier.EasyTierProcessExisted += () =>
//{
// OnHint?.Invoke("EasyTierCore异常退出", CoreHintType.Critical);
// OnServerShutDown?.Invoke();
//}; this code will be invoked when EasyTier process successfully exited, not in failed state.
serverEntity.Server.Start();
_SetState(LobbyState.Connected);
_isGameWatcherRunnable = true;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Failed to create lobby.");
HintWrapper.Show(Lang.Text("Link.Lobby.CreateLobbyFailed"), HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
return true;
}
private static void _ServerOnServerException(Exception? ex)
{
if (ex is null)
{
return;
}
OnServerException?.Invoke(ex);
}
private static void _ServerOnServerStarted(IReadOnlyList<PlayerProfile> profiles)
{
LogWrapper.Debug("LobbyService", "Send server started event.");
OnServerStarted?.Invoke();
_ServerOnPlayerPing(profiles);
}
private static void _ServerOnPlayerPing(IReadOnlyList<PlayerProfile> players)
{
_ = _RunInUiAsync(() =>
{
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
if (currentMachineIds.SetEquals(newMachineIds))
{
LogWrapper.Debug("Player list has not changed");
return; // nothing was changed
}
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
var sortedNewPlayers = PlayerListHandler.Sort(players);
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
if (idsToRemove.Any())
{
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
foreach (var player in playersToRemove)
{
Players.Remove(player);
}
}
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
if (idsToAdd.Any())
{
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
foreach (var player in playersToAdd)
{
Players.Add(player);
}
}
});
}
/// <summary>
/// Join an exist lobby.
/// </summary>
/// <param name="lobbyCode">Lobby share code.</param>
/// <param name="username">Current use name.</param>
public static async Task<bool> JoinLobbyAsync(string lobbyCode, string username)
{
await _discoveringCts.CancelAsync().ConfigureAwait(false);
_SetState(LobbyState.Joining);
LogWrapper.Info("LobbyService", $"Try to join lobby {lobbyCode}");
try
{
CurrentUserName = username;
CurrentLobbyCode = lobbyCode;
var clientEntity = await _LobbyController.LaunchClientAsync(username, lobbyCode, _lobbyCts.Token).ConfigureAwait(false);
if (clientEntity is null)
{
throw new InvalidOperationException(
Lang.Text("Link.Lobby.JoinFailed"));
}
clientEntity.Client.Heartbeat += _ClientOnHeartbeat;
clientEntity.Client.ServerShuttedDown += _ClientOnServerShutDown;
_SetState(LobbyState.Connected);
}
catch (ArgumentException codeEx)
{
LogWrapper.Error(codeEx, "LobbyService", $"Failed to join lobby {lobbyCode}.");
HintWrapper.Show(Lang.Text("Link.Lobby.InvalidCodeFormat"), HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", $"Failed to join lobby {lobbyCode}.");
HintWrapper.Show(ex.Message, HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
return true;
}
private static void _ClientOnServerShutDown()
{
OnServerShutDown?.Invoke();
}
private static void _ClientOnHeartbeat(IReadOnlyList<PlayerProfile> players, long latency)
{
_ = _RunInUiAsync(() =>
{
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
if (currentMachineIds.SetEquals(newMachineIds))
{
return; // nothing was changed
}
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
var sortedNewPlayers = PlayerListHandler.Sort(players);
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
if (idsToRemove.Any())
{
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
foreach (var player in playersToRemove)
{
Players.Remove(player);
}
}
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
if (idsToAdd.Any())
{
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
foreach (var player in playersToAdd)
{
Players.Add(player);
}
}
OnClientPing?.Invoke(latency);
});
}
/// <summary>
/// Leave from lobby.
/// </summary>
public static async Task LeaveLobbyAsync()
{
if (Interlocked.Exchange(ref _isLeaving, 1) == 1)
return;
try
{
_SetState(LobbyState.Leaving);
await _lobbyCts.CancelAsync().ConfigureAwait(false);
Players.Clear();
CurrentLobbyCode = null;
CurrentUserName = null;
if (_LobbyController.ScfClientEntity?.Client is not null)
{
_LobbyController.ScfClientEntity.Client.Heartbeat -= _ClientOnHeartbeat;
}
if (_LobbyController.ScfServerEntity?.Server is not null)
{
_LobbyController.ScfServerEntity.Server.PlayerProfilePing -= _ServerOnPlayerPing;
_LobbyController.ScfServerEntity.Server.ServerStarted -= _ServerOnServerStarted;
}
await _LobbyController.CloseAsync().ConfigureAwait(false);
_lobbyCts = new CancellationTokenSource();
_SetState(LobbyState.Initialized);
LogWrapper.Info("LobbyService", "Left lobby and cleaned up resources.");
_isGameWatcherRunnable = false;
_discoveringCts = new CancellationTokenSource();
_ = DiscoverWorldAsync();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Failed when leave lobby.");
}
finally
{
_isLeaving = 0;
}
}
private static void _SetState(LobbyState newState)
{
var oldState = CurrentState;
if (oldState == newState)
{
return;
}
CurrentState = newState;
LogWrapper.Info("LobbyService", $"Lobby state changed from {oldState} to {newState}");
StateChanged?.Invoke(oldState, newState);
}
private static void _CheckGameState(object? state)
{
if (!_isGameWatcherRunnable)
{
return;
}
if (_LobbyController.ScfServerEntity is null)
{
return;
}
LobbyController.IsHostInstanceAvailableAsync(_LobbyController.ScfServerEntity.EasyTier.MinecraftPort)
.ContinueWith(async (task) =>
{
var isExist = await task.ConfigureAwait(false);
if (!isExist)
{
_isGameWatcherRunnable = false;
OnUserStopGame?.Invoke();
}
});
}
private static async Task _RunInUiAsync(Action action)
{
await Application.Current.Dispatcher.InvokeAsync(action);
}
}
/// <summary>
/// Founded minecraft world information.
/// </summary>
/// <param name="Name">World name.</param>
/// <param name="Port">World share port.</param>
public record FoundWorld(string Name, int Port);