From 4b299bfd1afaa540b14840a2028c621f6e0946ad Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 12:39:22 +0200 Subject: [PATCH 1/4] fix: add state machine validation and fix vote concurrency on captain disconnect Add allowed-transitions dictionary to MatchManager.UpdateMapStatus so illegal state jumps (e.g. Finished -> Live) are rejected with a warning. Fix CaptainSystem.RemoveCaptain to allow captain removal during knife round in addition to warmup. Replace hardcoded captain vote count of 2 in VoteSystem.CheckVotes with the dynamic expectedVoteCount so a single remaining captain can resolve a vote when the other disconnects. Remove stray extra semicolon in GetExpectedVoteCount. --- src/FiveStack.Services/CaptainSystem.cs | 2 +- src/FiveStack.Services/MatchManager.cs | 82 +++++++++++++++++++++++++ src/FiveStack.Services/VoteSystem.cs | 5 +- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/FiveStack.Services/CaptainSystem.cs b/src/FiveStack.Services/CaptainSystem.cs index 5659a89..bdacd84 100644 --- a/src/FiveStack.Services/CaptainSystem.cs +++ b/src/FiveStack.Services/CaptainSystem.cs @@ -75,7 +75,7 @@ public void RemoveCaptain(CCSPlayerController player) if ( match == null - || !match.IsWarmup() + || !(match.IsWarmup() || match.IsKnife()) || team == CsTeam.None || team == CsTeam.Spectator || _captains[team] == null diff --git a/src/FiveStack.Services/MatchManager.cs b/src/FiveStack.Services/MatchManager.cs index 8581dac..3378058 100644 --- a/src/FiveStack.Services/MatchManager.cs +++ b/src/FiveStack.Services/MatchManager.cs @@ -16,6 +16,76 @@ namespace FiveStack; public class MatchManager { + private static readonly Dictionary> _allowedTransitions = + new() + { + { + eMapStatus.Unknown, + new HashSet + { + eMapStatus.Scheduled, + eMapStatus.Warmup, + eMapStatus.Knife, + eMapStatus.Live, + eMapStatus.Paused, + eMapStatus.Overtime, + eMapStatus.Finished, + eMapStatus.Surrendered, + eMapStatus.UploadingDemo, + } + }, + { eMapStatus.Scheduled, new HashSet { eMapStatus.Warmup } }, + { + eMapStatus.Warmup, + new HashSet + { + eMapStatus.Knife, + eMapStatus.Live, + eMapStatus.Paused, + } + }, + { + eMapStatus.Knife, + new HashSet { eMapStatus.Live, eMapStatus.Paused } + }, + { + eMapStatus.Live, + new HashSet + { + eMapStatus.Paused, + eMapStatus.Finished, + eMapStatus.Overtime, + eMapStatus.Surrendered, + eMapStatus.UploadingDemo, + } + }, + { + eMapStatus.Overtime, + new HashSet + { + eMapStatus.Paused, + eMapStatus.Finished, + eMapStatus.Surrendered, + eMapStatus.UploadingDemo, + } + }, + { + eMapStatus.Paused, + new HashSet + { + eMapStatus.Live, + eMapStatus.Warmup, + eMapStatus.Overtime, + eMapStatus.Knife, + eMapStatus.Finished, + eMapStatus.Surrendered, + } + }, + { eMapStatus.UploadingDemo, new HashSet { eMapStatus.Finished } }, + { eMapStatus.Finished, new HashSet() }, + { eMapStatus.Surrendered, new HashSet() }, + }; + private MatchData? _matchData; private eMapStatus _currentMapStatus = eMapStatus.Unknown; private Timer? _resumeMessageTimer; @@ -270,6 +340,18 @@ public void UpdateMapStatus(eMapStatus status, Guid? winningLineupId = null) _backUpManagement.CheckForBackupRestore(); } + if ( + _currentMapStatus != eMapStatus.Unknown + && _allowedTransitions.TryGetValue(_currentMapStatus, out var allowed) + && !allowed.Contains(status) + ) + { + _logger.LogWarning( + $"Illegal map status transition {_currentMapStatus} -> {status}, ignoring" + ); + return; + } + var currentMap = GetCurrentMap(); // TODO - this should only happen discord matches diff --git a/src/FiveStack.Services/VoteSystem.cs b/src/FiveStack.Services/VoteSystem.cs index 68b4175..868de9f 100644 --- a/src/FiveStack.Services/VoteSystem.cs +++ b/src/FiveStack.Services/VoteSystem.cs @@ -319,7 +319,7 @@ private void CheckVotes(bool fail = false) if (IsCaptainVoteOnly()) { - if (_votes.Count < 2) + if (_votes.Count < expectedVoteCount) { if (fail) { @@ -328,7 +328,7 @@ private void CheckVotes(bool fail = false) return; } - if (totalYesVotes >= 2) + if (totalYesVotes >= Math.Ceiling(expectedVoteCount / 2.0)) { VoteSuccess(); return; @@ -367,7 +367,6 @@ private int GetExpectedVoteCount() .Where(player => { return CanVote(player); - ; }) .ToList(); From 75eac587c09939723921ed2fec8fd6575d4d956e Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 13:01:34 +0200 Subject: [PATCH 2/4] fix: allow Surrendered -> Finished transition in state machine SendSurrender() in GameEnd.cs calls UpdateMapStatus(Finished) after a surrender, so Surrendered cannot be a terminal state. Without this, the surrender flow silently fails to mark the map as Finished. --- src/FiveStack.Services/MatchManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FiveStack.Services/MatchManager.cs b/src/FiveStack.Services/MatchManager.cs index 3378058..5ca87a1 100644 --- a/src/FiveStack.Services/MatchManager.cs +++ b/src/FiveStack.Services/MatchManager.cs @@ -83,7 +83,7 @@ public class MatchManager }, { eMapStatus.UploadingDemo, new HashSet { eMapStatus.Finished } }, { eMapStatus.Finished, new HashSet() }, - { eMapStatus.Surrendered, new HashSet() }, + { eMapStatus.Surrendered, new HashSet { eMapStatus.Finished } }, }; private MatchData? _matchData; From 78d8b57dd619e4176a95213109cdadc17b7f72ce Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 13:05:49 +0200 Subject: [PATCH 3/4] fix: remove disconnected player votes from all active vote systems When a player disconnects, their vote was persisting in active votes while GetExpectedVoteCount excluded them, causing inconsistent tallies. Now cleans up votes from surrender, pause, resume, and restore systems. --- src/FiveStack.Events/PlayerDisconnected.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/FiveStack.Events/PlayerDisconnected.cs b/src/FiveStack.Events/PlayerDisconnected.cs index 620848e..f7580a6 100644 --- a/src/FiveStack.Events/PlayerDisconnected.cs +++ b/src/FiveStack.Events/PlayerDisconnected.cs @@ -49,6 +49,11 @@ public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo match.captainSystem.RemoveCaptain(@event.Userid); } + _surrenderSystem.surrenderingVote?.RemovePlayerVote(player.SteamID); + _timeoutSystem.pauseVote?.RemovePlayerVote(player.SteamID); + _timeoutSystem.resumeVote?.RemovePlayerVote(player.SteamID); + _gameBackupRounds.restoreRoundVote?.RemovePlayerVote(player.SteamID); + if (match.IsInProgress()) { if (match.IsFreezePeriod()) From bc4f2c60c112fa517ecd2e1ac680c7d6f12034b4 Mon Sep 17 00:00:00 2001 From: Flegma Date: Wed, 8 Apr 2026 14:47:02 +0200 Subject: [PATCH 4/4] fix: validate state transition before running backup check Move the allowed-transition guard above CheckForBackupRestore so illegal transitions are rejected before any side effects run. --- src/FiveStack.Services/MatchManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FiveStack.Services/MatchManager.cs b/src/FiveStack.Services/MatchManager.cs index 5ca87a1..fb4b1a5 100644 --- a/src/FiveStack.Services/MatchManager.cs +++ b/src/FiveStack.Services/MatchManager.cs @@ -335,11 +335,6 @@ public void UpdateMapStatus(eMapStatus status, Guid? winningLineupId = null) _logger.LogInformation($"Update Map Status {_currentMapStatus} -> {status}"); - if (_currentMapStatus == eMapStatus.Unknown) - { - _backUpManagement.CheckForBackupRestore(); - } - if ( _currentMapStatus != eMapStatus.Unknown && _allowedTransitions.TryGetValue(_currentMapStatus, out var allowed) @@ -352,6 +347,11 @@ public void UpdateMapStatus(eMapStatus status, Guid? winningLineupId = null) return; } + if (_currentMapStatus == eMapStatus.Unknown) + { + _backUpManagement.CheckForBackupRestore(); + } + var currentMap = GetCurrentMap(); // TODO - this should only happen discord matches