Files
Nitrox/NitroxServer/GameLogic/PlayerManager.cs
2025-07-06 00:23:46 +02:00

360 lines
15 KiB
C#

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<string, Player> allPlayersByName;
private readonly ThreadSafeDictionary<ushort, Player> connectedPlayersById = [];
private readonly ThreadSafeDictionary<INitroxConnection, ConnectionAssets> assetsByConnection = new();
private readonly ThreadSafeDictionary<string, PlayerContext> reservations = new();
private readonly ThreadSafeSet<string> reservedPlayerNames = new("Player"); // "Player" is often used to identify the local player and should not be used by any user
private ThreadSafeQueue<KeyValuePair<INitroxConnection, MultiplayerSessionReservationRequest>> JoinQueue { get; set; } = new();
private bool PlayerCurrentlyJoining { get; set; }
private Timer initialSyncTimer;
private readonly SubnauticaServerConfig serverConfig;
private ushort currentPlayerId;
public PlayerManager(List<Player> players, SubnauticaServerConfig serverConfig)
{
allPlayersByName = new ThreadSafeDictionary<string, Player>(players.ToDictionary(x => x.Name), false);
currentPlayerId = players.Count == 0 ? (ushort)0 : players.Max(x => x.Id);
this.serverConfig = serverConfig;
}
public List<Player> GetConnectedPlayers()
{
return ConnectedPlayers().ToList();
}
public List<Player> GetConnectedPlayersExcept(Player excludePlayer)
{
return ConnectedPlayers().Where(player => player != excludePlayer).ToList();
}
public IEnumerable<Player> 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<INitroxConnection, MultiplayerSessionReservationRequest>(
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<NitroxTechType>(),
Array.Empty<Optional<NitroxId>>(),
new Dictionary<string, NitroxId>(),
new Dictionary<string, float>(),
new Dictionary<string, PingInstancePreference>(),
new List<int>()
);
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<INitroxConnection, MultiplayerSessionReservationRequest> 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<Player> 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<Player> 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);
}
}
}