From a1886bdd64cc52e6723fd4c33c29c6846bc94de7 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Thu, 16 Apr 2026 00:01:09 -0500 Subject: [PATCH 1/4] Add early requeuing --- src/Matchmaking/Interfaces/IKoEarlyRequeue.cs | 19 ++ .../Interfaces/IRecklessPlayPenalty.cs | 17 ++ src/Matchmaking/Modules/KoRequeue.cs | 233 ++++++++++++++++++ .../Modules/RecklessPlayPenalty.cs | 17 ++ src/Matchmaking/Modules/TeamVersusMatch.cs | 31 ++- src/Matchmaking/Modules/TeamVersusStats.cs | 53 ++++ .../Zone/arenas/2v2pub/arena.conf | 5 + .../Zone/arenas/3v3pub/arena.conf | 5 + .../Zone/arenas/4v4league/arena.conf | 5 + .../Zone/arenas/4v4prac/arena.conf | 5 + src/SubspaceServer/Zone/conf/Modules.config | 1 + 11 files changed, 388 insertions(+), 3 deletions(-) create mode 100644 src/Matchmaking/Interfaces/IKoEarlyRequeue.cs create mode 100644 src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs create mode 100644 src/Matchmaking/Modules/KoRequeue.cs diff --git a/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs b/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs new file mode 100644 index 00000000..b030f16c --- /dev/null +++ b/src/Matchmaking/Interfaces/IKoEarlyRequeue.cs @@ -0,0 +1,19 @@ +using SS.Core; +using SS.Matchmaking.TeamVersus; + +namespace SS.Matchmaking.Interfaces +{ + /// + /// Interface that allows external modules to signal that a KO'd player was early-requeued into a new match, + /// so that the old match's end-of-match cleanup skips calling UnsetPlayingByName for them. + /// + public interface IKoEarlyRequeue : IComponentInterface + { + /// + /// Marks that the specified player was early-requeued out of the given match. + /// End-of-match cleanup for that match will then skip removing the player from the Playing state, + /// preserving their entry for any newer match they have joined. + /// + void MarkPlayerKoEarlyRequeued(IMatchData matchData, string playerName); + } +} diff --git a/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs b/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs new file mode 100644 index 00000000..b5a567c2 --- /dev/null +++ b/src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs @@ -0,0 +1,17 @@ +using SS.Core; +using SS.Matchmaking.TeamVersus; + +namespace SS.Matchmaking.Interfaces +{ + /// + /// Interface for querying whether a player has a pending reckless play penalty for a match. + /// + public interface IRecklessPlayPenalty : IComponentInterface + { + /// + /// Returns if the specified player has a pending reckless play penalty + /// recorded for the given match (i.e. they were KO'd too quickly and the match has not yet ended). + /// + bool HasPendingPenalty(IMatchData matchData, string playerName); + } +} diff --git a/src/Matchmaking/Modules/KoRequeue.cs b/src/Matchmaking/Modules/KoRequeue.cs new file mode 100644 index 00000000..3fc9ce0f --- /dev/null +++ b/src/Matchmaking/Modules/KoRequeue.cs @@ -0,0 +1,233 @@ +using SS.Core; +using SS.Core.ComponentInterfaces; +using SS.Matchmaking.Callbacks; +using SS.Matchmaking.Interfaces; +using SS.Matchmaking.TeamVersus; + +namespace SS.Matchmaking.Modules +{ + /// + /// Module that allows KO'd players to re-enter the matchmaking queue after a configurable cooldown, + /// potentially joining a new match before their old one finishes. + /// + /// Early requeue is only granted if the player has no pending reckless play penalty for the match. + /// The player's auto-requeue preference is respected: if they have auto-requeue enabled they are + /// placed back in the queue automatically; otherwise they must type ?next again. + /// + /// For use with the module. + /// + [ModuleInfo($""" + Allows KO'd players to re-enter the matchmaking queue after a configurable cooldown. + For use with the {nameof(TeamVersusMatch)} module. + """)] + public sealed class KoRequeue : IModule, IArenaAttachableModule + { + private readonly IChat _chat; + private readonly IConfigManager _configManager; + private readonly ILogManager _logManager; + private readonly IMainloopTimer _mainloopTimer; + private readonly IPlayerData _playerData; + + private IMatchmakingQueues? _matchmakingQueues; + private IKoEarlyRequeue? _koEarlyRequeue; + private IRecklessPlayPenalty? _recklessPlayPenalty; // optional + + private readonly Dictionary _arenaDataDictionary = new(Constants.TargetArenaCount); + + public KoRequeue( + IChat chat, + IConfigManager configManager, + ILogManager logManager, + IMainloopTimer mainloopTimer, + IPlayerData playerData) + { + _chat = chat ?? throw new ArgumentNullException(nameof(chat)); + _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); + _logManager = logManager ?? throw new ArgumentNullException(nameof(logManager)); + _mainloopTimer = mainloopTimer ?? throw new ArgumentNullException(nameof(mainloopTimer)); + _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData)); + } + + #region Module members + + bool IModule.Load(IComponentBroker broker) + { + _matchmakingQueues = broker.GetInterface(); + if (_matchmakingQueues is null) + { + _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IMatchmakingQueues)}."); + return false; + } + + _koEarlyRequeue = broker.GetInterface(); + if (_koEarlyRequeue is null) + { + _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IKoEarlyRequeue)}."); + broker.ReleaseInterface(ref _matchmakingQueues); + return false; + } + + // Optional: if RecklessPlayPenalty is not loaded, all KO'd players are eligible for early requeue. + _recklessPlayPenalty = broker.GetInterface(); + + return true; + } + + bool IModule.Unload(IComponentBroker broker) + { + if (_recklessPlayPenalty is not null) + broker.ReleaseInterface(ref _recklessPlayPenalty); + + broker.ReleaseInterface(ref _koEarlyRequeue); + broker.ReleaseInterface(ref _matchmakingQueues); + return true; + } + + #endregion + + #region IArenaAttachableModule members + + [ConfigHelp("SS.Matchmaking.KoRequeue", "Enabled", ConfigScope.Arena, Default = false, + Description = "Set to 1 to enable early requeue for KO'd players.")] + [ConfigHelp("SS.Matchmaking.KoRequeue", "CooldownSeconds", ConfigScope.Arena, Default = 30, + Description = "Seconds after KO before the player may queue for a new match.")] + bool IArenaAttachableModule.AttachModule(Arena arena) + { + ArenaData arenaData = new(); + arenaData.Enabled = _configManager.GetBool(arena.Cfg!, "SS.Matchmaking.KoRequeue", "Enabled", false); + arenaData.Cooldown = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.KoRequeue", "CooldownSeconds", 30)); + + _arenaDataDictionary.Add(arena, arenaData); + + TeamVersusMatchPlayerKilledCallback.Register(arena, Callback_TeamVersusMatchPlayerKilled); + TeamVersusMatchEndedCallback.Register(arena, Callback_TeamVersusMatchEnded); + + return true; + } + + bool IArenaAttachableModule.DetachModule(Arena arena) + { + TeamVersusMatchPlayerKilledCallback.Unregister(arena, Callback_TeamVersusMatchPlayerKilled); + TeamVersusMatchEndedCallback.Unregister(arena, Callback_TeamVersusMatchEnded); + + _arenaDataDictionary.Remove(arena); + return true; + } + + #endregion + + #region Callbacks + + private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlayerSlot killerSlot, bool isKnockout) + { + if (!isKnockout) + return; + + IMatchData matchData = killedSlot.MatchData; + Arena? arena = matchData.Arena; + if (arena is null) + return; + + if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData) || !arenaData.Enabled) + return; + + string? playerName = killedSlot.PlayerName; + if (string.IsNullOrEmpty(playerName)) + return; + + // If the player has a pending reckless play penalty, they forfeit early requeue. + if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true) + return; + + Player? player = killedSlot.Player; + if (player is not null) + { + _chat.SendMessage(player, + $"You have been knocked out. You may queue for a new match in {FormatDuration(arenaData.Cooldown)}."); + } + + _mainloopTimer.SetTimer(MainloopTimer_KoCooldown, (int)arenaData.Cooldown.TotalMilliseconds, Timeout.Infinite, + new KoTimerContext(matchData, playerName), matchData); + + _logManager.LogM(LogLevel.Info, nameof(KoRequeue), + $"[{arena.Name}] [{playerName}] KO'd; early requeue cooldown started ({arenaData.Cooldown.TotalSeconds:F0}s)."); + } + + private void Callback_TeamVersusMatchEnded(IMatchData matchData, MatchEndReason reason, ITeam? winnerTeam) + { + // Cancel any pending cooldown timers for this match. + _mainloopTimer.ClearTimer(MainloopTimer_KoCooldown, matchData); + } + + #endregion + + #region Timer + + private bool MainloopTimer_KoCooldown(KoTimerContext context) + { + IMatchData matchData = context.MatchData; + string playerName = context.PlayerName; + + // Defensive check: verify the player's slot is still in KnockedOut status. + // (A sub could theoretically fill the slot before the timer fires.) + bool stillKnockedOut = false; + foreach (ITeam team in matchData.Teams) + { + foreach (IPlayerSlot slot in team.Slots) + { + if (string.Equals(slot.PlayerName, playerName, StringComparison.OrdinalIgnoreCase)) + { + stillKnockedOut = slot.Status == PlayerSlotStatus.KnockedOut; + goto foundSlot; + } + } + } + foundSlot: + + if (!stillKnockedOut) + return false; + + // Free the player from the Playing state so they can queue again. + // allowRequeue: true respects the player's auto-requeue preference: + // - if auto-requeue is on → they are placed back in queue automatically + // - if auto-requeue is off → PreviousQueued is cleared; they must type ?next + _matchmakingQueues!.UnsetPlayingByName(playerName, allowRequeue: true); + + // Mark this in the old match's participation record so EndMatch skips them. + _koEarlyRequeue!.MarkPlayerKoEarlyRequeued(matchData, playerName); + + Player? player = _playerData.FindPlayer(playerName); + if (player is not null) + { + _chat.SendMessage(player, "You are now free to queue for a new match. Use ?next to join."); + } + + _logManager.LogM(LogLevel.Info, nameof(KoRequeue), + $"[{matchData.ArenaName}] [{playerName}] Early requeue cooldown elapsed; player freed from Playing state."); + + return false; // one-shot timer + } + + #endregion + + private static string FormatDuration(TimeSpan duration) + { + int totalSeconds = Math.Max(0, (int)duration.TotalSeconds); + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + return minutes > 0 ? $"{minutes}m {seconds}s" : $"{seconds}s"; + } + + private sealed class ArenaData + { + public bool Enabled; + public TimeSpan Cooldown; + } + + private sealed class KoTimerContext(IMatchData matchData, string playerName) + { + public IMatchData MatchData { get; } = matchData; + public string PlayerName { get; } = playerName; + } + } +} diff --git a/src/Matchmaking/Modules/RecklessPlayPenalty.cs b/src/Matchmaking/Modules/RecklessPlayPenalty.cs index c742961f..8a613b87 100644 --- a/src/Matchmaking/Modules/RecklessPlayPenalty.cs +++ b/src/Matchmaking/Modules/RecklessPlayPenalty.cs @@ -107,6 +107,23 @@ bool IArenaAttachableModule.DetachModule(Arena arena) #endregion + #region IRecklessPlayPenalty members + + bool IRecklessPlayPenalty.HasPendingPenalty(IMatchData matchData, string playerName) + { + Arena? arena = matchData.Arena; + if (arena is null) + return false; + + if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData)) + return false; + + return arenaData.PendingPenalties.TryGetValue(matchData, out Dictionary? matchPenalties) + && matchPenalties.ContainsKey(playerName); + } + + #endregion + #region Callbacks private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlayerSlot killerSlot, bool isKnockout) diff --git a/src/Matchmaking/Modules/TeamVersusMatch.cs b/src/Matchmaking/Modules/TeamVersusMatch.cs index 8bfd5459..ee6a90fb 100644 --- a/src/Matchmaking/Modules/TeamVersusMatch.cs +++ b/src/Matchmaking/Modules/TeamVersusMatch.cs @@ -55,7 +55,7 @@ namespace SS.Matchmaking.Modules Manages team versus matches. Configuration: {nameof(TeamVersusMatch)}.conf """)] - public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IFreqManagerEnforcerAdvisor, IMatchFocusAdvisor, ILeagueGameMode, ILeagueHelp + public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IFreqManagerEnforcerAdvisor, IMatchFocusAdvisor, ILeagueGameMode, ILeagueHelp, IKoEarlyRequeue { private const string ConfigurationFileName = "TeamVersus.conf"; @@ -87,6 +87,7 @@ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IF private AdvisorRegistrationToken? _iMatchFocusAdvisorToken; private AdvisorRegistrationToken? _iMatchmakingQueueAdvisorToken; + private InterfaceRegistrationToken? _iKoEarlyRequeueToken; private ConfigHandle? _teamVersusConfig; @@ -265,6 +266,7 @@ async Task IAsyncModule.LoadAsync(IComponentBroker broker, CancellationTok _iMatchFocusAdvisorToken = broker.RegisterAdvisor(this); _iMatchmakingQueueAdvisorToken = broker.RegisterAdvisor(this); + _iKoEarlyRequeueToken = broker.RegisterInterface(this); return true; @@ -298,6 +300,9 @@ bool GetSpawnClientSettingIdentifiers() Task IAsyncModule.UnloadAsync(IComponentBroker broker, CancellationToken cancellationToken) { + if (broker.UnregisterInterface(ref _iKoEarlyRequeueToken) != 0) + return Task.FromResult(false); + if (!broker.UnregisterAdvisor(ref _iMatchFocusAdvisorToken)) return Task.FromResult(false); @@ -762,6 +767,25 @@ void PrintCommand(Player player, string command, string description) #endregion + #region IKoEarlyRequeue members + + void IKoEarlyRequeue.MarkPlayerKoEarlyRequeued(IMatchData matchData, string playerName) + { + if (matchData is not MatchData md) + return; + + for (int i = 0; i < md.ParticipationList.Count; i++) + { + if (string.Equals(md.ParticipationList[i].PlayerName, playerName, StringComparison.OrdinalIgnoreCase)) + { + md.ParticipationList[i] = md.ParticipationList[i] with { WasEarlyRequeued = true }; + return; + } + } + } + + #endregion + #region Callbacks [ConfigHelp("SS.Matchmaking.TeamVersusMatch", "PublicPlayEnabled", ConfigScope.Arena, Default = false, @@ -6708,9 +6732,10 @@ private async void EndMatch(MatchData matchData, MatchEndReason reason, Team? wi int playerNameIndex = 0; // Unset the players that are allowed to automatically requeue. + // Skip players that were early-requeued (already unset by KoRequeue and may now be playing in a new match). foreach (PlayerParticipationRecord record in matchData.ParticipationList) { - if (!record.LeftWithoutSub) + if (!record.LeftWithoutSub && !record.WasEarlyRequeued) { playerNames[playerNameIndex++] = record.PlayerName; } @@ -7192,7 +7217,7 @@ public void Reset() /// The name of the player. /// Whether the player entered the match as a sub-in. /// Whether the player left the match without having a replacement player ready sub-in. - private record struct PlayerParticipationRecord(string PlayerName, bool WasSubIn, bool LeftWithoutSub); + private record struct PlayerParticipationRecord(string PlayerName, bool WasSubIn, bool LeftWithoutSub, bool WasEarlyRequeued = false); private class Team : ITeam { diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index d5939765..7a89c469 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -120,6 +120,15 @@ static TeamVersusStats() /// private readonly Dictionary _playerMemberDictionary = new(StringComparer.OrdinalIgnoreCase); + /// + /// When a player early-requeues into a new match while still tracked in + /// for an older match, the older match's is saved here. + /// This enables bidirectional OpenSkill rating propagation regardless of which match ends first. + /// Cleared for a player when either of their concurrent matches ends. + /// + /// Key: player name + private readonly Dictionary _concurrentMemberStats = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _arenaDataDictionary = []; private readonly DefaultObjectPool _arenaDataPool = new(new DefaultPooledObjectPolicy(), Constants.TargetArenaCount); @@ -512,6 +521,8 @@ async Task ITeamVersusStatsBehavior.InitializeAsync(IMatchData matchData) slotStats.Current = memberStats; slotStats.AssignTimestamp = now; // set it just so that it's not null, we'll update it at start to be more accurate + if (_playerMemberDictionary.TryGetValue(slot.PlayerName, out MemberStats? existing)) + _concurrentMemberStats[slot.PlayerName] = existing; _playerMemberDictionary[slot.PlayerName] = memberStats; leaderboardRatings.Add(slot.PlayerName, DefaultRating); @@ -1544,6 +1555,40 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, original.Sigma = playerRating.Sigma; } } + + // Propagate updated ratings to any concurrent active match (bidirectional). + // This handles the case where a KO'd player early-requeued into a new match while this one was still running. + foreach (TeamStats teamStats2 in matchStats.Teams.Values) + { + foreach (SlotStats slotStats2 in teamStats2.Slots) + { + foreach (MemberStats memberStats2 in slotStats2.Members) + { + if (memberStats2.PlayerName is null) continue; + if (!matchStats.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? updatedRating)) continue; + + // Forward: this match ends first → propagate to the newer match (Match B). + if (_playerMemberDictionary.TryGetValue(memberStats2.PlayerName, out MemberStats? fwdStats) + && fwdStats != memberStats2 + && fwdStats.MatchStats?.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? fwdRating) == true) + { + fwdRating.Mu = updatedRating.Mu; + fwdRating.Sigma = updatedRating.Sigma; + fwdRating.LastUpdated = matchStats.EndTimestamp; + } + + // Backward: the newer match ends first → propagate back to the older match (Match A). + if (_concurrentMemberStats.TryGetValue(memberStats2.PlayerName, out MemberStats? bwdStats) + && bwdStats != memberStats2 + && bwdStats.MatchStats?.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? bwdRating) == true) + { + bwdRating.Mu = updatedRating.Mu; + bwdRating.Sigma = updatedRating.Sigma; + bwdRating.LastUpdated = matchStats.EndTimestamp; + } + } + } + } } foreach (TeamStats teamStats in matchStats.Teams.Values) @@ -1560,6 +1605,9 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, _playerMemberDictionary.Add(memberStats.PlayerName!, otherStats); } } + + // Once either match in a concurrent pair ends, the window is closed. + _concurrentMemberStats.Remove(memberStats.PlayerName!); } Player? currentPlayer = slotStats.Slot?.Player; @@ -1686,6 +1734,11 @@ async Task SaveGameToDatabase(IMatchData matchData, ITeam? winningTeam, MatchSta if (rating.LastUpdated is not null) { writer.WriteString("last_updated"u8, rating.LastUpdated.Value); + + // new_last_updated tells the database what timestamp to store after the update (instead of using NOW()). + // This allows the server to predict and propagate the updated timestamp to any concurrent matches, + // enabling both matches to contribute OpenSkill updates via sequential optimistic-lock checks. + writer.WriteString("new_last_updated"u8, matchStats.EndTimestamp!.Value); } writer.WriteEndObject(); // player rating object diff --git a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf index 7ec9c1d2..47d88697 100644 --- a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf +++ b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf @@ -14,6 +14,7 @@ AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -31,3 +32,7 @@ PublicPlayEnabled = 0 [SS.Matchmaking.MatchFocus] FilterKillPackets = 1 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf b/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf index 520b255d..04da2d96 100644 --- a/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf +++ b/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf @@ -16,6 +16,7 @@ AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -34,3 +35,7 @@ PublicPlayEnabled = 1 [SS.Matchmaking.MatchFocus] FilterKillPackets = 1 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/arenas/4v4league/arena.conf b/src/SubspaceServer/Zone/arenas/4v4league/arena.conf index 8fac060a..54ee151a 100644 --- a/src/SubspaceServer/Zone/arenas/4v4league/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4league/arena.conf @@ -18,6 +18,7 @@ AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -42,3 +43,7 @@ FilterKillPackets = 1 ;; for commands: ?schedule, ?standings, ?results, ?roster, etc... [SS.Matchmaking.League] DefaultSeasonId = 3 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf b/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf index 3d1e4a52..1dbc7133 100644 --- a/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf @@ -16,6 +16,7 @@ AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -44,3 +45,7 @@ ThresholdSeconds = 300 PenaltyMinimumSeconds = 120 ; Penalty when KO'd almost instantly after match start (maximum penalty). PenaltyMaximumSeconds = 600 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/conf/Modules.config b/src/SubspaceServer/Zone/conf/Modules.config index 1b20caa2..8fbed207 100644 --- a/src/SubspaceServer/Zone/conf/Modules.config +++ b/src/SubspaceServer/Zone/conf/Modules.config @@ -202,6 +202,7 @@ For plug-in modules (e.g. custom modules that you build): + From b0973a61a8fe7cc6ac33b8de226612a5a34e41ce Mon Sep 17 00:00:00 2001 From: Crescendo Date: Thu, 16 Apr 2026 01:04:53 -0500 Subject: [PATCH 2/4] improve openskill update technique when multiple concurrent matches finish in diff orders --- src/Matchmaking/Modules/TeamVersusStats.cs | 75 +++++++--------------- 1 file changed, 22 insertions(+), 53 deletions(-) diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 7a89c469..32b58492 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -120,15 +120,6 @@ static TeamVersusStats() /// private readonly Dictionary _playerMemberDictionary = new(StringComparer.OrdinalIgnoreCase); - /// - /// When a player early-requeues into a new match while still tracked in - /// for an older match, the older match's is saved here. - /// This enables bidirectional OpenSkill rating propagation regardless of which match ends first. - /// Cleared for a player when either of their concurrent matches ends. - /// - /// Key: player name - private readonly Dictionary _concurrentMemberStats = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _arenaDataDictionary = []; private readonly DefaultObjectPool _arenaDataPool = new(new DefaultPooledObjectPolicy(), Constants.TargetArenaCount); @@ -521,8 +512,6 @@ async Task ITeamVersusStatsBehavior.InitializeAsync(IMatchData matchData) slotStats.Current = memberStats; slotStats.AssignTimestamp = now; // set it just so that it's not null, we'll update it at start to be more accurate - if (_playerMemberDictionary.TryGetValue(slot.PlayerName, out MemberStats? existing)) - _concurrentMemberStats[slot.PlayerName] = existing; _playerMemberDictionary[slot.PlayerName] = memberStats; leaderboardRatings.Add(slot.PlayerName, DefaultRating); @@ -1446,6 +1435,28 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, // Rate players using the OpenSkill model. // + // Re-query ratings from DB before computing the update. + // This picks up any changes made by concurrent matches that ended while this match was running + // (e.g. a KO'd player who early-requeued and played another match before this one finished). + // Using the freshest available baseline ensures the optimistic-lock CAS check in SaveGameToDatabase + // will succeed without any in-memory propagation between matches. + // If the re-query fails, fall through using the cached start-of-match ratings. + if (_gameStatsRepository is not null && matchData.Configuration.GameTypeId is not null) + { + try + { + await _gameStatsRepository.GetPlayerOpenSkillRatingsAsync( + matchData.Configuration.GameTypeId.Value, matchStats.OpenSkillRatings); + + // Re-apply decay based on the freshly-read LastUpdated timestamps. + AdjustOpenSkillRatingsForDecay(matchData.Configuration, matchStats.OpenSkillRatings, matchStats.EndTimestamp.Value); + } + catch + { + // DB unavailable; fall through with cached start-of-match ratings. + } + } + // Prepare the rating calcuation inputs. TimeSpan matchDuration = matchStats.EndTimestamp.Value - matchStats.StartTimestamp; List teams = new(matchStats.Teams.Count); @@ -1555,40 +1566,6 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, original.Sigma = playerRating.Sigma; } } - - // Propagate updated ratings to any concurrent active match (bidirectional). - // This handles the case where a KO'd player early-requeued into a new match while this one was still running. - foreach (TeamStats teamStats2 in matchStats.Teams.Values) - { - foreach (SlotStats slotStats2 in teamStats2.Slots) - { - foreach (MemberStats memberStats2 in slotStats2.Members) - { - if (memberStats2.PlayerName is null) continue; - if (!matchStats.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? updatedRating)) continue; - - // Forward: this match ends first → propagate to the newer match (Match B). - if (_playerMemberDictionary.TryGetValue(memberStats2.PlayerName, out MemberStats? fwdStats) - && fwdStats != memberStats2 - && fwdStats.MatchStats?.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? fwdRating) == true) - { - fwdRating.Mu = updatedRating.Mu; - fwdRating.Sigma = updatedRating.Sigma; - fwdRating.LastUpdated = matchStats.EndTimestamp; - } - - // Backward: the newer match ends first → propagate back to the older match (Match A). - if (_concurrentMemberStats.TryGetValue(memberStats2.PlayerName, out MemberStats? bwdStats) - && bwdStats != memberStats2 - && bwdStats.MatchStats?.OpenSkillRatings.TryGetValue(memberStats2.PlayerName, out PlayerRating? bwdRating) == true) - { - bwdRating.Mu = updatedRating.Mu; - bwdRating.Sigma = updatedRating.Sigma; - bwdRating.LastUpdated = matchStats.EndTimestamp; - } - } - } - } } foreach (TeamStats teamStats in matchStats.Teams.Values) @@ -1605,9 +1582,6 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, _playerMemberDictionary.Add(memberStats.PlayerName!, otherStats); } } - - // Once either match in a concurrent pair ends, the window is closed. - _concurrentMemberStats.Remove(memberStats.PlayerName!); } Player? currentPlayer = slotStats.Slot?.Player; @@ -1734,11 +1708,6 @@ async Task SaveGameToDatabase(IMatchData matchData, ITeam? winningTeam, MatchSta if (rating.LastUpdated is not null) { writer.WriteString("last_updated"u8, rating.LastUpdated.Value); - - // new_last_updated tells the database what timestamp to store after the update (instead of using NOW()). - // This allows the server to predict and propagate the updated timestamp to any concurrent matches, - // enabling both matches to contribute OpenSkill updates via sequential optimistic-lock checks. - writer.WriteString("new_last_updated"u8, matchStats.EndTimestamp!.Value); } writer.WriteEndObject(); // player rating object From e18e6ba445e7da53f43bd9b86087165a7766af64 Mon Sep 17 00:00:00 2001 From: Crescendo Date: Thu, 16 Apr 2026 01:12:35 -0500 Subject: [PATCH 3/4] minor fixes --- src/Matchmaking/Modules/KoRequeue.cs | 17 ++++++++++++++++- src/Matchmaking/Modules/TeamVersusStats.cs | 2 +- .../Zone/arenas/4v4caps/arena.conf | 10 ++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/Matchmaking/Modules/KoRequeue.cs b/src/Matchmaking/Modules/KoRequeue.cs index 3fc9ce0f..ba7535a0 100644 --- a/src/Matchmaking/Modules/KoRequeue.cs +++ b/src/Matchmaking/Modules/KoRequeue.cs @@ -97,6 +97,12 @@ bool IArenaAttachableModule.AttachModule(Arena arena) arenaData.Enabled = _configManager.GetBool(arena.Cfg!, "SS.Matchmaking.KoRequeue", "Enabled", false); arenaData.Cooldown = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.KoRequeue", "CooldownSeconds", 30)); + if (arenaData.Enabled && arenaData.Cooldown <= TimeSpan.Zero) + { + _logManager.LogM(LogLevel.Warn, nameof(KoRequeue), + $"[{arena.Name}] CooldownSeconds must be positive. KO'd players will be immediately early-requeued."); + } + _arenaDataDictionary.Add(arena, arenaData); TeamVersusMatchPlayerKilledCallback.Register(arena, Callback_TeamVersusMatchPlayerKilled); @@ -136,6 +142,9 @@ private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlaye return; // If the player has a pending reckless play penalty, they forfeit early requeue. + // NOTE: this relies on RecklessPlayPenalty being attached before KoRequeue in AttachModules, + // so that its kill callback has already recorded the penalty before this one fires. + // A defensive second check is also performed in the timer callback. if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true) return; @@ -187,6 +196,12 @@ private bool MainloopTimer_KoCooldown(KoTimerContext context) if (!stillKnockedOut) return false; + // Defensive check: re-verify no reckless play penalty was recorded. + // The kill callback already checks this, but that check depends on RecklessPlayPenalty's + // callback having fired first. Checking again here makes correctness order-independent. + if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true) + return false; + // Free the player from the Playing state so they can queue again. // allowRequeue: true respects the player's auto-requeue preference: // - if auto-requeue is on → they are placed back in queue automatically @@ -199,7 +214,7 @@ private bool MainloopTimer_KoCooldown(KoTimerContext context) Player? player = _playerData.FindPlayer(playerName); if (player is not null) { - _chat.SendMessage(player, "You are now free to queue for a new match. Use ?next to join."); + _chat.SendMessage(player, "You are now free to queue for a new match."); } _logManager.LogM(LogLevel.Info, nameof(KoRequeue), diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index 32b58492..66a251b1 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -1457,7 +1457,7 @@ await _gameStatsRepository.GetPlayerOpenSkillRatingsAsync( } } - // Prepare the rating calcuation inputs. + // Prepare the rating calculation inputs. TimeSpan matchDuration = matchStats.EndTimestamp.Value - matchStats.StartTimestamp; List teams = new(matchStats.Teams.Count); List? ranks = null; diff --git a/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf b/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf index a7555cff..8bffc49c 100644 --- a/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf @@ -57,3 +57,13 @@ Freq400StartLocation = 710,360 [SS.Matchmaking.MatchFocus] FilterKillPackets = 1 + +[SS.Matchmaking.RecklessPlayPenalty] +; Set to 1 to enable the reckless play penalty feature. +Enabled = 0 +; KO within this many seconds of match start counts as reckless. +ThresholdSeconds = 180 +; Penalty when KO'd right at the threshold boundary (minimum penalty). +PenaltyMinimumSeconds = 120 +; Penalty when KO'd almost instantly after match start (maximum penalty). +PenaltyMaximumSeconds = 600 From be7ba30102c7d0ac15c1769ac434d425f64d276f Mon Sep 17 00:00:00 2001 From: Crescendo Date: Sun, 19 Apr 2026 10:27:35 -0500 Subject: [PATCH 4/4] fixes after rebase --- src/Matchmaking/Modules/KoRequeue.cs | 14 +++++++------- src/Matchmaking/Modules/RecklessPlayPenalty.cs | 6 +++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Matchmaking/Modules/KoRequeue.cs b/src/Matchmaking/Modules/KoRequeue.cs index ba7535a0..2a076420 100644 --- a/src/Matchmaking/Modules/KoRequeue.cs +++ b/src/Matchmaking/Modules/KoRequeue.cs @@ -28,7 +28,7 @@ public sealed class KoRequeue : IModule, IArenaAttachableModule private readonly IMainloopTimer _mainloopTimer; private readonly IPlayerData _playerData; - private IMatchmakingQueues? _matchmakingQueues; + private IPlayManager? _playManager; private IKoEarlyRequeue? _koEarlyRequeue; private IRecklessPlayPenalty? _recklessPlayPenalty; // optional @@ -52,10 +52,10 @@ public KoRequeue( bool IModule.Load(IComponentBroker broker) { - _matchmakingQueues = broker.GetInterface(); - if (_matchmakingQueues is null) + _playManager = broker.GetInterface(); + if (_playManager is null) { - _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IMatchmakingQueues)}."); + _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IPlayManager)}."); return false; } @@ -63,7 +63,7 @@ bool IModule.Load(IComponentBroker broker) if (_koEarlyRequeue is null) { _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IKoEarlyRequeue)}."); - broker.ReleaseInterface(ref _matchmakingQueues); + broker.ReleaseInterface(ref _playManager); return false; } @@ -79,7 +79,7 @@ bool IModule.Unload(IComponentBroker broker) broker.ReleaseInterface(ref _recklessPlayPenalty); broker.ReleaseInterface(ref _koEarlyRequeue); - broker.ReleaseInterface(ref _matchmakingQueues); + broker.ReleaseInterface(ref _playManager); return true; } @@ -206,7 +206,7 @@ private bool MainloopTimer_KoCooldown(KoTimerContext context) // allowRequeue: true respects the player's auto-requeue preference: // - if auto-requeue is on → they are placed back in queue automatically // - if auto-requeue is off → PreviousQueued is cleared; they must type ?next - _matchmakingQueues!.UnsetPlayingByName(playerName, allowRequeue: true); + _playManager!.UnsetPlayingByName(playerName, allowRequeue: true); // Mark this in the old match's participation record so EndMatch skips them. _koEarlyRequeue!.MarkPlayerKoEarlyRequeued(matchData, playerName); diff --git a/src/Matchmaking/Modules/RecklessPlayPenalty.cs b/src/Matchmaking/Modules/RecklessPlayPenalty.cs index 8a613b87..504a64bb 100644 --- a/src/Matchmaking/Modules/RecklessPlayPenalty.cs +++ b/src/Matchmaking/Modules/RecklessPlayPenalty.cs @@ -24,7 +24,7 @@ public sealed class RecklessPlayPenalty( IConfigManager configManager, ILogManager logManager, IPlayerData playerData, - IPlayManager playManager) : IModule, IArenaAttachableModule + IPlayManager playManager) : IModule, IArenaAttachableModule, IRecklessPlayPenalty { private readonly IChat _chat = chat ?? throw new ArgumentNullException(nameof(chat)); private readonly IConfigManager _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); @@ -32,17 +32,21 @@ public sealed class RecklessPlayPenalty( private readonly IPlayerData _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData)); private readonly IPlayManager _playManager = playManager ?? throw new ArgumentNullException(nameof(playManager)); + private InterfaceRegistrationToken? _iRecklessPlayPenaltyToken; + private readonly Dictionary _arenaDataDictionary = new(Constants.TargetArenaCount); #region Module members bool IModule.Load(IComponentBroker broker) { + _iRecklessPlayPenaltyToken = broker.RegisterInterface(this); return true; } bool IModule.Unload(IComponentBroker broker) { + broker.UnregisterInterface(ref _iRecklessPlayPenaltyToken); return true; }