diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs index e9bc75c6..6ef8b6b7 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerConnected.cs @@ -33,19 +33,7 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event, GameEventInfo i // timer, which is never the case for someone who left during warmup or // knife, or when the pause came from RoundStart going short-handed. If // the roster is whole again there is nothing left to wait for. - // - // Counted across the roster rather than Players(), which also returns - // casters, admins and the client kicked further down this handler -- a - // spectator joining would otherwise resume a match still a man down. A - // pause the teams have to release themselves is never auto-resumed. - if ( - match.IsPaused() - && !match.timeoutSystem.ShouldRequireTeamResume() - && ConnectedRosterCount(matchData) >= match.GetExpectedPlayerCount() - ) - { - match.ResumeMatch(); - } + _surrenderSystem.ResumeIfRosterWhole(); CCSPlayerController player = @event.Userid; @@ -192,19 +180,4 @@ public HookResult HandleJoinTeam(CCSPlayerController? player, CommandInfo info) return HookResult.Continue; } - - // How many of the two lineups are actually in the server right now. - private static int ConnectedRosterCount(MatchData matchData) - { - HashSet roster = matchData - .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) - .Select(member => member.steam_id) - .Where(steamId => !string.IsNullOrEmpty(steamId)) - .Select(steamId => steamId!) - .ToHashSet(); - - return MatchUtility - .Players() - .Count(controller => roster.Contains(controller.SteamID.ToString())); - } } diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs index 19675742..9ce0aecf 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDisconnected.cs @@ -62,7 +62,7 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo match.PauseMatch( "Player disconnected, pausing match", stillRequired: () => - ConnectedRosterCount(match.GetMatchData() ?? matchData) + MatchUtility.ConnectedRosterCount(match.GetMatchData() ?? matchData) < match.GetExpectedPlayerCount() ); } diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index 116979cb..ba2de22a 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -519,6 +519,8 @@ public void SetupMatch(MatchData match) _rankSystem.OnMatchSetup(_matchData); + _surrenderSystem.OnMatchSetup(_matchData); + MatchMap? _currentMap = GetCurrentMap(); if (_currentMap == null) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/SurrenderSystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/SurrenderSystem.cs index b2532890..ba9f36cc 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/SurrenderSystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/SurrenderSystem.cs @@ -1,3 +1,4 @@ +using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities; @@ -21,6 +22,10 @@ public class SurrenderSystem private Dictionary> _disconnectTimers = new Dictionary>(); + // Survives Reset(), which runs on every map of a series. + private readonly HashSet _reportedAbandons = new HashSet(); + private Guid? _reportedAbandonsMatchId; + private Guid? winningLineupId; public SurrenderSystem( @@ -59,6 +64,10 @@ public void SetupDisconnectTimer(CsTeam team, ulong steamId) return; } + // Overwriting the slot without killing what is in it leaves a live timer + // nothing can reach, and it fires its own abandon three minutes later. + KillDisconnectTimer(steamId); + if (!_disconnectTimers.ContainsKey(team)) { _disconnectTimers[team] = new Dictionary(); @@ -77,39 +86,61 @@ public void SetupDisconnectTimer(CsTeam team, ulong steamId) // we dont pass the team in because they may not be on the team immediately after reconnecting public void CancelDisconnectTimer(ulong steamId) { - bool canceledTimer = false; - foreach (var _team in MatchUtility.Teams()) + if (!KillDisconnectTimer(steamId)) + { + return; + } + + ResumeIfRosterWhole(); + } + + // player_connect_full runs before the connecting client is on the runtime's + // player list, so the roster is only whole a frame later. + public void ResumeIfRosterWhole() + { + Server.NextFrame(() => { - CsTeam team = TeamUtility.TeamNumToCSTeam(_team.TeamNum); + MatchManager? match = _matchService.GetCurrentMatch(); + MatchData? matchData = match?.GetMatchData(); - if (_disconnectTimers.ContainsKey(team)) + if (match == null || matchData == null || !match.IsPaused()) { - if (_disconnectTimers[team].ContainsKey(steamId)) - { - _disconnectTimers[team][steamId].Kill(); - _disconnectTimers[team].Remove(steamId); - canceledTimer = true; - } + return; } - } - if (!canceledTimer) - { - return; - } + // A tactical or technical pause is released by the teams themselves. + if (match.timeoutSystem.ShouldRequireTeamResume()) + { + return; + } - int currentPlayers = MatchUtility.Players().Count; + if (MatchUtility.ConnectedRosterCount(matchData) < match.GetExpectedPlayerCount()) + { + return; + } - int expectedPlayers = _matchService.GetCurrentMatch()?.GetExpectedPlayerCount() ?? 10; + Reset(); + match.ResumeMatch(); + }); + } - if ( - _matchService.GetCurrentMatch()?.IsPaused() == true - && currentPlayers == expectedPlayers - ) + // Swept across every bucket, not just the team the player is on now: a + // player who reconnects onto the other side leaves a timer filed under the + // side they left from. + private bool KillDisconnectTimer(ulong steamId) + { + bool killed = false; + + foreach (Dictionary timers in _disconnectTimers.Values) { - Reset(); - _matchService.GetCurrentMatch()?.ResumeMatch(); + if (timers.Remove(steamId, out Timer? timer)) + { + timer?.Kill(); + killed = true; + } } + + return killed; } public void SetupSurrender(CsTeam team, CCSPlayerController? player = null) @@ -164,6 +195,19 @@ public void SetupSurrender(CsTeam team, CCSPlayerController? player = null) ); } + // Abandons are reported once per match, never once per map, so the reported + // set is only cleared when a different match loads. + public void OnMatchSetup(MatchData matchData) + { + if (_reportedAbandonsMatchId == matchData.id) + { + return; + } + + _reportedAbandonsMatchId = matchData.id; + _reportedAbandons.Clear(); + } + public void Reset() { surrenderingVote = null; @@ -254,6 +298,15 @@ public void Surrender(Guid lineupId) public void PlayerAbandonedMatch(ulong steamId) { + // The api escalates the ban on every report, so one leave reported twice + // -- after a reconnect, or again on the next map -- costs the player a + // longer ban than they earned. + if (!_reportedAbandons.Add(steamId)) + { + _logger.LogInformation($"{steamId} was already reported as abandoned this match"); + return; + } + _matchEvents.PublishGameEvent( "abandoned", new Dictionary diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs index 612ce102..d1de5edf 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/MatchUtility.cs @@ -160,6 +160,26 @@ public static List Players() return validPlayers; } + public static HashSet RosterSteamIds(MatchData matchData) + { + return matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Select(member => member.steam_id) + .Where(steamId => !string.IsNullOrEmpty(steamId)) + .Select(steamId => steamId!) + .ToHashSet(); + } + + // How many of the two lineups are actually in the server right now. + // Players() also returns casters and admins, who must never count + // towards a match being whole again. + public static int ConnectedRosterCount(MatchData matchData) + { + HashSet roster = RosterSteamIds(matchData); + + return Players().Count(controller => roster.Contains(controller.SteamID.ToString())); + } + public static IEnumerable Teams() { return CounterStrikeSharp.API.Utilities.FindAllEntitiesByDesignerName( diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs index f61e63c6..f640bb3e 100644 --- a/apps/counterstrikesharp/test/MatchUtilityTests.cs +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -98,4 +98,32 @@ public void MapStatus_UnknownStringThrows() { Assert.Throws(() => MatchUtility.MapStatusStringToEnum("Bogus")); } + + [Fact] + public void RosterSteamIds_SpansBothLineups() + { + MatchData match = BuildMatch(); + match.lineup_2.lineup_players[0].steam_id = "76561198000000002"; + match.lineup_2.lineup_players[0].placeholder_name = ""; + + HashSet roster = MatchUtility.RosterSteamIds(match); + + Assert.Equal(new HashSet { "76561198000000001", "76561198000000002" }, roster); + } + + [Fact] + public void RosterSteamIds_SkipsPlaceholderSeats() + { + HashSet roster = MatchUtility.RosterSteamIds(BuildMatch()); + + Assert.Equal(new HashSet { "76561198000000001" }, roster); + } + + [Fact] + public void RosterSteamIds_ExcludesNonRosterSteamIds() + { + HashSet roster = MatchUtility.RosterSteamIds(BuildMatch()); + + Assert.DoesNotContain("76561198000009999", roster); + } } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs index 583e7f14..328e5075 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerConnected.cs @@ -33,19 +33,7 @@ public HookResult OnPlayerConnect(EventPlayerConnectFull @event) // timer, which is never the case for someone who left during warmup or // knife, or when the pause came from RoundStart going short-handed. If // the roster is whole again there is nothing left to wait for. - // - // Counted across the roster rather than Players(), which also returns - // casters, admins and the client kicked further down this handler -- a - // spectator joining would otherwise resume a match still a man down. A - // pause the teams have to release themselves is never auto-resumed. - if ( - match.IsPaused() - && !match.timeoutSystem.ShouldRequireTeamResume() - && ConnectedRosterCount(matchData) >= match.GetExpectedPlayerCount() - ) - { - match.ResumeMatch(); - } + _surrenderSystem.ResumeIfRosterWhole(); IPlayer player = @event.UserIdPlayer; @@ -196,20 +184,4 @@ public HookResult HandleJoinTeam(IPlayer? player, string[] args) return HookResult.Continue; } - - // How many of the two lineups are actually in the server right now. - private static int ConnectedRosterCount(MatchData matchData) - { - HashSet roster = matchData - .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) - .Select(member => member.steam_id) - .Where(steamId => !string.IsNullOrEmpty(steamId)) - .Select(steamId => steamId!) - .ToHashSet(); - - return MatchUtility - .Players() - .Count(player => roster.Contains(player.SteamID.ToString())); - } - } diff --git a/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs b/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs index fab017fe..2ec80dc1 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerDisconnected.cs @@ -65,7 +65,7 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event) match.PauseMatch( "Player disconnected, pausing match", stillRequired: () => - ConnectedRosterCount(match.GetMatchData() ?? matchData) + MatchUtility.ConnectedRosterCount(match.GetMatchData() ?? matchData) < match.GetExpectedPlayerCount() ); } diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index c5363d6c..1d11647f 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -506,6 +506,8 @@ public void SetupMatch(MatchData match) _rankSystem.OnMatchSetup(_matchData); + _surrenderSystem.OnMatchSetup(_matchData); + MatchMap? _currentMap = GetCurrentMap(); if (_currentMap == null) diff --git a/apps/swiftly/src/FiveStack.Services/SurrenderSystem.cs b/apps/swiftly/src/FiveStack.Services/SurrenderSystem.cs index 90ec45ed..d8383191 100644 --- a/apps/swiftly/src/FiveStack.Services/SurrenderSystem.cs +++ b/apps/swiftly/src/FiveStack.Services/SurrenderSystem.cs @@ -20,6 +20,10 @@ public class SurrenderSystem private Dictionary> _disconnectTimers = new Dictionary>(); + // Survives Reset(), which runs on every map of a series. + private readonly HashSet _reportedAbandons = new HashSet(); + private Guid? _reportedAbandonsMatchId; + private Guid? winningLineupId; public SurrenderSystem( @@ -58,6 +62,10 @@ public void SetupDisconnectTimer(Team team, ulong steamId) return; } + // Overwriting the slot without killing what is in it leaves a live timer + // nothing can reach, and it fires its own abandon three minutes later. + KillDisconnectTimer(steamId); + if (!_disconnectTimers.ContainsKey(team)) { _disconnectTimers[team] = new Dictionary(); @@ -73,41 +81,64 @@ public void SetupDisconnectTimer(Team team, ulong steamId) ); } + // we dont pass the team in because they may not be on the team immediately after reconnecting public void CancelDisconnectTimer(ulong steamId) { - bool canceledTimer = false; - foreach (var _team in MatchUtility.Teams()) + if (!KillDisconnectTimer(steamId)) + { + return; + } + + ResumeIfRosterWhole(); + } + + // player_connect_full runs before the connecting client is on the runtime's + // player list, so the roster is only whole a tick later. + public void ResumeIfRosterWhole() + { + MatchUtility.Core.Scheduler.NextTick(() => { - Team team = TeamUtility.TeamNumToTeam(_team.TeamNum); + MatchManager? match = _matchService.GetCurrentMatch(); + MatchData? matchData = match?.GetMatchData(); - if (_disconnectTimers.ContainsKey(team)) + if (match == null || matchData == null || !match.IsPaused()) { - if (_disconnectTimers[team].ContainsKey(steamId)) - { - TimerUtility.Kill(_disconnectTimers[team][steamId]); - _disconnectTimers[team].Remove(steamId); - canceledTimer = true; - } + return; } - } - if (!canceledTimer) - { - return; - } + // A tactical or technical pause is released by the teams themselves. + if (match.timeoutSystem.ShouldRequireTeamResume()) + { + return; + } - int currentPlayers = MatchUtility.PlayerCount(); + if (MatchUtility.ConnectedRosterCount(matchData) < match.GetExpectedPlayerCount()) + { + return; + } - int expectedPlayers = _matchService.GetCurrentMatch()?.GetExpectedPlayerCount() ?? 10; + Reset(); + match.ResumeMatch(); + }); + } - if ( - _matchService.GetCurrentMatch()?.IsPaused() == true - && currentPlayers == expectedPlayers - ) + // Swept across every bucket, not just the team the player is on now: a + // player who reconnects onto the other side leaves a timer filed under the + // side they left from. + private bool KillDisconnectTimer(ulong steamId) + { + bool killed = false; + + foreach (Dictionary timers in _disconnectTimers.Values) { - Reset(); - _matchService.GetCurrentMatch()?.ResumeMatch(); + if (timers.Remove(steamId, out CancellationTokenSource? timer)) + { + TimerUtility.Kill(timer); + killed = true; + } } + + return killed; } public void SetupSurrender(Team team, IPlayer? player = null) @@ -167,6 +198,19 @@ public void SetupSurrender(Team team, IPlayer? player = null) ); } + // Abandons are reported once per match, never once per map, so the reported + // set is only cleared when a different match loads. + public void OnMatchSetup(MatchData matchData) + { + if (_reportedAbandonsMatchId == matchData.id) + { + return; + } + + _reportedAbandonsMatchId = matchData.id; + _reportedAbandons.Clear(); + } + public void Reset() { surrenderingVote = null; @@ -257,6 +301,15 @@ public void Surrender(Guid lineupId) public void PlayerAbandonedMatch(ulong steamId) { + // The api escalates the ban on every report, so one leave reported twice + // -- after a reconnect, or again on the next map -- costs the player a + // longer ban than they earned. + if (!_reportedAbandons.Add(steamId)) + { + _logger.LogInformation($"{steamId} was already reported as abandoned this match"); + return; + } + _matchEvents.PublishGameEvent( "abandoned", new Dictionary diff --git a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs index e1dfacff..69b7483a 100644 --- a/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/MatchUtility.cs @@ -207,6 +207,26 @@ public static int PlayerCount() return count; } + public static HashSet RosterSteamIds(MatchData matchData) + { + return matchData + .lineup_1.lineup_players.Concat(matchData.lineup_2.lineup_players) + .Select(member => member.steam_id) + .Where(steamId => !string.IsNullOrEmpty(steamId)) + .Select(steamId => steamId!) + .ToHashSet(); + } + + // How many of the two lineups are actually in the server right now. + // Players() also returns casters and admins, who must never count + // towards a match being whole again. + public static int ConnectedRosterCount(MatchData matchData) + { + HashSet roster = RosterSteamIds(matchData); + + return Players().Count(player => roster.Contains(player.SteamID.ToString())); + } + public static IEnumerable Teams() { if (_cachedTeams != null) diff --git a/apps/swiftly/test/MatchUtilityTests.cs b/apps/swiftly/test/MatchUtilityTests.cs index 315b53b1..8dede300 100644 --- a/apps/swiftly/test/MatchUtilityTests.cs +++ b/apps/swiftly/test/MatchUtilityTests.cs @@ -74,4 +74,32 @@ public void GetSafeMatchPrefix_StripsDashesAndJoinsIds() Assert.DoesNotContain("-", prefix); Assert.Equal("11111111111111111111111111111111_22222222222222222222222222222222", prefix); } + + [Fact] + public void RosterSteamIds_SpansBothLineups() + { + MatchData match = BuildMatch(); + match.lineup_2.lineup_players[0].steam_id = "76561198000000002"; + match.lineup_2.lineup_players[0].placeholder_name = ""; + + HashSet roster = MatchUtility.RosterSteamIds(match); + + Assert.Equal(new HashSet { "76561198000000001", "76561198000000002" }, roster); + } + + [Fact] + public void RosterSteamIds_SkipsPlaceholderSeats() + { + HashSet roster = MatchUtility.RosterSteamIds(BuildMatch()); + + Assert.Equal(new HashSet { "76561198000000001" }, roster); + } + + [Fact] + public void RosterSteamIds_ExcludesNonRosterSteamIds() + { + HashSet roster = MatchUtility.RosterSteamIds(BuildMatch()); + + Assert.DoesNotContain("76561198000009999", roster); + } }