diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs index 08095fee..3fbc49ec 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs @@ -5,11 +5,14 @@ using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities; using FiveStack.Utilities; +using Microsoft.Extensions.Logging; namespace FiveStack; public partial class FiveStackPlugin { + private readonly HashSet _overCapacityKicks = new(); + [GameEventHandler] public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo info) { @@ -27,7 +30,45 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i return HookResult.Continue; } - _surrenderSystem.CancelDisconnectTimer(@event.Userid.SteamID); + CCSPlayerController player = @event.Userid; + + _overCapacityKicks.Remove(player.SteamID); + + Guid? lineup_id = MatchUtility.GetPlayerLineup(matchData, player); + + CsTeam placementTeam = match.GetPlacementSide(match.GetExpectedTeam(player)); + int capacity = match.GetExpectedPlayerCount() / 2; + + // Decided before the disconnect timer and the roster resume: the player + // is about to be kicked, so they must neither count towards a whole + // roster nor have the kick treated as them leaving the match. + if ( + LineupCapacityUtility.IsOverCapacity( + MatchUtility + .Players() + .Select(connected => + ( + connected.SteamID.ToString(), + MatchUtility.GetPlayerLineup(matchData, connected), + (int)connected.Team + ) + ), + player.SteamID.ToString(), + lineup_id, + (int)placementTeam, + capacity + ) + ) + { + _logger.LogInformation( + $"Kicking {player.PlayerName} ({player.SteamID}): their lineup already has {capacity} playing" + ); + _overCapacityKicks.Add(player.SteamID); + Server.ExecuteCommand($"kickid {player.UserId}"); + return HookResult.Continue; + } + + _surrenderSystem.CancelDisconnectTimer(player.SteamID); // CancelDisconnectTimer only resumes when that player actually had a // timer, which is never the case for someone who left during warmup or @@ -35,9 +76,6 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i // the roster is whole again there is nothing left to wait for. _surrenderSystem.ResumeIfRosterWhole(); - CCSPlayerController player = @event.Userid; - - Guid? lineup_id = MatchUtility.GetPlayerLineup(matchData, player); List players = matchData .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) .ToList(); @@ -74,21 +112,6 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i } } - CsTeam placementTeam = match.GetPlacementSide(match.GetExpectedTeam(player)); - int expectedTeamCount = match.GetExpectedPlayerCount() / 2; - int teamCount = TeamUtility.GetTeamCount(placementTeam); - - if (player.Team == placementTeam) - { - teamCount--; - } - - if (teamCount > expectedTeamCount) - { - Server.ExecuteCommand($"kickid {player.UserId}"); - return HookResult.Continue; - } - match.EnforceMemberTeam(player, CsTeam.None); _matchEvents.PublishGameEvent( diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs index 9ce0aecf..646c24c8 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs @@ -27,6 +27,11 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo CCSPlayerController player = @event.Userid; + if (_overCapacityKicks.Remove(player.SteamID)) + { + return HookResult.Continue; + } + MatchMember? member = MatchUtility.GetMemberFromLineup( matchData, player.SteamID.ToString(), diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/TeamUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/TeamUtility.cs index f02b39f4..a30f0c9a 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/TeamUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/TeamUtility.cs @@ -242,14 +242,5 @@ int round return totalCash; } - - public static int GetTeamCount(CsTeam csTeam) - { - return MatchUtility - .Teams() - .Count(matchTeam => - matchTeam.PlayerControllers.Count > 0 && matchTeam.TeamNum == (int)csTeam - ); - } } } diff --git a/apps/counterstrikesharp/test/LineupCapacityTests.cs b/apps/counterstrikesharp/test/LineupCapacityTests.cs new file mode 100644 index 00000000..b64bab94 --- /dev/null +++ b/apps/counterstrikesharp/test/LineupCapacityTests.cs @@ -0,0 +1,124 @@ +using FiveStack.Utilities; +using Xunit; + +public class LineupCapacityTests +{ + private const int Spectator = 1; + private const int T = 2; + private const int CT = 3; + private const int Capacity = 5; + private const string Joiner = "joiner"; + + private static readonly Guid LineupA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid LineupB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private static List<(string SteamId, Guid? LineupId, int TeamNum)> Players( + Guid? lineupId, + int teamNum, + int count, + string prefix + ) + { + return Enumerable + .Range(0, count) + .Select(index => ($"{prefix}{index}", lineupId, teamNum)) + .ToList(); + } + + [Fact] + public void SixthMemberOfAPlayingLineupIsOverCapacity() + { + var connected = Players(LineupA, CT, 5, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.True( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void LineupStillOnTheOtherSideThroughHalftimeCounts() + { + var connected = Players(LineupA, T, 5, "a"); + connected.AddRange(Players(LineupB, CT, 5, "b")); + connected.Add((Joiner, LineupA, 0)); + + Assert.True( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void FifthMemberFitsWhetherOrNotTheyAreAlreadyPlaced() + { + var connected = Players(LineupA, CT, 4, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + + connected[^1] = (Joiner, LineupA, CT); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void OtherLineupOnTheSameSideDoesNotCount() + { + var connected = Players(LineupB, CT, 5, "b"); + connected.AddRange(Players(LineupA, T, 4, "a")); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void BenchedMembersDoNotCount() + { + var connected = Players(LineupA, CT, 4, "a"); + connected.AddRange(Players(LineupA, Spectator, 2, "bench")); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void MemberNotHeadedForTOrCTIsNotOverCapacity() + { + var connected = Players(LineupA, CT, 5, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, 0, Capacity) + ); + } + + [Fact] + public void PlayerOutsideEveryLineupIsNeverOverCapacity() + { + var connected = Players(null, CT, 5, "x"); + connected.Add((Joiner, null, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, null, CT, Capacity) + ); + } + + [Fact] + public void SpectatorsAreNeverOverCapacity() + { + var connected = Players(null, Spectator, 8, "caster"); + connected.Add((Joiner, null, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, null, Spectator, Capacity) + ); + } +} diff --git a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs index dc953f24..8dbdbc9d 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs @@ -1,5 +1,6 @@ using FiveStack.Entities; using FiveStack.Utilities; +using Microsoft.Extensions.Logging; using SwiftlyS2.Shared.GameEventDefinitions; using SwiftlyS2.Shared.GameEvents; using SwiftlyS2.Shared.Misc; @@ -10,6 +11,8 @@ namespace FiveStack; public partial class FiveStackPlugin { + private readonly HashSet _overCapacityKicks = new(); + [GameEventHandler(HookMode.Post)] public HookResult OnPlayerConnect(EventPlayerConnectFull @event) { @@ -27,7 +30,45 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event) return HookResult.Continue; } - _surrenderSystem.CancelDisconnectTimer(@event.UserIdPlayer.SteamID); + IPlayer player = @event.UserIdPlayer; + + _overCapacityKicks.Remove(player.SteamID); + + Guid? lineup_id = MatchUtility.GetPlayerLineup(matchData, player); + + Team placementTeam = match.GetPlacementSide(match.GetExpectedTeam(player)); + int capacity = match.GetExpectedPlayerCount() / 2; + + // Decided before the disconnect timer and the roster resume: the player + // is about to be kicked, so they must neither count towards a whole + // roster nor have the kick treated as them leaving the match. + if ( + LineupCapacityUtility.IsOverCapacity( + MatchUtility + .Players() + .Select(connected => + ( + connected.SteamID.ToString(), + MatchUtility.GetPlayerLineup(matchData, connected), + (int)connected.Controller.Team + ) + ), + player.SteamID.ToString(), + lineup_id, + (int)placementTeam, + capacity + ) + ) + { + _logger.LogInformation( + $"Kicking {player.Name} ({player.SteamID}): their lineup already has {capacity} playing" + ); + _overCapacityKicks.Add(player.SteamID); + _core.Engine.ExecuteCommand($"kickid {player.UserID}"); + return HookResult.Continue; + } + + _surrenderSystem.CancelDisconnectTimer(player.SteamID); // CancelDisconnectTimer only resumes when that player actually had a // timer, which is never the case for someone who left during warmup or @@ -35,9 +76,6 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event) // the roster is whole again there is nothing left to wait for. _surrenderSystem.ResumeIfRosterWhole(); - IPlayer player = @event.UserIdPlayer; - - Guid? lineup_id = MatchUtility.GetPlayerLineup(matchData, player); List players = matchData .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) .ToList(); @@ -75,21 +113,6 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event) } } - Team placementTeam = match.GetPlacementSide(match.GetExpectedTeam(player)); - int expectedTeamCount = match.GetExpectedPlayerCount() / 2; - int teamCount = TeamUtility.GetTeamCount(placementTeam); - - if (player.Controller.Team == placementTeam) - { - teamCount--; - } - - if (teamCount > expectedTeamCount) - { - _core.Engine.ExecuteCommand($"kickid {player.UserID}"); - return HookResult.Continue; - } - match.EnforceMemberTeam(player, Team.None); _matchEvents.PublishGameEvent( diff --git a/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs b/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs index 2ec80dc1..c695ac5a 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs @@ -30,6 +30,11 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event) IPlayer player = @event.UserIdPlayer; + if (_overCapacityKicks.Remove(player.SteamID)) + { + return HookResult.Continue; + } + MatchMember? member = MatchUtility.GetMemberFromLineup( matchData, player.SteamID.ToString(), diff --git a/apps/swiftly/src/FiveStack.Utilities/TeamUtility.cs b/apps/swiftly/src/FiveStack.Utilities/TeamUtility.cs index 6568c625..a141b84f 100644 --- a/apps/swiftly/src/FiveStack.Utilities/TeamUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/TeamUtility.cs @@ -170,10 +170,5 @@ int round return totalCash; } - - public static int GetTeamCount(Team team) - { - return MatchUtility.Core.PlayerManager.GetInTeam(team).Any() ? 1 : 0; - } } } diff --git a/apps/swiftly/test/LineupCapacityTests.cs b/apps/swiftly/test/LineupCapacityTests.cs new file mode 100644 index 00000000..b64bab94 --- /dev/null +++ b/apps/swiftly/test/LineupCapacityTests.cs @@ -0,0 +1,124 @@ +using FiveStack.Utilities; +using Xunit; + +public class LineupCapacityTests +{ + private const int Spectator = 1; + private const int T = 2; + private const int CT = 3; + private const int Capacity = 5; + private const string Joiner = "joiner"; + + private static readonly Guid LineupA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid LineupB = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + + private static List<(string SteamId, Guid? LineupId, int TeamNum)> Players( + Guid? lineupId, + int teamNum, + int count, + string prefix + ) + { + return Enumerable + .Range(0, count) + .Select(index => ($"{prefix}{index}", lineupId, teamNum)) + .ToList(); + } + + [Fact] + public void SixthMemberOfAPlayingLineupIsOverCapacity() + { + var connected = Players(LineupA, CT, 5, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.True( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void LineupStillOnTheOtherSideThroughHalftimeCounts() + { + var connected = Players(LineupA, T, 5, "a"); + connected.AddRange(Players(LineupB, CT, 5, "b")); + connected.Add((Joiner, LineupA, 0)); + + Assert.True( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void FifthMemberFitsWhetherOrNotTheyAreAlreadyPlaced() + { + var connected = Players(LineupA, CT, 4, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + + connected[^1] = (Joiner, LineupA, CT); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void OtherLineupOnTheSameSideDoesNotCount() + { + var connected = Players(LineupB, CT, 5, "b"); + connected.AddRange(Players(LineupA, T, 4, "a")); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void BenchedMembersDoNotCount() + { + var connected = Players(LineupA, CT, 4, "a"); + connected.AddRange(Players(LineupA, Spectator, 2, "bench")); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, CT, Capacity) + ); + } + + [Fact] + public void MemberNotHeadedForTOrCTIsNotOverCapacity() + { + var connected = Players(LineupA, CT, 5, "a"); + connected.Add((Joiner, LineupA, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, LineupA, 0, Capacity) + ); + } + + [Fact] + public void PlayerOutsideEveryLineupIsNeverOverCapacity() + { + var connected = Players(null, CT, 5, "x"); + connected.Add((Joiner, null, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, null, CT, Capacity) + ); + } + + [Fact] + public void SpectatorsAreNeverOverCapacity() + { + var connected = Players(null, Spectator, 8, "caster"); + connected.Add((Joiner, null, 0)); + + Assert.False( + LineupCapacityUtility.IsOverCapacity(connected, Joiner, null, Spectator, Capacity) + ); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/LineupCapacityUtility.cs b/shared/dotnet/FiveStack.Utilities/LineupCapacityUtility.cs new file mode 100644 index 00000000..76dc093f --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/LineupCapacityUtility.cs @@ -0,0 +1,38 @@ +namespace FiveStack.Utilities +{ + public static class LineupCapacityUtility + { + private const int TerroristTeamNum = 2; + private const int CounterTerroristTeamNum = 3; + + // Counted by lineup, not by the team the joiner is headed for: through a + // halftime swap the other lineup still stands on that team until the + // round resets. + public static bool IsOverCapacity( + IEnumerable<(string SteamId, Guid? LineupId, int TeamNum)> connected, + string joinerSteamId, + Guid? joinerLineupId, + int placementTeamNum, + int capacity + ) + { + if (joinerLineupId == null || !IsPlaying(placementTeamNum)) + { + return false; + } + + int playing = connected.Count(player => + player.SteamId != joinerSteamId + && player.LineupId == joinerLineupId + && IsPlaying(player.TeamNum) + ); + + return playing >= capacity; + } + + private static bool IsPlaying(int teamNum) + { + return teamNum == TerroristTeamNum || teamNum == CounterTerroristTeamNum; + } + } +}