v1.0.13.1 (first post-1.0 patch)

This commit is contained in:
Regalis11
2023-05-10 15:07:17 +03:00
parent 96fb49ba14
commit ee1db852b1
272 changed files with 5738 additions and 2413 deletions
@@ -13,6 +13,15 @@ namespace Barotrauma
{
static partial class DebugConsole
{
private static readonly RateLimiter rateLimiter = new(
maxRequests: 50,
expiryInSeconds: 5,
punishmentRules: new[]
{
(RateLimitAction.OnLimitReached, RateLimitPunishment.Announce),
(RateLimitAction.OnLimitDoubled, RateLimitPunishment.Kick)
});
public partial class Command
{
/// <summary>
@@ -608,12 +617,12 @@ namespace Barotrauma
NewMessage("Valid ranks are:", Color.White);
foreach (PermissionPreset permissionPreset in PermissionPreset.List)
{
NewMessage(" - " + permissionPreset.Name, Color.White);
NewMessage(" - " + permissionPreset.DisplayName, Color.White);
}
ShowQuestionPrompt("Rank to grant to \"" + client.Name + "\"?", (rank) =>
{
PermissionPreset preset = PermissionPreset.List.Find(p => p.Name.Equals(rank, StringComparison.OrdinalIgnoreCase));
PermissionPreset preset = PermissionPreset.List.Find(p => p.DisplayName.Equals(rank, StringComparison.OrdinalIgnoreCase));
if (preset == null)
{
ThrowError("Rank \"" + rank + "\" not found.");
@@ -622,7 +631,7 @@ namespace Barotrauma
client.SetPermissions(preset.Permissions, preset.PermittedCommands);
GameMain.Server.UpdateClientPermissions(client);
NewMessage("Assigned the rank \"" + preset.Name + "\" to " + client.Name + ".", Color.White);
NewMessage("Assigned the rank \"" + preset.DisplayName + "\" to " + client.Name + ".", Color.White);
}, args, 1);
});
@@ -1992,6 +2001,7 @@ namespace Barotrauma
"freecam",
(Client client, Vector2 cursorWorldPos, string[] args) =>
{
client.UsingFreeCam = true;
GameMain.Server.SetClientCharacter(client, null);
client.SpectateOnly = true;
}
@@ -2105,7 +2115,7 @@ namespace Barotrauma
}
string rank = string.Join("", args.Skip(1));
PermissionPreset preset = PermissionPreset.List.Find(p => p.Name.Equals(rank, StringComparison.OrdinalIgnoreCase));
PermissionPreset preset = PermissionPreset.List.Find(p => p.DisplayName.Equals(rank, StringComparison.OrdinalIgnoreCase));
if (preset == null)
{
GameMain.Server.SendConsoleMessage("Rank \"" + rank + "\" not found.", senderClient, Color.Red);
@@ -2114,8 +2124,8 @@ namespace Barotrauma
client.SetPermissions(preset.Permissions, preset.PermittedCommands);
GameMain.Server.UpdateClientPermissions(client);
GameMain.Server.SendConsoleMessage($"Assigned the rank \"{preset.Name}\" to {client.Name}.", senderClient);
NewMessage(senderClient.Name + " granted the rank \"" + preset.Name + "\" to " + client.Name + ".", Color.White);
GameMain.Server.SendConsoleMessage($"Assigned the rank \"{preset.DisplayName}\" to {client.Name}.", senderClient);
NewMessage(senderClient.Name + " granted the rank \"" + preset.DisplayName + "\" to " + client.Name + ".", Color.White);
}
);
@@ -2509,26 +2519,37 @@ namespace Barotrauma
foreach (Item item in Item.ItemList)
{
item.TryCreateServerEventSpam();
item.CreateStatusEvent();
item.CreateStatusEvent(loadingRound: false);
}
foreach (Structure wall in Structure.WallList)
{
GameMain.Server.CreateEntityEvent(wall);
}
}));
commands.Add(new Command("stallfiletransfers", "stallfiletransfers [seconds]: A debug command that stalls each file transfer packet by the specified duration.", (string[] args) =>
commands.Add(new Command("stallfiletransfers", "stallfiletransfers [seconds]: A debug command that makes all file transfers take at least the specified duration.", (string[] args) =>
{
float seconds = 0.0f;
if (args.Length > 0)
{
float.TryParse(args[0], out seconds);
}
GameMain.Server.FileSender.StallPacketsTime = seconds;
GameMain.Server.FileSender.ForceMinimumFileTransferDuration = seconds;
NewMessage("Set file transfer stall time to " + seconds);
}));
#endif
}
public static void ServerRead(IReadMessage inc, Client sender)
{
string consoleCommand = inc.ReadString();
float cursorX = inc.ReadSingle();
float cursorY = inc.ReadSingle();
if (rateLimiter.IsLimitReached(sender)) { return; }
ExecuteClientCommand(sender, new Vector2(cursorX, cursorY), consoleCommand);
}
public static void ExecuteClientCommand(Client client, Vector2 cursorWorldPos, string command)
{
if (GameMain.Server == null) return;
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Barotrauma.Extensions;
using Barotrauma.Networking;
@@ -11,25 +12,16 @@ namespace Barotrauma
{
internal partial class MedicalClinic
{
private enum RateLimitResult
{
OK,
LimitReached
}
private struct RateLimitInfo
{
public int Requests;
public const int MaxRequests = 10;
public DateTimeOffset Expiry;
}
// allow 20 requests per 5 seconds, announce to chat if the limit is reached
private readonly RateLimiter rateLimiter = new(
maxRequests: RateLimitMaxRequests,
expiryInSeconds: RateLimitExpiry,
punishmentRules: (RateLimitAction.OnLimitReached, RateLimitPunishment.Announce));
private readonly record struct AfflictionSubscriber(Client Subscriber, CharacterInfo Target, DateTimeOffset Expiry);
private readonly List<AfflictionSubscriber> afflictionSubscribers = new();
private readonly Dictionary<Client, RateLimitInfo> rateLimits = new();
public void ServerRead(IReadMessage inc, Client sender)
{
NetworkHeader header = (NetworkHeader)inc.ReadByte();
@@ -65,7 +57,7 @@ namespace Barotrauma
private void ProcessNewAddition(IReadMessage inc, Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
NetCrewMember newCrewMember = INetSerializableStruct.Read<NetCrewMember>(inc);
InsertPendingCrewMember(newCrewMember);
@@ -74,7 +66,7 @@ namespace Barotrauma
private void ProcessAddEverything(Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
AddEverythingToPending();
ServerSend(PendingHeals.ToNetCollection(), NetworkHeader.ADD_PENDING, DeliveryMethod.Reliable, reponseClient: client);
}
@@ -92,7 +84,7 @@ namespace Barotrauma
private void ProcessNewRemoval(IReadMessage inc, Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
NetRemovedAffliction removed = INetSerializableStruct.Read<NetRemovedAffliction>(inc);
RemovePendingAffliction(removed.CrewMember, removed.Affliction);
@@ -101,14 +93,14 @@ namespace Barotrauma
private void ProcessRequestedPending(Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
ServerSend(PendingHeals.ToNetCollection(), NetworkHeader.REQUEST_PENDING, DeliveryMethod.Reliable, targetClient: client);
}
private void ProcessHealing(Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
HealRequestResult result = HealAllPending(client: client);
ServerSend(new NetHealRequest { Result = result }, NetworkHeader.HEAL_PENDING, DeliveryMethod.Reliable, reponseClient: client);
@@ -116,7 +108,7 @@ namespace Barotrauma
private void ProcessClearing(Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
if (!PendingHeals.Any()) { return; }
@@ -126,7 +118,7 @@ namespace Barotrauma
private void ProcessRequestedAfflictions(IReadMessage inc, Client client)
{
if (CheckRateLimit(client) == RateLimitResult.LimitReached) { return; }
if (rateLimiter.IsLimitReached(client)) { return; }
NetCrewMember crewMember = INetSerializableStruct.Read<NetCrewMember>(inc);
@@ -135,6 +127,17 @@ namespace Barotrauma
ImmutableArray<NetAffliction> pendingAfflictions = ImmutableArray<NetAffliction>.Empty;
int infoId = 0;
if (foundInfo is null)
{
StringBuilder sb = new();
foreach (CharacterInfo character in GetCrewCharacters())
{
sb.AppendLine($" - {character.DisplayName} ({character.ID})");
}
DebugConsole.ThrowError($"Could not find the requested crew member with ID {crewMember.CharacterInfoID}.\n{sb}");
}
if (foundInfo is { Character.CharacterHealth: { } health })
{
pendingAfflictions = GetAllAfflictions(health);
@@ -158,32 +161,6 @@ namespace Barotrauma
ServerSend(writeCrewMember, NetworkHeader.REQUEST_AFFLICTIONS, DeliveryMethod.Unreliable, client);
}
private RateLimitResult CheckRateLimit(Client client)
{
if (rateLimits.TryGetValue(client, out RateLimitInfo rateLimitInfo))
{
if (rateLimitInfo.Expiry < DateTimeOffset.Now)
{
rateLimitInfo.Expiry = DateTimeOffset.Now.AddSeconds(5);
rateLimitInfo.Requests = 1;
}
else
{
if (rateLimitInfo.Requests > RateLimitInfo.MaxRequests) { return RateLimitResult.LimitReached; }
rateLimitInfo.Requests++;
}
rateLimits[client] = rateLimitInfo;
}
else
{
rateLimits.Add(client, new RateLimitInfo { Requests = 1, Expiry = DateTimeOffset.Now.AddSeconds(5) });
}
return RateLimitResult.OK;
}
private IWriteMessage StartSending()
{
IWriteMessage msg = new WriteOnlyMessage();
@@ -4,6 +4,7 @@ using Barotrauma.Networking;
using Microsoft.Xna.Framework;
using System.Collections.Generic;
using System.Linq;
using Barotrauma.Extensions;
namespace Barotrauma
{
@@ -135,6 +136,8 @@ namespace Barotrauma
}
}
EnsureItemsInBothHands(c.Character);
CreateNetworkEvent();
foreach (Inventory prevInventory in prevItemInventories.Distinct())
{
@@ -174,6 +177,33 @@ namespace Barotrauma
}
}
private void EnsureItemsInBothHands(Character character)
{
if (this is not CharacterInventory charInv) { return; }
int leftHandSlot = charInv.FindLimbSlot(InvSlotType.LeftHand),
rightHandSlot = charInv.FindLimbSlot(InvSlotType.RightHand);
if (IsSlotIndexOutOfBound(leftHandSlot) || IsSlotIndexOutOfBound(rightHandSlot)) { return; }
TryPutInOppositeHandSlot(rightHandSlot, leftHandSlot);
TryPutInOppositeHandSlot(leftHandSlot, rightHandSlot);
void TryPutInOppositeHandSlot(int originalSlot, int otherHandSlot)
{
const InvSlotType bothHandSlot = InvSlotType.LeftHand | InvSlotType.RightHand;
foreach (Item it in slots[originalSlot].Items)
{
if (it.AllowedSlots.None(static s => s.HasFlag(bothHandSlot)) || slots[otherHandSlot].Contains(it)) { continue; }
TryPutItem(it, otherHandSlot, true, true, character, false);
}
}
bool IsSlotIndexOutOfBound(int index) => index < 0 || index >= slots.Length;
}
public void ServerEventWrite(IWriteMessage msg, Client c, NetEntityEvent.IData extraData = null)
{
SharedWrite(msg, extraData);
@@ -65,7 +65,8 @@ namespace Barotrauma
msg.WriteUInt16(GameMain.Server.EntityEventManager.Events.Last()?.ID ?? (ushort)0);
itemContainer.Inventory.ServerEventWrite(msg, c);
break;
case ItemStatusEventData _:
case ItemStatusEventData statusEvent:
msg.WriteBoolean(statusEvent.LoadingRound);
msg.WriteSingle(condition);
break;
case AssignCampaignInteractionEventData _:
@@ -102,9 +102,9 @@ namespace Barotrauma.Networking
similarity *= 0.25f;
}
bool isOwner = GameMain.Server.OwnerConnection != null && c.Connection == GameMain.Server.OwnerConnection;
bool isSpamExempt = RateLimiter.IsExempt(c);
if (similarity + c.ChatSpamSpeed > 5.0f && !isOwner)
if (similarity + c.ChatSpamSpeed > 5.0f && !isSpamExempt)
{
GameMain.Server.KarmaManager.OnSpamFilterTriggered(c);
@@ -125,7 +125,7 @@ namespace Barotrauma.Networking
c.ChatSpamSpeed += similarity + 0.5f;
if (c.ChatSpamTimer > 0.0f && !isOwner)
if (c.ChatSpamTimer > 0.0f && !isSpamExempt)
{
ChatMessage denyMsg = Create("", TextManager.Get("SpamFilterBlocked").Value, ChatMessageType.Server, null);
c.ChatSpamTimer = 10.0f;
@@ -108,9 +108,7 @@ namespace Barotrauma.Networking
const int MaxTransferCount = 16;
const int MaxTransferCountPerRecipient = 5;
public static TimeSpan MaxTransferDuration = new TimeSpan(0, 2, 0);
public delegate void FileTransferDelegate(FileTransferOut fileStreamReceiver);
public FileTransferDelegate OnStarted;
public FileTransferDelegate OnEnded;
@@ -121,8 +119,9 @@ namespace Barotrauma.Networking
private readonly ServerPeer peer;
public static DateTime StartTime;
#if DEBUG
public float StallPacketsTime { get; set; }
public float ForceMinimumFileTransferDuration { get; set; }
#endif
public IReadOnlyList<FileTransferOut> ActiveTransfers => activeTransfers;
@@ -172,6 +171,8 @@ namespace Barotrauma.Networking
return null;
}
StartTime = DateTime.Now;
OnStarted(transfer);
GameMain.Server.LastClientListUpdateID++;
@@ -259,7 +260,18 @@ namespace Barotrauma.Networking
for (int i = 0; i < Math.Floor(transfer.PacketsPerUpdate); i++)
{
long remaining = transfer.Data.Length - transfer.SentOffset;
int sendByteCount = (remaining > chunkLen ? chunkLen : (int)remaining);
#if DEBUG
bool stalling = false;
float elapsedTime = (float)(DateTime.Now - StartTime).TotalSeconds;
if (elapsedTime < ForceMinimumFileTransferDuration)
{
int remainingChunks = (int)Math.Max(remaining / chunkLen, 1);
transfer.WaitTimer =
Math.Max(transfer.WaitTimer, (ForceMinimumFileTransferDuration - elapsedTime) / remainingChunks);
if (remainingChunks <= 1) { break; }
}
#endif
int sendByteCount = remaining > chunkLen ? chunkLen : (int)remaining;
message = new WriteOnlyMessage();
message.WriteByte((byte)ServerPacketHeader.FILE_TRANSFER);
@@ -293,11 +305,10 @@ namespace Barotrauma.Networking
//this gets reset when packet loss or disorder sets in
transfer.PacketsPerUpdate = Math.Min(FileTransferOut.MaxPacketsPerUpdate,
transfer.PacketsPerUpdate + 0.05f);
}
#if DEBUG
transfer.WaitTimer = Math.Max(transfer.WaitTimer, StallPacketsTime);
if (stalling) { break; }
#endif
}
}
catch (Exception e)
@@ -330,7 +341,7 @@ namespace Barotrauma.Networking
{
byte transferId = inc.ReadByte();
var matchingTransfer = activeTransfers.Find(t => t.Connection == inc.Sender && t.ID == transferId);
if (matchingTransfer != null) CancelTransfer(matchingTransfer);
if (matchingTransfer != null) { CancelTransfer(matchingTransfer); }
return;
}
else if (messageType == FileTransferMessageType.Data)
@@ -359,6 +370,7 @@ namespace Barotrauma.Networking
if (matchingTransfer.KnownReceivedOffset >= matchingTransfer.Data.Length)
{
matchingTransfer.Status = FileTransferStatus.Finished;
DebugConsole.Log($"Finished sending file \"{matchingTransfer.FilePath}\" to \"{client.Name}\". Took {DateTime.Now - StartTime}");
}
}
return;
@@ -303,7 +303,7 @@ namespace Barotrauma.Networking
}
else
{
var defaultPerms = PermissionPreset.List.Find(p => p.Name == "None");
var defaultPerms = PermissionPreset.List.Find(p => p.Identifier == "None");
if (defaultPerms != null)
{
newClient.SetPermissions(defaultPerms.Permissions, defaultPerms.PermittedCommands);
@@ -332,9 +332,8 @@ namespace Barotrauma.Networking
public void Update(float deltaTime)
{
#if CLIENT
if (ShowNetStats) { netStats.Update(deltaTime); }
#endif
dosProtection.Update(deltaTime);
if (!started) { return; }
if (ChildServerRelay.HasShutDown)
@@ -387,10 +386,10 @@ namespace Barotrauma.Networking
Voting.Update(deltaTime);
bool isCrewDead =
connectedClients.All(c => c.Character == null || c.Character.IsDead || c.Character.IsIncapacitated);
connectedClients.All(c => !c.UsingFreeCam && (c.Character == null || c.Character.IsDead || c.Character.IsIncapacitated));
bool subAtLevelEnd = false;
if (Submarine.MainSub != null && !(GameMain.GameSession.GameMode is PvPMode))
if (Submarine.MainSub != null && GameMain.GameSession.GameMode is not PvPMode)
{
if (Level.Loaded?.EndOutpost != null)
{
@@ -692,10 +691,14 @@ namespace Barotrauma.Networking
}
}
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();
switch (header)
{
@@ -772,9 +775,12 @@ namespace Barotrauma.Networking
string localSavePath = SaveUtil.CreateSavePath(SaveUtil.SaveType.Multiplayer, saveName);
if (CampaignMode.AllowedToManageCampaign(connectedClient, ClientPermissions.ManageRound))
{
ServerSettings.CampaignSettings = settings;
ServerSettings.SaveSettings();
MultiPlayerCampaign.StartNewCampaign(localSavePath, matchingSub.FilePath, seed, settings);
using (dosProtection.Pause(connectedClient))
{
ServerSettings.CampaignSettings = settings;
ServerSettings.SaveSettings();
MultiPlayerCampaign.StartNewCampaign(localSavePath, matchingSub.FilePath, seed, settings);
}
}
}
}
@@ -784,11 +790,14 @@ namespace Barotrauma.Networking
if (GameStarted)
{
SendDirectChatMessage(TextManager.Get("CampaignStartFailedRoundRunning").Value, connectedClient, ChatMessageType.MessageBox);
return;
break;
}
if (CampaignMode.AllowedToManageCampaign(connectedClient, ClientPermissions.ManageRound))
{
MultiPlayerCampaign.LoadCampaign(saveName);
{
using (dosProtection.Pause(connectedClient))
{
MultiPlayerCampaign.LoadCampaign(saveName);
}
}
}
break;
@@ -1085,7 +1094,7 @@ namespace Barotrauma.Networking
ChatMessage.ServerRead(inc, c);
break;
case ClientNetSegment.Vote:
Voting.ServerRead(inc, c);
Voting.ServerRead(inc, c, dosProtection);
break;
default:
return SegmentTableReader<ClientNetSegment>.BreakSegmentReading.Yes;
@@ -1246,7 +1255,7 @@ namespace Barotrauma.Networking
entityEventManager.Read(inc, c);
break;
case ClientNetSegment.Vote:
Voting.ServerRead(inc, c);
Voting.ServerRead(inc, c, dosProtection);
break;
case ClientNetSegment.SpectatingPos:
c.SpectatePos = new Vector2(inc.ReadSingle(), inc.ReadSingle());
@@ -1406,19 +1415,23 @@ namespace Barotrauma.Networking
bool quitCampaign = inc.ReadBoolean();
if (GameStarted)
{
Log($"Client \"{ClientLogName(sender)}\" ended the round.", ServerLog.MessageType.ServerMessage);
if (mpCampaign != null && Level.IsLoadedFriendlyOutpost && save)
using (dosProtection.Pause(sender))
{
mpCampaign.SavePlayers();
GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine);
mpCampaign.UpdateStoreStock();
SaveUtil.SaveGame(GameMain.GameSession.SavePath);
Log($"Client \"{ClientLogName(sender)}\" ended the round.", ServerLog.MessageType.ServerMessage);
if (mpCampaign != null && Level.IsLoadedFriendlyOutpost && save)
{
mpCampaign.SavePlayers();
GameMain.GameSession.SubmarineInfo = new SubmarineInfo(GameMain.GameSession.Submarine);
mpCampaign.UpdateStoreStock();
GameMain.GameSession?.EventManager?.RegisterEventHistory(registerFinishedOnly: true);
SaveUtil.SaveGame(GameMain.GameSession.SavePath);
}
else
{
save = false;
}
EndGame(wasSaved: save);
}
else
{
save = false;
}
EndGame(wasSaved: save);
}
else if (mpCampaign != null)
{
@@ -1442,45 +1455,54 @@ namespace Barotrauma.Networking
}
else if (CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageCampaign) || CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMap))
{
MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.SavePath);
using (dosProtection.Pause(sender))
{
MultiPlayerCampaign.LoadCampaign(GameMain.GameSession.SavePath);
}
}
}
else if (!GameStarted && !initiatedStartGame)
{
Log("Client \"" + ClientLogName(sender) + "\" started the round.", ServerLog.MessageType.ServerMessage);
TryStartGame();
using (dosProtection.Pause(sender))
{
Log("Client \"" + ClientLogName(sender) + "\" started the round.", ServerLog.MessageType.ServerMessage);
TryStartGame();
}
}
else if (mpCampaign != null && (CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageCampaign) || CampaignMode.AllowedToManageCampaign(sender, ClientPermissions.ManageMap)))
{
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)
using (dosProtection.Pause(sender))
{
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
return;
default:
Log("Client \"" + ClientLogName(sender) + "\" ended the round.", ServerLog.MessageType.ServerMessage);
mpCampaign.LoadNewLevel();
break;
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;
}
}
}
}
@@ -1520,11 +1542,7 @@ namespace Barotrauma.Networking
mpCampaign?.ServerRead(inc, sender);
break;
case ClientPermissions.ConsoleCommands:
{
string consoleCommand = inc.ReadString();
Vector2 clientCursorPos = new Vector2(inc.ReadSingle(), inc.ReadSingle());
DebugConsole.ExecuteClientCommand(sender, clientCursorPos, consoleCommand);
}
DebugConsole.ServerRead(inc, sender);
break;
case ClientPermissions.ManagePermissions:
byte targetClientID = inc.ReadByte();
@@ -2582,16 +2600,20 @@ namespace Barotrauma.Networking
{
if (!CampaignMode.AllowedToManageCampaign(client, ClientPermissions.ManageRound)) { return false; }
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++)
using (dosProtection.Pause(client))
{
msg.WriteNetSerializableStruct(saveInfos[i]);
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);
}
serverPeer.Send(msg, client.Connection, DeliveryMethod.Reliable);
return true;
}
@@ -3588,15 +3610,28 @@ namespace Barotrauma.Networking
}
}
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)
{
sender.SpectateOnly = message.ReadBoolean() && (ServerSettings.AllowSpectating || sender.Connection == OwnerConnection);
if (sender.SpectateOnly)
{
return;
}
bool spectateOnly = message.ReadBoolean();
message.ReadPadBits();
string newName = message.ReadString();
sender.SpectateOnly = spectateOnly && (ServerSettings.AllowSpectating || sender.Connection == OwnerConnection);
if (sender.SpectateOnly) { return; }
var netInfo = INetSerializableStruct.Read<NetCharacterInfo>(message);
if (charInfoRateLimiter.IsLimitReached(sender)) { return; }
string newName = netInfo.NewName;
if (string.IsNullOrEmpty(newName))
{
newName = sender.Name;
@@ -3614,42 +3649,31 @@ namespace Barotrauma.Networking
}
}
int tagCount = message.ReadByte();
HashSet<Identifier> tagSet = new HashSet<Identifier>();
for (int i = 0; i < tagCount; i++)
{
tagSet.Add(message.ReadIdentifier());
}
int hairIndex = message.ReadByte();
int beardIndex = message.ReadByte();
int moustacheIndex = message.ReadByte();
int faceAttachmentIndex = message.ReadByte();
Color skinColor = message.ReadColorR8G8B8();
Color hairColor = message.ReadColorR8G8B8();
Color facialHairColor = message.ReadColorR8G8B8();
List<JobVariant> jobPreferences = new List<JobVariant>();
int count = message.ReadByte();
for (int i = 0; i < Math.Min(count, 3); i++)
{
string jobIdentifier = message.ReadString();
int variant = message.ReadByte();
if (JobPrefab.Prefabs.TryGet(jobIdentifier, out JobPrefab jobPrefab))
{
if (jobPrefab.HiddenJob) { continue; }
jobPreferences.Add(new JobVariant(jobPrefab, variant));
}
}
sender.CharacterInfo = new CharacterInfo(CharacterPrefab.HumanSpeciesName, newName);
sender.CharacterInfo.RecreateHead(tagSet.ToImmutableHashSet(), hairIndex, beardIndex, moustacheIndex, faceAttachmentIndex);
sender.CharacterInfo.Head.SkinColor = skinColor;
sender.CharacterInfo.Head.HairColor = hairColor;
sender.CharacterInfo.Head.FacialHairColor = facialHairColor;
if (jobPreferences.Count > 0)
sender.CharacterInfo.RecreateHead(
tags: netInfo.Tags.ToImmutableHashSet(),
hairIndex: netInfo.HairIndex,
beardIndex: netInfo.BeardIndex,
moustacheIndex: netInfo.MoustacheIndex,
faceAttachmentIndex: netInfo.FaceAttachmentIndex);
sender.CharacterInfo.Head.SkinColor = netInfo.SkinColor;
sender.CharacterInfo.Head.HairColor = netInfo.HairColor;
sender.CharacterInfo.Head.FacialHairColor = netInfo.FacialHairColor;
if (netInfo.JobVariants.Length > 0)
{
sender.JobPreferences = jobPreferences;
List<JobVariant> variants = new List<JobVariant>();
foreach (NetJobVariant jv in netInfo.JobVariants)
{
if (jv.ToJobVariant() is { } variant)
{
variants.Add(variant);
}
}
sender.JobPreferences = variants;
}
}
@@ -24,11 +24,28 @@ namespace Barotrauma.Networking
}
protected readonly Callbacks callbacks;
private readonly ImmutableArray<ContentPackage> contentPackages;
protected ServerPeer(Callbacks callbacks)
{
this.callbacks = callbacks;
}
List<ContentPackage> contentPackageList = new List<ContentPackage>();
foreach (var cp in ContentPackageManager.EnabledPackages.All)
{
if (!cp.Files.Any()) { continue; }
if (!cp.HasMultiplayerSyncedContent && !cp.Files.All(f => f is SubmarineFile)) { continue; }
if (cp.UgcId.TryUnwrap(out var id1) &&
contentPackageList.FirstOrDefault(cp => cp.UgcId.TryUnwrap(out var id2) && id1.Equals(id2)) is ContentPackage existingPackage)
{
//there can be multiple enabled mods with the same UgcId if the player has e.g. created a local copy of a workshop mod
DebugConsole.AddWarning($"The content package \"{existingPackage.Name}\" ({existingPackage.Path}) has the same id as \"{cp.Name}\" ({cp.Path}). Ignoring the latter package.");
continue;
}
contentPackageList.Add(cp);
}
contentPackages = contentPackageList.ToImmutableArray();
}
public abstract void InitializeSteamServerCallbacks();
@@ -250,9 +267,7 @@ namespace Barotrauma.Networking
structToSend = new ServerPeerContentPackageOrderPacket
{
ServerName = GameMain.Server.ServerName,
ContentPackages = ContentPackageManager.EnabledPackages.All
.Where(cp => cp.Files.Any())
.Where(cp => cp.HasMultiplayerSyncedContent || cp.Files.All(f => f is SubmarineFile))
ContentPackages = contentPackages
.Select(contentPackage => new ServerContentPackage(contentPackage, timeNow))
.ToImmutableArray()
};
@@ -520,7 +520,7 @@ namespace Barotrauma.Networking
else
{
string presetName = clientElement.GetAttributeString("preset", "");
PermissionPreset preset = PermissionPreset.List.Find(p => p.Name == presetName);
PermissionPreset preset = PermissionPreset.List.Find(p => p.DisplayName == presetName);
if (preset == null)
{
DebugConsole.ThrowError("Failed to restore saved permissions to the client \"" + clientName + "\". Permission preset \"" + presetName + "\" not found.");
@@ -585,8 +585,7 @@ namespace Barotrauma.Networking
foreach (SavedClientPermission clientPermission in ClientPermissions)
{
var matchingPreset = PermissionPreset.List.Find(p => p.MatchesPermissions(clientPermission.Permissions, clientPermission.PermittedCommands));
#warning TODO: this is broken because of localization
if (matchingPreset != null && matchingPreset.Name == "None")
if (matchingPreset != null && matchingPreset.Identifier == "None")
{
continue;
}
@@ -600,7 +599,7 @@ namespace Barotrauma.Networking
clientElement.Add(matchingPreset == null
? new XAttribute("permissions", clientPermission.Permissions.ToString())
: new XAttribute("preset", matchingPreset.Name));
: new XAttribute("preset", matchingPreset.DisplayName));
if (clientPermission.Permissions.HasFlag(Networking.ClientPermissions.ConsoleCommands))
{
@@ -225,7 +225,7 @@ namespace Barotrauma
}
}
public void ServerRead(IReadMessage inc, Client sender)
public void ServerRead(IReadMessage inc, Client sender, DoSProtection dosProtection)
{
if (GameMain.Server == null || sender == null) { return; }
@@ -337,7 +337,10 @@ namespace Barotrauma
inc.ReadPadBits();
GameMain.Server.UpdateVoteStatus();
using (dosProtection.Pause(sender))
{
GameMain.Server.UpdateVoteStatus();
}
}
public void ServerWrite(IWriteMessage msg)
@@ -158,7 +158,10 @@ namespace Barotrauma
sb.AppendLine("Language: " + GameSettings.CurrentConfig.Language);
if (ContentPackageManager.EnabledPackages.All != null)
{
sb.AppendLine("Selected content packages: " + (!ContentPackageManager.EnabledPackages.All.Any() ? "None" : string.Join(", ", ContentPackageManager.EnabledPackages.All.Select(c => c.Name))));
sb.AppendLine("Selected content packages: " +
(!ContentPackageManager.EnabledPackages.All.Any() ?
"None" :
string.Join(", ", ContentPackageManager.EnabledPackages.All.Select(c => $"{c.Name} ({c.Hash?.ShortRepresentation ?? "unknown"})"))));
}
sb.AppendLine("Level seed: " + ((Level.Loaded == null) ? "no level loaded" : Level.Loaded.Seed));
sb.AppendLine("Loaded submarine: " + ((Submarine.MainSub == null) ? "None" : Submarine.MainSub.Info.Name + " (" + Submarine.MainSub.Info.MD5Hash + ")"));
@@ -5,12 +5,13 @@ namespace Barotrauma.Steam
{
partial class SteamManager
{
private static void InitializeProjectSpecific() { IsInitialized = true; }
private static void InitializeProjectSpecific() { }
private static bool IsInitializedProjectSpecific
=> Steamworks.SteamServer.IsValid;
public static bool CreateServer(Networking.GameServer server, bool isPublic)
{
IsInitialized = true;
Steamworks.SteamServerInit options = new Steamworks.SteamServerInit("Barotrauma", "Barotrauma")
{
GamePort = (ushort)server.Port,
@@ -56,10 +57,15 @@ namespace Barotrauma.Steam
Steamworks.SteamServer.SetKey("message", server.ServerSettings.ServerMessageText);
Steamworks.SteamServer.SetKey("version", GameMain.Version.ToString());
Steamworks.SteamServer.SetKey("playercount", server.ConnectedClients.Count.ToString());
Steamworks.SteamServer.SetKey("contentpackage", string.Join(",", contentPackages.Select(cp => cp.Name)));
Steamworks.SteamServer.SetKey("contentpackagehash", string.Join(",", contentPackages.Select(cp => cp.Hash.StringRepresentation)));
Steamworks.SteamServer.SetKey("contentpackageid", string.Join(",", contentPackages.Select(cp
=> cp.UgcId.TryUnwrap(out var ugcId) ? ugcId.StringRepresentation : "")));
int index = 0;
foreach (var contentPackage in contentPackages)
{
string ugcIdStr = contentPackage.UgcId.TryUnwrap(out var ugcId) ? ugcId.StringRepresentation : string.Empty;
Steamworks.SteamServer.SetKey(
$"contentpackage{index}",
contentPackage.Name+","+ contentPackage.Hash.StringRepresentation + "," + ugcIdStr);
index++;
}
Steamworks.SteamServer.SetKey("modeselectionmode", server.ServerSettings.ModeSelectionMode.ToString());
Steamworks.SteamServer.SetKey("subselectionmode", server.ServerSettings.SubSelectionMode.ToString());
Steamworks.SteamServer.SetKey("voicechatenabled", server.ServerSettings.VoiceChatEnabled.ToString());
@@ -20,19 +20,6 @@ namespace Barotrauma
}
public delegate void MessageSender(string message);
public void Greet(GameServer server, string codeWords, string codeResponse, MessageSender messageSender)
{
string greetingMessage = TextManager.FormatServerMessage(Mission.StartText,
("[codewords]", codeWords),
("[coderesponse]", codeResponse));
messageSender(greetingMessage);
Client traitorClient = server.ConnectedClients.Find(c => c.Character == Character);
Client ownerClient = server.ConnectedClients.Find(c => c.Connection == server.OwnerConnection);
if (traitorClient != ownerClient && ownerClient != null && ownerClient.Character == null)
{
GameMain.Server.SendTraitorMessage(ownerClient, CurrentObjective.StartMessageServerText.Value, Mission.Identifier, TraitorMessageType.ServerMessageBox);
}
}
public void SendChatMessage(string serverText, Identifier iconIdentifier)
{
@@ -224,10 +224,6 @@ namespace Barotrauma
{
pendingMessages.Add(traitor, new List<string>());
}
foreach (var traitor in Traitors.Values)
{
traitor.Greet(server, CodeWords, CodeResponse, message => pendingMessages[traitor].Add(message));
}
pendingMessages.ForEach(traitor => traitor.Value.ForEach(message => traitor.Key.SendChatMessage(message, Identifier)));
pendingMessages.ForEach(traitor => traitor.Value.ForEach(message => traitor.Key.SendChatMessageBox(message, Identifier)));
@@ -0,0 +1,232 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Barotrauma.Networking;
namespace Barotrauma
{
internal sealed class DoSProtection
{
/// <summary>
/// A struct that executes an action when it's created and another one when it's disposed.
/// </summary>
public readonly ref struct DoSAction
{
private readonly Client sender;
private readonly Action<Client> end;
public DoSAction(Client sender, Action<Client> start, Action<Client> end)
{
this.sender = sender;
this.end = end;
start(sender);
}
public void Dispose()
{
end(sender);
}
}
private sealed class OffenseData
{
/// <summary>
/// Timer that keeps track of how long it takes to process a packet.
/// </summary>
public readonly Stopwatch Stopwatch = new();
/// <summary>
/// Amount of strikes the client has received for causing the server to slow down.
/// </summary>
public int Strikes;
/// <summary>
/// How many packets have been sent in the last minute.
/// </summary>
public int PacketCount;
/// <summary>
/// Resets the strikes and packet count.
/// </summary>
public void ResetStrikes()
{
Strikes = 0;
PacketCount = 0;
}
/// <summary>
/// Resets the timer.
/// </summary>
public void ResetTimer() => Stopwatch.Reset();
}
private readonly Dictionary<Client, OffenseData> clients = new();
private float stopwatchResetTimer,
strikesResetTimer;
private const int StopwatchResetInterval = 1,
StrikesResetInterval = 60,
StrikeThreshold = 6;
/// <summary>
/// Called when the server receives a packet to start logging how much time it takes to process.
/// </summary>
/// <param name="client">The client to start a timer for.</param>
/// <returns>Nothing useful. Required for the "using" keyword.</returns>
/// <remarks>
/// Calling stop is not required, the timer will be stopped automatically when the function it was started in returns.
/// </remarks>
/// <example>
/// <code>
/// public void ServerRead(IReadMessage msg, Client c)
/// {
/// // start the timer
/// using var _ = dosProtection.Start(connectedClient);
///
/// if (condition)
/// {
/// // the timer will be stopped here.
/// return;
/// }
///
/// ProcessMessage(msg);
/// // the timer will be stopped here.
/// }
/// </code>
/// </example>
public DoSAction Start(Client client) => new DoSAction(client, StartFor, EndFor);
/// <summary>
/// Temporary pauses the timer for the client.
/// Used when we know a packet is going to slow down the server but we don't want to count it as a strike.
/// For example when a client is starting a round.
/// </summary>
/// <param name="client">The client to pause the timer for.</param>
/// <returns>Nothing useful. Required for the "using" keyword.</returns>
/// <remarks>
/// Calling resume is not required, the timer will be resumed automatically when the using block ends.
/// </remarks>
/// <example>
/// <code>
/// using (dos.Pause(client))
/// {
/// // do something that will slow down the server
/// }
/// // the timer will be resumed here
/// </code>
/// </example>
public DoSAction Pause(Client client) => new DoSAction(client, PauseFor, ResumeFor);
private void StartFor(Client client)
{
if (!clients.ContainsKey(client))
{
clients.Add(client, new OffenseData());
}
clients[client].Stopwatch.Start();
}
private void EndFor(Client client)
{
if (GetData(client) is not { } data) { return; }
data.PacketCount++;
data.Stopwatch.Stop();
UpdateOffense(client, data);
}
// stops the clock but doesn't update offenses
private void PauseFor(Client client) => GetData(client)?.Stopwatch.Stop();
private void ResumeFor(Client client) => GetData(client)?.Stopwatch.Start();
private void UpdateOffense(Client client, OffenseData data)
{
if (GameMain.Server?.ServerSettings is not { } settings) { return; }
// client is sending too many packets, kick them
if (data.PacketCount > settings.MaxPacketAmount && settings.MaxPacketAmount > ServerSettings.PacketLimitMin)
{
AttemptKickClient(client, TextManager.Get("PacketLimitKicked"));
clients.Remove(client);
return;
}
// if the stopwatch has been running for an entire second without the Update() method resetting it (which it does every second) then something is wrong
if (data.Stopwatch.ElapsedMilliseconds < 100) { return; }
data.Strikes++;
data.ResetTimer();
GameServer.Log($"{NetworkMember.ClientLogName(client)} is causing the server to slow down.", ServerLog.MessageType.DoSProtection);
// too many strikes, get them out of here
if (data.Strikes < StrikeThreshold) { return; }
if (settings.EnableDoSProtection)
{
AttemptKickClient(client, TextManager.Get("DoSProtectionKicked"));
}
clients.Remove(client);
static void AttemptKickClient(Client client, LocalizedString reason)
{
// ReSharper disable once ConvertToConstant.Local
bool doesRateLimitAffectClient =
#if DEBUG
true; // for testing
#else
!RateLimiter.IsExempt(client);
#endif
if (!doesRateLimitAffectClient)
{
return;
}
GameMain.Server?.KickClient(client, reason.Value);
}
}
public void Update(float deltaTime)
{
stopwatchResetTimer += deltaTime;
strikesResetTimer += deltaTime;
// reset the stopwatch every second
if (stopwatchResetTimer > StopwatchResetInterval)
{
stopwatchResetTimer = 0;
foreach (OffenseData data in clients.Values)
{
data.ResetTimer();
}
}
// reset the strikes every minute
if (strikesResetTimer > StrikesResetInterval)
{
strikesResetTimer = 0;
foreach (var (client, data) in clients)
{
if (GameMain.Server?.ServerSettings is { MaxPacketAmount: > ServerSettings.PacketLimitMin } settings)
{
if (data.PacketCount > settings.MaxPacketAmount * 0.9f)
{
GameServer.Log($"{NetworkMember.ClientLogName(client)} is sending a lot of packets and almost got kicked! ({data.PacketCount}).", ServerLog.MessageType.DoSProtection);
}
}
data.ResetStrikes();
}
}
}
private OffenseData? GetData(Client client) => clients.TryGetValue(client, out OffenseData? data) ? data : null;
}
}
@@ -0,0 +1,135 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Barotrauma.Networking;
namespace Barotrauma
{
public enum RateLimitAction
{
Invalid,
OnLimitReached,
OnLimitDoubled,
}
public enum RateLimitPunishment
{
None, // just ignore
Announce, // announce to the server
Kick, // kick the player
Ban // ban the player
}
internal sealed class RateLimiter
{
private sealed record RateLimit(DateTimeOffset Expiry)
{
public int RequestAmount;
}
private readonly Dictionary<Client, RateLimit> rateLimits = new();
private readonly HashSet<Client> expiredRateLimits = new();
private readonly Dictionary<Client, DateTimeOffset> recentlyAnnouncedOffenders = new();
private readonly int maxRequests, expiryInSeconds;
private readonly ImmutableDictionary<RateLimitAction, RateLimitPunishment> punishments;
public RateLimiter(int maxRequests, int expiryInSeconds, params (RateLimitAction Action, RateLimitPunishment Punishment)[] punishmentRules)
{
this.maxRequests = maxRequests;
this.expiryInSeconds = expiryInSeconds;
punishments = punishmentRules.ToImmutableDictionary(
static pair => pair.Action,
static pair => pair.Punishment);
}
public bool IsLimitReached(Client client)
{
#if !DEBUG
if (IsExempt(client)) { return false; }
#endif
expiredRateLimits.Clear();
foreach (var (c, limit) in rateLimits)
{
if (limit.Expiry < DateTimeOffset.Now)
{
expiredRateLimits.Add(c);
}
}
foreach (Client c in expiredRateLimits)
{
rateLimits.Remove(c);
}
if (!rateLimits.TryGetValue(client, out RateLimit? rateLimit))
{
rateLimit = new RateLimit(DateTimeOffset.Now.AddSeconds(expiryInSeconds));
rateLimits.Add(client, rateLimit);
}
rateLimit.RequestAmount++;
if (rateLimit.RequestAmount > maxRequests)
{
ProcessPunishment(client, rateLimit.RequestAmount);
return true;
}
return false;
}
private void ProcessPunishment(Client client, int requests)
{
bool isDosProtectionEnabled = GameMain.Server is { ServerSettings.EnableDoSProtection: true };
foreach (var (action, punishment) in punishments)
{
switch (action)
{
case RateLimitAction.Invalid:
continue;
case RateLimitAction.OnLimitReached when requests >= maxRequests:
case RateLimitAction.OnLimitDoubled when requests >= maxRequests * 2:
switch (punishment)
{
case RateLimitPunishment.None:
continue;
case RateLimitPunishment.Announce:
AnnounceOffender(client);
break;
case RateLimitPunishment.Ban when isDosProtectionEnabled:
GameMain.Server?.BanClient(client, TextManager.Get("SpamFilterKicked").Value);
break;
case RateLimitPunishment.Kick when isDosProtectionEnabled:
GameMain.Server?.KickClient(client, TextManager.Get("SpamFilterKicked").Value);
break;
}
break;
}
}
}
private void AnnounceOffender(Client client)
{
if (recentlyAnnouncedOffenders.TryGetValue(client, out DateTimeOffset expiry))
{
if (expiry > DateTimeOffset.Now) { return; }
recentlyAnnouncedOffenders.Remove(client);
}
GameServer.Log($"{NetworkMember.ClientLogName(client)} is sending too many packets!", ServerLog.MessageType.DoSProtection);
recentlyAnnouncedOffenders.Add(client, DateTimeOffset.Now.AddSeconds(expiryInSeconds));
}
public static bool IsExempt(Client client) =>
(GameMain.Server.OwnerConnection != null && client.Connection == GameMain.Server.OwnerConnection)
|| client.HasPermission(ClientPermissions.SpamImmunity);
}
}