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..2a076420 --- /dev/null +++ b/src/Matchmaking/Modules/KoRequeue.cs @@ -0,0 +1,248 @@ +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 IPlayManager? _playManager; + 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) + { + _playManager = broker.GetInterface(); + if (_playManager is null) + { + _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IPlayManager)}."); + return false; + } + + _koEarlyRequeue = broker.GetInterface(); + if (_koEarlyRequeue is null) + { + _logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IKoEarlyRequeue)}."); + broker.ReleaseInterface(ref _playManager); + 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 _playManager); + 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)); + + 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); + 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. + // 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; + + 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; + + // 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 + // - if auto-requeue is off → PreviousQueued is cleared; they must type ?next + _playManager!.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."); + } + + _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..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; } @@ -107,6 +111,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..66a251b1 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -1435,7 +1435,29 @@ async Task ITeamVersusStatsBehavior.MatchEndedAsync(IMatchData matchData, // Rate players using the OpenSkill model. // - // Prepare the rating calcuation inputs. + // 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 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/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/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 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): +