using System; using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using NitroxModel.DataStructures; using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.Unity; using NitroxModel.DataStructures.Util; using NitroxModel.MultiplayerSession; using NitroxModel.Packets; using NitroxModel.Server; using NitroxModel.Serialization; using NitroxServer.Communication; namespace NitroxServer.GameLogic { // TODO: These methods are a little chunky. Need to look at refactoring just to clean them up and get them around 30 lines a piece. public class PlayerManager { private readonly ThreadSafeDictionary allPlayersByName; private readonly ThreadSafeDictionary connectedPlayersById = []; private readonly ThreadSafeDictionary assetsByConnection = new(); private readonly ThreadSafeDictionary reservations = new(); private readonly ThreadSafeSet reservedPlayerNames = new("Player"); // "Player" is often used to identify the local player and should not be used by any user private ThreadSafeQueue> JoinQueue { get; set; } = new(); private bool PlayerCurrentlyJoining { get; set; } private Timer initialSyncTimer; private readonly SubnauticaServerConfig serverConfig; private ushort currentPlayerId; public PlayerManager(List players, SubnauticaServerConfig serverConfig) { allPlayersByName = new ThreadSafeDictionary(players.ToDictionary(x => x.Name), false); currentPlayerId = players.Count == 0 ? (ushort)0 : players.Max(x => x.Id); this.serverConfig = serverConfig; } public List GetConnectedPlayers() { return ConnectedPlayers().ToList(); } public List GetConnectedPlayersExcept(Player excludePlayer) { return ConnectedPlayers().Where(player => player != excludePlayer).ToList(); } public IEnumerable GetAllPlayers() { return allPlayersByName.Values; } public MultiplayerSessionReservation ReservePlayerContext( INitroxConnection connection, PlayerSettings playerSettings, AuthenticationContext authenticationContext, string correlationId) { if (reservedPlayerNames.Count >= serverConfig.MaxConnections) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.SERVER_PLAYER_CAPACITY_REACHED; return new MultiplayerSessionReservation(correlationId, rejectedState); } if (!string.IsNullOrEmpty(serverConfig.ServerPassword) && (!authenticationContext.ServerPassword.HasValue || authenticationContext.ServerPassword.Value != serverConfig.ServerPassword)) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.AUTHENTICATION_FAILED; return new MultiplayerSessionReservation(correlationId, rejectedState); } //https://regex101.com/r/eTWiEs/2/ if (!Regex.IsMatch(authenticationContext.Username, @"^[a-zA-Z0-9._-]{3,25}$")) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.INCORRECT_USERNAME; return new MultiplayerSessionReservation(correlationId, rejectedState); } if (PlayerCurrentlyJoining) { if (JoinQueue.Any(pair => ReferenceEquals(pair.Key, connection))) { // Don't enqueue the request if there is already another enqueued request by the same user return new MultiplayerSessionReservation(correlationId, MultiplayerSessionReservationState.REJECTED); } JoinQueue.Enqueue(new KeyValuePair( connection, new MultiplayerSessionReservationRequest(correlationId, playerSettings, authenticationContext))); return new MultiplayerSessionReservation(correlationId, MultiplayerSessionReservationState.ENQUEUED_IN_JOIN_QUEUE); } string playerName = authenticationContext.Username; allPlayersByName.TryGetValue(playerName, out Player player); if (player?.IsPermaDeath == true && serverConfig.IsHardcore()) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.HARDCORE_PLAYER_DEAD; return new MultiplayerSessionReservation(correlationId, rejectedState); } if (reservedPlayerNames.Contains(playerName)) { MultiplayerSessionReservationState rejectedState = MultiplayerSessionReservationState.REJECTED | MultiplayerSessionReservationState.UNIQUE_PLAYER_NAME_CONSTRAINT_VIOLATED; return new MultiplayerSessionReservation(correlationId, rejectedState); } assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage); if (assetPackage == null) { assetPackage = new ConnectionAssets(); assetsByConnection.Add(connection, assetPackage); reservedPlayerNames.Add(playerName); } bool hasSeenPlayerBefore = player != null; ushort playerId = hasSeenPlayerBefore ? player.Id : ++currentPlayerId; NitroxId playerNitroxId = hasSeenPlayerBefore ? player.GameObjectId : new NitroxId(); NitroxGameMode gameMode = hasSeenPlayerBefore ? player.GameMode : serverConfig.GameMode; IntroCinematicMode introCinematicMode = hasSeenPlayerBefore ? IntroCinematicMode.COMPLETED : IntroCinematicMode.LOADING; // TODO: At some point, store the muted state of a player PlayerContext playerContext = new(playerName, playerId, playerNitroxId, !hasSeenPlayerBefore, playerSettings, false, gameMode, null, introCinematicMode); string reservationKey = Guid.NewGuid().ToString(); reservations.Add(reservationKey, playerContext); assetPackage.ReservationKey = reservationKey; PlayerCurrentlyJoining = true; InitialSyncTimerData timerData = new InitialSyncTimerData(connection, authenticationContext, serverConfig.InitialSyncTimeout); initialSyncTimer = new Timer(InitialSyncTimerElapsed, timerData, 0, 200); return new MultiplayerSessionReservation(correlationId, playerId, reservationKey); } private void InitialSyncTimerElapsed(object state) { if (state is InitialSyncTimerData timerData && !timerData.Disposing) { allPlayersByName.TryGetValue(timerData.Context.Username, out Player player); if (timerData.Connection.State < NitroxConnectionState.Connected) { if (player == null) // player can cancel the joining process before this timer elapses { Log.Error("Player was nulled while joining"); PlayerDisconnected(timerData.Connection); } else { player.SendPacket(new PlayerKicked("An error occured while loading, Initial sync took too long to complete")); PlayerDisconnected(player.Connection); SendPacketToOtherPlayers(new Disconnect(player.Id), player); } timerData.Disposing = true; FinishProcessingReservation(); } if (timerData.Counter >= timerData.MaxCounter) { Log.Error("An unexpected Error occured during InitialSync"); PlayerDisconnected(timerData.Connection); timerData.Disposing = true; initialSyncTimer.Dispose(); // Looped long enough to require an override } timerData.Counter++; } } public void NonPlayerDisconnected(INitroxConnection connection) { // Remove any requests sent by the connection from the join queue JoinQueue = new(JoinQueue.Where(pair => !Equals(pair.Key, connection))); } public Player PlayerConnected(INitroxConnection connection, string reservationKey, out bool wasBrandNewPlayer) { PlayerContext playerContext = reservations[reservationKey]; Validate.NotNull(playerContext); ConnectionAssets assetPackage = assetsByConnection[connection]; Validate.NotNull(assetPackage); wasBrandNewPlayer = playerContext.WasBrandNewPlayer; if (!allPlayersByName.TryGetValue(playerContext.PlayerName, out Player player)) { player = new Player(playerContext.PlayerId, playerContext.PlayerName, false, playerContext, connection, NitroxVector3.Zero, NitroxQuaternion.Identity, playerContext.PlayerNitroxId, Optional.Empty, serverConfig.DefaultPlayerPerm, serverConfig.DefaultPlayerStats, serverConfig.GameMode, new List(), Array.Empty>(), new Dictionary(), new Dictionary(), new Dictionary(), new List() ); allPlayersByName[playerContext.PlayerName] = player; } connectedPlayersById.Add(playerContext.PlayerId, player); // TODO: make a ConnectedPlayer wrapper so this is not stateful player.PlayerContext = playerContext; player.Connection = connection; // reconnecting players need to have their cell visibility refreshed player.ClearVisibleCells(); assetPackage.Player = player; assetPackage.ReservationKey = null; reservations.Remove(reservationKey); return player; } public void PlayerDisconnected(INitroxConnection connection) { assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage); if (assetPackage == null) { return; } if (assetPackage.ReservationKey != null) { PlayerContext playerContext = reservations[assetPackage.ReservationKey]; reservedPlayerNames.Remove(playerContext.PlayerName); reservations.Remove(assetPackage.ReservationKey); } if (assetPackage.Player != null) { Player player = assetPackage.Player; reservedPlayerNames.Remove(player.Name); connectedPlayersById.Remove(player.Id); } assetsByConnection.Remove(connection); if (!ConnectedPlayers().Any()) { Server.Instance.PauseServer(); Server.Instance.Save(); } } public void FinishProcessingReservation(Player player = null) { initialSyncTimer.Dispose(); PlayerCurrentlyJoining = false; if (player != null) { BroadcastPlayerJoined(player); } Log.Info($"Finished processing reservation. Remaining requests: {JoinQueue.Count}"); // Tell next client that it can start joining. if (JoinQueue.Count > 0) { KeyValuePair keyValuePair = JoinQueue.Dequeue(); INitroxConnection requestConnection = keyValuePair.Key; MultiplayerSessionReservationRequest reservationRequest = keyValuePair.Value; MultiplayerSessionReservation reservation = ReservePlayerContext(requestConnection, reservationRequest.PlayerSettings, reservationRequest.AuthenticationContext, reservationRequest.CorrelationId); requestConnection.SendPacket(reservation); } } public bool TryGetPlayerByName(string playerName, out Player foundPlayer) { foundPlayer = null; foreach (Player player in ConnectedPlayers()) { if (player.Name == playerName) { foundPlayer = player; return true; } } return false; } public bool TryGetPlayerById(ushort playerId, out Player player) { return connectedPlayersById.TryGetValue(playerId, out player); } public Player GetPlayer(INitroxConnection connection) { if (!assetsByConnection.TryGetValue(connection, out ConnectionAssets assetPackage)) { return null; } return assetPackage.Player; } public Optional GetPlayer(string playerName) { allPlayersByName.TryGetValue(playerName, out Player player); return Optional.OfNullable(player); } public void SendPacketToAllPlayers(Packet packet) { foreach (Player player in ConnectedPlayers()) { player.SendPacket(packet); } } public void SendPacketToOtherPlayers(Packet packet, Player sendingPlayer) { foreach (Player player in ConnectedPlayers()) { if (player != sendingPlayer) { player.SendPacket(packet); } } } public IEnumerable ConnectedPlayers() { return assetsByConnection.Values .Where(assetPackage => assetPackage.Player != null) .Select(assetPackage => assetPackage.Player); } public void BroadcastPlayerJoined(Player player) { PlayerJoinedMultiplayerSession playerJoinedPacket = new(player.PlayerContext, player.SubRootId, player.Entity); SendPacketToOtherPlayers(playerJoinedPacket, player); } } }