Files
LuaCsForBarotraumaEP/Barotrauma/BarotraumaServer/ServerSource/Networking/GameServer.cs
Eero 7b8275100d Improve thread safety and performance in core systems
Refactors event, entity, and physics management to use thread-safe and lock-free data structures (Immutable collections, ConcurrentQueue, ConcurrentDictionary, Channel) for improved concurrency and performance. Replaces O(n) queue lookups with O(1) set/dictionary checks, ensures atomic updates for shared state, and optimizes queue draining and deferred action processing. Updates related code to use new APIs and patterns, and adds documentation for thread safety and workflow.
2025-12-29 16:47:10 +08:00

5071 lines
233 KiB
C#

using Barotrauma.Extensions;
using Barotrauma.IO;
using Barotrauma.Items.Components;
using Barotrauma.Steam;
using Lidgren.Network;
using Microsoft.Xna.Framework;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Xml.Linq;
using MoonSharp.Interpreter;
using System.Net;
using Barotrauma.PerkBehaviors;
namespace Barotrauma.Networking
{
sealed class GameServer : NetworkMember
{
public override bool IsServer => true;
public override bool IsClient => false;
public override Voting Voting { get; }
public string ServerName
{
get { return ServerSettings.ServerName; }
set
{
if (string.IsNullOrEmpty(value)) { return; }
ServerSettings.ServerName = value;
}
}
public bool SubmarineSwitchLoad = false;
private readonly List<Client> connectedClients = new List<Client>();
/// <summary>
/// For keeping track of disconnected clients in case they reconnect shortly after.
/// </summary>
private readonly List<Client> clientsAttemptingToReconnectSoon = new List<Client>();
//keeps track of players who've previously been playing on the server
//so kick votes persist during the session and the server can let the clients know what name this client used previously
private readonly List<PreviousPlayer> previousPlayers = new List<PreviousPlayer>();
private int roundStartSeed;
//is the server running
private bool started;
private ServerPeer serverPeer;
public ServerPeer ServerPeer { get { return serverPeer; } }
private DateTime refreshMasterTimer;
private readonly TimeSpan refreshMasterInterval = new TimeSpan(0, 0, 60);
private bool registeredToSteamMaster;
private DateTime roundStartTime;
private bool wasReadyToStartAutomatically;
private bool autoRestartTimerRunning;
public float EndRoundTimer { get; private set; }
public float EndRoundDelay { get; private set; }
public float EndRoundTimeRemaining => EndRoundTimer > 0 ? EndRoundDelay - EndRoundTimer : 0;
private const int PvpAutoBalanceCountdown = 10;
private static float pvpAutoBalanceCountdownRemaining = -1;
private int Team1Count => GetPlayingClients().Count(static c => c.TeamID == CharacterTeamType.Team1);
private int Team2Count => GetPlayingClients().Count(static c => c.TeamID == CharacterTeamType.Team2);
/// <summary>
/// Chat messages that get sent to the owner of the server when the owner is determined
/// </summary>
private static readonly Queue<ChatMessage> pendingMessagesToOwner = new Queue<ChatMessage>();
public VoipServer VoipServer
{
get;
private set;
}
private bool initiatedStartGame;
private CoroutineHandle startGameCoroutine;
private readonly ServerEntityEventManager entityEventManager;
public FileSender FileSender { get; private set; }
public ModSender ModSender { get; private set; }
private TraitorManager traitorManager;
public TraitorManager TraitorManager
{
get
{
traitorManager ??= new TraitorManager(this);
return traitorManager;
}
}
#if DEBUG
public void PrintSenderTransters()
{
foreach (var transfer in FileSender.ActiveTransfers)
{
DebugConsole.NewMessage(transfer.FileName + " " + transfer.Progress.ToString());
}
}
#endif
public override IReadOnlyList<Client> ConnectedClients
{
get
{
return connectedClients;
}
}
public ServerEntityEventManager EntityEventManager
{
get { return entityEventManager; }
}
public int Port => ServerSettings?.Port ?? 0;
//only used when connected to steam
public int QueryPort => ServerSettings?.QueryPort ?? 0;
public NetworkConnection OwnerConnection { get; private set; }
private readonly Option<int> ownerKey;
private readonly Option<P2PEndpoint> ownerEndpoint;
public void ClearRecentlyDisconnectedClients()
{
lock (clientsAttemptingToReconnectSoon)
{
clientsAttemptingToReconnectSoon.Clear();
}
}
public bool FindAndRemoveRecentlyDisconnectedConnection(NetworkConnection conn)
{
lock (clientsAttemptingToReconnectSoon)
{
Client found = null;
foreach (var client in clientsAttemptingToReconnectSoon)
{
if (conn.AddressMatches(client.Connection))
{
found = client;
break;
}
}
if (found is not null)
{
clientsAttemptingToReconnectSoon.Remove(found);
return true;
}
}
return false;
}
public GameServer(
string name,
IPAddress listenIp,
int port,
int queryPort,
bool isPublic,
string password,
bool attemptUPnP,
int maxPlayers,
Option<int> ownerKey,
Option<P2PEndpoint> ownerEndpoint)
{
if (name.Length > NetConfig.ServerNameMaxLength)
{
name = name.Substring(0, NetConfig.ServerNameMaxLength);
}
LastClientListUpdateID = 0;
ServerSettings = new ServerSettings(this, name, port, queryPort, maxPlayers, isPublic, attemptUPnP, listenIp);
KarmaManager.SelectPreset(ServerSettings.KarmaPreset);
ServerSettings.SetPassword(password);
ServerSettings.SaveSettings();
Voting = new Voting();
this.ownerKey = ownerKey;
this.ownerEndpoint = ownerEndpoint;
entityEventManager = new ServerEntityEventManager(this);
}
public void StartServer(bool registerToServerList)
{
Log("Starting the server...", ServerLog.MessageType.ServerMessage);
var callbacks = new ServerPeer.Callbacks(
ReadDataMessage,
OnClientDisconnect,
OnInitializationComplete,
GameMain.Instance.CloseServer,
OnOwnerDetermined);
if (ownerEndpoint.TryUnwrap(out var endpoint))
{
Log("Using P2P networking.", ServerLog.MessageType.ServerMessage);
serverPeer = new P2PServerPeer(endpoint, ownerKey.Fallback(0), ServerSettings, callbacks);
}
else
{
Log("Using Lidgren networking. Manual port forwarding may be required. If players cannot connect to the server, you may want to use the in-game hosting menu (which uses Steamworks and EOS networking and does not require port forwarding).", ServerLog.MessageType.ServerMessage);
serverPeer = new LidgrenServerPeer(ownerKey, ServerSettings, callbacks);
if (registerToServerList)
{
try
{
registeredToSteamMaster = SteamManager.CreateServer(this, ServerSettings.IsPublic);
}
catch (Exception e)
{
DebugConsole.NewMessage($"Steam registering skipped due to error (and probably more of it was printed above): {e.Message}");
}
Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings);
}
}
FileSender = new FileSender(serverPeer, MsgConstants.MTU);
FileSender.OnEnded += FileTransferChanged;
FileSender.OnStarted += FileTransferChanged;
if (ServerSettings.AllowModDownloads) { ModSender = new ModSender(); }
serverPeer.Start();
VoipServer = new VoipServer(serverPeer);
GameMain.LuaCs.Initialize();
Log("Server started", ServerLog.MessageType.ServerMessage);
GameMain.NetLobbyScreen.Select();
GameMain.NetLobbyScreen.RandomizeSettings();
if (!string.IsNullOrEmpty(ServerSettings.SelectedSubmarine))
{
SubmarineInfo sub = SubmarineInfo.SavedSubmarines.FirstOrDefault(s => s.Name == ServerSettings.SelectedSubmarine);
if (sub != null) { GameMain.NetLobbyScreen.SelectedSub = sub; }
}
if (!string.IsNullOrEmpty(ServerSettings.SelectedShuttle))
{
SubmarineInfo shuttle = SubmarineInfo.SavedSubmarines.FirstOrDefault(s => s.Name == ServerSettings.SelectedShuttle);
if (shuttle != null) { GameMain.NetLobbyScreen.SelectedShuttle = shuttle; }
}
started = true;
GameAnalyticsManager.AddDesignEvent("GameServer:Start");
}
/// <summary>
/// Creates a message that gets sent to the server owner once the connection is initialized. Can be used to for example notify the owner of problems during initialization
/// </summary>
public static void AddPendingMessageToOwner(string message, ChatMessageType messageType)
{
pendingMessagesToOwner.Enqueue(ChatMessage.Create(string.Empty, message, messageType, sender: null));
}
private void OnOwnerDetermined(NetworkConnection connection)
{
OwnerConnection = connection;
var ownerClient = ConnectedClients.Find(c => c.Connection == connection);
if (ownerClient == null)
{
DebugConsole.ThrowError("Owner client not found! Can't set permissions");
return;
}
ownerClient.SetPermissions(ClientPermissions.All, DebugConsole.Commands);
UpdateClientPermissions(ownerClient);
}
public void NotifyCrash()
{
var tempList = ConnectedClients.Where(c => c.Connection != OwnerConnection).ToList();
foreach (var c in tempList)
{
DisconnectClient(c.Connection, PeerDisconnectPacket.WithReason(DisconnectReason.ServerCrashed));
}
if (OwnerConnection != null)
{
var conn = OwnerConnection; OwnerConnection = null;
DisconnectClient(conn, PeerDisconnectPacket.WithReason(DisconnectReason.ServerCrashed));
}
Thread.Sleep(500);
}
private void OnInitializationComplete(NetworkConnection connection, string clientName)
{
clientName = Client.SanitizeName(clientName);
Client newClient = new Client(clientName, GetNewClientSessionId());
newClient.InitClientSync();
newClient.Connection = connection;
newClient.Connection.Status = NetworkConnectionStatus.Connected;
newClient.AccountInfo = connection.AccountInfo;
newClient.Language = connection.Language;
connectedClients.Add(newClient);
var previousPlayer = previousPlayers.Find(p => p.MatchesClient(newClient));
if (previousPlayer != null)
{
newClient.Karma = previousPlayer.Karma;
newClient.KarmaKickCount = previousPlayer.KarmaKickCount;
foreach (Client c in previousPlayer.KickVoters)
{
if (!connectedClients.Contains(c)) { continue; }
newClient.AddKickVote(c);
}
}
IncrementLastClientListUpdateID();
if (newClient.Connection == OwnerConnection && OwnerConnection != null)
{
newClient.GivePermission(ClientPermissions.All);
foreach (var command in DebugConsole.Commands)
{
newClient.PermittedConsoleCommands.Add(command);
}
SendConsoleMessage("Granted all permissions to " + newClient.Name + ".", newClient);
}
GameMain.LuaCs.Hook.Call("client.connected", newClient);
SendChatMessage($"ServerMessage.JoinedServer~[client]={ClientLogName(newClient)}", ChatMessageType.Server, changeType: PlayerConnectionChangeType.Joined);
ServerSettings.ServerDetailsChanged = true;
if (previousPlayer != null && previousPlayer.Name != newClient.Name)
{
string prevNameSanitized = previousPlayer.Name.Replace("‖", "");
SendChatMessage($"ServerMessage.PreviousClientName~[client]={ClientLogName(newClient)}~[previousname]={prevNameSanitized}", ChatMessageType.Server);
previousPlayer.Name = newClient.Name;
}
if (!ServerSettings.ServerMessageText.IsNullOrEmpty())
{
SendDirectChatMessage((TextManager.Get("servermotd") + '\n' + ServerSettings.ServerMessageText).Value, newClient, ChatMessageType.Server);
}
var savedPermissions = ServerSettings.ClientPermissions.Find(scp =>
scp.AddressOrAccountId.TryGet(out AccountId accountId)
? newClient.AccountId.ValueEquals(accountId)
: newClient.Connection.Endpoint.Address == scp.AddressOrAccountId);
if (savedPermissions != null)
{
newClient.SetPermissions(savedPermissions.Permissions, savedPermissions.PermittedCommands);
}
else
{
var defaultPerms = PermissionPreset.List.Find(p => p.Identifier == "None");
if (defaultPerms != null)
{
newClient.SetPermissions(defaultPerms.Permissions, defaultPerms.PermittedCommands);
}
else
{
newClient.SetPermissions(ClientPermissions.None, Enumerable.Empty<DebugConsole.Command>());
}
}
UpdateClientPermissions(newClient);
//notify the client of everyone else's permissions
foreach (Client otherClient in connectedClients)
{
if (otherClient == newClient) { continue; }
CoroutineManager.StartCoroutine(SendClientPermissionsAfterClientListSynced(newClient, otherClient));
}
}
private void OnClientDisconnect(NetworkConnection connection, PeerDisconnectPacket peerDisconnectPacket)
{
Client connectedClient = connectedClients.Find(c => c.Connection == connection);
DisconnectClient(connectedClient, peerDisconnectPacket);
}
public void Update(float deltaTime)
{
dosProtection.Update(deltaTime);
if (!started) { return; }
if (ChildServerRelay.HasShutDown)
{
GameMain.Instance.CloseServer();
return;
}
FileSender.Update(deltaTime);
KarmaManager.UpdateClients(ConnectedClients, deltaTime);
UpdatePing();
if (ServerSettings.VoiceChatEnabled)
{
VoipServer.SendToClients(connectedClients);
foreach (var c in connectedClients)
{
c.VoipServerDecoder.DebugUpdate(deltaTime);
}
}
if (GameStarted)
{
RespawnManager?.Update(deltaTime);
entityEventManager.Update(connectedClients);
bool permadeathMode = ServerSettings.RespawnMode == RespawnMode.Permadeath;
//go through the characters backwards to give rejoining clients control of the latest created character
for (int i = Character.CharacterList.Count - 1; i >= 0; i--)
{
Character character = Character.CharacterList[i];
Client owner = connectedClients.Find(c => (c.Character == null || c.Character == character) && character.IsClientOwner(c));
bool spectating = owner is { SpectateOnly: true } && ServerSettings.AllowSpectating;
if (!character.ClientDisconnected && !spectating) { continue; }
bool canOwnerTakeControl =
owner != null && owner.InGame && !owner.NeedsMidRoundSync &&
(!spectating ||
(permadeathMode && (!character.IsDead || character.CauseOfDeath?.Type == CauseOfDeathType.Disconnected)));
if (!character.IsDead)
{
if (!GameMain.LuaCs.Game.disableDisconnectCharacter)
{
character.KillDisconnectedTimer += deltaTime;
character.SetStun(1.0f);
}
float killTime = permadeathMode ? ServerSettings.DespawnDisconnectedPermadeathTime : ServerSettings.KillDisconnectedTime;
//owner decided to spectate -> kill the character immediately,
//it's no longer needed and should not be considered the character this client is controlling
//the client can still regain control, because the character can be revived in the block below if the client rejoins as a non-spectator
if (spectating)
{
killTime = 0.0f;
}
if ((OwnerConnection == null || owner?.Connection != OwnerConnection) &&
character.KillDisconnectedTimer > killTime)
{
character.Kill(CauseOfDeathType.Disconnected, null);
continue;
}
if (canOwnerTakeControl)
{
SetClientCharacter(owner, character);
}
}
else if (canOwnerTakeControl &&
character.CauseOfDeath?.Type == CauseOfDeathType.Disconnected &&
character.CharacterHealth.VitalityDisregardingDeath > 0)
{
//create network event immediately to ensure the character is revived client-side
//before the client gains control of it (normally status events are created periodically)
character.Revive(removeAfflictions: false, createNetworkEvent: true);
SetClientCharacter(owner, character);
}
}
TraitorManager?.Update(deltaTime);
Voting.Update(deltaTime);
bool isCrewDown =
connectedClients.All(c => !c.UsingFreeCam && (c.Character == null || c.Character.IsDead || c.Character.IsIncapacitated));
bool isSomeoneIncapacitatedNotDead =
connectedClients.Any(c => !c.UsingFreeCam && c.Character is { IsDead: false, IsIncapacitated: true });
bool subAtLevelEnd = false;
if (Submarine.MainSub != null && GameMain.GameSession.GameMode is not PvPMode)
{
if (Level.Loaded?.EndOutpost != null)
{
int charactersInsideOutpost = connectedClients.Count(c =>
c.Character != null &&
!c.Character.IsDead && !c.Character.IsUnconscious &&
c.Character.Submarine == Level.Loaded.EndOutpost);
int charactersOutsideOutpost = connectedClients.Count(c =>
c.Character != null &&
!c.Character.IsDead && !c.Character.IsUnconscious &&
c.Character.Submarine != Level.Loaded.EndOutpost);
//level finished if the sub is docked to the outpost
//or very close and someone from the crew made it inside the outpost
subAtLevelEnd =
Submarine.MainSub.DockedTo.Contains(Level.Loaded.EndOutpost) ||
(Submarine.MainSub.AtEndExit && charactersInsideOutpost > 0) ||
(charactersInsideOutpost > charactersOutsideOutpost);
}
else
{
subAtLevelEnd = Submarine.MainSub.AtEndExit;
}
}
EndRoundDelay = 1.0f;
if (permadeathMode && isCrewDown)
{
if (EndRoundTimer <= 0.0f)
{
CreateEntityEvent(RespawnManager);
}
EndRoundDelay = 120.0f;
EndRoundTimer += deltaTime;
}
else if (ServerSettings.AutoRestart && isCrewDown)
{
EndRoundDelay = isSomeoneIncapacitatedNotDead ? 120.0f : 5.0f;
EndRoundTimer += deltaTime;
}
else if (subAtLevelEnd && GameMain.GameSession?.GameMode is not CampaignMode)
{
EndRoundDelay = 5.0f;
EndRoundTimer += deltaTime;
}
else if (isCrewDown &&
(RespawnManager == null || (!RespawnManager.CanRespawnAgain(CharacterTeamType.Team1) && !RespawnManager.CanRespawnAgain(CharacterTeamType.Team2))))
{
#if !DEBUG
if (EndRoundTimer <= 0.0f)
{
SendChatMessage(TextManager.GetWithVariable("CrewDeadNoRespawns", "[time]", "120").Value, ChatMessageType.Server);
}
EndRoundDelay = 120.0f;
EndRoundTimer += deltaTime;
#endif
}
else if (isCrewDown && (GameMain.GameSession?.GameMode is CampaignMode))
{
#if !DEBUG
EndRoundDelay = isSomeoneIncapacitatedNotDead ? 120.0f : 2.0f;
EndRoundTimer += deltaTime;
#endif
}
else
{
EndRoundTimer = 0.0f;
}
if (EndRoundTimer >= EndRoundDelay)
{
if (permadeathMode && isCrewDown)
{
Log("Ending round (entire crew dead or down and did not acquire new characters in time)", ServerLog.MessageType.ServerMessage);
}
else if (ServerSettings.AutoRestart && isCrewDown)
{
Log("Ending round (entire crew down)", ServerLog.MessageType.ServerMessage);
}
else if (subAtLevelEnd)
{
Log("Ending round (submarine reached the end of the level)", ServerLog.MessageType.ServerMessage);
}
else if (RespawnManager == null)
{
Log("Ending round (no players left standing and respawning is not enabled during this round)", ServerLog.MessageType.ServerMessage);
}
else
{
Log("Ending round (no players left standing)", ServerLog.MessageType.ServerMessage);
}
EndGame(wasSaved: false);
return;
}
}
else if (initiatedStartGame)
{
//tried to start up the game and StartGame coroutine is not running anymore
// -> something wen't wrong during startup, re-enable start button and reset AutoRestartTimer
if (startGameCoroutine != null && !CoroutineManager.IsCoroutineRunning(startGameCoroutine))
{
if (ServerSettings.AutoRestart) { ServerSettings.AutoRestartTimer = Math.Max(ServerSettings.AutoRestartInterval, 5.0f); }
if (startGameCoroutine.Exception != null && OwnerConnection != null)
{
SendConsoleMessage(
startGameCoroutine.Exception.Message + '\n' +
(startGameCoroutine.Exception.StackTrace?.CleanupStackTrace() ?? "null"),
connectedClients.Find(c => c.Connection == OwnerConnection),
Color.Red);
}
EndGame();
GameMain.NetLobbyScreen.LastUpdateID++;
startGameCoroutine = null;
initiatedStartGame = false;
}
}
else if (Screen.Selected == GameMain.NetLobbyScreen && !GameStarted && !initiatedStartGame)
{
if (ServerSettings.AutoRestart)
{
//autorestart if there are any non-spectators on the server (ignoring the server owner)
bool shouldAutoRestart = connectedClients.Any(c =>
c.Connection != OwnerConnection &&
(!c.SpectateOnly || !ServerSettings.AllowSpectating));
if (shouldAutoRestart != autoRestartTimerRunning)
{
autoRestartTimerRunning = shouldAutoRestart;
GameMain.NetLobbyScreen.LastUpdateID++;
}
if (autoRestartTimerRunning)
{
ServerSettings.AutoRestartTimer -= deltaTime;
}
}
bool readyToStartAutomatically = false;
if (ServerSettings.AutoRestart && autoRestartTimerRunning && ServerSettings.AutoRestartTimer < 0.0f)
{
readyToStartAutomatically = true;
}
else if (ServerSettings.StartWhenClientsReady)
{
var startVoteEligibleClients = connectedClients.Where(c => Voting.CanVoteToStartRound(c));
int clientsReady = startVoteEligibleClients.Count(c => c.GetVote<bool>(VoteType.StartRound));
if (clientsReady / (float)startVoteEligibleClients.Count() >= ServerSettings.StartWhenClientsReadyRatio)
{
readyToStartAutomatically = true;
}
}
if (readyToStartAutomatically && !isRoundStartWarningActive)
{
if (!wasReadyToStartAutomatically) { GameMain.NetLobbyScreen.LastUpdateID++; }
TryStartGame();
}
wasReadyToStartAutomatically = readyToStartAutomatically;
}
lock (clientsAttemptingToReconnectSoon)
{
foreach (var client in clientsAttemptingToReconnectSoon)
{
client.DeleteDisconnectedTimer -= deltaTime;
}
clientsAttemptingToReconnectSoon.RemoveAll(static c => c.DeleteDisconnectedTimer < 0f);
}
foreach (Client c in connectedClients)
{
//slowly reset spam timers
c.ChatSpamTimer = Math.Max(0.0f, c.ChatSpamTimer - deltaTime);
c.ChatSpamSpeed = Math.Max(0.0f, c.ChatSpamSpeed - deltaTime);
//constantly increase AFK timer if the client is controlling a character (gets reset to zero every time an input is received)
if (GameStarted && c.Character != null && !c.Character.IsDead && !c.Character.IsIncapacitated &&
(!c.AFK || !ServerSettings.AllowAFK))
{
if (c.Connection != OwnerConnection && c.Permissions != ClientPermissions.All) { c.KickAFKTimer += deltaTime; }
}
}
if (pvpAutoBalanceCountdownRemaining > 0)
{
if (GameStarted || initiatedStartGame || Screen.Selected != GameMain.NetLobbyScreen ||
ServerSettings.PvpTeamSelectionMode == PvpTeamSelectionMode.PlayerPreference || ServerSettings.PvpAutoBalanceThreshold == 0)
{
StopAutoBalanceCountdown();
}
else
{
float prevTimeRemaining = pvpAutoBalanceCountdownRemaining;
pvpAutoBalanceCountdownRemaining -= deltaTime;
if (pvpAutoBalanceCountdownRemaining <= 0)
{
pvpAutoBalanceCountdownRemaining = -1;
RefreshPvpTeamAssignments(autoBalanceNow: true);
}
else
{
// Send a chat message about the countdown every 5 seconds the countdown is running, but not when
// it (=its integer part, which gets printed out) is still at the starting value, or zero
int currentTimeRemainingInteger = (int)Math.Ceiling(pvpAutoBalanceCountdownRemaining);
if (Math.Ceiling(prevTimeRemaining) > currentTimeRemainingInteger && currentTimeRemainingInteger % 5 == 0)
{
SendChatMessage(
TextManager.GetWithVariable("AutoBalance.CountdownRemaining", "[number]", currentTimeRemainingInteger.ToString()).Value,
ChatMessageType.Server);
}
}
}
}
if (connectedClients.Any(c => c.KickAFKTimer >= ServerSettings.KickAFKTime))
{
IEnumerable<Client> kickAFK = connectedClients.FindAll(c =>
c.KickAFKTimer >= ServerSettings.KickAFKTime &&
(OwnerConnection == null || c.Connection != OwnerConnection));
foreach (Client c in kickAFK)
{
KickClient(c, "DisconnectMessage.AFK");
}
}
serverPeer.Update(deltaTime);
//don't run the rest of the method if something in serverPeer.Update causes the server to shutdown
if (!started) { return; }
// if update interval has passed
if (updateTimer < DateTime.Now)
{
if (ConnectedClients.Count > 0)
{
foreach (Client c in ConnectedClients)
{
try
{
ClientWrite(c);
}
catch (Exception e)
{
DebugConsole.ThrowError("Failed to write a network message for the client \"" + c.Name + "\"!", e);
string errorMsg = "Failed to write a network message for a client! (MidRoundSyncing: " + c.NeedsMidRoundSync + ")\n"
+ e.Message + "\n" + e.StackTrace.CleanupStackTrace();
if (e.InnerException != null)
{
errorMsg += "\nInner exception: " + e.InnerException.Message + "\n" + e.InnerException.StackTrace.CleanupStackTrace();
}
}
}
foreach (Character character in Character.CharacterList)
{
if (character.healthUpdateTimer <= 0.0f)
{
if (!character.HealthUpdatePending)
{
character.healthUpdateTimer = character.HealthUpdateInterval;
}
character.HealthUpdatePending = true;
}
else
{
character.healthUpdateTimer -= (float)UpdateInterval.TotalSeconds;
}
character.HealthUpdateInterval += (float)UpdateInterval.TotalSeconds;
}
}
updateTimer = DateTime.Now + UpdateInterval;
}
if (DateTime.Now > refreshMasterTimer || ServerSettings.ServerDetailsChanged)
{
if (registeredToSteamMaster)
{
bool refreshSuccessful = SteamManager.RefreshServerDetails(this);
if (GameSettings.CurrentConfig.VerboseLogging)
{
Log(refreshSuccessful ?
"Refreshed server info on the Steam server list." :
"Refreshing server info on the Steam server list failed.", ServerLog.MessageType.ServerMessage);
}
}
Eos.EosSessionManager.UpdateOwnedSession(Option.None, ServerSettings);
ServerSettings.ServerDetailsChanged = false;
refreshMasterTimer = DateTime.Now + refreshMasterInterval;
}
}
private double lastPingTime;
private byte[] lastPingData;
private void UpdatePing()
{
if (Timing.TotalTime > lastPingTime + 1.0)
{
lastPingData ??= new byte[64];
for (int i = 0; i < lastPingData.Length; i++)
{
lastPingData[i] = (byte)Rand.Range(33, 126);
}
lastPingTime = Timing.TotalTime;
ConnectedClients.ForEach(c =>
{
IWriteMessage pingReq = new WriteOnlyMessage();
pingReq.WriteByte((byte)ServerPacketHeader.PING_REQUEST);
pingReq.WriteByte((byte)lastPingData.Length);
pingReq.WriteBytes(lastPingData, 0, lastPingData.Length);
serverPeer.Send(pingReq, c.Connection, DeliveryMethod.Unreliable);
IWriteMessage pingInf = new WriteOnlyMessage();
pingInf.WriteByte((byte)ServerPacketHeader.CLIENT_PINGS);
pingInf.WriteByte((byte)ConnectedClients.Count);
ConnectedClients.ForEach(c2 =>
{
pingInf.WriteByte(c2.SessionId);
pingInf.WriteUInt16(c2.Ping);
});
serverPeer.Send(pingInf, c.Connection, DeliveryMethod.Unreliable);
});
}
}
private readonly DoSProtection dosProtection = new();
private void ReadDataMessage(NetworkConnection sender, IReadMessage inc)
{
var connectedClient = connectedClients.Find(c => c.Connection == sender);
using var _ = dosProtection.Start(connectedClient);
ClientPacketHeader header = (ClientPacketHeader)inc.ReadByte();
GameMain.LuaCs.Networking.NetMessageReceived(inc, header, connectedClient);
switch (header)
{
case ClientPacketHeader.PING_RESPONSE:
byte responseLen = inc.ReadByte();
if (responseLen != lastPingData.Length) { return; }
for (int i = 0; i < responseLen; i++)
{
byte b = inc.ReadByte();
if (b != lastPingData[i]) { return; }
}
connectedClient.Ping = (UInt16)((Timing.TotalTime - lastPingTime) * 1000);
break;
case ClientPacketHeader.RESPONSE_STARTGAME:
if (connectedClient != null)
{
connectedClient.ReadyToStart = inc.ReadBoolean();
connectedClient.AFK = inc.ReadBoolean();
UpdateCharacterInfo(inc, connectedClient);
//game already started -> send start message immediately
if (GameStarted)
{
SendStartMessage(roundStartSeed, GameMain.GameSession.Level.Seed, GameMain.GameSession, connectedClient, true);
}
}
break;
case ClientPacketHeader.RESPONSE_CANCEL_STARTGAME:
if (isRoundStartWarningActive)
{
foreach (Client c in connectedClients)
{
IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.CANCEL_STARTGAME);
serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable);
}
AbortStartGameIfWarningActive();
}
break;
case ClientPacketHeader.REQUEST_STARTGAMEFINALIZE:
if (connectedClient == null)
{
DebugConsole.AddWarning("Received a REQUEST_STARTGAMEFINALIZE message. Client not connected, ignoring the message.");
}
else if (!GameStarted)
{
DebugConsole.AddWarning("Received a REQUEST_STARTGAMEFINALIZE message. Game not started, ignoring the message.");
}
else
{
SendRoundStartFinalize(connectedClient);
}
break;
case ClientPacketHeader.UPDATE_LOBBY:
ClientReadLobby(inc);
break;
case ClientPacketHeader.UPDATE_INGAME:
if (!GameStarted) { return; }
ClientReadIngame(inc);
break;
case ClientPacketHeader.CAMPAIGN_SETUP_INFO:
bool isNew = inc.ReadBoolean(); inc.ReadPadBits();
if (isNew)
{
string saveName = inc.ReadString();
string seed = inc.ReadString();
string subName = inc.ReadString();
string subHash = inc.ReadString();
CampaignSettings settings = INetSerializableStruct.Read<CampaignSettings>(inc);
var matchingSub = SubmarineInfo.SavedSubmarines.FirstOrDefault(s => s.Name == subName && s.MD5Hash.StringRepresentation == subHash);
if (GameStarted)
{
SendDirectChatMessage(TextManager.Get("CampaignStartFailedRoundRunning").Value, connectedClient, ChatMessageType.MessageBox);
return;
}
if (matchingSub == null)
{
SendDirectChatMessage(
TextManager.GetWithVariable("CampaignStartFailedSubNotFound", "[subname]", subName).Value,
connectedClient, ChatMessageType.MessageBox);
}
else
{
string localSavePath = SaveUtil.CreateSavePath(SaveUtil.SaveType.Multiplayer, saveName);
if (CampaignMode.AllowedToManageCampaign(connectedClient, ClientPermissions.ManageRound))
{
using (dosProtection.Pause(connectedClient))
{
ServerSettings.CampaignSettings = settings;
ServerSettings.SaveSettings();
MultiPlayerCampaign.StartNewCampaign(localSavePath, matchingSub.FilePath, seed, settings);
}
}
}
}
else
{
string savePath = inc.ReadString();
bool isBackup = inc.ReadBoolean();
inc.ReadPadBits();
uint backupIndex = isBackup ? inc.ReadUInt32() : uint.MinValue;
if (GameStarted)
{
SendDirectChatMessage(TextManager.Get("CampaignStartFailedRoundRunning").Value, connectedClient, ChatMessageType.MessageBox);
break;
}
if (CampaignMode.AllowedToManageCampaign(connectedClient, ClientPermissions.ManageRound))
{
using (dosProtection.Pause(connectedClient))
{
CampaignDataPath dataPath;
if (isBackup)
{
string backupPath = SaveUtil.GetBackupPath(savePath, backupIndex);
dataPath = new CampaignDataPath(loadPath: backupPath, savePath: savePath);
}
else
{
dataPath = CampaignDataPath.CreateRegular(savePath);
}
MultiPlayerCampaign.LoadCampaign(dataPath, connectedClient);
}
}
}
break;
case ClientPacketHeader.VOICE:
if (ServerSettings.VoiceChatEnabled && !connectedClient.Muted)
{
byte id = inc.ReadByte();
if (connectedClient.SessionId != id)
{
#if DEBUG
DebugConsole.ThrowError(
"Client \"" + connectedClient.Name + "\" sent a VOIP update that didn't match its ID (" + id.ToString() + "!=" + connectedClient.SessionId.ToString() + ")");
#endif
return;
}
VoipServer.Read(inc, connectedClient);
}
break;
case ClientPacketHeader.SERVER_SETTINGS:
ServerSettings.ServerRead(inc, connectedClient);
break;
case ClientPacketHeader.SERVER_SETTINGS_PERKS:
ServerSettings.ReadPerks(inc, connectedClient);
break;
case ClientPacketHeader.SERVER_COMMAND:
ClientReadServerCommand(inc);
break;
case ClientPacketHeader.ENDROUND_SELF:
connectedClient.InGame = false;
connectedClient.ResetSync();
break;
case ClientPacketHeader.CREW:
ReadCrewMessage(inc, connectedClient);
break;
case ClientPacketHeader.TRANSFER_MONEY:
ReadMoneyMessage(inc, connectedClient);
break;
case ClientPacketHeader.REWARD_DISTRIBUTION:
ReadRewardDistributionMessage(inc, connectedClient);
break;
case ClientPacketHeader.RESET_REWARD_DISTRIBUTION:
ResetRewardDistribution(connectedClient);
break;
case ClientPacketHeader.MEDICAL:
ReadMedicalMessage(inc, connectedClient);
break;
case ClientPacketHeader.CIRCUITBOX:
ReadCircuitBoxMessage(inc, connectedClient);
break;
case ClientPacketHeader.READY_CHECK:
ReadyCheck.ServerRead(inc, connectedClient);
break;
case ClientPacketHeader.READY_TO_SPAWN:
ReadReadyToSpawnMessage(inc, connectedClient);
break;
case ClientPacketHeader.TAKEOVERBOT:
ReadTakeOverBotMessage(inc, connectedClient);
break;
case ClientPacketHeader.TOGGLE_RESERVE_BENCH:
GameMain.GameSession?.CrewManager?.ReadToggleReserveBenchMessage(inc, connectedClient);
break;
case ClientPacketHeader.FILE_REQUEST:
if (ServerSettings.AllowFileTransfers)
{
FileSender.ReadFileRequest(inc, connectedClient);
}
break;
case ClientPacketHeader.EVENTMANAGER_RESPONSE:
GameMain.GameSession?.EventManager.ServerRead(inc, connectedClient);
break;
case ClientPacketHeader.UPDATE_CHARACTERINFO:
UpdateCharacterInfo(inc, connectedClient);
break;
case ClientPacketHeader.REQUEST_BACKUP_INDICES:
SendBackupIndices(inc, connectedClient);
break;
case ClientPacketHeader.ERROR:
HandleClientError(inc, connectedClient);
break;
}
}
private void SendBackupIndices(IReadMessage inc, Client connectedClient)
{
string savePath = inc.ReadString();
var indexData = SaveUtil.GetIndexData(savePath);
IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.SEND_BACKUP_INDICES);
msg.WriteString(savePath);
msg.WriteNetSerializableStruct(indexData.ToNetCollection());
serverPeer?.Send(msg, connectedClient.Connection, DeliveryMethod.Reliable);
}
private void HandleClientError(IReadMessage inc, Client c)
{
string errorStr = "Unhandled error report";
string errorStrNoName = errorStr;
bool malformedData = false;
try
{
ClientNetError error = (ClientNetError)inc.ReadByte();
switch (error)
{
case ClientNetError.MISSING_EVENT:
UInt16 expectedID = inc.ReadUInt16();
UInt16 receivedID = inc.ReadUInt16();
errorStr = errorStrNoName = "Expecting event id " + expectedID.ToString() + ", received " + receivedID.ToString();
break;
case ClientNetError.MISSING_ENTITY:
UInt16 eventID = inc.ReadUInt16();
UInt16 entityID = inc.ReadUInt16();
int subCount = inc.ReadByte();
List<string> subNames = new List<string>();
for (int i = 0; i < Math.Min(subCount, 5); i++)
{
string subName = inc.ReadString();
if (subName == null || subName.Length > MaxSubNameLengthInErrorMessages)
{
malformedData = true;
}
else
{
subNames.Add(subName);
}
}
Entity entity = Entity.FindEntityByID(entityID);
if (entity == null)
{
errorStr = errorStrNoName = "Received an update for an entity that doesn't exist (event id " + eventID.ToString() + ", entity id " + entityID.ToString() + ").";
}
else if (entity is Character character)
{
errorStr = $"Missing character {character.Name} (event id {eventID}, entity id {entityID}).";
errorStrNoName = $"Missing character {character.SpeciesName} (event id {eventID}, entity id {entityID}).";
}
else if (entity is Item item)
{
errorStr = errorStrNoName = $"Missing item {item.Name} ({item.Prefab.Identifier}), sub: {item.Submarine?.Info?.Name ?? "none"} (event id {eventID}, entity id {entityID}).";
}
else
{
errorStr = errorStrNoName = $"Missing entity {entity}, sub: {entity.Submarine?.Info?.Name ?? "none"} (event id {eventID}, entity id {entityID}).";
}
if (GameStarted)
{
var serverSubNames = Submarine.Loaded.Select(s =>
s.Info.Name.Length > MaxSubNameLengthInErrorMessages ? s.Info.Name.Substring(0, MaxSubNameLengthInErrorMessages) : s.Info.Name);
if (subCount != Submarine.Loaded.Count || !subNames.SequenceEqual(serverSubNames))
{
string subErrorStr = $" Loaded submarines don't match (client: {string.Join(", ", subNames)}, server: {string.Join(", ", serverSubNames)}).";
errorStr += subErrorStr;
errorStrNoName += subErrorStr;
}
}
break;
}
}
catch (Exception e)
{
DebugConsole.ThrowError($"Failed to read error data from the client {ClientLogName(c)}.", e);
malformedData = true;
}
if (malformedData)
{
KickClient(c, "Received malformed error data.");
return;
}
Log(ClientLogName(c) + " has reported an error: " + errorStr, ServerLog.MessageType.Error);
GameAnalyticsManager.AddErrorEventOnce("GameServer.HandleClientError:" + errorStrNoName, GameAnalyticsManager.ErrorSeverity.Error, errorStr);
try
{
WriteEventErrorData(c, errorStr);
}
catch (Exception e)
{
DebugConsole.ThrowError("Failed to write event error data", e);
}
if (c.Connection == OwnerConnection)
{
SendDirectChatMessage(errorStr, c, ChatMessageType.MessageBox);
EndGame(wasSaved: false);
}
else
{
KickClient(c, errorStr);
}
}
private void WriteEventErrorData(Client client, string errorStr)
{
if (!Directory.Exists(ServerLog.SavePath))
{
Directory.CreateDirectory(ServerLog.SavePath);
}
string filePath = $"event_error_log_server_{client.Name}_{DateTime.UtcNow.ToShortTimeString()}.log";
filePath = Path.Combine(ServerLog.SavePath, ToolBox.RemoveInvalidFileNameChars(filePath));
if (File.Exists(filePath)) { return; }
List<string> errorLines = new List<string>
{
errorStr, ""
};
if (GameMain.GameSession?.GameMode != null)
{
errorLines.Add("Game mode: " + GameMain.GameSession.GameMode.Name.Value);
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign)
{
errorLines.Add("Campaign ID: " + campaign.CampaignID);
errorLines.Add("Campaign save ID: " + campaign.LastSaveID);
}
foreach (Mission mission in GameMain.GameSession.Missions)
{
errorLines.Add("Mission: " + mission.Prefab.Identifier);
}
}
if (GameMain.GameSession?.Submarine != null)
{
errorLines.Add("Submarine: " + GameMain.GameSession.Submarine.Info.Name);
}
if (GameMain.NetworkMember?.RespawnManager is { } respawnManager)
{
errorLines.Add("Respawn shuttles: " + string.Join(", ", respawnManager.RespawnShuttles.Select(s => s.Info.Name)));
}
if (Level.Loaded != null)
{
errorLines.Add("Level: " + Level.Loaded.Seed + ", "
+ string.Join("; ", Level.Loaded.EqualityCheckValues.Select(cv
=> cv.Key + "=" + cv.Value.ToString("X"))));
errorLines.Add("Entity count before generating level: " + Level.Loaded.EntityCountBeforeGenerate);
errorLines.Add("Entities:");
foreach (Entity e in Level.Loaded.EntitiesBeforeGenerate.OrderBy(e => e.CreationIndex))
{
errorLines.Add(e.ErrorLine);
}
errorLines.Add("Entity count after generating level: " + Level.Loaded.EntityCountAfterGenerate);
}
errorLines.Add("Entity IDs:");
Entity[] sortedEntities = Entity.GetEntities().OrderBy(e => e.CreationIndex).ToArray();
foreach (Entity e in sortedEntities)
{
errorLines.Add(e.ErrorLine);
}
errorLines.Add("");
errorLines.Add("EntitySpawner events:");
try
{
foreach (var entityEvent in entityEventManager.UniqueEvents.ToList())
{
if (entityEvent.Entity is EntitySpawner)
{
var spawnData = entityEvent.Data as EntitySpawner.SpawnOrRemove;
errorLines.Add(
entityEvent.ID + ": " +
(spawnData is EntitySpawner.RemoveEntity ? "Remove " : "Create ") +
spawnData.Entity.ToString() +
" (" + spawnData.ID + ", " + spawnData.Entity.ID + ")");
}
}
}
catch
{
errorLines.Add("Failed to write EntitySpawner events.");
}
errorLines.Add("");
errorLines.Add("Last debug messages:");
for (int i = DebugConsole.Messages.Count - 1; i > 0 && i > DebugConsole.Messages.Count - 15; i--)
{
errorLines.Add(" " + DebugConsole.Messages[i].Time + " - " + DebugConsole.Messages[i].Text);
}
File.WriteAllLines(filePath, errorLines);
}
public override void CreateEntityEvent(INetSerializable entity, NetEntityEvent.IData extraData = null)
{
if (!(entity is IServerSerializable serverSerializable))
{
throw new InvalidCastException($"Entity is not {nameof(IServerSerializable)}");
}
entityEventManager.CreateEvent(serverSerializable, extraData);
}
private byte GetNewClientSessionId()
{
byte userId = 1;
while (connectedClients.Any(c => c.SessionId == userId))
{
userId++;
}
return userId;
}
private void ClientReadLobby(IReadMessage inc)
{
Client c = ConnectedClients.Find(x => x.Connection == inc.Sender);
if (c == null)
{
//TODO: remove?
//inc.Sender.Disconnect("You're not a connected client.");
return;
}
SegmentTableReader<ClientNetSegment>.Read(inc, (segment, inc) =>
{
switch (segment)
{
case ClientNetSegment.SyncIds:
//TODO: might want to use a clever class for this
c.LastRecvLobbyUpdate = NetIdUtils.Clamp(inc.ReadUInt16(), c.LastRecvLobbyUpdate, GameMain.NetLobbyScreen.LastUpdateID);
if (c.HasPermission(ClientPermissions.ManageSettings) &&
NetIdUtils.IdMoreRecentOrMatches(c.LastRecvLobbyUpdate, c.LastSentServerSettingsUpdate))
{
c.LastRecvServerSettingsUpdate = c.LastSentServerSettingsUpdate;
}
c.LastRecvChatMsgID = NetIdUtils.Clamp(inc.ReadUInt16(), c.LastRecvChatMsgID, c.LastChatMsgQueueID);
c.LastRecvClientListUpdate = NetIdUtils.Clamp(inc.ReadUInt16(), c.LastRecvClientListUpdate, LastClientListUpdateID);
c.AFK = inc.ReadBoolean();
ReadClientNameChange(c, inc);
c.LastRecvCampaignSave = inc.ReadUInt16();
if (c.LastRecvCampaignSave > 0)
{
byte campaignID = inc.ReadByte();
foreach (MultiPlayerCampaign.NetFlags netFlag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags)))
{
c.LastRecvCampaignUpdate[netFlag] = inc.ReadUInt16();
}
bool characterDiscarded = inc.ReadBoolean();
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign)
{
if (characterDiscarded) { campaign.DiscardClientCharacterData(c); }
//the client has a campaign save for another campaign
//(the server started a new campaign and the client isn't aware of it yet?)
if (campaign.CampaignID != campaignID)
{
c.LastRecvCampaignSave = (ushort)(campaign.LastSaveID - 1);
foreach (MultiPlayerCampaign.NetFlags netFlag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags)))
{
c.LastRecvCampaignUpdate[netFlag] =
(UInt16)(campaign.GetLastUpdateIdForFlag(netFlag) - 1);
}
}
}
}
break;
case ClientNetSegment.ChatMessage:
ChatMessage.ServerRead(inc, c);
break;
case ClientNetSegment.Vote:
Voting.ServerRead(inc, c, dosProtection);
break;
default:
return SegmentTableReader<ClientNetSegment>.BreakSegmentReading.Yes;
}
//don't read further messages if the client has been disconnected (kicked due to spam for example)
return connectedClients.Contains(c)
? SegmentTableReader<ClientNetSegment>.BreakSegmentReading.No
: SegmentTableReader<ClientNetSegment>.BreakSegmentReading.Yes;
});
}
private void ClientReadIngame(IReadMessage inc)
{
Client c = ConnectedClients.Find(x => x.Connection == inc.Sender);
if (c == null)
{
//TODO: remove?
//inc.SenderConnection.Disconnect("You're not a connected client.");
return;
}
bool midroundSyncingDone = inc.ReadBoolean();
inc.ReadPadBits();
if (GameStarted)
{
if (!c.InGame)
{
//check if midround syncing is needed due to missed unique events
if (!midroundSyncingDone) { entityEventManager.InitClientMidRoundSync(c); }
MissionAction.NotifyMissionsUnlockedThisRound(c);
UnlockPathAction.NotifyPathsUnlockedThisRound(c);
if (GameMain.GameSession.GameMode is PvPMode)
{
if (c.TeamID == CharacterTeamType.None)
{
AssignClientToPvpTeamMidgame(c);
}
}
else
{
if (GameMain.GameSession.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.SendCrewState();
}
//everyone's in team 1 in non-pvp game modes
c.TeamID = CharacterTeamType.Team1;
}
c.InGame = true;
c.AFK = false;
}
}
SegmentTableReader<ClientNetSegment>.Read(inc, (segment, inc) =>
{
switch (segment)
{
case ClientNetSegment.SyncIds:
//TODO: switch this to INetSerializableStruct
UInt16 lastRecvChatMsgID = inc.ReadUInt16();
UInt16 lastRecvEntityEventID = inc.ReadUInt16();
UInt16 lastRecvClientListUpdate = inc.ReadUInt16();
//last msgs we've created/sent, the client IDs should never be higher than these
UInt16 lastEntityEventID = entityEventManager.Events.Count == 0 ? (UInt16)0 : entityEventManager.Events.Last().ID;
c.LastRecvCampaignSave = inc.ReadUInt16();
if (c.LastRecvCampaignSave > 0)
{
byte campaignID = inc.ReadByte();
foreach (MultiPlayerCampaign.NetFlags netFlag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags)))
{
c.LastRecvCampaignUpdate[netFlag] = inc.ReadUInt16();
}
bool characterDiscarded = inc.ReadBoolean();
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign)
{
if (characterDiscarded) { campaign.DiscardClientCharacterData(c); }
//the client has a campaign save for another campaign
//(the server started a new campaign and the client isn't aware of it yet?)
if (campaign.CampaignID != campaignID)
{
c.LastRecvCampaignSave = (ushort)(campaign.LastSaveID - 1);
foreach (MultiPlayerCampaign.NetFlags netFlag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags)))
{
c.LastRecvCampaignUpdate[netFlag] =
(UInt16)(campaign.GetLastUpdateIdForFlag(netFlag) - 1);
}
}
}
}
if (c.NeedsMidRoundSync)
{
//received all the old events -> client in sync, we can switch to normal behavior
if (lastRecvEntityEventID >= c.UnreceivedEntityEventCount - 1 ||
c.UnreceivedEntityEventCount == 0)
{
ushort prevID = lastRecvEntityEventID;
c.NeedsMidRoundSync = false;
lastRecvEntityEventID = (UInt16)(c.FirstNewEventID - 1);
c.LastRecvEntityEventID = lastRecvEntityEventID;
DebugConsole.Log("Finished midround syncing " + c.Name + " - switching from ID " + prevID + " to " + c.LastRecvEntityEventID);
//notify the client of the state of the respawn manager (so they show the respawn prompt if needed)
if (RespawnManager != null) { CreateEntityEvent(RespawnManager); }
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign)
{
//notify the client of the current bank balance and purchased repairs
campaign.Bank.ForceUpdate();
campaign.IncrementLastUpdateIdForFlag(MultiPlayerCampaign.NetFlags.Misc);
}
}
else
{
lastEntityEventID = (UInt16)(c.UnreceivedEntityEventCount - 1);
}
}
if (NetIdUtils.IsValidId(lastRecvChatMsgID, c.LastRecvChatMsgID, c.LastChatMsgQueueID))
{
c.LastRecvChatMsgID = lastRecvChatMsgID;
}
else if (lastRecvChatMsgID != c.LastRecvChatMsgID && GameSettings.CurrentConfig.VerboseLogging)
{
DebugConsole.ThrowError(
"Invalid lastRecvChatMsgID " + lastRecvChatMsgID +
" (previous: " + c.LastChatMsgQueueID + ", latest: " + c.LastChatMsgQueueID + ")");
}
if (NetIdUtils.IsValidId(lastRecvEntityEventID, c.LastRecvEntityEventID, lastEntityEventID))
{
if (c.NeedsMidRoundSync)
{
//give midround-joining clients a bit more time to get in sync if they keep receiving messages
int receivedEventCount = lastRecvEntityEventID - c.LastRecvEntityEventID;
if (receivedEventCount < 0) { receivedEventCount += ushort.MaxValue; }
c.MidRoundSyncTimeOut += receivedEventCount * 0.01f;
DebugConsole.Log("Midround sync timeout " + c.MidRoundSyncTimeOut.ToString("0.##") + "/" + Timing.TotalTime.ToString("0.##"));
}
c.LastRecvEntityEventID = lastRecvEntityEventID;
}
else if (lastRecvEntityEventID != c.LastRecvEntityEventID && GameSettings.CurrentConfig.VerboseLogging)
{
DebugConsole.ThrowError(
"Invalid lastRecvEntityEventID " + lastRecvEntityEventID +
" (previous: " + c.LastRecvEntityEventID + ", latest: " + lastEntityEventID + ")");
}
if (NetIdUtils.IdMoreRecent(lastRecvClientListUpdate, c.LastRecvClientListUpdate))
{
c.LastRecvClientListUpdate = lastRecvClientListUpdate;
}
break;
case ClientNetSegment.ChatMessage:
ChatMessage.ServerRead(inc, c);
break;
case ClientNetSegment.CharacterInput:
if (c.Character != null)
{
c.Character.ServerReadInput(inc, c);
}
else
{
DebugConsole.AddWarning($"Received character inputs from a client who's not controlling a character ({c.Name}).");
}
break;
case ClientNetSegment.EntityState:
entityEventManager.Read(inc, c);
break;
case ClientNetSegment.Vote:
Voting.ServerRead(inc, c, dosProtection);
break;
case ClientNetSegment.SpectatingPos:
c.SpectatePos = new Vector2(inc.ReadSingle(), inc.ReadSingle());
break;
default:
return SegmentTableReader<ClientNetSegment>.BreakSegmentReading.Yes;
}
//don't read further messages if the client has been disconnected (kicked due to spam for example)
return connectedClients.Contains(c)
? SegmentTableReader<ClientNetSegment>.BreakSegmentReading.No
: SegmentTableReader<ClientNetSegment>.BreakSegmentReading.Yes;
});
}
private void ReadCrewMessage(IReadMessage inc, Client sender)
{
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.ServerReadCrew(inc, sender);
}
}
private void ReadMoneyMessage(IReadMessage inc, Client sender)
{
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.ServerReadMoney(inc, sender);
}
}
private void ReadRewardDistributionMessage(IReadMessage inc, Client sender)
{
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.ServerReadRewardDistribution(inc, sender);
}
}
private void ResetRewardDistribution(Client client)
{
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.ResetSalaries(client);
}
}
private void ReadMedicalMessage(IReadMessage inc, Client sender)
{
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
mpCampaign.MedicalClinic.ServerRead(inc, sender);
}
}
private static void ReadCircuitBoxMessage(IReadMessage inc, Client sender)
{
var header = INetSerializableStruct.Read<NetCircuitBoxHeader>(inc);
INetSerializableStruct data = header.Opcode switch
{
CircuitBoxOpcode.Cursor => INetSerializableStruct.Read<NetCircuitBoxCursorInfo>(inc),
_ => throw new ArgumentOutOfRangeException(nameof(header.Opcode), header.Opcode, "This data cannot be handled using direct network messages.")
};
if (header.FindTarget().TryUnwrap(out var box))
{
box.ServerRead(data, sender);
}
}
private void ReadReadyToSpawnMessage(IReadMessage inc, Client sender)
{
sender.SpectateOnly = inc.ReadBoolean() && (ServerSettings.AllowSpectating || sender.Connection == OwnerConnection);
sender.WaitForNextRoundRespawn = inc.ReadBoolean();
if (!(GameMain.GameSession?.GameMode is CampaignMode))
{
sender.WaitForNextRoundRespawn = null;
}
}
private void ReadTakeOverBotMessage(IReadMessage inc, Client sender)
{
UInt16 botId = inc.ReadUInt16();
if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign campaign) { return; }
if (ServerSettings.IronmanModeActive)
{
DebugConsole.ThrowError($"Client {sender.Name} has requested to take over a bot in Ironman mode!");
return;
}
if (campaign.CurrentLocation.GetHireableCharacters().FirstOrDefault(c => c.ID == botId) is CharacterInfo hireableCharacter)
{
if (ServerSettings.ReplaceCostPercentage <= 0 ||
CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMoney) ||
CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageHires))
{
if (campaign.TryHireCharacter(campaign.CurrentLocation, hireableCharacter, takeMoney: true, sender, buyingNewCharacter: true))
{
campaign.CurrentLocation.RemoveHireableCharacter(hireableCharacter);
SpawnAndTakeOverBot(campaign, hireableCharacter, sender);
campaign.SendCrewState(createNotification: false);
}
else
{
SendConsoleMessage($"Could not hire the bot {hireableCharacter.Name}.", sender, Color.Red);
DebugConsole.ThrowError($"Client {sender.Name} failed to hire the bot {hireableCharacter.Name}.");
}
}
else
{
SendConsoleMessage($"Could not hire the bot {hireableCharacter.Name}. No permission to manage money or hires.", sender, Color.Red);
DebugConsole.ThrowError($"Client {sender.Name} failed to hire the bot {hireableCharacter.Name}. No permission to manage money or hires.");
}
}
else
{
CharacterInfo botInfo = GameMain.GameSession.CrewManager?.GetCharacterInfos(includeReserveBench: true)?.FirstOrDefault(i => i.ID == botId);
if (botInfo is { Character: null } && (botInfo.IsNewHire || botInfo.IsOnReserveBench))
{
if (IsUsingRespawnShuttle())
{
SpawnAndTakeOverBotInShuttle(campaign, botInfo, sender);
}
else
{
SpawnAndTakeOverBot(campaign, botInfo, sender);
}
}
else if (botInfo?.Character == null || !botInfo.Character.IsBot)
{
SendConsoleMessage($"Could not find a bot with the id {botId}.", sender, Color.Red);
DebugConsole.ThrowError($"Client {sender.Name} failed to take over a bot (Could not find a bot with the id {botId}).");
}
else if (ServerSettings.AllowBotTakeoverOnPermadeath)
{
sender.TryTakeOverBot(botInfo.Character);
}
else
{
SendConsoleMessage($"Failed to take over a bot (taking control of bots is disallowed).", sender, Color.Red);
DebugConsole.ThrowError($"Client {sender.Name} failed to take over a bot (taking control of bots is disallowed).");
}
}
}
private static void SpawnAndTakeOverBot(CampaignMode campaign, CharacterInfo botInfo, Client client)
{
WayPoint mainSubSpawnpoint = WayPoint.SelectCrewSpawnPoints(botInfo.ToEnumerable().ToList(), Submarine.MainSub).FirstOrDefault();
WayPoint outpostWaypoint = campaign.CrewManager.GetOutpostSpawnpoints()?.FirstOrDefault();
WayPoint spawnWaypoint;
//give the bot the same salary the player had
TransferPreviousSalaryToBot(campaign, botInfo, client);
if (botInfo.IsOnReserveBench)
{
spawnWaypoint = mainSubSpawnpoint ?? outpostWaypoint;
}
else
{
spawnWaypoint = outpostWaypoint ?? mainSubSpawnpoint;
}
if (spawnWaypoint == null)
{
DebugConsole.ThrowError("SpawnAndTakeOverBot: Unable to find any spawn waypoints inside the sub");
return;
}
Entity.Spawner.AddCharacterToSpawnQueue(botInfo.SpeciesName, spawnWaypoint.WorldPosition, botInfo, onSpawn: newCharacter =>
{
if (newCharacter == null)
{
DebugConsole.ThrowError("SpawnAndTakeOverBot: newCharacter is null somehow");
return;
}
if (botInfo.IsOnReserveBench)
{
campaign.CrewManager.ToggleReserveBenchStatus(botInfo, client);
}
newCharacter.TeamID = CharacterTeamType.Team1;
campaign.CrewManager.InitializeCharacter(newCharacter, mainSubSpawnpoint, spawnWaypoint);
client.TryTakeOverBot(newCharacter);
Log($"Client \"{client.Name}\" took over the bot \"{botInfo.DisplayName}\".", ServerLog.MessageType.ServerMessage);
});
}
private static void SpawnAndTakeOverBotInShuttle(CampaignMode campaign, CharacterInfo botInfo, Client client)
{
if (botInfo.IsOnReserveBench && campaign is MultiPlayerCampaign mpCampaign)
{
//give the bot the same salary the player had
TransferPreviousSalaryToBot(campaign, botInfo, client);
// Bring the bot from the reserve bench to active service
mpCampaign.CrewManager.ToggleReserveBenchStatus(botInfo, client);
Debug.Assert(botInfo.BotStatus == BotStatus.ActiveService);
Log($"Client \"{client.Name}\" chose to spawn as the bot \"{botInfo.DisplayName}\" in the next respawn shuttle.", ServerLog.MessageType.ServerMessage);
// Note: The following does what ServerSource/Networking/Client.cs:TryTakeOverBot() would do, but here we have
// to do it without a Character (before the Character has spawned), to get them on the respawn shuttle
// Now that the old permanently killed character will be replaced, we can fully discard it
mpCampaign.DiscardClientCharacterData(client);
client.CharacterInfo = botInfo;
client.CharacterInfo.RenamingEnabled = true; // Grant one opportunity to rename a taken over bot
client.CharacterInfo.IsNewHire = false;
client.SpectateOnly = false;
client.WaitForNextRoundRespawn = false; // =respawn asap
// Generate a new, less dead CharacterCampaignData for the client
if (mpCampaign.SetClientCharacterData(client) is CharacterCampaignData characterData)
{
//the bot has spawned, but the new CharacterCampaignData technically hasn't, because we just created it
characterData.HasSpawned = true;
characterData.ChosenNewBotViaShuttle = true;
}
}
}
private static void TransferPreviousSalaryToBot(CampaignMode campaign, CharacterInfo botInfo, Client client)
{
//give the bot the same salary the player had
botInfo.LastRewardDistribution = Option<int>.Some(client?.Character?.Wallet.RewardDistribution ?? campaign.Bank.RewardDistribution);
}
private void ClientReadServerCommand(IReadMessage inc)
{
Client sender = ConnectedClients.Find(x => x.Connection == inc.Sender);
if (sender == null)
{
//TODO: remove?
//inc.SenderConnection.Disconnect("You're not a connected client.");
return;
}
ClientPermissions command = ClientPermissions.None;
try
{
command = (ClientPermissions)inc.ReadUInt16();
}
catch
{
return;
}
var mpCampaign = GameMain.GameSession?.GameMode as MultiPlayerCampaign;
if (command == ClientPermissions.ManageRound && mpCampaign != null)
{
//do nothing, ending campaign rounds is checked in more detail below
}
else if (command == ClientPermissions.ManageCampaign && mpCampaign != null)
{
//do nothing, campaign permissions are checked in more detail in MultiplayerCampaign.ServerRead
}
else if (!sender.HasPermission(command))
{
Log("Client \"" + GameServer.ClientLogName(sender) + "\" sent a server command \"" + command + "\". Permission denied.", ServerLog.MessageType.ServerMessage);
return;
}
switch (command)
{
case ClientPermissions.Kick:
string kickedName = inc.ReadString().ToLowerInvariant();
string kickReason = inc.ReadString();
var kickedClient = connectedClients.Find(cl => cl != sender && cl.Name.Equals(kickedName, StringComparison.OrdinalIgnoreCase) && cl.Connection != OwnerConnection);
if (kickedClient != null)
{
Log("Client \"" + GameServer.ClientLogName(sender) + "\" kicked \"" + GameServer.ClientLogName(kickedClient) + "\".", ServerLog.MessageType.ServerMessage);
KickClient(kickedClient, string.IsNullOrEmpty(kickReason) ? $"ServerMessage.KickedBy~[initiator]={sender.Name}" : kickReason);
}
else
{
SendDirectChatMessage(TextManager.GetServerMessage($"ServerMessage.PlayerNotFound~[player]={kickedName}").Value, sender, ChatMessageType.Console);
}
break;
case ClientPermissions.Ban:
string bannedName = inc.ReadString().ToLowerInvariant();
string banReason = inc.ReadString();
double durationSeconds = inc.ReadDouble();
TimeSpan? banDuration = null;
if (durationSeconds > 0) { banDuration = TimeSpan.FromSeconds(durationSeconds); }
var bannedClient = connectedClients.Find(cl => cl != sender && cl.Name.Equals(bannedName, StringComparison.OrdinalIgnoreCase) && cl.Connection != OwnerConnection);
if (bannedClient != null)
{
Log("Client \"" + ClientLogName(sender) + "\" banned \"" + ClientLogName(bannedClient) + "\".", ServerLog.MessageType.ServerMessage);
BanClient(bannedClient, string.IsNullOrEmpty(banReason) ? $"ServerMessage.BannedBy~[initiator]={sender.Name}" : banReason, banDuration);
}
else
{
var bannedPreviousClient = previousPlayers.Find(p => p.Name.Equals(bannedName, StringComparison.OrdinalIgnoreCase));
if (bannedPreviousClient != null)
{
Log("Client \"" + ClientLogName(sender) + "\" banned \"" + bannedPreviousClient.Name + "\".", ServerLog.MessageType.ServerMessage);
BanPreviousPlayer(bannedPreviousClient, string.IsNullOrEmpty(banReason) ? $"ServerMessage.BannedBy~[initiator]={sender.Name}" : banReason, banDuration);
}
else
{
SendDirectChatMessage(TextManager.GetServerMessage($"ServerMessage.PlayerNotFound~[player]={bannedName}").Value, sender, ChatMessageType.Console);
}
}
break;
case ClientPermissions.Unban:
bool isPlayerName = inc.ReadBoolean(); inc.ReadPadBits();
string str = inc.ReadString();
if (isPlayerName)
{
UnbanPlayer(playerName: str);
}
else if (Endpoint.Parse(str).TryUnwrap(out var endpoint))
{
UnbanPlayer(endpoint);
}
break;
case ClientPermissions.ManageRound:
bool end = inc.ReadBoolean();
if (end)
{
if (mpCampaign == null ||
CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageRound))
{
bool save = inc.ReadBoolean();
bool quitCampaign = inc.ReadBoolean();
if (GameStarted)
{
using (dosProtection.Pause(sender))
{
Log($"Client \"{ClientLogName(sender)}\" ended the round.", ServerLog.MessageType.ServerMessage);
if (mpCampaign != null && Level.IsLoadedFriendlyOutpost && save)
{
mpCampaign.SavePlayers();
mpCampaign.HandleSaveAndQuit();
GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine);
SaveUtil.SaveGame(GameMain.GameSession.DataPath);
}
else
{
save = false;
}
EndGame(wasSaved: save);
}
}
else if (mpCampaign != null)
{
Log($"Client \"{ClientLogName(sender)}\" quit the currently active campaign.", ServerLog.MessageType.ServerMessage);
GameMain.GameSession = null;
GameMain.NetLobbyScreen.SelectedModeIdentifier = GameModePreset.Sandbox.Identifier;
GameMain.NetLobbyScreen.LastUpdateID++;
}
}
}
else
{
//client presumably isn't afk if they clicked to start a round
sender.AFK = false;
bool continueCampaign = inc.ReadBoolean();
if (mpCampaign != null && mpCampaign.GameOver || continueCampaign)
{
if (GameStarted)
{
SendDirectChatMessage("Cannot continue the campaign from the previous save (round already running).", sender, ChatMessageType.Error);
break;
}
else if (CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageCampaign) || CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMap))
{
using (dosProtection.Pause(sender))
{
MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.DataPath, sender);
}
}
}
else if (!GameStarted && !initiatedStartGame)
{
using (dosProtection.Pause(sender))
{
Log("Client \"" + ClientLogName(sender) + "\" started the round.", ServerLog.MessageType.ServerMessage);
var result = TryStartGame();
if (result != TryStartGameResult.Success)
{
SendDirectChatMessage(TextManager.Get($"TryStartGameError.{result}").Value, sender, ChatMessageType.Error);
}
}
}
else if (mpCampaign != null && (CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageCampaign) || CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMap)))
{
using (dosProtection.Pause(sender))
{
var availableTransition = mpCampaign.GetAvailableTransition(out _, out _);
//don't force location if we've teleported
bool forceLocation = !mpCampaign.Map.AllowDebugTeleport || mpCampaign.Map.CurrentLocation == Level.Loaded.StartLocation;
switch (availableTransition)
{
case CampaignMode.TransitionType.ReturnToPreviousEmptyLocation:
if (forceLocation)
{
mpCampaign.Map.SelectLocation(
mpCampaign.Map.CurrentLocation.Connections.Find(c => c.LevelData == Level.Loaded?.LevelData).OtherLocation(mpCampaign.Map.CurrentLocation));
}
mpCampaign.LoadNewLevel();
break;
case CampaignMode.TransitionType.ProgressToNextEmptyLocation:
if (forceLocation)
{
mpCampaign.Map.SetLocation(mpCampaign.Map.Locations.IndexOf(Level.Loaded.EndLocation));
}
mpCampaign.LoadNewLevel();
break;
case CampaignMode.TransitionType.None:
#if DEBUG || UNSTABLE
DebugConsole.ThrowError($"Client \"{sender.Name}\" attempted to trigger a level transition. No transitions available.");
#endif
break;
default:
Log("Client \"" + ClientLogName(sender) + "\" ended the round.", ServerLog.MessageType.ServerMessage);
mpCampaign.LoadNewLevel();
break;
}
}
}
}
break;
case ClientPermissions.SelectSub:
SelectedSubType subType = (SelectedSubType)inc.ReadByte();
string subHash = inc.ReadString();
var subList = GameMain.NetLobbyScreen.GetSubList();
var sub = subList.FirstOrDefault(s => s.MD5Hash.StringRepresentation == subHash);
if (sub == null)
{
DebugConsole.NewMessage($"Client \"{ClientLogName(sender)}\" attempted to select a sub, could not find a sub with the MD5 hash \"{subHash}\".", Color.Red);
}
else
{
switch (subType)
{
case SelectedSubType.Shuttle:
GameMain.NetLobbyScreen.SelectedShuttle = sub;
break;
case SelectedSubType.Sub:
GameMain.NetLobbyScreen.SelectedSub = sub;
break;
case SelectedSubType.EnemySub:
GameMain.NetLobbyScreen.SelectedEnemySub = sub;
break;
}
}
break;
case ClientPermissions.SelectMode:
UInt16 modeIndex = inc.ReadUInt16();
GameMain.NetLobbyScreen.SelectedModeIndex = modeIndex;
Log("Gamemode changed to " + (GameMain.NetLobbyScreen.SelectedMode?.Name.Value ?? "none"), ServerLog.MessageType.ServerMessage);
if (GameMain.NetLobbyScreen.GameModes[modeIndex] == GameModePreset.MultiPlayerCampaign)
{
TrySendCampaignSetupInfo(sender);
}
break;
case ClientPermissions.ManageCampaign:
mpCampaign?.ServerRead(inc, sender);
break;
case ClientPermissions.ConsoleCommands:
DebugConsole.ServerRead(inc, sender);
break;
case ClientPermissions.ManagePermissions:
byte targetClientID = inc.ReadByte();
Client targetClient = connectedClients.Find(c => c.SessionId == targetClientID);
if (targetClient == null || targetClient == sender || targetClient.Connection == OwnerConnection) { return; }
targetClient.ReadPermissions(inc);
List<string> permissionNames = new List<string>();
foreach (ClientPermissions permission in Enum.GetValues(typeof(ClientPermissions)))
{
if (permission == ClientPermissions.None || permission == ClientPermissions.All)
{
continue;
}
if (targetClient.Permissions.HasFlag(permission)) { permissionNames.Add(permission.ToString()); }
}
string logMsg;
if (permissionNames.Any())
{
logMsg = "Client \"" + GameServer.ClientLogName(sender) + "\" set the permissions of the client \"" + GameServer.ClientLogName(targetClient) + "\" to "
+ string.Join(", ", permissionNames);
}
else
{
logMsg = "Client \"" + GameServer.ClientLogName(sender) + "\" removed all permissions from the client \"" + GameServer.ClientLogName(targetClient) + ".";
}
Log(logMsg, ServerLog.MessageType.ServerMessage);
UpdateClientPermissions(targetClient);
break;
}
inc.ReadPadBits();
}
private void ClientWrite(Client c)
{
if (GameStarted && c.InGame)
{
ClientWriteIngame(c);
}
else
{
//if 30 seconds have passed since the round started and the client isn't ingame yet,
//consider the client's character disconnected (causing it to die if the client does not join soon)
if (GameStarted && c.Character != null && (DateTime.Now - roundStartTime).Seconds > 30.0f)
{
c.Character.ClientDisconnected = true;
}
ClientWriteLobby(c);
}
if (c.Connection == OwnerConnection)
{
while (pendingMessagesToOwner.Any())
{
SendDirectChatMessage(pendingMessagesToOwner.Dequeue(), c);
}
}
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign &&
GameMain.NetLobbyScreen.SelectedMode == campaign.Preset &&
NetIdUtils.IdMoreRecent(campaign.LastSaveID, c.LastRecvCampaignSave))
{
//already sent an up-to-date campaign save
if (c.LastCampaignSaveSendTime != default && campaign.LastSaveID == c.LastCampaignSaveSendTime.saveId)
{
//the save was sent less than 5 second ago, don't attempt to resend yet
//(the client may have received it but hasn't acked us yet)
if (c.LastCampaignSaveSendTime.time > NetTime.Now - 5.0f)
{
return;
}
}
//don't send the campaign save if there's any other transfers running (client waiting for subs, mods, or already transferring the campaign save)
if (FileSender.ActiveTransfers.None(t => t.Connection == c.Connection))
{
FileSender.StartTransfer(c.Connection, FileTransferType.CampaignSave, GameMain.GameSession.DataPath.SavePath);
c.LastCampaignSaveSendTime = (campaign.LastSaveID, (float)NetTime.Now);
}
}
}
/// <summary>
/// Write info that the client needs when joining the server
/// </summary>
private void ClientWriteInitial(Client c, IWriteMessage outmsg)
{
if (GameSettings.CurrentConfig.VerboseLogging)
{
DebugConsole.NewMessage($"Sending initial lobby update to {c.Name}", Color.Gray);
}
outmsg.WriteByte(c.SessionId);
var subList = GameMain.NetLobbyScreen.GetSubList();
outmsg.WriteUInt16((UInt16)subList.Count);
for (int i = 0; i < subList.Count; i++)
{
var sub = subList[i];
outmsg.WriteString(sub.Name);
outmsg.WriteString(sub.MD5Hash.ToString());
outmsg.WriteByte((byte)sub.SubmarineClass);
outmsg.WriteBoolean(sub.HasTag(SubmarineTag.Shuttle));
outmsg.WriteBoolean(sub.RequiredContentPackagesInstalled);
}
outmsg.WriteBoolean(GameStarted);
outmsg.WriteBoolean(ServerSettings.AllowSpectating);
outmsg.WriteBoolean(ServerSettings.AllowAFK);
outmsg.WriteBoolean(ServerSettings.RespawnMode == RespawnMode.Permadeath);
outmsg.WriteBoolean(ServerSettings.IronmanMode);
c.WritePermissions(outmsg);
}
private void ClientWriteIngame(Client c)
{
//don't send position updates to characters who are still midround syncing
//characters or items spawned mid-round don't necessarily exist at the client's end yet
if (!c.NeedsMidRoundSync)
{
Character clientCharacter = c.Character;
foreach (Character otherCharacter in Character.CharacterList)
{
if (!otherCharacter.Enabled) { continue; }
if (c.SpectatePos == null)
{
//not spectating ->
// check if the client's character, or the entity they're viewing,
// is close enough to the other character or the entity the other character is viewing
float distSqr = GetShortestDistance(clientCharacter.WorldPosition, otherCharacter);
if (clientCharacter.ViewTarget != null && clientCharacter.ViewTarget != clientCharacter)
{
distSqr = Math.Min(distSqr, GetShortestDistance(clientCharacter.ViewTarget.WorldPosition, otherCharacter));
}
if (distSqr >= MathUtils.Pow2(otherCharacter.Params.DisableDistance)) { continue; }
}
else if (otherCharacter != clientCharacter)
{
//spectating ->
// check if the position the client is viewing
// is close enough to the other character or the entity the other character is viewing
if (GetShortestDistance(c.SpectatePos.Value, otherCharacter) >= MathUtils.Pow2(otherCharacter.Params.DisableDistance)) { continue; }
}
static float GetShortestDistance(Vector2 viewPos, Character targetCharacter)
{
float distSqr = Vector2.DistanceSquared(viewPos, targetCharacter.WorldPosition);
if (targetCharacter.ViewTarget != null && targetCharacter.ViewTarget != targetCharacter)
{
//if the character is viewing something (far-away turret?),
//we might want to send updates about it to the spectating client even though they're far away from the actual character
distSqr = Math.Min(distSqr, Vector2.DistanceSquared(viewPos, targetCharacter.ViewTarget.WorldPosition));
}
return distSqr;
}
float updateInterval = otherCharacter.GetPositionUpdateInterval(c);
c.PositionUpdateLastSent.TryGetValue(otherCharacter, out float lastSent);
if (lastSent > NetTime.Now)
{
//sent in the future -> can't be right, remove
c.PositionUpdateLastSent.Remove(otherCharacter);
}
else
{
if (lastSent > NetTime.Now - updateInterval) { continue; }
}
c.TryEnqueuePositionUpdate(otherCharacter);
}
foreach (Submarine sub in Submarine.Loaded)
{
//if docked to a sub with a smaller ID, don't send an update
// (= update is only sent for the docked sub that has the smallest ID, doesn't matter if it's the main sub or a shuttle)
if (sub.Info.IsOutpost || sub.DockedTo.Any(s => s.ID < sub.ID)) { continue; }
if (sub.PhysicsBody == null || sub.PhysicsBody.BodyType == FarseerPhysics.BodyType.Static) { continue; }
c.TryEnqueuePositionUpdate(sub);
}
foreach (Item item in Item.ItemList)
{
if (item.PositionUpdateInterval == float.PositiveInfinity) { continue; }
float updateInterval = item.GetPositionUpdateInterval(c);
c.PositionUpdateLastSent.TryGetValue(item, out float lastSent);
if (lastSent > NetTime.Now)
{
//sent in the future -> can't be right, remove
c.PositionUpdateLastSent.Remove(item);
}
else
{
if (lastSent > NetTime.Now - updateInterval) { continue; }
}
c.TryEnqueuePositionUpdate(item);
}
}
IWriteMessage outmsg = new WriteOnlyMessage();
outmsg.WriteByte((byte)ServerPacketHeader.UPDATE_INGAME);
outmsg.WriteSingle((float)NetTime.Now);
outmsg.WriteSingle(EndRoundTimeRemaining);
using (var segmentTable = SegmentTableWriter<ServerNetSegment>.StartWriting(outmsg))
{
segmentTable.StartNewSegment(ServerNetSegment.SyncIds);
outmsg.WriteUInt16(c.LastSentChatMsgID); //send this to client so they know which chat messages weren't received by the server
outmsg.WriteUInt16(c.LastSentEntityEventID);
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign && campaign.Preset == GameMain.NetLobbyScreen.SelectedMode)
{
outmsg.WriteBoolean(true);
outmsg.WritePadBits();
campaign.ServerWrite(outmsg, c);
}
else
{
outmsg.WriteBoolean(false);
outmsg.WritePadBits();
}
int clientListBytes = outmsg.LengthBytes;
WriteClientList(segmentTable, c, outmsg);
clientListBytes = outmsg.LengthBytes - clientListBytes;
int chatMessageBytes = outmsg.LengthBytes;
WriteChatMessages(segmentTable, outmsg, c);
chatMessageBytes = outmsg.LengthBytes - chatMessageBytes;
//write as many position updates as the message can fit (only after midround syncing is done)
int positionUpdateBytes = outmsg.LengthBytes;
while (!c.NeedsMidRoundSync && c.PendingPositionUpdates.Count > 0)
{
var entity = c.PendingPositionUpdates.Peek();
if (entity is not IServerPositionSync entityPositionSync ||
entity.Removed ||
(entity is Item item && float.IsInfinity(item.PositionUpdateInterval)))
{
c.DequeuePositionUpdate();
continue;
}
var tempBuffer = new ReadWriteMessage();
var entityPositionHeader = EntityPositionHeader.FromEntity(entity);
tempBuffer.WriteNetSerializableStruct(entityPositionHeader);
entityPositionSync.ServerWritePosition(tempBuffer, c);
//no more room in this packet
if (outmsg.LengthBytes + tempBuffer.LengthBytes > MsgConstants.MTU - 100)
{
break;
}
segmentTable.StartNewSegment(ServerNetSegment.EntityPosition);
outmsg.WritePadBits(); //padding is required here to make sure any padding bits within tempBuffer are read correctly
outmsg.WriteVariableUInt32((uint)tempBuffer.LengthBytes);
outmsg.WriteBytes(tempBuffer.Buffer, 0, tempBuffer.LengthBytes);
outmsg.WritePadBits();
c.PositionUpdateLastSent[entity] = (float)NetTime.Now;
c.DequeuePositionUpdate();
}
positionUpdateBytes = outmsg.LengthBytes - positionUpdateBytes;
if (outmsg.LengthBytes > MsgConstants.MTU)
{
string errorMsg = "Maximum packet size exceeded (" + outmsg.LengthBytes + " > " + MsgConstants.MTU + ")\n";
errorMsg +=
" Client list size: " + clientListBytes + " bytes\n" +
" Chat message size: " + chatMessageBytes + " bytes\n" +
" Position update size: " + positionUpdateBytes + " bytes\n\n";
DebugConsole.ThrowError(errorMsg);
GameAnalyticsManager.AddErrorEventOnce("GameServer.ClientWriteIngame1:PacketSizeExceeded" + outmsg.LengthBytes, GameAnalyticsManager.ErrorSeverity.Error, errorMsg);
}
}
serverPeer.Send(outmsg, c.Connection, DeliveryMethod.Unreliable);
//---------------------------------------------------------------------------
for (int i = 0; i < NetConfig.MaxEventPacketsPerUpdate; i++)
{
outmsg = new WriteOnlyMessage();
outmsg.WriteByte((byte)ServerPacketHeader.UPDATE_INGAME);
outmsg.WriteSingle((float)NetTime.Now);
outmsg.WriteSingle(EndRoundTimeRemaining);
using (var segmentTable = SegmentTableWriter<ServerNetSegment>.StartWriting(outmsg))
{
int eventManagerBytes = outmsg.LengthBytes;
entityEventManager.Write(segmentTable, c, outmsg, out List<NetEntityEvent> sentEvents);
eventManagerBytes = outmsg.LengthBytes - eventManagerBytes;
if (sentEvents.Count == 0)
{
break;
}
if (outmsg.LengthBytes > MsgConstants.MTU)
{
string errorMsg = "Maximum packet size exceeded (" + outmsg.LengthBytes + " > " +
MsgConstants.MTU + ")\n";
errorMsg +=
" Event size: " + eventManagerBytes + " bytes\n";
if (sentEvents != null && sentEvents.Count > 0)
{
errorMsg += "Sent events: \n";
foreach (var entityEvent in sentEvents)
{
errorMsg += " - " + (entityEvent.Entity?.ToString() ?? "null") + "\n";
}
}
DebugConsole.ThrowError(errorMsg);
GameAnalyticsManager.AddErrorEventOnce(
"GameServer.ClientWriteIngame2:PacketSizeExceeded" + outmsg.LengthBytes,
GameAnalyticsManager.ErrorSeverity.Error, errorMsg);
}
}
serverPeer.Send(outmsg, c.Connection, DeliveryMethod.Unreliable);
}
}
private void WriteClientList(in SegmentTableWriter<ServerNetSegment> segmentTable, Client c, IWriteMessage outmsg)
{
bool hasChanged = NetIdUtils.IdMoreRecent(LastClientListUpdateID, c.LastRecvClientListUpdate);
if (!hasChanged) { return; }
segmentTable.StartNewSegment(ServerNetSegment.ClientList);
outmsg.WriteUInt16(LastClientListUpdateID);
GameMain.LuaCs.Hook.Call("writeClientList", c, outmsg);
outmsg.WriteByte((byte)Team1Count);
outmsg.WriteByte((byte)Team2Count);
outmsg.WriteByte((byte)connectedClients.Count);
foreach (Client client in connectedClients)
{
var tempClientData = new TempClient
{
SessionId = client.SessionId,
AccountInfo = client.AccountInfo,
NameId = client.NameId,
Name = client.Name,
PreferredJob = client.Character?.Info?.Job != null && GameStarted
? client.Character.Info.Job.Prefab.Identifier
: client.PreferredJob,
PreferredTeam = client.PreferredTeam,
TeamID = client.TeamID,
CharacterId = client.Character == null || !GameStarted ? (ushort)0 : client.Character.ID,
Karma = c.HasPermission(ClientPermissions.ServerLog) ? client.Karma : 100.0f,
Muted = client.Muted,
InGame = client.InGame,
HasPermissions = client.Permissions != ClientPermissions.None,
IsOwner = client.Connection == OwnerConnection,
IsDownloading = FileSender.ActiveTransfers.Any(t => t.Connection == client.Connection)
};
var result = GameMain.LuaCs.Hook.Call<TempClient?>("writeClientList.modifyTempClientData", c, client, tempClientData, outmsg);
if (result != null)
{
tempClientData = result.Value;
}
outmsg.WriteNetSerializableStruct(tempClientData);
outmsg.WritePadBits();
}
}
public void ClientWriteLobby(Client c)
{
bool isInitialUpdate = false;
IWriteMessage outmsg = new WriteOnlyMessage();
outmsg.WriteByte((byte)ServerPacketHeader.UPDATE_LOBBY);
bool messageTooLarge;
using (var segmentTable = SegmentTableWriter<ServerNetSegment>.StartWriting(outmsg))
{
segmentTable.StartNewSegment(ServerNetSegment.SyncIds);
int settingsBytes = outmsg.LengthBytes;
int initialUpdateBytes = 0;
if (ServerSettings.UnsentFlags() != ServerSettings.NetFlags.None)
{
GameMain.NetLobbyScreen.LastUpdateID++;
}
IWriteMessage settingsBuf = null;
if (NetIdUtils.IdMoreRecent(GameMain.NetLobbyScreen.LastUpdateID, c.LastRecvLobbyUpdate))
{
outmsg.WriteBoolean(true);
outmsg.WritePadBits();
outmsg.WriteUInt16(GameMain.NetLobbyScreen.LastUpdateID);
settingsBuf = new ReadWriteMessage();
ServerSettings.ServerWrite(settingsBuf, c);
outmsg.WriteUInt16((UInt16)settingsBuf.LengthBytes);
outmsg.WriteBytes(settingsBuf.Buffer, 0, settingsBuf.LengthBytes);
outmsg.WriteBoolean(!c.InitialLobbyUpdateSent);
if (!c.InitialLobbyUpdateSent)
{
isInitialUpdate = true;
initialUpdateBytes = outmsg.LengthBytes;
ClientWriteInitial(c, outmsg);
c.InitialLobbyUpdateSent = true;
initialUpdateBytes = outmsg.LengthBytes - initialUpdateBytes;
}
outmsg.WriteString(GameMain.NetLobbyScreen.SelectedSub.Name);
outmsg.WriteString(GameMain.NetLobbyScreen.SelectedSub.MD5Hash.ToString());
if (GameMain.NetLobbyScreen.SelectedEnemySub is { } enemySub)
{
outmsg.WriteBoolean(true);
outmsg.WriteString(enemySub.Name);
outmsg.WriteString(enemySub.MD5Hash.ToString());
}
else
{
outmsg.WriteBoolean(false);
}
outmsg.WriteBoolean(IsUsingRespawnShuttle());
var selectedShuttle = GameStarted && RespawnManager != null && RespawnManager.UsingShuttle ?
RespawnManager.RespawnShuttles.First().Info :
GameMain.NetLobbyScreen.SelectedShuttle;
outmsg.WriteString(selectedShuttle.Name);
outmsg.WriteString(selectedShuttle.MD5Hash.ToString());
outmsg.WriteBoolean(ServerSettings.AllowSubVoting);
outmsg.WriteBoolean(ServerSettings.AllowModeVoting);
outmsg.WriteBoolean(ServerSettings.VoiceChatEnabled);
outmsg.WriteBoolean(ServerSettings.AllowSpectating);
outmsg.WriteBoolean(ServerSettings.AllowAFK);
outmsg.WriteSingle(ServerSettings.TraitorProbability);
outmsg.WriteRangedInteger(ServerSettings.TraitorDangerLevel, TraitorEventPrefab.MinDangerLevel, TraitorEventPrefab.MaxDangerLevel);
outmsg.WriteVariableUInt32((uint)GameMain.NetLobbyScreen.MissionTypes.Count());
foreach (var missionType in GameMain.NetLobbyScreen.MissionTypes)
{
outmsg.WriteIdentifier(missionType);
}
outmsg.WriteByte((byte)GameMain.NetLobbyScreen.SelectedModeIndex);
outmsg.WriteString(GameMain.NetLobbyScreen.LevelSeed);
outmsg.WriteSingle(ServerSettings.SelectedLevelDifficulty);
outmsg.WriteByte((byte)ServerSettings.BotCount);
outmsg.WriteBoolean(ServerSettings.BotSpawnMode == BotSpawnMode.Fill);
outmsg.WriteBoolean(ServerSettings.AutoRestart);
if (ServerSettings.AutoRestart)
{
outmsg.WriteSingle(autoRestartTimerRunning ? ServerSettings.AutoRestartTimer : 0.0f);
}
if (GameMain.NetLobbyScreen.SelectedMode == GameModePreset.MultiPlayerCampaign &&
connectedClients.None(c => c.Connection == OwnerConnection || c.HasPermission(ClientPermissions.ManageRound) || c.HasPermission(ClientPermissions.ManageCampaign)))
{
//if no-one has permissions to manage the campaign, show the setup UI to everyone
TrySendCampaignSetupInfo(c);
}
}
else
{
outmsg.WriteBoolean(false);
outmsg.WritePadBits();
}
settingsBytes = outmsg.LengthBytes - settingsBytes;
int campaignBytes = outmsg.LengthBytes;
bool hasSpaceForCampaignData = outmsg.LengthBytes < MsgConstants.MTU - 500;
if (hasSpaceForCampaignData &&
GameMain.GameSession?.GameMode is MultiPlayerCampaign campaign && campaign.Preset == GameMain.NetLobbyScreen.SelectedMode)
{
outmsg.WriteBoolean(true);
outmsg.WritePadBits();
campaign.ServerWrite(outmsg, c);
}
else
{
outmsg.WriteBoolean(false);
outmsg.WritePadBits();
if (!hasSpaceForCampaignData)
{
DebugConsole.Log($"Not enough space to fit campaign data in the lobby update (length {outmsg.LengthBytes} bytes), omitting...");
}
}
campaignBytes = outmsg.LengthBytes - campaignBytes;
outmsg.WriteUInt16(c.LastSentChatMsgID); //send this to client so they know which chat messages weren't received by the server
int clientListBytes = outmsg.LengthBytes;
if (outmsg.LengthBytes < MsgConstants.MTU - 500)
{
WriteClientList(segmentTable, c, outmsg);
}
else
{
DebugConsole.Log($"Not enough space to fit client list in the lobby update (length {outmsg.LengthBytes} bytes), omitting...");
}
clientListBytes = outmsg.LengthBytes - clientListBytes;
int chatMessageBytes = outmsg.LengthBytes;
WriteChatMessages(segmentTable, outmsg, c);
chatMessageBytes = outmsg.LengthBytes - chatMessageBytes;
messageTooLarge = outmsg.LengthBytes > MsgConstants.MTU;
if (messageTooLarge && !isInitialUpdate)
{
string warningMsg = "Maximum packet size exceeded, will send using reliable mode (" + outmsg.LengthBytes + " > " + MsgConstants.MTU + ")\n";
warningMsg +=
" Client list size: " + clientListBytes + " bytes\n" +
" Chat message size: " + chatMessageBytes + " bytes\n" +
" Campaign size: " + campaignBytes + " bytes\n" +
" Settings size: " + settingsBytes + " bytes\n";
if (initialUpdateBytes > 0)
{
warningMsg +=
" Initial update size: " + initialUpdateBytes + " bytes\n";
}
if (settingsBuf != null)
{
warningMsg +=
" Settings buffer size: " + settingsBuf.LengthBytes + " bytes\n";
}
#if DEBUG || UNSTABLE
DebugConsole.ThrowError(warningMsg);
#else
if (GameSettings.CurrentConfig.VerboseLogging) { DebugConsole.AddWarning(warningMsg); }
#endif
GameAnalyticsManager.AddErrorEventOnce("GameServer.ClientWriteIngame1:ClientWriteLobby" + outmsg.LengthBytes, GameAnalyticsManager.ErrorSeverity.Warning, warningMsg);
}
}
if (isInitialUpdate || messageTooLarge)
{
//the initial update may be very large if the host has a large number
//of submarine files, so the message may have to be fragmented
//unreliable messages don't play nicely with fragmenting, so we'll send the message reliably
serverPeer.Send(outmsg, c.Connection, DeliveryMethod.Reliable);
//and assume the message was received, so we don't have to keep resending
//these large initial messages until the client acknowledges receiving them
c.LastRecvLobbyUpdate = GameMain.NetLobbyScreen.LastUpdateID;
}
else
{
serverPeer.Send(outmsg, c.Connection, DeliveryMethod.Unreliable);
}
if (isInitialUpdate)
{
SendVoteStatus(new List<Client>() { c });
}
}
private static void WriteChatMessages(in SegmentTableWriter<ServerNetSegment> segmentTable, IWriteMessage outmsg, Client c)
{
c.ChatMsgQueue.RemoveAll(cMsg => !NetIdUtils.IdMoreRecent(cMsg.NetStateID, c.LastRecvChatMsgID));
for (int i = 0; i < c.ChatMsgQueue.Count && i < ChatMessage.MaxMessagesPerPacket; i++)
{
if (outmsg.LengthBytes + c.ChatMsgQueue[i].EstimateLengthBytesServer(c) > MsgConstants.MTU - 5 && i > 0)
{
//not enough room in this packet
return;
}
c.ChatMsgQueue[i].ServerWrite(segmentTable, outmsg, c);
}
}
public enum TryStartGameResult
{
Success,
GameAlreadyStarted,
PerksExceedAllowance,
SubmarineNotFound,
GameModeNotSelected,
CannotStartMultiplayerCampaign,
}
public TryStartGameResult TryStartGame()
{
if (initiatedStartGame || GameStarted) { return TryStartGameResult.GameAlreadyStarted; }
GameModePreset selectedMode =
Voting.HighestVoted<GameModePreset>(VoteType.Mode, connectedClients) ?? GameMain.NetLobbyScreen.SelectedMode;
if (selectedMode == null)
{
return TryStartGameResult.GameModeNotSelected;
}
if (selectedMode == GameModePreset.MultiPlayerCampaign && GameMain.GameSession?.GameMode is not MultiPlayerCampaign)
{
//DebugConsole.ThrowError($"{nameof(TryStartGame)} failed. Cannot start a multiplayer campaign via {nameof(TryStartGame)} - use {nameof(MultiPlayerCampaign.StartNewCampaign)} or {nameof(MultiPlayerCampaign.LoadCampaign)} instead.");
if (GameMain.NetLobbyScreen.SelectedMode != GameModePreset.MultiPlayerCampaign)
{
GameMain.NetLobbyScreen.SelectedModeIdentifier = GameModePreset.MultiPlayerCampaign.Identifier;
}
return TryStartGameResult.CannotStartMultiplayerCampaign;
}
bool applyPerks = GameSession.ShouldApplyDisembarkPoints(selectedMode);
if (applyPerks)
{
if (!GameSession.ValidatedDisembarkPoints(selectedMode, GameMain.NetLobbyScreen.MissionTypes))
{
return TryStartGameResult.PerksExceedAllowance;
}
}
Log("Starting a new round...", ServerLog.MessageType.ServerMessage);
SubmarineInfo selectedShuttle = GameMain.NetLobbyScreen.SelectedShuttle;
SubmarineInfo selectedSub;
Option<SubmarineInfo> selectedEnemySub = Option.None;
if (ServerSettings.AllowSubVoting)
{
if (selectedMode == GameModePreset.PvP)
{
var team1Voters = connectedClients.Where(static c => c.PreferredTeam == CharacterTeamType.Team1);
var team2Voters = connectedClients.Where(static c => c.PreferredTeam == CharacterTeamType.Team2);
SubmarineInfo team1Sub = Voting.HighestVoted<SubmarineInfo>(VoteType.Sub, team1Voters, out int team1VoteCount);
SubmarineInfo team2Sub = Voting.HighestVoted<SubmarineInfo>(VoteType.Sub, team2Voters, out int team2VoteCount);
// check if anyone on coalition voted for a sub
if (team1VoteCount > 0)
{
// use the most voted one
selectedSub = team1Sub;
}
else
{
selectedSub = team2VoteCount > 0
? team2Sub // only separatists voted for a sub, use theirs
: GameMain.NetLobbyScreen.SelectedSub; // nobody voted for a sub so use the default one
}
// check if separatists voted for a sub
if (team2VoteCount > 0 && team2Sub != null)
{
selectedEnemySub = Option.Some(team2Sub);
}
// no reason to fall back to coalition sub,
// since not selecting an enemy submarine automatically selects the coalition sub
// deeper in the code
}
else
{
selectedSub = Voting.HighestVoted<SubmarineInfo>(VoteType.Sub, connectedClients) ?? GameMain.NetLobbyScreen.SelectedSub;
}
}
else
{
selectedSub = GameMain.NetLobbyScreen.SelectedSub;
SubmarineInfo enemySub = GameMain.NetLobbyScreen.SelectedEnemySub ?? GameMain.NetLobbyScreen.SelectedSub;
// Option throws an exception if the value is null, prevent that
if (enemySub != null)
{
selectedEnemySub = Option.Some(enemySub);
}
}
if (selectedSub == null || selectedShuttle == null)
{
return TryStartGameResult.SubmarineNotFound;
}
if (applyPerks && CheckIfAnyPerksAreIncompatible(selectedSub, selectedEnemySub.Fallback(selectedSub), selectedMode, out var incompatiblePerks))
{
CoroutineManager.StartCoroutine(WarnAndDelayStartGame(incompatiblePerks, selectedSub, selectedEnemySub, selectedShuttle, selectedMode), nameof(WarnAndDelayStartGame));
return TryStartGameResult.Success;
}
initiatedStartGame = true;
startGameCoroutine = CoroutineManager.StartCoroutine(InitiateStartGame(selectedSub, selectedEnemySub, selectedShuttle, selectedMode), "InitiateStartGame");
return TryStartGameResult.Success;
}
private bool CheckIfAnyPerksAreIncompatible(SubmarineInfo team1Sub, SubmarineInfo team2Sub, GameModePreset preset, out PerkCollection incompatiblePerks)
{
var incompatibleTeam1Perks = ImmutableArray.CreateBuilder<DisembarkPerkPrefab>();
var incompatibleTeam2Perks = ImmutableArray.CreateBuilder<DisembarkPerkPrefab>();
bool hasIncompatiblePerks = false;
PerkCollection perks = GameSession.GetPerks();
bool ignorePerksThatCanNotApplyWithoutSubmarine = GameSession.ShouldIgnorePerksThatCanNotApplyWithoutSubmarine(preset, GameMain.NetLobbyScreen.MissionTypes);
foreach (DisembarkPerkPrefab perk in perks.Team1Perks)
{
if (ignorePerksThatCanNotApplyWithoutSubmarine && perk.PerkBehaviors.Any(static p => !p.CanApplyWithoutSubmarine())) { continue; }
bool anyCanNotApply = perk.PerkBehaviors.Any(p => !p.CanApply(team1Sub));
if (anyCanNotApply)
{
incompatibleTeam1Perks.Add(perk);
hasIncompatiblePerks = true;
}
}
if (preset == GameModePreset.PvP)
{
foreach (DisembarkPerkPrefab perk in perks.Team2Perks)
{
if (ignorePerksThatCanNotApplyWithoutSubmarine && perk.PerkBehaviors.Any(static p => !p.CanApplyWithoutSubmarine())) { continue; }
bool anyCanNotApply = perk.PerkBehaviors.Any(p => !p.CanApply(team2Sub));
if (anyCanNotApply)
{
incompatibleTeam2Perks.Add(perk);
hasIncompatiblePerks = true;
}
}
}
incompatiblePerks = new PerkCollection(incompatibleTeam1Perks.ToImmutable(), incompatibleTeam2Perks.ToImmutable());
return hasIncompatiblePerks;
}
private bool isRoundStartWarningActive;
private void AbortStartGameIfWarningActive()
{
isRoundStartWarningActive = false;
//reset autorestart countdown to give the clients time to reselect perks
if (ServerSettings.AutoRestart)
{
ServerSettings.AutoRestartTimer = Math.Max(ServerSettings.AutoRestartInterval, 5.0f);
}
//reset start round votes so we don't immediately attempt to restart
foreach (var client in connectedClients)
{
client.SetVote(VoteType.StartRound, false);
}
int clientsReady = connectedClients.Count(c => c.GetVote<bool>(VoteType.StartRound));
GameMain.NetLobbyScreen.LastUpdateID++;
CoroutineManager.StopCoroutines(nameof(WarnAndDelayStartGame));
}
private IEnumerable<CoroutineStatus> WarnAndDelayStartGame(PerkCollection incompatiblePerks, SubmarineInfo selectedSub, Option<SubmarineInfo> selectedEnemySub, SubmarineInfo selectedShuttle, GameModePreset selectedMode)
{
isRoundStartWarningActive = true;
const float warningDuration = 15.0f;
SerializableDateTime waitUntilTime = SerializableDateTime.UtcNow + TimeSpan.FromSeconds(warningDuration);
if (connectedClients.Any())
{
IWriteMessage msg = new WriteOnlyMessage().WithHeader(ServerPacketHeader.WARN_STARTGAME);
INetSerializableStruct warnData = new RoundStartWarningData(
RoundStartsAnywaysTimeInSeconds: warningDuration,
Team1Sub: selectedSub.Name,
Team1IncompatiblePerks: ToolBox.PrefabCollectionToUintIdentifierArray(incompatiblePerks.Team1Perks),
Team2Sub: selectedEnemySub.Fallback(selectedSub).Name,
Team2IncompatiblePerks: ToolBox.PrefabCollectionToUintIdentifierArray(incompatiblePerks.Team2Perks));
msg.WriteNetSerializableStruct(warnData);
foreach (Client c in connectedClients)
{
serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable);
}
}
while (waitUntilTime > SerializableDateTime.UtcNow)
{
yield return CoroutineStatus.Running;
}
CoroutineManager.StartCoroutine(InitiateStartGame(selectedSub, selectedEnemySub, selectedShuttle, selectedMode), "InitiateStartGame");
yield return CoroutineStatus.Success;
}
private IEnumerable<CoroutineStatus> InitiateStartGame(SubmarineInfo selectedSub, Option<SubmarineInfo> selectedEnemySub, SubmarineInfo selectedShuttle, GameModePreset selectedMode)
{
isRoundStartWarningActive = false;
initiatedStartGame = true;
if (connectedClients.Any())
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.QUERY_STARTGAME);
msg.WriteString(selectedSub.Name);
msg.WriteString(selectedSub.MD5Hash.StringRepresentation);
if (selectedEnemySub.TryUnwrap(out var enemySub))
{
msg.WriteBoolean(true);
msg.WriteString(enemySub.Name);
msg.WriteString(enemySub.MD5Hash.StringRepresentation);
}
else
{
msg.WriteBoolean(false);
}
msg.WriteBoolean(IsUsingRespawnShuttle());
msg.WriteString(selectedShuttle.Name);
msg.WriteString(selectedShuttle.MD5Hash.StringRepresentation);
var campaign = GameMain.GameSession?.GameMode as MultiPlayerCampaign;
msg.WriteByte(campaign == null ? (byte)0 : campaign.CampaignID);
msg.WriteUInt16(campaign == null ? (UInt16)0 : campaign.LastSaveID);
foreach (MultiPlayerCampaign.NetFlags flag in Enum.GetValues(typeof(MultiPlayerCampaign.NetFlags)))
{
msg.WriteUInt16(campaign == null ? (UInt16)0 : campaign.GetLastUpdateIdForFlag(flag));
}
connectedClients.ForEach(c => c.ReadyToStart = false);
foreach (NetworkConnection conn in connectedClients.Select(c => c.Connection))
{
serverPeer.Send(msg, conn, DeliveryMethod.Reliable);
}
//give the clients a few seconds to request missing sub/shuttle files before starting the round
float waitForResponseTimer = 5.0f;
while (connectedClients.Any(c => !c.ReadyToStart && !c.AFK) && waitForResponseTimer > 0.0f)
{
waitForResponseTimer -= CoroutineManager.DeltaTime;
yield return CoroutineStatus.Running;
}
if (FileSender.ActiveTransfers.Count > 0)
{
float waitForTransfersTimer = 20.0f;
while (FileSender.ActiveTransfers.Count > 0 && waitForTransfersTimer > 0.0f)
{
waitForTransfersTimer -= CoroutineManager.DeltaTime;
yield return CoroutineStatus.Running;
}
}
}
startGameCoroutine = GameMain.Instance.ShowLoading(StartGame(selectedSub, selectedShuttle, selectedEnemySub, selectedMode, CampaignSettings.Empty), false);
yield return CoroutineStatus.Success;
}
private IEnumerable<CoroutineStatus> StartGame(SubmarineInfo selectedSub, SubmarineInfo selectedShuttle, Option<SubmarineInfo> selectedEnemySub, GameModePreset selectedMode, CampaignSettings settings)
{
PerkCollection perkCollection = PerkCollection.Empty;
if (GameSession.ShouldApplyDisembarkPoints(selectedMode))
{
perkCollection = GameSession.GetPerks();
}
entityEventManager.Clear();
roundStartSeed = DateTime.Now.Millisecond;
Rand.SetSyncedSeed(roundStartSeed);
int teamCount = 1;
bool isPvP = selectedMode == GameModePreset.PvP;
MultiPlayerCampaign campaign = selectedMode == GameMain.GameSession?.GameMode.Preset ?
GameMain.GameSession?.GameMode as MultiPlayerCampaign : null;
if (campaign != null && campaign.Map == null)
{
initiatedStartGame = false;
startGameCoroutine = null;
string errorMsg = "Starting the round failed. Campaign was still active, but the map has been disposed. Try selecting another game mode.";
DebugConsole.ThrowError(errorMsg);
GameAnalyticsManager.AddErrorEventOnce("GameServer.StartGame:InvalidCampaignState", GameAnalyticsManager.ErrorSeverity.Error, errorMsg);
if (OwnerConnection != null)
{
SendDirectChatMessage(errorMsg, connectedClients.Find(c => c.Connection == OwnerConnection), ChatMessageType.Error);
}
yield return CoroutineStatus.Failure;
}
bool initialSuppliesSpawned = false;
//don't instantiate a new gamesession if we're playing a campaign
if (campaign == null || GameMain.GameSession == null)
{
traitorManager = new TraitorManager(this);
GameMain.GameSession = new GameSession(selectedSub, selectedEnemySub, CampaignDataPath.Empty, selectedMode, settings, GameMain.NetLobbyScreen.LevelSeed, missionTypes: GameMain.NetLobbyScreen.MissionTypes);
}
else
{
initialSuppliesSpawned = GameMain.GameSession.SubmarineInfo is { InitialSuppliesSpawned: true };
}
if (GameMain.GameSession.GameMode is PvPMode pvpMode)
{
teamCount = 2;
// In Player Preference mode, team assignments are handled only at this point, and in Player Choice mode,
// everyone should already have chosen a team, ie. players can no longer make choices now and we should
// finalize all the team assignments without further delay.
RefreshPvpTeamAssignments(assignUnassignedNow: true, autoBalanceNow: true);
}
else
{
connectedClients.ForEach(c => c.TeamID = CharacterTeamType.Team1);
}
bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || missionMode.Missions.All(m => m.AllowRespawning);
foreach (var mission in GameMain.GameSession.GameMode.Missions)
{
if (mission.Prefab.ForceRespawnMode.HasValue)
{
ServerSettings.RespawnMode = mission.Prefab.ForceRespawnMode.Value;
}
}
List<Client> playingClients = GetPlayingClients();
if (campaign != null)
{
if (campaign.Map == null)
{
throw new Exception("Campaign map was null.");
}
if (campaign.NextLevel == null)
{
string errorMsg = "Failed to start a campaign round (next level not set).";
DebugConsole.ThrowError(errorMsg);
GameAnalyticsManager.AddErrorEventOnce("GameServer.StartGame:InvalidCampaignState", GameAnalyticsManager.ErrorSeverity.Error, errorMsg);
if (OwnerConnection != null)
{
SendDirectChatMessage(errorMsg, connectedClients.Find(c => c.Connection == OwnerConnection), ChatMessageType.Error);
}
yield return CoroutineStatus.Failure;
}
campaign.RoundID++;
SendStartMessage(roundStartSeed, campaign.NextLevel.Seed, GameMain.GameSession, connectedClients, includesFinalize: false);
GameMain.GameSession.StartRound(campaign.NextLevel, startOutpost: campaign.GetPredefinedStartOutpost(), mirrorLevel: campaign.MirrorLevel);
SubmarineSwitchLoad = false;
campaign.AssignClientCharacterInfos(connectedClients);
Log("Game mode: " + selectedMode.Name.Value, ServerLog.MessageType.ServerMessage);
Log("Submarine: " + GameMain.GameSession.SubmarineInfo.Name, ServerLog.MessageType.ServerMessage);
Log("Level seed: " + campaign.NextLevel.Seed, ServerLog.MessageType.ServerMessage);
}
else
{
SendStartMessage(roundStartSeed, GameMain.NetLobbyScreen.LevelSeed, GameMain.GameSession, connectedClients, false);
GameMain.GameSession.StartRound(GameMain.NetLobbyScreen.LevelSeed, ServerSettings.SelectedLevelDifficulty, forceBiome: ServerSettings.Biome);
Log("Game mode: " + selectedMode.Name.Value, ServerLog.MessageType.ServerMessage);
Log("Submarine: " + selectedSub.Name, ServerLog.MessageType.ServerMessage);
Log("Level seed: " + GameMain.NetLobbyScreen.LevelSeed, ServerLog.MessageType.ServerMessage);
}
foreach (Mission mission in GameMain.GameSession.Missions)
{
Log("Mission: " + mission.Prefab.Name.Value, ServerLog.MessageType.ServerMessage);
}
if (GameMain.GameSession.SubmarineInfo.IsFileCorrupted)
{
CoroutineManager.StopCoroutines(startGameCoroutine);
initiatedStartGame = false;
SendChatMessage(TextManager.FormatServerMessage($"SubLoadError~[subname]={GameMain.GameSession.SubmarineInfo.Name}"), ChatMessageType.Error);
yield return CoroutineStatus.Failure;
}
bool isOutpost = campaign != null && campaign.NextLevel?.Type == LevelData.LevelType.Outpost;
if (ServerSettings.RespawnMode != RespawnMode.BetweenRounds && missionAllowRespawn)
{
RespawnManager = new RespawnManager(this, ServerSettings.UseRespawnShuttle && !isOutpost ? selectedShuttle : null);
}
if (campaign != null)
{
campaign.CargoManager.CreatePurchasedItems();
//midround-joining clients need to be informed of pending/new hires at outposts
if (isOutpost) { campaign.SendCrewState(); }
//campaign.SendCrewState(); // pending/new hires, reserve bench
}
if (GameMain.GameSession.Missions.None(m => !m.Prefab.AllowOutpostNPCs))
{
Level.Loaded?.SpawnNPCs();
}
Level.Loaded?.SpawnCorpses();
Level.Loaded?.PrepareBeaconStation();
AutoItemPlacer.SpawnItems(campaign?.Settings.StartItemSet);
CrewManager crewManager = GameMain.GameSession.CrewManager;
bool hadBots = true;
List<Character> team1Characters = new(),
team2Characters = new();
//assign jobs and spawnpoints separately for each team
for (int n = 0; n < teamCount; n++)
{
var teamID = n == 0 ? CharacterTeamType.Team1 : CharacterTeamType.Team2;
Submarine teamSub = Submarine.MainSubs[n];
if (teamSub != null)
{
teamSub.TeamID = teamID;
foreach (Item item in Item.ItemList)
{
if (item.Submarine == null) { continue; }
if (item.Submarine != teamSub && !teamSub.DockedTo.Contains(item.Submarine)) { continue; }
foreach (WifiComponent wifiComponent in item.GetComponents<WifiComponent>())
{
wifiComponent.TeamID = teamSub.TeamID;
}
}
foreach (Submarine sub in teamSub.DockedTo)
{
if (sub.Info.Type != SubmarineType.Player) { continue; }
sub.TeamID = teamID;
}
}
//find the clients in this team
List<Client> teamClients = teamCount == 1 ? new List<Client>(playingClients) : playingClients.FindAll(c => c.TeamID == teamID);
if (ServerSettings.AllowSpectating)
{
teamClients.RemoveAll(c => c.SpectateOnly);
}
//always allow the server owner to spectate even if it's disallowed in server settings
teamClients.RemoveAll(c => c.Connection == OwnerConnection && c.SpectateOnly);
// Clients with last character permanently dead spectate regardless of server settings
teamClients.RemoveAll(c => c.CharacterInfo != null && c.CharacterInfo.PermanentlyDead);
//if (!teamClients.Any() && n > 0) { continue; }
AssignJobs(teamClients);
List<CharacterInfo> characterInfos = new List<CharacterInfo>();
foreach (Client client in teamClients)
{
client.ResetSync();
if (client.CharacterInfo == null)
{
client.CharacterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, client.Name);
}
characterInfos.Add(client.CharacterInfo);
if (client.CharacterInfo.Job == null ||
client.CharacterInfo.Job.Prefab != client.AssignedJob.Prefab ||
//always recreate the job to reset the skills in non-campaign modes
campaign == null)
{
client.CharacterInfo.Job = new Job(client.AssignedJob.Prefab, isPvP, Rand.RandSync.Unsynced, client.AssignedJob.Variant);
}
}
List<CharacterInfo> bots = new List<CharacterInfo>();
// do not load new bots if we already have them
if (!crewManager.HasBots || campaign == null)
{
int botsToSpawn = ServerSettings.BotSpawnMode == BotSpawnMode.Fill ? ServerSettings.BotCount - characterInfos.Count : ServerSettings.BotCount;
for (int i = 0; i < botsToSpawn; i++)
{
var botInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName)
{
TeamID = teamID
};
characterInfos.Add(botInfo);
bots.Add(botInfo);
}
AssignBotJobs(bots, teamID, isPvP);
foreach (CharacterInfo bot in bots)
{
crewManager.AddCharacterInfo(bot);
}
crewManager.HasBots = true;
hadBots = false;
}
WayPoint[] spawnWaypoints = null;
WayPoint[] mainSubWaypoints = teamSub != null ? WayPoint.SelectCrewSpawnPoints(characterInfos, Submarine.MainSubs[n]) : null;
if (Level.Loaded != null && Level.Loaded.ShouldSpawnCrewInsideOutpost())
{
spawnWaypoints = WayPoint.SelectOutpostSpawnPoints(characterInfos, teamID);
}
if (teamSub != null)
{
if (spawnWaypoints == null || !spawnWaypoints.Any())
{
spawnWaypoints = mainSubWaypoints;
}
Debug.Assert(spawnWaypoints.Length == mainSubWaypoints.Length);
}
// Spawn players
for (int i = 0; i < teamClients.Count; i++)
{
//if there's a main sub waypoint available (= the spawnpoint the character would've spawned at, if they'd spawned in the main sub instead of the outpost),
//give the job items based on that spawnpoint
WayPoint jobItemSpawnPoint = mainSubWaypoints != null ? mainSubWaypoints[i] : spawnWaypoints[i];
Character spawnedCharacter = Character.Create(teamClients[i].CharacterInfo, spawnWaypoints[i].WorldPosition, teamClients[i].CharacterInfo.Name, isRemotePlayer: true, hasAi: false);
//spawnedCharacter.AnimController.Frozen = true;
spawnedCharacter.TeamID = teamID;
teamClients[i].Character = spawnedCharacter;
var characterData = campaign?.GetClientCharacterData(teamClients[i]);
if (characterData == null)
{
spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint);
if (campaign != null)
{
characterData = campaign.SetClientCharacterData(teamClients[i]);
characterData.HasSpawned = true;
}
}
else
{
if (!characterData.HasItemData && !characterData.CharacterInfo.StartItemsGiven)
{
//clients who've chosen to spawn with the respawn penalty can have CharacterData without inventory data
spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint);
}
else
{
characterData.SpawnInventoryItems(spawnedCharacter, spawnedCharacter.Inventory);
}
characterData.ApplyHealthData(spawnedCharacter);
characterData.ApplyOrderData(spawnedCharacter);
characterData.ApplyWalletData(spawnedCharacter);
spawnedCharacter.GiveIdCardTags(jobItemSpawnPoint);
spawnedCharacter.LoadTalents();
characterData.HasSpawned = true;
}
if (GameMain.GameSession?.GameMode is MultiPlayerCampaign mpCampaign && spawnedCharacter.Info != null)
{
spawnedCharacter.Info.SetExperience(Math.Max(spawnedCharacter.Info.ExperiencePoints, mpCampaign.GetSavedExperiencePoints(teamClients[i])));
mpCampaign.ClearSavedExperiencePoints(teamClients[i]);
if (spawnedCharacter.Info.LastRewardDistribution.TryUnwrap(out int salary))
{
spawnedCharacter.Wallet.SetRewardDistribution(salary);
}
}
spawnedCharacter.SetOwnerClient(teamClients[i]);
AddCharacterToList(teamID, spawnedCharacter);
}
// Spawn bots
for (int i = teamClients.Count; i < teamClients.Count + bots.Count; i++)
{
WayPoint jobItemSpawnPoint = mainSubWaypoints != null ? mainSubWaypoints[i] : spawnWaypoints[i];
Character spawnedCharacter = Character.Create(characterInfos[i], spawnWaypoints[i].WorldPosition, characterInfos[i].Name, isRemotePlayer: false, hasAi: true);
spawnedCharacter.TeamID = teamID;
spawnedCharacter.GiveJobItems(GameMain.GameSession.GameMode is PvPMode, jobItemSpawnPoint);
spawnedCharacter.GiveIdCardTags(jobItemSpawnPoint);
spawnedCharacter.Info.InventoryData = new XElement("inventory");
spawnedCharacter.Info.StartItemsGiven = true;
spawnedCharacter.SaveInventory();
spawnedCharacter.LoadTalents();
AddCharacterToList(teamID, spawnedCharacter);
}
void AddCharacterToList(CharacterTeamType team, Character character)
{
switch (team)
{
case CharacterTeamType.Team1:
team1Characters.Add(character);
break;
case CharacterTeamType.Team2:
team2Characters.Add(character);
break;
}
}
}
if (campaign != null && crewManager.HasBots)
{
if (hadBots)
{
//loaded existing bots -> init them
crewManager.InitRound();
}
else
{
//created new bots -> save them
SaveUtil.SaveGame(GameMain.GameSession.DataPath);
}
}
campaign?.LoadPets();
campaign?.LoadActiveOrders();
campaign?.CargoManager.InitPurchasedIDCards();
if (campaign == null || !initialSuppliesSpawned)
{
foreach (Submarine sub in Submarine.MainSubs)
{
if (sub == null) { continue; }
List<PurchasedItem> spawnList = new List<PurchasedItem>();
foreach (KeyValuePair<ItemPrefab, int> kvp in ServerSettings.ExtraCargo)
{
spawnList.Add(new PurchasedItem(kvp.Key, kvp.Value, buyer: null));
}
CargoManager.DeliverItemsToSub(spawnList, sub, cargoManager: null);
}
}
TraitorManager.Initialize(GameMain.GameSession.EventManager, Level.Loaded);
if (GameMain.LuaCs.Game.overrideTraitors)
{
TraitorManager.Enabled = false;
}
else
{
TraitorManager.Enabled = Rand.Range(0.0f, 1.0f) < ServerSettings.TraitorProbability;
}
GameAnalyticsManager.AddDesignEvent("Traitors:" + (TraitorManager == null ? "Disabled" : "Enabled"));
perkCollection.ApplyAll(team1Characters, team2Characters);
yield return CoroutineStatus.Running;
Voting.ResetVotes(GameMain.Server.ConnectedClients, resetKickVotes: false);
GameMain.GameScreen.Select();
Log("Round started.", ServerLog.MessageType.ServerMessage);
GameStarted = true;
initiatedStartGame = false;
GameMain.ResetFrameTime();
IncrementLastClientListUpdateID();
roundStartTime = DateTime.Now;
GameMain.LuaCs.Hook.Call("roundStart");
startGameCoroutine = null;
yield return CoroutineStatus.Success;
}
private void SendStartMessage(int seed, string levelSeed, GameSession gameSession, List<Client> clients, bool includesFinalize)
{
foreach (Client client in clients)
{
SendStartMessage(seed, levelSeed, gameSession, client, includesFinalize);
}
}
private void SendStartMessage(int seed, string levelSeed, GameSession gameSession, Client client, bool includesFinalize)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.STARTGAME);
msg.WriteInt32(seed);
msg.WriteIdentifier(gameSession.GameMode.Preset.Identifier);
bool missionAllowRespawn = GameMain.GameSession.GameMode is not MissionMode missionMode || !missionMode.Missions.Any(m => !m.AllowRespawning);
msg.WriteBoolean(ServerSettings.RespawnMode != RespawnMode.BetweenRounds && missionAllowRespawn);
msg.WriteBoolean(ServerSettings.AllowDisguises);
msg.WriteBoolean(ServerSettings.AllowRewiring);
msg.WriteBoolean(ServerSettings.AllowImmediateItemDelivery);
msg.WriteBoolean(ServerSettings.AllowFriendlyFire);
msg.WriteBoolean(ServerSettings.AllowDragAndDropGive);
msg.WriteBoolean(ServerSettings.LockAllDefaultWires);
msg.WriteBoolean(ServerSettings.AllowLinkingWifiToChat);
msg.WriteInt32(ServerSettings.MaximumMoneyTransferRequest);
msg.WriteByte((byte)ServerSettings.RespawnMode);
msg.WriteBoolean(IsUsingRespawnShuttle());
msg.WriteByte((byte)ServerSettings.LosMode);
msg.WriteByte((byte)ServerSettings.ShowEnemyHealthBars);
msg.WriteBoolean(includesFinalize); msg.WritePadBits();
ServerSettings.WriteMonsterEnabled(msg);
if (GameMain.GameSession?.GameMode is not MultiPlayerCampaign campaign)
{
msg.WriteString(levelSeed);
msg.WriteSingle(ServerSettings.SelectedLevelDifficulty);
msg.WriteIdentifier(ServerSettings.Biome == "Random".ToIdentifier() ? Identifier.Empty : ServerSettings.Biome);
msg.WriteString(gameSession.SubmarineInfo.Name);
msg.WriteString(gameSession.SubmarineInfo.MD5Hash.StringRepresentation);
var selectedShuttle = GameStarted && RespawnManager != null && RespawnManager.UsingShuttle ?
RespawnManager.RespawnShuttles.First().Info : GameMain.NetLobbyScreen.SelectedShuttle;
msg.WriteString(selectedShuttle.Name);
msg.WriteString(selectedShuttle.MD5Hash.StringRepresentation);
if (gameSession.EnemySubmarineInfo is { } enemySub)
{
msg.WriteBoolean(true);
msg.WriteString(enemySub.Name);
msg.WriteString(enemySub.MD5Hash.StringRepresentation);
}
else
{
msg.WriteBoolean(false);
}
msg.WriteByte((byte)GameMain.GameSession.GameMode.Missions.Count());
foreach (Mission mission in GameMain.GameSession.GameMode.Missions)
{
msg.WriteUInt32(mission.Prefab.UintIdentifier);
}
}
else
{
int nextLocationIndex = campaign.Map.Locations.FindIndex(l => l.LevelData == campaign.NextLevel);
int nextConnectionIndex = campaign.Map.Connections.FindIndex(c => c.LevelData == campaign.NextLevel);
msg.WriteByte(campaign.CampaignID);
msg.WriteByte(campaign == null ? (byte)0 : campaign.RoundID);
msg.WriteUInt16(campaign.LastSaveID);
msg.WriteInt32(nextLocationIndex);
msg.WriteInt32(nextConnectionIndex);
msg.WriteInt32(campaign.Map.SelectedLocationIndex);
msg.WriteBoolean(campaign.MirrorLevel);
}
if (includesFinalize)
{
WriteRoundStartFinalize(msg, client);
}
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
private bool TrySendCampaignSetupInfo(Client client)
{
if (!CampaignMode.AllowedToManageCampaign(client, ClientPermissions.ManageRound)) { return false; }
using (dosProtection.Pause(client))
{
const int MaxSaves = 255;
var saveInfos = SaveUtil.GetSaveFiles(SaveUtil.SaveType.Multiplayer, includeInCompatible: false);
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.CAMPAIGN_SETUP_INFO);
msg.WriteByte((byte)Math.Min(saveInfos.Count, MaxSaves));
for (int i = 0; i < saveInfos.Count && i < MaxSaves; i++)
{
msg.WriteNetSerializableStruct(saveInfos[i]);
}
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
return true;
}
private bool IsUsingRespawnShuttle()
{
return ServerSettings.UseRespawnShuttle || (GameStarted && RespawnManager != null && RespawnManager.UsingShuttle);
}
private void SendRoundStartFinalize(Client client)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.STARTGAMEFINALIZE);
WriteRoundStartFinalize(msg, client);
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
private void WriteRoundStartFinalize(IWriteMessage msg, Client client)
{
//tell the client what content files they should preload
var contentToPreload = GameMain.GameSession.EventManager.GetFilesToPreload();
msg.WriteUInt16((ushort)contentToPreload.Count());
foreach (ContentFile contentFile in contentToPreload)
{
msg.WriteString(contentFile.Path.Value);
}
msg.WriteByte((GameMain.GameSession.Campaign as MultiPlayerCampaign)?.RoundID ?? 0);
msg.WriteInt32(Submarine.MainSub?.Info.EqualityCheckVal ?? 0);
msg.WriteByte((byte)GameMain.GameSession.Missions.Count());
foreach (Mission mission in GameMain.GameSession.Missions)
{
msg.WriteIdentifier(mission.Prefab.Identifier);
}
foreach (Level.LevelGenStage stage in Enum.GetValues(typeof(Level.LevelGenStage)).OfType<Level.LevelGenStage>().OrderBy(s => s))
{
msg.WriteInt32(GameMain.GameSession.Level.EqualityCheckValues[stage]);
}
foreach (Mission mission in GameMain.GameSession.Missions)
{
mission.ServerWriteInitial(msg, client);
}
msg.WriteBoolean(GameMain.GameSession.CrewManager != null);
GameMain.GameSession.CrewManager?.ServerWriteActiveOrders(msg);
msg.WriteBoolean(GameSession.ShouldApplyDisembarkPoints(GameMain.GameSession.GameMode?.Preset));
}
public void EndGame(CampaignMode.TransitionType transitionType = CampaignMode.TransitionType.None, bool wasSaved = false, IEnumerable<Mission> missions = null)
{
if (GameStarted)
{
if (GameSettings.CurrentConfig.VerboseLogging)
{
Log("Ending the round...\n" + Environment.StackTrace.CleanupStackTrace(), ServerLog.MessageType.ServerMessage);
}
else
{
Log("Ending the round...", ServerLog.MessageType.ServerMessage);
}
}
string endMessage = TextManager.FormatServerMessage("RoundSummaryRoundHasEnded");
missions ??= GameMain.GameSession.Missions.ToList();
if (GameMain.GameSession is { IsRunning: true })
{
GameMain.GameSession.EndRound(endMessage);
}
TraitorManager.TraitorResults? traitorResults = traitorManager?.GetEndResults() ?? null;
var result = GameMain.LuaCs.Hook.Call<List<object>>("roundEnd");
if (result != null)
{
foreach (var data in result)
{
if (data is TraitorManager.TraitorResults traitorResultData) { traitorResults = traitorResultData; }
if (data is string endMessageData) { endMessage = endMessageData; }
}
}
EndRoundTimer = 0.0f;
if (ServerSettings.AutoRestart)
{
ServerSettings.AutoRestartTimer = ServerSettings.AutoRestartInterval;
//send a netlobby update to get the clients' autorestart timers up to date
GameMain.NetLobbyScreen.LastUpdateID++;
}
if (ServerSettings.SaveServerLogs) { ServerSettings.ServerLog.Save(); }
GameMain.GameScreen.Cam.TargetPos = Vector2.Zero;
entityEventManager.Clear();
foreach (Client c in connectedClients)
{
c.ResetSync();
}
if (GameStarted)
{
KarmaManager.OnRoundEnded();
}
RespawnManager = null;
GameStarted = false;
if (connectedClients.Count > 0)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.ENDGAME);
msg.WriteByte((byte)transitionType);
msg.WriteBoolean(wasSaved);
msg.WriteString(endMessage);
msg.WriteByte((byte)missions.Count());
foreach (Mission mission in missions)
{
msg.WriteBoolean(mission.Completed);
}
msg.WriteByte(GameMain.GameSession?.WinningTeam == null ? (byte)0 : (byte)GameMain.GameSession.WinningTeam);
msg.WriteBoolean(traitorResults.HasValue);
if (traitorResults.HasValue)
{
msg.WriteNetSerializableStruct(traitorResults.Value);
}
foreach (Client client in connectedClients)
{
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
client.Character?.Info?.ClearCurrentOrders();
client.Character = null;
client.HasSpawned = false;
client.InGame = false;
client.WaitForNextRoundRespawn = null;
}
}
entityEventManager.Clear();
Submarine.Unload();
GameMain.NetLobbyScreen.Select();
Log("Round ended.", ServerLog.MessageType.ServerMessage);
GameMain.NetLobbyScreen.RandomizeSettings();
}
public override void AddChatMessage(ChatMessage message)
{
if (string.IsNullOrEmpty(message.Text)) { return; }
string logMsg;
if (message.SenderClient != null)
{
logMsg = GameServer.ClientLogName(message.SenderClient) + ": " + message.TranslatedText;
}
else
{
logMsg = message.TextWithSender;
}
if (message.Sender is Character sender)
{
sender.TextChatVolume = 1f;
}
Log(logMsg, ServerLog.MessageType.Chat);
}
private bool ReadClientNameChange(Client c, IReadMessage inc)
{
UInt16 nameId = inc.ReadUInt16();
string newName = inc.ReadString();
Identifier newJob = inc.ReadIdentifier();
CharacterTeamType newTeam = (CharacterTeamType)inc.ReadByte();
if (c == null || string.IsNullOrEmpty(newName) || !NetIdUtils.IdMoreRecent(nameId, c.NameId)) { return false; }
if (!newJob.IsEmpty)
{
if (!JobPrefab.Prefabs.TryGet(newJob, out JobPrefab newJobPrefab) || newJobPrefab.HiddenJob)
{
newJob = Identifier.Empty;
}
}
if (newName == c.Name && newJob == c.PreferredJob && newTeam == c.PreferredTeam) { return false; }
c.NameId = nameId;
c.PreferredJob = newJob;
if (newTeam != c.PreferredTeam)
{
c.PreferredTeam = newTeam;
RefreshPvpTeamAssignments();
}
var timeSinceNameChange = DateTime.Now - c.LastNameChangeTime;
if (timeSinceNameChange < Client.NameChangeCoolDown && newName != c.Name)
{
//only send once per second at most to prevent using this for spamming
if (timeSinceNameChange.TotalSeconds > 1)
{
var coolDownRemaining = Client.NameChangeCoolDown - timeSinceNameChange;
SendDirectChatMessage($"ServerMessage.NameChangeFailedCooldownActive~[seconds]={(int)coolDownRemaining.TotalSeconds}", c);
IncrementLastClientListUpdateID();
//increment the ID to make sure the current server-side name is treated as the "latest",
//and the client correctly reverts back to the old name
c.NameId++;
}
c.RejectedName = newName;
return false;
}
var result = GameMain.LuaCs.Hook.Call<bool?>("tryChangeClientName", c, newName, newJob, newTeam);
if (result != null)
{
IncrementLastClientListUpdateID();
return result.Value;
}
return TryChangeClientName(c, newName, clientRenamingSelf: true);
}
public bool TryChangeClientName(Client c, string newName, bool clientRenamingSelf = false)
{
newName = Client.SanitizeName(newName);
if (newName != c.Name && !string.IsNullOrEmpty(newName) && IsNameValid(c, newName, clientRenamingSelf))
{
c.LastNameChangeTime = DateTime.Now;
string oldName = c.Name;
c.Name = newName;
c.RejectedName = string.Empty;
SendChatMessage($"ServerMessage.NameChangeSuccessful~[oldname]={oldName}~[newname]={newName}", ChatMessageType.Server);
IncrementLastClientListUpdateID();
return true;
}
else
{
//update client list even if the name cannot be changed to the one sent by the client,
//so the client will be informed what their actual name is
IncrementLastClientListUpdateID();
return false;
}
}
public bool IsNameValid(Client c, string newName, bool clientRenamingSelf = false)
{
if (c.Connection != OwnerConnection)
{
if (!Client.IsValidName(newName, ServerSettings))
{
SendDirectChatMessage($"ServerMessage.NameChangeFailedSymbols~[newname]={newName}", c, ChatMessageType.ServerMessageBox);
return false;
}
if (Homoglyphs.Compare(newName.ToLower(), ServerName.ToLower()))
{
SendDirectChatMessage($"ServerMessage.NameChangeFailedServerTooSimilar~[newname]={newName}", c, ChatMessageType.ServerMessageBox);
return false;
}
if (c.KickVoteCount > 0)
{
SendDirectChatMessage($"ServerMessage.NameChangeFailedVoteKick~[newname]={newName}", c, ChatMessageType.ServerMessageBox);
return false;
}
}
Client nameTakenByClient = ConnectedClients.Find(c2 =>
!(clientRenamingSelf && c == c2) && // only allow renaming one's own client with a similar name
Homoglyphs.Compare(c2.Name.ToLower(), newName.ToLower()));
if (nameTakenByClient != null)
{
SendDirectChatMessage($"ServerMessage.NameChangeFailedClientTooSimilar~[newname]={newName}~[takenname]={nameTakenByClient.Name}", c, ChatMessageType.ServerMessageBox);
return false;
}
string existingTooSimilarName = GameMain.GameSession?.CrewManager?
.GetCharacterInfos(includeReserveBench: true)
.FirstOrDefault(ci =>
(!clientRenamingSelf || ci.ID != c.Character?.ID) &&
Homoglyphs.Compare(ci.Name.ToLower(), newName.ToLower()))?.Name;
if (!existingTooSimilarName.IsNullOrEmpty())
{
SendDirectChatMessage($"ServerMessage.NameChangeFailedTooSimilar~[newname]={newName}~[takenname]={existingTooSimilarName}", c, ChatMessageType.ServerMessageBox);
return false;
}
return true;
}
public override void KickPlayer(string playerName, string reason)
{
Client client = connectedClients.Find(c =>
c.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase) ||
(c.Character != null && c.Character.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase)));
KickClient(client, reason);
}
public void KickClient(NetworkConnection conn, string reason)
{
if (conn == OwnerConnection) return;
Client client = connectedClients.Find(c => c.Connection == conn);
KickClient(client, reason);
}
public void KickClient(Client client, string reason, bool resetKarma = false)
{
if (client == null || client.Connection == OwnerConnection) { return; }
if (resetKarma)
{
var previousPlayer = previousPlayers.Find(p => p.MatchesClient(client));
if (previousPlayer != null)
{
previousPlayer.Karma = Math.Max(previousPlayer.Karma, 50.0f);
}
client.Karma = Math.Max(client.Karma, 50.0f);
}
DisconnectClient(client, PeerDisconnectPacket.Kicked(reason));
}
public override void BanPlayer(string playerName, string reason, TimeSpan? duration = null)
{
Client client = connectedClients.Find(c =>
c.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase) ||
(c.Character != null && c.Character.Name.Equals(playerName, StringComparison.OrdinalIgnoreCase)));
if (client == null)
{
DebugConsole.ThrowError("Client \"" + playerName + "\" not found.");
return;
}
BanClient(client, reason, duration);
}
public void BanClient(Client client, string reason, TimeSpan? duration = null)
{
if (client == null || client.Connection == OwnerConnection) { return; }
var previousPlayer = previousPlayers.Find(p => p.MatchesClient(client));
if (previousPlayer != null)
{
//reset karma to a neutral value, so if/when the ban is revoked the client wont get immediately punished by low karma again
previousPlayer.Karma = Math.Max(previousPlayer.Karma, 50.0f);
}
client.Karma = Math.Max(client.Karma, 50.0f);
DisconnectClient(client, PeerDisconnectPacket.Banned(reason));
if (client.AccountInfo.AccountId.TryUnwrap(out var accountId))
{
ServerSettings.BanList.BanPlayer(client.Name, accountId, reason, duration);
}
else
{
ServerSettings.BanList.BanPlayer(client.Name, client.Connection.Endpoint, reason, duration);
}
foreach (var relatedId in client.AccountInfo.OtherMatchingIds)
{
ServerSettings.BanList.BanPlayer(client.Name, relatedId, reason, duration);
}
}
public void BanPreviousPlayer(PreviousPlayer previousPlayer, string reason, TimeSpan? duration = null)
{
if (previousPlayer == null) { return; }
//reset karma to a neutral value, so if/when the ban is revoked the client wont get immediately punished by low karma again
previousPlayer.Karma = Math.Max(previousPlayer.Karma, 50.0f);
ServerSettings.BanList.BanPlayer(previousPlayer.Name, previousPlayer.Address, reason, duration);
if (previousPlayer.AccountInfo.AccountId.TryUnwrap(out var accountId))
{
ServerSettings.BanList.BanPlayer(previousPlayer.Name, accountId, reason, duration);
}
foreach (var relatedId in previousPlayer.AccountInfo.OtherMatchingIds)
{
ServerSettings.BanList.BanPlayer(previousPlayer.Name, relatedId, reason, duration);
}
string msg = $"ServerMessage.BannedFromServer~[client]={previousPlayer.Name}";
if (!string.IsNullOrWhiteSpace(reason))
{
msg += $"/ /ServerMessage.Reason/: /{reason}";
}
SendChatMessage(msg, ChatMessageType.Server, changeType: PlayerConnectionChangeType.Banned);
}
public override void UnbanPlayer(string playerName)
{
BannedPlayer bannedPlayer
= ServerSettings.BanList.BannedPlayers.FirstOrDefault(bp => bp.Name == playerName);
if (bannedPlayer is null) { return; }
ServerSettings.BanList.UnbanPlayer(bannedPlayer.AddressOrAccountId);
}
public override void UnbanPlayer(Endpoint endpoint)
{
ServerSettings.BanList.UnbanPlayer(endpoint);
}
public void DisconnectClient(NetworkConnection senderConnection, PeerDisconnectPacket peerDisconnectPacket)
{
Client client = connectedClients.Find(x => x.Connection == senderConnection);
if (client == null) { return; }
DisconnectClient(client, peerDisconnectPacket);
}
public void DisconnectClient(Client client, PeerDisconnectPacket peerDisconnectPacket)
{
if (client == null) return;
GameMain.LuaCs.Hook.Call("client.disconnected", client);
if (client.Character != null)
{
client.Character.ClientDisconnected = true;
client.Character.ClearInputs();
}
client.Character = null;
client.HasSpawned = false;
client.WaitForNextRoundRespawn = null;
client.InGame = false;
var previousPlayer = previousPlayers.Find(p => p.MatchesClient(client));
if (previousPlayer == null)
{
previousPlayer = new PreviousPlayer(client);
previousPlayers.Add(previousPlayer);
}
if (peerDisconnectPacket.ShouldAttemptReconnect)
{
lock (clientsAttemptingToReconnectSoon)
{
client.DeleteDisconnectedTimer = ServerSettings.KillDisconnectedTime;
clientsAttemptingToReconnectSoon.Add(client);
}
}
previousPlayer.Name = client.Name;
previousPlayer.Karma = client.Karma;
previousPlayer.KarmaKickCount = client.KarmaKickCount;
previousPlayer.KickVoters.Clear();
foreach (Client c in connectedClients)
{
if (client.HasKickVoteFrom(c)) { previousPlayer.KickVoters.Add(c); }
}
client.Dispose();
connectedClients.Remove(client);
serverPeer.Disconnect(client.Connection, peerDisconnectPacket);
KarmaManager.OnClientDisconnected(client);
// A player disconnecting might impact PvP team assignments if still in the lobby
if (!GameStarted)
{
RefreshPvpTeamAssignments();
}
UpdateVoteStatus();
SendChatMessage(peerDisconnectPacket.ChatMessage(client.Name).Value, ChatMessageType.Server, changeType: peerDisconnectPacket.ConnectionChangeType);
UpdateCrewFrame();
ServerSettings.ServerDetailsChanged = true;
refreshMasterTimer = DateTime.Now;
}
private void UpdateCrewFrame()
{
foreach (Client c in connectedClients)
{
if (c.Character == null || !c.InGame) continue;
}
}
public void SendDirectChatMessage(string txt, Client recipient, ChatMessageType messageType = ChatMessageType.Server)
{
ChatMessage msg = ChatMessage.Create("", txt, messageType, null);
SendDirectChatMessage(msg, recipient);
}
public void SendConsoleMessage(string txt, Client recipient, Color? color = null)
{
ChatMessage msg = ChatMessage.Create("", txt, ChatMessageType.Console, sender: null, textColor: color);
SendDirectChatMessage(msg, recipient);
}
public void SendDirectChatMessage(ChatMessage msg, Client recipient)
{
if (recipient == null)
{
string errorMsg = "Attempted to send a chat message to a null client.\n" + Environment.StackTrace.CleanupStackTrace();
DebugConsole.ThrowError(errorMsg);
GameAnalyticsManager.AddErrorEventOnce("GameServer.SendDirectChatMessage:ClientNull", GameAnalyticsManager.ErrorSeverity.Error, errorMsg);
return;
}
msg.NetStateID = recipient.ChatMsgQueue.Count > 0 ?
(ushort)(recipient.ChatMsgQueue.Last().NetStateID + 1) :
(ushort)(recipient.LastRecvChatMsgID + 1);
recipient.ChatMsgQueue.Add(msg);
recipient.LastChatMsgQueueID = msg.NetStateID;
}
/// <summary>
/// Add the message to the chatbox and pass it to all clients who can receive it
/// </summary>
public void SendChatMessage(string message, ChatMessageType? type = null, Client senderClient = null, Character senderCharacter = null, PlayerConnectionChangeType changeType = PlayerConnectionChangeType.None, ChatMode chatMode = ChatMode.None)
{
string senderName = "";
Client targetClient = null;
if (type == null)
{
string command = ChatMessage.GetChatMessageCommand(message, out string tempStr);
switch (command.ToLowerInvariant())
{
case "r":
case "radio":
type = ChatMessageType.Radio;
break;
case "d":
case "dead":
type = ChatMessageType.Dead;
break;
default:
if (command != "")
{
if (command.ToLower() == ServerName.ToLower())
{
//a private message to the host
if (OwnerConnection != null)
{
targetClient = connectedClients.Find(c => c.Connection == OwnerConnection);
}
}
else
{
targetClient = connectedClients.Find(c =>
command.ToLower() == c.Name.ToLower() ||
command.ToLower() == c.Character?.Name?.ToLower());
if (targetClient == null)
{
if (senderClient != null)
{
var chatMsg = ChatMessage.Create(
"", $"ServerMessage.PlayerNotFound~[player]={command}",
ChatMessageType.Error, null);
SendDirectChatMessage(chatMsg, senderClient);
}
else
{
AddChatMessage($"ServerMessage.PlayerNotFound~[player]={command}", ChatMessageType.Error);
}
return;
}
}
type = ChatMessageType.Private;
}
else if (chatMode == ChatMode.Radio)
{
type = ChatMessageType.Radio;
}
else
{
type = ChatMessageType.Default;
}
break;
}
message = tempStr;
}
if (GameStarted)
{
if (senderClient == null)
{
//msg sent by the server
if (senderCharacter == null)
{
senderName = ServerName;
}
else //msg sent by an AI character
{
senderName = senderCharacter.DisplayName;
}
}
else //msg sent by a client
{
senderCharacter = senderClient.Character;
senderName = senderCharacter == null ? senderClient.Name : senderCharacter.DisplayName;
if (type == ChatMessageType.Private)
{
if (senderCharacter != null && !senderCharacter.IsDead || targetClient.Character != null && !targetClient.Character.IsDead)
{
//sender or target has an alive character, sending private messages not allowed
SendDirectChatMessage(ChatMessage.Create("", $"ServerMessage.PrivateMessagesNotAllowed", ChatMessageType.Error, null), senderClient);
return;
}
}
//sender doesn't have a character or the character can't speak -> only ChatMessageType.Dead allowed
else if (senderCharacter == null || senderCharacter.IsDead || senderCharacter.SpeechImpediment >= 100.0f)
{
type = ChatMessageType.Dead;
}
}
}
else
{
if (senderClient == null)
{
//msg sent by the server
if (senderCharacter == null)
{
senderName = ServerName;
}
else //sent by an AI character, not allowed when the game is not running
{
return;
}
}
else //msg sent by a client
{
//game not started -> clients can only send normal, private, and team chatmessages
if (type != ChatMessageType.Private && type != ChatMessageType.Team) type = ChatMessageType.Default;
senderName = senderClient.Name;
}
}
//check if the client is allowed to send the message
WifiComponent senderRadio = null;
switch (type)
{
case ChatMessageType.Radio:
case ChatMessageType.Order:
if (senderCharacter == null) { return; }
if (!ChatMessage.CanUseRadio(senderCharacter, out senderRadio)) { return; }
break;
case ChatMessageType.Dead:
//character still alive and capable of speaking -> dead chat not allowed
if (senderClient != null && senderCharacter != null && !senderCharacter.IsDead && senderCharacter.SpeechImpediment < 100.0f)
{
return;
}
break;
}
if (type == ChatMessageType.Server || type == ChatMessageType.Error)
{
senderName = null;
senderCharacter = null;
}
else if (type == ChatMessageType.Radio && !GameMain.LuaCs.Game.overrideSignalRadio)
{
//send to chat-linked wifi components
Signal s = new Signal(message, sender: senderCharacter, source: senderRadio.Item);
senderRadio.TransmitSignal(s, sentFromChat: true);
}
var hookChatMsg = ChatMessage.Create(senderName, message, (ChatMessageType)type, senderCharacter, senderClient, changeType);
var should = GameMain.LuaCs.Hook.Call<bool?>("modifyChatMessage", hookChatMsg, senderRadio);
if (should != null && should.Value)
return;
//check which clients can receive the message and apply distance effects
foreach (Client client in ConnectedClients)
{
string modifiedMessage = message;
switch (type)
{
case ChatMessageType.Default:
case ChatMessageType.Radio:
case ChatMessageType.Order:
if (senderCharacter != null &&
client.Character != null && !client.Character.IsDead)
{
if (senderCharacter != client.Character)
{
modifiedMessage = ChatMessage.ApplyDistanceEffect(message, (ChatMessageType)type, senderCharacter, client.Character);
}
//too far to hear the msg -> don't send
if (string.IsNullOrWhiteSpace(modifiedMessage)) { continue; }
}
break;
case ChatMessageType.Dead:
//character still alive -> don't send
if (client != senderClient && client.Character != null && !client.Character.IsDead) { continue; }
break;
case ChatMessageType.Private:
//private msg sent to someone else than this client -> don't send
if (client != targetClient && client != senderClient) { continue; }
break;
case ChatMessageType.Team:
// No need to relay team messages at all to clients in opposing teams (or without a team)
if (client.TeamID == CharacterTeamType.None || client.TeamID != senderClient.TeamID) { continue; }
break;
}
var chatMsg = ChatMessage.Create(
senderName,
modifiedMessage,
(ChatMessageType)type,
senderCharacter,
senderClient,
changeType);
SendDirectChatMessage(chatMsg, client);
}
if (type.Value != ChatMessageType.MessageBox)
{
string myReceivedMessage = type == ChatMessageType.Server || type == ChatMessageType.Error ? TextManager.GetServerMessage(message).Value : message;
if (!string.IsNullOrWhiteSpace(myReceivedMessage))
{
AddChatMessage(myReceivedMessage, (ChatMessageType)type, senderName, senderClient, senderCharacter);
}
}
}
public void SendOrderChatMessage(OrderChatMessage message)
{
if (message.SenderCharacter == null || message.SenderCharacter.SpeechImpediment >= 100.0f) { return; }
//check which clients can receive the message and apply distance effects
foreach (Client client in ConnectedClients)
{
if (message.SenderCharacter != null && client.Character != null && !client.Character.IsDead)
{
//too far to hear the msg -> don't send
if (!client.Character.CanHearCharacter(message.SenderCharacter)) { continue; }
}
SendDirectChatMessage(new OrderChatMessage(message.Order, message.Text, message.TargetCharacter, message.Sender, isNewOrder: message.IsNewOrder), client);
}
if (!string.IsNullOrWhiteSpace(message.Text))
{
AddChatMessage(new OrderChatMessage(message.Order, message.Text, message.TargetCharacter, message.Sender, isNewOrder: message.IsNewOrder));
if (ChatMessage.CanUseRadio(message.SenderCharacter, out var senderRadio))
{
//send to chat-linked wifi components
Signal s = new Signal(message.Text, sender: message.SenderCharacter, source: senderRadio.Item);
senderRadio.TransmitSignal(s, sentFromChat: true);
}
}
}
private void FileTransferChanged(FileSender.FileTransferOut transfer)
{
Client recipient = connectedClients.Find(c => c.Connection == transfer.Connection);
if (transfer.FileType == FileTransferType.CampaignSave &&
(transfer.Status == FileTransferStatus.Sending || transfer.Status == FileTransferStatus.Finished) &&
recipient.LastCampaignSaveSendTime != default)
{
recipient.LastCampaignSaveSendTime.time = (float)NetTime.Now;
}
}
public void SendCancelTransferMsg(FileSender.FileTransferOut transfer)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.FILE_TRANSFER);
msg.WriteByte((byte)FileTransferMessageType.Cancel);
msg.WriteByte((byte)transfer.ID);
serverPeer.Send(msg, transfer.Connection, DeliveryMethod.Reliable);
}
public void UpdateVoteStatus(bool checkActiveVote = true)
{
if (connectedClients.Count == 0) { return; }
if (checkActiveVote && Voting.ActiveVote != null)
{
#warning TODO: this is mostly the same as Voting.Update, deduplicate (if/when refactoring the Voting class?)
var inGameClients = GameMain.Server.ConnectedClients.Where(c => c.InGame);
if (inGameClients.Count() == 1 && inGameClients.First() == Voting.ActiveVote.VoteStarter)
{
Voting.ActiveVote.Finish(Voting, passed: true);
}
else if (inGameClients.Any())
{
var eligibleClients = inGameClients.Where(c => c != Voting.ActiveVote.VoteStarter);
int yes = eligibleClients.Count(c => c.GetVote<int>(Voting.ActiveVote.VoteType) == 2);
int no = eligibleClients.Count(c => c.GetVote<int>(Voting.ActiveVote.VoteType) == 1);
int max = eligibleClients.Count();
// Required ratio cannot be met
if (no / (float)max > 1f - ServerSettings.VoteRequiredRatio)
{
Voting.ActiveVote.Finish(Voting, passed: false);
}
else if (yes / (float)max >= ServerSettings.VoteRequiredRatio)
{
Voting.ActiveVote.Finish(Voting, passed: true);
}
}
}
Client.UpdateKickVotes(connectedClients);
var kickVoteEligibleClients = connectedClients.Where(c => (DateTime.Now - c.JoinTime).TotalSeconds > ServerSettings.DisallowKickVoteTime);
float minimumKickVotes = Math.Max(2.0f, kickVoteEligibleClients.Count() * ServerSettings.KickVoteRequiredRatio);
var clientsToKick = connectedClients.FindAll(c =>
c.Connection != OwnerConnection &&
!c.HasPermission(ClientPermissions.Kick) &&
!c.HasPermission(ClientPermissions.Ban) &&
!c.HasPermission(ClientPermissions.Unban) &&
c.KickVoteCount >= minimumKickVotes);
foreach (Client c in clientsToKick)
{
//reset the client's kick votes (they can rejoin after their ban expires)
c.ResetVotes(resetKickVotes: true);
previousPlayers.Where(p => p.MatchesClient(c)).ForEach(p => p.KickVoters.Clear());
BanClient(c, "ServerMessage.KickedByVoteAutoBan", duration: TimeSpan.FromSeconds(ServerSettings.AutoBanTime));
}
//GameMain.NetLobbyScreen.LastUpdateID++;
SendVoteStatus(connectedClients);
var endVoteEligibleClients = connectedClients.Where(c => Voting.CanVoteToEndRound(c));
int endVoteCount = endVoteEligibleClients.Count(c => c.GetVote<bool>(VoteType.EndRound));
int endVoteMax = endVoteEligibleClients.Count();
if (ServerSettings.AllowEndVoting && endVoteMax > 0 &&
(endVoteCount / (float)endVoteMax) >= ServerSettings.EndVoteRequiredRatio)
{
Log("Ending round by votes (" + endVoteCount + "/" + (endVoteMax - endVoteCount) + ")", ServerLog.MessageType.ServerMessage);
EndGame(wasSaved: false);
}
}
public void SendVoteStatus(List<Client> recipients)
{
if (!recipients.Any()) { return; }
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.UPDATE_LOBBY);
using (var segmentTable = SegmentTableWriter<ServerNetSegment>.StartWriting(msg))
{
segmentTable.StartNewSegment(ServerNetSegment.Vote);
Voting.ServerWrite(msg);
}
foreach (var c in recipients)
{
serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable);
}
}
public bool TrySwitchSubmarine()
{
if (Voting.ActiveVote is not Voting.SubmarineVote subVote) { return false; }
SubmarineInfo targetSubmarine = subVote.Sub;
VoteType voteType = Voting.ActiveVote.VoteType;
Client starter = Voting.ActiveVote.VoteStarter;
bool purchaseFailed = false;
switch (voteType)
{
case VoteType.PurchaseAndSwitchSub:
case VoteType.PurchaseSub:
// Pay for submarine
purchaseFailed = !GameMain.GameSession.TryPurchaseSubmarine(targetSubmarine, starter);
break;
case VoteType.SwitchSub:
break;
default:
return false;
}
if (voteType != VoteType.PurchaseSub && !purchaseFailed)
{
GameMain.GameSession.SwitchSubmarine(targetSubmarine, subVote.TransferItems, starter);
}
Voting.StopSubmarineVote(passed: !purchaseFailed);
return !purchaseFailed;
}
public void UpdateClientPermissions(Client client)
{
if (client.AccountId.TryUnwrap(out var accountId))
{
ServerSettings.ClientPermissions.RemoveAll(scp => scp.AddressOrAccountId == accountId);
if (client.Permissions != ClientPermissions.None)
{
ServerSettings.ClientPermissions.Add(new ServerSettings.SavedClientPermission(
client.Name,
accountId,
client.Permissions,
client.PermittedConsoleCommands));
}
}
else
{
ServerSettings.ClientPermissions.RemoveAll(scp => client.Connection.Endpoint.Address == scp.AddressOrAccountId);
if (client.Permissions != ClientPermissions.None)
{
ServerSettings.ClientPermissions.Add(new ServerSettings.SavedClientPermission(
client.Name,
client.Connection.Endpoint.Address,
client.Permissions,
client.PermittedConsoleCommands));
}
}
foreach (Client recipient in connectedClients)
{
CoroutineManager.StartCoroutine(SendClientPermissionsAfterClientListSynced(recipient, client));
}
ServerSettings.SaveClientPermissions();
}
private IEnumerable<CoroutineStatus> SendClientPermissionsAfterClientListSynced(Client recipient, Client client)
{
DateTime timeOut = DateTime.Now + new TimeSpan(0, 0, 10);
while (NetIdUtils.IdMoreRecent(LastClientListUpdateID, recipient.LastRecvClientListUpdate))
{
if (DateTime.Now > timeOut || GameMain.Server == null || !connectedClients.Contains(recipient))
{
yield return CoroutineStatus.Success;
}
yield return null;
}
SendClientPermissions(recipient, client);
yield return CoroutineStatus.Success;
}
private void SendClientPermissions(Client recipient, Client client)
{
if (recipient?.Connection == null) { return; }
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.PERMISSIONS);
client.WritePermissions(msg);
serverPeer.Send(msg, recipient.Connection, DeliveryMethod.Reliable);
}
public void GiveAchievement(Character character, Identifier achievementIdentifier)
{
foreach (Client client in connectedClients)
{
if (client.Character == character)
{
GiveAchievement(client, achievementIdentifier);
return;
}
}
}
public void IncrementStat(Character character, AchievementStat stat, int amount)
{
foreach (Client client in connectedClients)
{
if (client.Character == character)
{
IncrementStat(client, stat, amount);
return;
}
}
}
public void GiveAchievement(Client client, Identifier achievementIdentifier)
{
if (client.GivenAchievements.Contains(achievementIdentifier)) { return; }
DebugConsole.NewMessage($"Attempting to give the achievement {achievementIdentifier} to {client.Name}...");
client.GivenAchievements.Add(achievementIdentifier);
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.ACHIEVEMENT);
msg.WriteIdentifier(achievementIdentifier);
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
public void UnlockRecipe(CharacterTeamType team, Identifier identifier)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.UNLOCKRECIPE);
msg.WriteByte((byte)team);
msg.WriteIdentifier(identifier);
foreach (var client in connectedClients)
{
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
}
public void IncrementStat(Client client, AchievementStat stat, int amount)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.ACHIEVEMENT_STAT);
INetSerializableStruct incrementedStat = new NetIncrementedStat(stat, amount);
incrementedStat.Write(msg);
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
public void SendTraitorMessage(WriteOnlyMessage msg, Client client)
{
if (client == null) { return; };
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
public void UpdateCheatsEnabled()
{
if (!connectedClients.Any()) { return; }
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.CHEATS_ENABLED);
msg.WriteBoolean(DebugConsole.CheatsEnabled);
msg.WritePadBits();
foreach (Client c in connectedClients)
{
serverPeer.Send(msg, c.Connection, DeliveryMethod.Reliable);
}
}
public void SetClientCharacter(Client client, Character newCharacter)
{
if (client == null) { return; }
//the client's previous character is no longer a remote player
if (client.Character != null)
{
client.Character.SetOwnerClient(null);
}
if (newCharacter == null)
{
if (client.Character != null) //removing control of the current character
{
CreateEntityEvent(client.Character, new Character.ControlEventData(null));
client.Character = null;
}
}
else //taking control of a new character
{
newCharacter.ClientDisconnected = false;
newCharacter.KillDisconnectedTimer = 0.0f;
newCharacter.ResetNetState();
if (client.Character != null)
{
newCharacter.LastNetworkUpdateID = client.Character.LastNetworkUpdateID;
}
if (newCharacter.Info is { Character: null })
{
newCharacter.Info.Character = newCharacter;
}
newCharacter.SetOwnerClient(client);
newCharacter.Enabled = true;
newCharacter.AnimController.Frozen = false;
client.Character = newCharacter;
client.CharacterInfo = newCharacter.Info;
CreateEntityEvent(newCharacter, new Character.ControlEventData(client));
}
}
private readonly RateLimiter charInfoRateLimiter = new(
maxRequests: 5,
expiryInSeconds: 10,
punishmentRules: new[]
{
(RateLimitAction.OnLimitReached, RateLimitPunishment.Announce),
(RateLimitAction.OnLimitDoubled, RateLimitPunishment.Kick)
});
private void UpdateCharacterInfo(IReadMessage message, Client sender)
{
bool spectateOnly = message.ReadBoolean();
bool characterDiscarded = message.ReadBoolean();
bool readInfo = message.ReadBoolean();
message.ReadPadBits();
sender.SpectateOnly = spectateOnly && (ServerSettings.AllowSpectating || sender.Connection == OwnerConnection);
if (!readInfo) { return; }
var netInfo = INetSerializableStruct.Read<NetCharacterInfo>(message);
if (sender.SpectateOnly) { return; }
if (charInfoRateLimiter.IsLimitReached(sender)) { return; }
string newName = netInfo.NewName;
if (string.IsNullOrEmpty(newName))
{
newName = sender.Name;
}
else
{
newName = Client.SanitizeName(newName);
if (!IsNameValid(sender, newName, clientRenamingSelf: true))
{
newName = sender.Name;
}
else
{
sender.PendingName = newName;
}
}
// If a CharacterInfo for this Client already exists on the server, make sure it is used, and prevent the Client from replacing it
if (GameMain.GameSession?.Campaign is MultiPlayerCampaign mpCampaign)
{
if (characterDiscarded) { mpCampaign.DiscardClientCharacterData(sender); }
var existingCampaignData = mpCampaign.GetClientCharacterData(sender);
if (existingCampaignData != null)
{
DebugConsole.NewMessage("Client attempted to modify their CharacterInfo, but they already have an existing campaign character. Ignoring the modifications.");
sender.CharacterInfo = existingCampaignData.CharacterInfo;
return;
}
}
sender.CharacterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, newName);
sender.CharacterInfo.RecreateHead(
tags: netInfo.Tags.ToImmutableHashSet(),
hairIndex: netInfo.HairIndex,
beardIndex: netInfo.BeardIndex,
moustacheIndex: netInfo.MoustacheIndex,
faceAttachmentIndex: netInfo.FaceAttachmentIndex);
sender.CharacterInfo.Head.SkinColor = validateColor(netInfo.SkinColor, "skin color", sender.CharacterInfo.SkinColors.Select(kvp => kvp.Color));
sender.CharacterInfo.Head.HairColor = validateColor(netInfo.HairColor, "hair color", sender.CharacterInfo.HairColors.Select(kvp => kvp.Color));
sender.CharacterInfo.Head.FacialHairColor = validateColor(netInfo.FacialHairColor, "facial hair color", sender.CharacterInfo.FacialHairColors.Select(kvp => kvp.Color));
Color validateColor(Color newColor, string colorName, IEnumerable<Color> supportedColors)
{
if (!supportedColors.Contains(newColor))
{
DebugConsole.AddWarning($"Client {sender.Name} attempted to set their {colorName} to an unsupported value ({newColor}).");
return supportedColors.First();
}
else
{
return newColor;
}
}
if (netInfo.JobVariants.Length > 0)
{
List<JobVariant> variants = new List<JobVariant>();
foreach (NetJobVariant jv in netInfo.JobVariants)
{
if (jv.ToJobVariant() is { } variant)
{
variants.Add(variant);
}
}
sender.JobPreferences = variants;
}
}
public readonly List<string> JobAssignmentDebugLog = new List<string>();
public void AssignJobs(List<Client> unassigned)
{
JobAssignmentDebugLog.Clear();
var jobList = JobPrefab.Prefabs.ToList();
unassigned = new List<Client>(unassigned);
unassigned = unassigned.OrderBy(sp => Rand.Int(int.MaxValue)).ToList();
Dictionary<JobPrefab, int> assignedClientCount = new Dictionary<JobPrefab, int>();
foreach (JobPrefab jp in jobList)
{
assignedClientCount.Add(jp, 0);
}
CharacterTeamType teamID = CharacterTeamType.None;
if (unassigned.Count > 0) { teamID = unassigned[0].TeamID; }
//if we're playing a multiplayer campaign, check which clients already have a character and a job
//(characters are persistent in campaigns)
if (GameMain.GameSession.GameMode is MultiPlayerCampaign multiplayerCampaign)
{
var campaignAssigned = multiplayerCampaign.GetAssignedJobs(connectedClients);
//remove already assigned clients from unassigned
unassigned.RemoveAll(u => campaignAssigned.ContainsKey(u));
//add up to assigned client count
foreach ((Client client, Job job) in campaignAssigned)
{
assignedClientCount[job.Prefab]++;
client.AssignedJob = new JobVariant(job.Prefab, job.Variant);
JobAssignmentDebugLog.Add($"Client {client.Name} has an existing campaign character, keeping the job {job.Name}.");
}
}
//count the clients who already have characters with an assigned job
foreach (Client c in connectedClients)
{
if (c.TeamID != teamID || unassigned.Contains(c)) { continue; }
if (c.Character?.Info?.Job != null && !c.Character.IsDead)
{
assignedClientCount[c.Character.Info.Job.Prefab]++;
}
}
//if any of the players has chosen a job that is Always Allowed, give them that job
for (int i = unassigned.Count - 1; i >= 0; i--)
{
if (unassigned[i].JobPreferences.Count == 0) { continue; }
if (!unassigned[i].JobPreferences.Any() || !unassigned[i].JobPreferences[0].Prefab.AllowAlways) { continue; }
JobAssignmentDebugLog.Add($"Client {unassigned[i].Name} has {unassigned[i].JobPreferences[0].Prefab.Name} as their first preference, assigning it because the job is always allowed.");
unassigned[i].AssignedJob = unassigned[i].JobPreferences[0];
unassigned.RemoveAt(i);
}
// Assign the necessary jobs that are always required at least one, in vanilla this means in practice the captain
bool unassignedJobsFound = true;
while (unassignedJobsFound && unassigned.Any())
{
unassignedJobsFound = false;
foreach (JobPrefab jobPrefab in jobList)
{
if (unassigned.Count == 0) { break; }
if (jobPrefab.MinNumber < 1 || assignedClientCount[jobPrefab] >= jobPrefab.MinNumber) { continue; }
// Find the client that wants the job the most, don't force any jobs yet, because it might be that we can meet the preference for other jobs.
Client client = FindClientWithJobPreference(unassigned, jobPrefab, forceAssign: false);
if (client != null)
{
JobAssignmentDebugLog.Add($"At least {jobPrefab.MinNumber} {jobPrefab.Name} required. Assigning {client.Name} as a {jobPrefab.Name} (has the job in their preferences).");
AssignJob(client, jobPrefab);
}
}
if (unassigned.Any())
{
// Another pass, force required jobs that are not yet filled.
foreach (JobPrefab jobPrefab in jobList)
{
if (unassigned.Count == 0) { break; }
if (jobPrefab.MinNumber < 1 || assignedClientCount[jobPrefab] >= jobPrefab.MinNumber) { continue; }
var client = FindClientWithJobPreference(unassigned, jobPrefab, forceAssign: true);
JobAssignmentDebugLog.Add(
$"At least {jobPrefab.MinNumber} {jobPrefab.Name} required. "+
$"A random client needs to be assigned because no one has the job in their preferences. Assigning {client.Name} as a {jobPrefab.Name}.");
AssignJob(client, jobPrefab);
}
}
void AssignJob(Client client, JobPrefab jobPrefab)
{
client.AssignedJob =
client.JobPreferences.FirstOrDefault(jp => jp.Prefab == jobPrefab) ??
new JobVariant(jobPrefab, Rand.Int(jobPrefab.Variants));
assignedClientCount[jobPrefab]++;
unassigned.Remove(client);
//the job still needs more crew members, set unassignedJobsFound to true to keep the while loop running
if (assignedClientCount[jobPrefab] < jobPrefab.MinNumber) { unassignedJobsFound = true; }
}
}
// Attempt to give the clients a job they have in their job preferences.
// First evaluate all the primary preferences, then all the secondary etc.
for (int preferenceIndex = 0; preferenceIndex < 3; preferenceIndex++)
{
for (int i = unassigned.Count - 1; i >= 0; i--)
{
Client client = unassigned[i];
if (preferenceIndex >= client.JobPreferences.Count) { continue; }
var preferredJob = client.JobPreferences[preferenceIndex];
JobPrefab jobPrefab = preferredJob.Prefab;
if (assignedClientCount[jobPrefab] >= jobPrefab.MaxNumber)
{
JobAssignmentDebugLog.Add($"{client.Name} has {jobPrefab.Name} as their {preferenceIndex + 1}. preference. Cannot assign, maximum number of the job has been reached.");
continue;
}
if (client.Karma < jobPrefab.MinKarma)
{
JobAssignmentDebugLog.Add($"{client.Name} has {jobPrefab.Name} as their {preferenceIndex + 1}. preference. Cannot assign, karma too low ({client.Karma} < {jobPrefab.MinKarma}).");
continue;
}
JobAssignmentDebugLog.Add($"{client.Name} has {jobPrefab.Name} as their {preferenceIndex + 1}. preference. Assigning {client.Name} as a {jobPrefab.Name}.");
client.AssignedJob = preferredJob;
assignedClientCount[jobPrefab]++;
unassigned.RemoveAt(i);
}
}
//give random jobs to rest of the clients
foreach (Client c in unassigned)
{
//find all jobs that are still available
var remainingJobs = jobList.FindAll(jp => !jp.HiddenJob && assignedClientCount[jp] < jp.MaxNumber && c.Karma >= jp.MinKarma);
//all jobs taken, give a random job
if (remainingJobs.Count == 0)
{
string errorMsg = $"Failed to assign a suitable job for \"{c.Name}\" (all jobs already have the maximum numbers of players). Assigning a random job...";
DebugConsole.ThrowError(errorMsg);
JobAssignmentDebugLog.Add(errorMsg);
int jobIndex = Rand.Range(0, jobList.Count);
int skips = 0;
while (c.Karma < jobList[jobIndex].MinKarma)
{
jobIndex++;
skips++;
if (jobIndex >= jobList.Count) { jobIndex -= jobList.Count; }
if (skips >= jobList.Count) { break; }
}
c.AssignedJob =
c.JobPreferences.FirstOrDefault(jp => jp.Prefab == jobList[jobIndex]) ??
new JobVariant(jobList[jobIndex], 0);
assignedClientCount[c.AssignedJob.Prefab]++;
}
//if one of the client's preferences is still available, give them that job
else if (c.JobPreferences.FirstOrDefault(jp => remainingJobs.Contains(jp.Prefab)) is { } remainingJob)
{
JobAssignmentDebugLog.Add(
$"{c.Name} has {remainingJob.Prefab.Name} as their {c.JobPreferences.IndexOf(remainingJob) + 1}. preference, and it is still available."+
$" Assigning {c.Name} as a {remainingJob.Prefab.Name}.");
c.AssignedJob = remainingJob;
assignedClientCount[remainingJob.Prefab]++;
}
else //none of the client's preferred jobs available, choose a random job
{
c.AssignedJob = new JobVariant(remainingJobs[Rand.Range(0, remainingJobs.Count)], 0);
assignedClientCount[c.AssignedJob.Prefab]++;
JobAssignmentDebugLog.Add(
$"No suitable jobs available for {c.Name} (karma {c.Karma}). Assigning a random job: {c.AssignedJob.Prefab.Name}.");
}
}
GameMain.LuaCs.Hook.Call("jobsAssigned", unassigned);
}
public void AssignBotJobs(List<CharacterInfo> bots, CharacterTeamType teamID, bool isPvP)
{
//shuffle first so the parts where we go through the prefabs
//and find ones there's too few of don't always pick the same job
List<JobPrefab> shuffledPrefabs = JobPrefab.Prefabs.Where(static jp => !jp.HiddenJob).ToList();
shuffledPrefabs.Shuffle(Rand.RandSync.Unsynced);
Dictionary<JobPrefab, int> assignedPlayerCount = new Dictionary<JobPrefab, int>();
foreach (JobPrefab jp in shuffledPrefabs)
{
if (jp.HiddenJob) { continue; }
assignedPlayerCount.Add(jp, 0);
}
//count the clients who already have characters with an assigned job
foreach (Client c in connectedClients)
{
if (c.TeamID != teamID) continue;
if (c.Character?.Info?.Job != null && !c.Character.IsDead)
{
assignedPlayerCount[c.Character.Info.Job.Prefab]++;
}
else if (c.CharacterInfo?.Job != null)
{
assignedPlayerCount[c.CharacterInfo?.Job.Prefab]++;
}
}
List<CharacterInfo> unassignedBots = new List<CharacterInfo>(bots);
while (unassignedBots.Count > 0)
{
//if there's any jobs left that must be included in the crew, assign those
var jobsBelowMinNumber = shuffledPrefabs.Where(jp => assignedPlayerCount[jp] < jp.MinNumber);
if (jobsBelowMinNumber.Any())
{
AssignJob(unassignedBots[0], jobsBelowMinNumber.GetRandomUnsynced());
}
else
{
//if there's any jobs left that are below the normal number of bots initially in the crew, assign those
var jobsBelowInitialCount = shuffledPrefabs.Where(jp => assignedPlayerCount[jp] < jp.InitialCount);
if (jobsBelowInitialCount.Any())
{
AssignJob(unassignedBots[0], jobsBelowInitialCount.GetRandomUnsynced());
}
else
{
//no "must-have-jobs" left, break and start assigning randomly
break;
}
}
}
foreach (CharacterInfo c in unassignedBots.ToList())
{
//find all jobs that are still available
var remainingJobs = shuffledPrefabs.Where(jp => assignedPlayerCount[jp] < jp.MaxNumber);
//all jobs taken, give a random job
if (remainingJobs.None())
{
DebugConsole.ThrowError("Failed to assign a suitable job for bot \"" + c.Name + "\" (all jobs already have the maximum numbers of players). Assigning a random job...");
AssignJob(c, shuffledPrefabs.GetRandomUnsynced());
}
else
{
//some jobs still left, choose one of them by random (preferring ones there's the least of in the crew)
var selectedJob = remainingJobs.GetRandomByWeight(jp => 1.0f / Math.Max(assignedPlayerCount[jp], 0.01f), Rand.RandSync.Unsynced);
AssignJob(c, selectedJob);
}
}
void AssignJob(CharacterInfo bot, JobPrefab job)
{
int variant = Rand.Range(0, job.Variants);
bot.Job = new Job(job, isPvP, Rand.RandSync.Unsynced, variant);
assignedPlayerCount[bot.Job.Prefab]++;
unassignedBots.Remove(bot);
}
}
private Client FindClientWithJobPreference(List<Client> clients, JobPrefab job, bool forceAssign = false)
{
int bestPreference = int.MaxValue;
Client preferredClient = null;
foreach (Client c in clients)
{
if (ServerSettings.KarmaEnabled && c.Karma < job.MinKarma) { continue; }
int index = c.JobPreferences.IndexOf(c.JobPreferences.Find(j => j.Prefab == job));
if (index > -1 && index < bestPreference)
{
bestPreference = index;
preferredClient = c;
}
}
//none of the clients wants the job, assign it to random client
if (forceAssign && preferredClient == null)
{
preferredClient = clients[Rand.Int(clients.Count)];
}
return preferredClient;
}
public void UpdateMissionState(Mission mission)
{
foreach (var client in connectedClients)
{
IWriteMessage msg = new WriteOnlyMessage();
msg.WriteByte((byte)ServerPacketHeader.MISSION);
int missionIndex = GameMain.GameSession.GetMissionIndex(mission);
msg.WriteByte((byte)(missionIndex == -1 ? 255: missionIndex));
mission?.ServerWrite(msg);
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
}
}
public static string CharacterLogName(Character character)
{
if (character == null) { return "[NULL]"; }
Client client = GameMain.Server.ConnectedClients.Find(c => c.Character == character);
return ClientLogName(client, character.LogName);
}
public static void Log(string line, ServerLog.MessageType messageType)
{
if (GameMain.Server == null || !GameMain.Server.ServerSettings.SaveServerLogs) { return; }
GameMain.LuaCs?.Hook?.Call("serverLog", line, messageType);
GameMain.Server.ServerSettings.ServerLog.WriteLine(line, messageType);
foreach (Client client in GameMain.Server.ConnectedClients)
{
if (!client.HasPermission(ClientPermissions.ServerLog)) continue;
//use sendername as the message type
GameMain.Server.SendDirectChatMessage(
ChatMessage.Create(messageType.ToString(), line, ChatMessageType.ServerLog, null),
client);
}
}
public void Quit()
{
if (started)
{
started = false;
ServerSettings.BanList.Save();
if (GameMain.NetLobbyScreen.SelectedSub != null) { ServerSettings.SelectedSubmarine = GameMain.NetLobbyScreen.SelectedSub.Name; }
if (GameMain.NetLobbyScreen.SelectedShuttle != null) { ServerSettings.SelectedShuttle = GameMain.NetLobbyScreen.SelectedShuttle.Name; }
ServerSettings.SaveSettings();
ModSender?.Dispose();
if (ServerSettings.SaveServerLogs)
{
Log("Shutting down the server...", ServerLog.MessageType.ServerMessage);
ServerSettings.ServerLog.Save();
}
GameAnalyticsManager.AddDesignEvent("GameServer:ShutDown");
serverPeer?.Close();
SteamManager.CloseServer();
}
}
private void UpdateClientLobbies()
{
// Triggers a call to WriteClientList(), which causes clients to call GameClient.ReadClientList()
IncrementLastClientListUpdateID();
}
private List<Client> GetPlayingClients()
{
List<Client> playingClients = new List<Client>(connectedClients.Where(c => !c.AFK || !ServerSettings.AllowAFK));
if (ServerSettings.AllowSpectating)
{
playingClients.RemoveAll(static c => c.SpectateOnly);
}
// Always allow the server owner to spectate even if it's disallowed in server settings
playingClients.RemoveAll(c => c.Connection == OwnerConnection && c.SpectateOnly);
return playingClients;
}
/// <summary>
/// Assigns currently playing clients into PvP teams according to current server settings.
/// </summary>
/// <param name="assignUnassignedNow">Should players without team preference be randomized into teams or given time to choose?</param>
/// <param name="autoBalanceNow">Should auto-balance be applied immediately? Otherwise, only the auto-balance countdown is started (in case of imbalance).</param>
public void RefreshPvpTeamAssignments(bool assignUnassignedNow = false, bool autoBalanceNow = false)
{
List<Client> team1 = new List<Client>();
List<Client> team2 = new List<Client>();
List<Client> playingClients = GetPlayingClients();
// First assign clients with a team preference/choice into the teams they want (applies in both team selection modes)
List<Client> unassignedClients = new List<Client>(playingClients);
for (int i = 0; i < unassignedClients.Count; i++)
{
if (unassignedClients[i].PreferredTeam == CharacterTeamType.Team1 ||
unassignedClients[i].PreferredTeam == CharacterTeamType.Team2)
{
assignTeam(unassignedClients[i], unassignedClients[i].PreferredTeam);
i--;
}
}
// Should unassigned players be forced into teams now? (eg. at round start when the time to make choices is over)
if (assignUnassignedNow)
{
if (unassignedClients.Any())
{
SendChatMessage(TextManager.Get("PvP.WithoutTeamWillBeRandomlyAssigned").Value, ChatMessageType.Server);
}
// Assign to the team that has the least players
while (unassignedClients.Any())
{
var randomClient = unassignedClients.GetRandom(Rand.RandSync.Unsynced);
assignTeam(randomClient, team1.Count < team2.Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2);
}
}
if (ServerSettings.PvpAutoBalanceThreshold > 0)
{
// Deal with team size balance as necessary
int sizeDifference = Math.Abs(team1.Count - team2.Count);
if (sizeDifference > ServerSettings.PvpAutoBalanceThreshold)
{
if (autoBalanceNow)
{
SendChatMessage(TextManager.Get("AutoBalance.Activating").Value, ChatMessageType.Server);
// Assign a random player from the bigger team into the smaller team until the teams are no longer too imbalanced
while (Math.Abs(team1.Count - team2.Count) > ServerSettings.PvpAutoBalanceThreshold)
{
// Note: team size difference never 0 at this point
var biggerTeam = GetPlayingClients().Where(
c => team1.Count > team2.Count ?
c.TeamID == CharacterTeamType.Team1 :
c.TeamID == CharacterTeamType.Team2)
.ToList();
switchTeam(biggerTeam.GetRandom(Rand.RandSync.Unsynced), team1.Count < team2.Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2);
}
}
else if (ServerSettings.PvpTeamSelectionMode != PvpTeamSelectionMode.PlayerPreference)
{
// Start a countdown (if not already running) to auto-balancing, so players have a chance to manually rebalance the team before that
if (pvpAutoBalanceCountdownRemaining == -1)
{
SendChatMessage(TextManager.GetWithVariables(
"AutoBalance.CountdownStarted",
("[teamname]", TextManager.Get(team1.Count > team2.Count ? "teampreference.team1" : "teampreference.team2")),
("[numberplayers]", (sizeDifference - ServerSettings.PvpAutoBalanceThreshold).ToString()),
("[numberseconds]", PvpAutoBalanceCountdown.ToString())
).Value, ChatMessageType.Server);
pvpAutoBalanceCountdownRemaining = PvpAutoBalanceCountdown;
}
}
}
else
{
// Stop countdown if there was one
StopAutoBalanceCountdown();
}
}
else
{
// Stop countdown if there was one (eg. if the settings were changed during countdown)
StopAutoBalanceCountdown();
}
// Finally, push the assignments to the clients
UpdateClientLobbies();
void assignTeam(Client client, CharacterTeamType newTeam)
{
client.TeamID = newTeam;
unassignedClients.Remove(client);
if (newTeam == CharacterTeamType.Team1)
{
team1.Add(client);
}
else if (newTeam == CharacterTeamType.Team2)
{
team2.Add(client);
}
}
void switchTeam(Client client, CharacterTeamType newTeam)
{
string teamNameVariable = "";
if (newTeam == CharacterTeamType.Team1)
{
team2.Remove(client);
team1.Add(client);
teamNameVariable = "teampreference.team1";
}
else if (newTeam == CharacterTeamType.Team2)
{
team1.Remove(client);
team2.Add(client);
teamNameVariable = "teampreference.team2";
}
SendChatMessage(TextManager.GetWithVariables(
"AutoBalance.PlayerMoved",
("[clientname]", client.Name),
("[teamname]", TextManager.Get(teamNameVariable))
).Value, ChatMessageType.Server);
client.TeamID = newTeam;
client.PreferredTeam = newTeam;
}
}
/// <summary>
/// Assign a team for single clients who join the server when a round is already running.
/// </summary>
public void AssignClientToPvpTeamMidgame(Client client)
{
if (client.PreferredTeam == CharacterTeamType.None)
{
// If teams are currently even, assign the preference-less new player into a random team
if (Team1Count == Team2Count)
{
client.TeamID = Rand.Value() > 0.5f ? CharacterTeamType.Team1 : CharacterTeamType.Team2;
}
else // Otherwise, just assign them to the smaller team
{
client.TeamID = Team1Count < Team2Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2;
}
}
else if (ServerSettings.PvpAutoBalanceThreshold > 0) // Check if the player can be put into their preferred team
{
int newTeam1Count = Team1Count + (client.PreferredTeam == CharacterTeamType.Team1 ? 1 : 0);
int newTeam2Count = Team2Count + (client.PreferredTeam == CharacterTeamType.Team2 ? 1 : 0);
// Threshold won't be crossed by assigning the player to their preferred team, so do it
if (Math.Abs(newTeam1Count - newTeam2Count) <= ServerSettings.PvpAutoBalanceThreshold)
{
client.TeamID = client.PreferredTeam;
}
else // Preferred team would go against balance threshold, assing the player to the smaller team
{
client.TeamID = Team1Count < Team2Count ? CharacterTeamType.Team1 : CharacterTeamType.Team2;
}
}
else // Nothing stopping us from assigning the player into their preferred team
{
client.TeamID = client.PreferredTeam;
}
}
private void StopAutoBalanceCountdown()
{
if (pvpAutoBalanceCountdownRemaining != -1)
{
SendChatMessage(TextManager.Get("AutoBalance.CountdownCancelled").Value, ChatMessageType.Server);
}
pvpAutoBalanceCountdownRemaining = -1;
}
}
class PreviousPlayer
{
public string Name;
public Address Address;
public AccountInfo AccountInfo;
public float Karma;
public int KarmaKickCount;
public readonly List<Client> KickVoters = new List<Client>();
public PreviousPlayer(Client c)
{
Name = c.Name;
Address = c.Connection.Endpoint.Address;
AccountInfo = c.AccountInfo;
}
public bool MatchesClient(Client c)
{
if (c.AccountInfo.AccountId.IsSome() && AccountInfo.AccountId.IsSome()) { return c.AccountInfo.AccountId == AccountInfo.AccountId; }
return c.AddressMatches(Address);
}
}
}