From e1bd85313a0a02729943f3b56138fc88be110f89 Mon Sep 17 00:00:00 2001 From: miniduikboot Date: Fri, 28 Feb 2025 00:00:21 +0100 Subject: [PATCH] Remove AllowNull from PlayerControl.PlayerInfo It is possible to spawn invalid network objects without an attached PlayerInfo, and Impostor will throw exceptions everywhere if you happen to spawn one. Officials do allow spawning objects like this, so allow it in Impostor for compatibility reasons --- .../Net/Inner/Objects/IInnerPlayerControl.cs | 2 +- .../Net/Messages/S2C/Message01JoinGameS2C.cs | 2 +- .../Messages/S2C/Message07JoinedGameS2C.cs | 2 +- .../Net/Inner/InnerNetObject.Anticheat.cs | 33 ++- .../Net/Inner/Objects/InnerMeetingHud.cs | 2 +- .../Inner/Objects/InnerPlayerControl.Api.cs | 61 +++++- .../Net/Inner/Objects/InnerPlayerControl.cs | 190 +++++++++++++++--- src/Impostor.Server/Net/State/Game.cs | 5 +- 8 files changed, 249 insertions(+), 48 deletions(-) diff --git a/src/Impostor.Api/Net/Inner/Objects/IInnerPlayerControl.cs b/src/Impostor.Api/Net/Inner/Objects/IInnerPlayerControl.cs index f6c439c..c339346 100644 --- a/src/Impostor.Api/Net/Inner/Objects/IInnerPlayerControl.cs +++ b/src/Impostor.Api/Net/Inner/Objects/IInnerPlayerControl.cs @@ -29,7 +29,7 @@ namespace Impostor.Api.Net.Inner.Objects /// Gets the of the . /// Contains metadata about the player. /// - IInnerPlayerInfo PlayerInfo { get; } + IInnerPlayerInfo? PlayerInfo { get; } /// /// Sets the name of the current . diff --git a/src/Impostor.Api/Net/Messages/S2C/Message01JoinGameS2C.cs b/src/Impostor.Api/Net/Messages/S2C/Message01JoinGameS2C.cs index 02df684..3c89cfb 100644 --- a/src/Impostor.Api/Net/Messages/S2C/Message01JoinGameS2C.cs +++ b/src/Impostor.Api/Net/Messages/S2C/Message01JoinGameS2C.cs @@ -18,7 +18,7 @@ namespace Impostor.Api.Net.Messages.S2C writer.Write(hostId); writer.Write(player.Client.Name); player.Client.PlatformSpecificData.Serialize(writer); - writer.WritePacked(player.Character?.PlayerInfo.PlayerLevel ?? 1); + writer.WritePacked(player.Character?.PlayerInfo?.PlayerLevel ?? 1); // ProductUserId and FriendCode are not yet known, so set them to an empty string writer.Write(string.Empty); diff --git a/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs b/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs index d5d3718..9ddeb03 100644 --- a/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs +++ b/src/Impostor.Api/Net/Messages/S2C/Message07JoinedGameS2C.cs @@ -22,7 +22,7 @@ namespace Impostor.Api.Net.Messages.S2C writer.WritePacked(ply.Client.Id); writer.Write(ply.Client.Name); ply.Client.PlatformSpecificData.Serialize(writer); - writer.WritePacked(ply.Character?.PlayerInfo.PlayerLevel ?? 1); + writer.WritePacked(ply.Character?.PlayerInfo?.PlayerLevel ?? 1); // ProductUserId and FriendCode are not yet known, so set them to an empty string writer.Write(string.Empty); diff --git a/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs b/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs index 594ff6f..1f38c91 100644 --- a/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs +++ b/src/Impostor.Server/Net/Inner/InnerNetObject.Anticheat.cs @@ -73,9 +73,16 @@ namespace Impostor.Server.Net.Inner return true; } - protected async ValueTask ValidateImpostor(CheatContext context, IClientPlayer sender, InnerPlayerInfo playerInfo, bool value = true) + protected async ValueTask ValidateImpostor(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, bool value = true) { - if (playerInfo.IsImpostor != value) + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check if Impostor, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.IsImpostor != value) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, "Failed impostor check")) { @@ -86,9 +93,16 @@ namespace Impostor.Server.Net.Inner return true; } - protected async ValueTask ValidateCanVent(CheatContext context, IClientPlayer sender, InnerPlayerInfo playerInfo, bool value = true) + protected async ValueTask ValidateCanVent(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, bool value = true) { - if (playerInfo.CanVent != value) + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check if can vent, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.CanVent != value) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, "Failed can vent check")) { @@ -99,9 +113,16 @@ namespace Impostor.Server.Net.Inner return true; } - protected async ValueTask ValidateRole(CheatContext context, IClientPlayer sender, InnerPlayerInfo playerInfo, RoleTypes role) + protected async ValueTask ValidateRole(CheatContext context, IClientPlayer sender, InnerPlayerInfo? playerInfo, RoleTypes role) { - if (playerInfo.RoleType != role) + if (playerInfo == null) + { + if (await sender.Client.ReportCheatAsync(context, CheatCategory.InvalidObject, "Couldn't check role, playerInfo not set")) + { + return false; + } + } + else if (playerInfo.RoleType != role) { if (await sender.Client.ReportCheatAsync(context, CheatCategory.Role, $"Failed role = {role} check")) { diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs index 78916c2..92e5793 100644 --- a/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs +++ b/src/Impostor.Server/Net/Inner/Objects/InnerMeetingHud.cs @@ -287,7 +287,7 @@ namespace Impostor.Server.Net.Inner.Objects var max = MaxPair(self, out var tie); var exiled = tie ? null : Game.GameNet.GameData!.GetPlayerById(max.Key)?.Controller; - if (exiled != null) + if (exiled != null && exiled.PlayerInfo != null) { exiled.PlayerInfo.LastDeathReason = DeathReason.Exile; await _eventManager.CallAsync(new PlayerExileEvent(Game, Game.GetClientPlayer(exiled!.OwnerId)!, exiled)); diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs index 31240be..99ca2e3 100644 --- a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.Api.cs @@ -17,10 +17,15 @@ namespace Impostor.Server.Net.Inner.Objects IInnerCustomNetworkTransform IInnerPlayerControl.NetworkTransform => NetworkTransform; - IInnerPlayerInfo IInnerPlayerControl.PlayerInfo => PlayerInfo; + IInnerPlayerInfo? IInnerPlayerControl.PlayerInfo => PlayerInfo; public async ValueTask SetNameAsync(string name) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set name, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.PlayerName = name; using var writer = Game.StartRpc(NetId, RpcCalls.SetName); @@ -30,6 +35,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetColorAsync(ColorType color) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set color, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.Color = color; using var writer = Game.StartRpc(NetId, RpcCalls.SetColor); @@ -39,6 +49,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetHatAsync(string hatId) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set hat, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.HatId = hatId; using var writer = Game.StartRpc(NetId, RpcCalls.SetHatStr); @@ -48,6 +63,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetPetAsync(string petId) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set pet, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.PetId = petId; using var writer = Game.StartRpc(NetId, RpcCalls.SetPetStr); @@ -57,6 +77,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetSkinAsync(string skinId) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set skin, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.SkinId = skinId; using var writer = Game.StartRpc(NetId, RpcCalls.SetSkinStr); @@ -66,6 +91,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetVisorAsync(string visorId) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set visor, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.VisorId = visorId; using var writer = Game.StartRpc(NetId, RpcCalls.SetVisorStr); @@ -75,6 +105,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask SetNamePlateAsync(string nameplateId) { + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Cannot set nameplate, PlayerInfo is null"); + } + PlayerInfo.CurrentOutfit.NamePlateId = nameplateId; using var writer = Game.StartRpc(NetId, RpcCalls.SetNamePlateStr); @@ -103,7 +138,11 @@ namespace Impostor.Server.Net.Inner.Objects private bool ValidateMurderPlayer(IInnerPlayerControl target, MurderResultFlags result, [NotNullWhen(false)] out string? invalidReason) { - if (!PlayerInfo.IsImpostor) + if (PlayerInfo == null) + { + invalidReason = "Tried to murder a player, but the murderer didn't have a playerinfo"; + } + else if (!PlayerInfo.IsImpostor) { invalidReason = "Tried to murder a player, but murderer was not an impostor."; } @@ -111,6 +150,10 @@ namespace Impostor.Server.Net.Inner.Objects { invalidReason = "Tried to murder a player, but murderer was not alive."; } + else if (target.PlayerInfo == null) + { + invalidReason = "Tried to murder a player, but the murderer didn't have a playerinfo"; + } else if (target.PlayerInfo.IsImpostor) { invalidReason = "Tried to murder a player, but target is an impostor"; @@ -159,7 +202,11 @@ namespace Impostor.Server.Net.Inner.Objects public async ValueTask ProtectPlayerAsync(IInnerPlayerControl target) { - if (target.PlayerInfo.IsDead) + if (target.PlayerInfo == null) + { + throw new ImpostorProtocolException("Tried to exile a player, but target didn't have a playerinfo"); + } + else if (target.PlayerInfo.IsDead) { throw new ImpostorProtocolException("Tried to protect a player that is dead"); } @@ -172,13 +219,17 @@ namespace Impostor.Server.Net.Inner.Objects ((InnerPlayerControl)target).Protect(this); using var writer = Game.StartRpc(NetId, RpcCalls.ProtectPlayer); - Rpc45ProtectPlayer.Serialize(writer, target, PlayerInfo.CurrentOutfit.Color); + Rpc45ProtectPlayer.Serialize(writer, target, PlayerInfo?.CurrentOutfit.Color ?? ColorType.Red); await Game.FinishRpcAsync(writer); } public async ValueTask ExileAsync() { - if (PlayerInfo.IsDead) + if (PlayerInfo == null) + { + throw new ImpostorProtocolException("Tried to exile a player, but target didn't have a playerinfo"); + } + else if (PlayerInfo.IsDead) { throw new ImpostorProtocolException("Tried to exile a player, but target was not alive."); } diff --git a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs index 4922dfd..fb49b74 100644 --- a/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs +++ b/src/Impostor.Server/Net/Inner/Objects/InnerPlayerControl.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Impostor.Api; @@ -57,8 +56,7 @@ namespace Impostor.Server.Net.Inner.Objects public InnerCustomNetworkTransform NetworkTransform { get; } - [AllowNull] - public InnerPlayerInfo PlayerInfo { get; internal set; } + public InnerPlayerInfo? PlayerInfo { get; internal set; } internal Queue RequestedPlayerName { get; } = new Queue(); @@ -385,15 +383,25 @@ namespace Impostor.Server.Net.Inner.Objects Rpc44SetRole.Deserialize(reader, out var role, out var _); - if (role is RoleTypes.ImpostorGhost or RoleTypes.CrewmateGhost or RoleTypes.GuardianAngel) + if (PlayerInfo == null) { - PlayerInfo.RoleWhenAlive = PlayerInfo.RoleType; - PlayerInfo.IsDead = true; + if (await sender.Client.ReportCheatAsync(RpcCalls.SetRole, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } } + else + { + if (role is RoleTypes.ImpostorGhost or RoleTypes.CrewmateGhost or RoleTypes.GuardianAngel) + { + PlayerInfo.RoleWhenAlive = PlayerInfo.RoleType; + PlayerInfo.IsDead = true; + } - PlayerInfo.RoleType = role; + PlayerInfo.RoleType = role; + } - if (Game.GameState == GameStates.Starting && Game.Players.All(clientPlayer => clientPlayer.Character?.PlayerInfo.RoleType != null)) + if (Game.GameState == GameStates.Starting && Game.Players.All(clientPlayer => clientPlayer.Character?.PlayerInfo?.RoleType != null)) { await Game.StartedAsync(); } @@ -585,8 +593,19 @@ namespace Impostor.Server.Net.Inner.Objects internal void Die(DeathReason reason) { - PlayerInfo.IsDead = true; - PlayerInfo.LastDeathReason = reason; + if (PlayerInfo == null) + { + // "Custom Net Objects" aka hacked up PlayerControl objects as popularized by host only mods may trigger this + if (!Game.IsHostAuthoritive) + { + _logger.LogWarning("Tried to kill player that didn't have a PlayerInfo set, this shouldn't happen"); + } + } + else + { + PlayerInfo.IsDead = true; + PlayerInfo.LastDeathReason = reason; + } } internal void Protect(InnerPlayerControl guardianAngel) @@ -598,7 +617,18 @@ namespace Impostor.Server.Net.Inner.Objects private async ValueTask HandleCompleteTask(ClientPlayer sender, uint taskId) { - var task = PlayerInfo.Tasks.ElementAtOrDefault((int)taskId); + TaskInfo? task = null; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CompleteTask, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return; + } + } + else + { + task = PlayerInfo.Tasks.ElementAtOrDefault((int)taskId); + } if (task != null) { @@ -662,7 +692,10 @@ namespace Impostor.Server.Net.Inner.Objects if (sender.IsOwner(this)) { - if (Game.Players.Any(x => x.Character != null && x.Character != this && x.Character.PlayerInfo.PlayerName == name)) + if (Game.Players.Any(x => x.Character != null && + x.Character != this && + x.Character.PlayerInfo != null && + x.Character.PlayerInfo.PlayerName == name)) { if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.NameLimits, "Client sent name that is already used")) { @@ -685,14 +718,20 @@ namespace Impostor.Server.Net.Inner.Objects var expected = RequestedPlayerName.Dequeue(); var requested = expected; - if (Game.Players.Any(x => x.Character != null && x.Character != this && x.Character.PlayerInfo.PlayerName == expected)) + if (Game.Players.Any(x => x.Character != null && + x.Character != this && + x.Character.PlayerInfo != null && + x.Character.PlayerInfo.PlayerName == expected)) { var i = 1; while (true) { var text = expected + " " + i; - if (Game.Players.All(x => x.Character == null || x.Character == this || x.Character.PlayerInfo.PlayerName != text)) + if (Game.Players.All(x => x.Character == null || + x.Character == this || + x.Character.PlayerInfo == null || + x.Character.PlayerInfo.PlayerName != text)) { expected = text; break; @@ -723,7 +762,17 @@ namespace Impostor.Server.Net.Inner.Objects } } - PlayerInfo.CurrentOutfit.PlayerName = name; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetName, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.PlayerName = name; + } return true; } @@ -843,7 +892,17 @@ namespace Impostor.Server.Net.Inner.Objects } } - PlayerInfo.CurrentOutfit.Color = color; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetColor, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.Color = color; + } // Record the color so it can be restored on the next game if (Game.TryGetPlayer(OwnerId, out var clientPlayer)) @@ -866,8 +925,18 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.CurrentOutfit.HatId = hat; - PlayerInfo.CurrentOutfit.HatSequenceId = nextRpcSequenceId; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetHatStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.HatId = hat; + PlayerInfo.CurrentOutfit.HatSequenceId = nextRpcSequenceId; + } return true; } @@ -880,8 +949,18 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.CurrentOutfit.SkinId = skin; - PlayerInfo.CurrentOutfit.SkinSequenceId = nextRpcSequenceId; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetSkinStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.SkinId = skin; + PlayerInfo.CurrentOutfit.SkinSequenceId = nextRpcSequenceId; + } return true; } @@ -894,8 +973,18 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.CurrentOutfit.VisorId = visor; - PlayerInfo.CurrentOutfit.VisorSequenceId = nextRpcSequenceId; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetVisorStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.VisorId = visor; + PlayerInfo.CurrentOutfit.VisorSequenceId = nextRpcSequenceId; + } return true; } @@ -908,8 +997,18 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.CurrentOutfit.NamePlateId = namePlate; - PlayerInfo.CurrentOutfit.NamePlateSequenceId = nextRpcSequenceId; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetNamePlateStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.NamePlateId = namePlate; + PlayerInfo.CurrentOutfit.NamePlateSequenceId = nextRpcSequenceId; + } return true; } @@ -922,14 +1021,31 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.PlayerLevel = level; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetLevel, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.PlayerLevel = level; + } return true; } private async ValueTask HandleCheckMurder(ClientPlayer sender, InnerPlayerControl? target) { - if (!PlayerInfo.CanMurder(Game, _dateTimeProvider)) + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.CheckMurder, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else if (!PlayerInfo.CanMurder(Game, _dateTimeProvider)) { if (IsMurdering == target) { @@ -944,10 +1060,10 @@ namespace Impostor.Server.Net.Inner.Objects return false; } } - } - PlayerInfo.LastMurder = _dateTimeProvider.UtcNow - TimeSpan.FromMilliseconds(sender.Client.Connection.AveragePing); - IsMurdering = target; + PlayerInfo.LastMurder = _dateTimeProvider.UtcNow - TimeSpan.FromMilliseconds(sender.Client.Connection.AveragePing); + IsMurdering = target; + } // Check if host authority mode is on if (_game.IsHostAuthoritive) @@ -1017,7 +1133,7 @@ namespace Impostor.Server.Net.Inner.Objects } } - if (target != null && !target.PlayerInfo.IsDead) + if (target != null && target.PlayerInfo != null && !target.PlayerInfo.IsDead) { // In host authoritive mode every client has to figure out if the kill was prevented by guardian protection on it's own if ((result & MurderResultFlags.Succeeded) != 0 && target.IsProtected) @@ -1086,8 +1202,18 @@ namespace Impostor.Server.Net.Inner.Objects return false; } - PlayerInfo.CurrentOutfit.PetId = pet; - PlayerInfo.CurrentOutfit.PetSequenceId = nextRpcSequenceId; + if (PlayerInfo == null) + { + if (await sender.Client.ReportCheatAsync(RpcCalls.SetPetStr, CheatCategory.InvalidObject, "PlayerControl doesn't have PlayerInfo")) + { + return false; + } + } + else + { + PlayerInfo.CurrentOutfit.PetId = pet; + PlayerInfo.CurrentOutfit.PetSequenceId = nextRpcSequenceId; + } return true; } diff --git a/src/Impostor.Server/Net/State/Game.cs b/src/Impostor.Server/Net/State/Game.cs index 5b5921d..07fd480 100644 --- a/src/Impostor.Server/Net/State/Game.cs +++ b/src/Impostor.Server/Net/State/Game.cs @@ -139,7 +139,10 @@ namespace Impostor.Server.Net.State /// True if there is player other than exceptBy that uses that color. internal bool IsColorUsed(ColorType color, IInnerPlayerControl? exceptBy = null) { - return Players.Any(p => p.Character != null && p.Character != exceptBy && p.Character.PlayerInfo.CurrentOutfit.Color == color); + return Players.Any(p => p.Character != null && + p.Character != exceptBy && + p.Character.PlayerInfo != null && + p.Character.PlayerInfo.CurrentOutfit.Color == color); } private ValueTask BroadcastJoinMessage(IMessageWriter message, bool clear, ClientPlayer player) -- 2.39.5