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/IMatchmakingPreferences.cs b/src/Matchmaking/Interfaces/IMatchmakingPreferences.cs new file mode 100644 index 00000000..404acbc4 --- /dev/null +++ b/src/Matchmaking/Interfaces/IMatchmakingPreferences.cs @@ -0,0 +1,31 @@ +using SS.Core; +using SS.Core.ComponentInterfaces; + +namespace SS.Matchmaking.Interfaces +{ + public enum MatchmakingMode + { + /// + /// Default. No restrictions on skill disparity. + /// + Casual, + + /// + /// Player prefers not to be placed in matches with large skill gaps. + /// + Strict, + } + + public interface IMatchmakingPreferences : IComponentInterface + { + /// + /// Gets the player's matchmaking mode preference. + /// + MatchmakingMode GetMatchmakingMode(string playerName); + + /// + /// Sets the player's matchmaking mode. Returns the new mode. + /// + MatchmakingMode SetMatchmakingMode(Player player, MatchmakingMode mode); + } +} 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/Interfaces/ITeamVersusStatsBehavior.cs b/src/Matchmaking/Interfaces/ITeamVersusStatsBehavior.cs index 7a4edd05..477165ac 100644 --- a/src/Matchmaking/Interfaces/ITeamVersusStatsBehavior.cs +++ b/src/Matchmaking/Interfaces/ITeamVersusStatsBehavior.cs @@ -80,5 +80,23 @@ Task PlayerKilledAsync( /// The team that won. for no winner. /// if chat notifications were sent. Otherwise, . Task MatchEndedAsync(IMatchData matchData, MatchEndReason reason, ITeam? winningTeam); + + /// + /// Selects the best N participants from a look-ahead candidate pool and balances them into teams. + /// Combines candidate selection (using ratings + skip boost + strict mode) and team assignment + /// (snake draft) into a single database-backed operation. + /// + /// Match configuration (provides N, skip nudge rate, strict disparity). + /// All N+W candidates with their skip counts. + /// Team lineups to fill. Must already contain the correct number of empty teams. + /// Optional strict mode preference lookup. Null if module not loaded. + /// Output: names of candidates in the window that were NOT selected. + /// True if selection and balancing succeeded; false if ratings unavailable. + Task GetBalancedParticipantsAsync( + IMatchConfiguration matchConfiguration, + IReadOnlyList candidates, + IReadOnlyList teamList, + IMatchmakingPreferences? preferences, + List skippedPlayerNames) => Task.FromResult(false); } } diff --git a/src/Matchmaking/Modules/KoRequeue.cs b/src/Matchmaking/Modules/KoRequeue.cs new file mode 100644 index 00000000..ba7535a0 --- /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 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)); + + 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 + _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."); + } + + _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/MatchmakingPreference.cs b/src/Matchmaking/Modules/MatchmakingPreference.cs new file mode 100644 index 00000000..575872f9 --- /dev/null +++ b/src/Matchmaking/Modules/MatchmakingPreference.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.ObjectPool; +using SS.Core; +using SS.Core.ComponentInterfaces; +using SS.Matchmaking.Interfaces; + +namespace SS.Matchmaking.Modules +{ + /// + /// Module that manages the per-player matchmaking mode preference (Casual / Strict). + /// Provides the ?matchmaking command. + /// + [ModuleInfo("Manages per-player matchmaking mode preference (casual/strict).")] + public sealed class MatchmakingPreference : IModule, IMatchmakingPreferences + { + private readonly IChat _chat; + private readonly ICommandManager _commandManager; + private readonly IPlayerData _playerData; + + private PlayerDataKey _pdKey; + private InterfaceRegistrationToken? _iToken; + + private const string CommandName = "matchmaking"; + + public MatchmakingPreference( + IChat chat, + ICommandManager commandManager, + IPlayerData playerData) + { + _chat = chat ?? throw new ArgumentNullException(nameof(chat)); + _commandManager = commandManager ?? throw new ArgumentNullException(nameof(commandManager)); + _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData)); + } + + bool IModule.Load(IComponentBroker broker) + { + _pdKey = _playerData.AllocatePlayerData(); + _commandManager.AddCommand(CommandName, Command_Matchmaking); + _iToken = broker.RegisterInterface(this); + return true; + } + + bool IModule.Unload(IComponentBroker broker) + { + if (broker.UnregisterInterface(ref _iToken) != 0) + return false; + + _commandManager.RemoveCommand(CommandName, Command_Matchmaking); + _playerData.FreePlayerData(ref _pdKey); + return true; + } + + MatchmakingMode IMatchmakingPreferences.GetMatchmakingMode(string playerName) + { + Player? player = _playerData.FindPlayer(playerName); + if (player is null) + return MatchmakingMode.Casual; + + return player.TryGetExtraData(_pdKey, out PreferenceData? d) ? d.Mode : MatchmakingMode.Casual; + } + + MatchmakingMode IMatchmakingPreferences.SetMatchmakingMode(Player player, MatchmakingMode mode) + { + if (!player.TryGetExtraData(_pdKey, out PreferenceData? d)) + return MatchmakingMode.Casual; + + d.Mode = mode; + return d.Mode; + } + + [CommandHelp( + Targets = CommandTarget.None, + Args = "[strict | casual]", + Description = """ + Controls your matchmaking preference. + - casual: Default. No restrictions on skill disparity. + - strict: Prefer not to be placed in matches with large skill gaps. + Use with no argument to see your current setting. + """)] + private void Command_Matchmaking(ReadOnlySpan commandName, ReadOnlySpan parameters, Player player, ITarget target) + { + if (!player.TryGetExtraData(_pdKey, out PreferenceData? data)) + return; + + if (parameters.IsEmpty) + { + _chat.SendMessage(player, $"Matchmaking preference: {data.Mode}"); + return; + } + + MatchmakingMode newMode; + if (parameters.Equals("strict", StringComparison.OrdinalIgnoreCase)) + newMode = MatchmakingMode.Strict; + else if (parameters.Equals("casual", StringComparison.OrdinalIgnoreCase)) + newMode = MatchmakingMode.Casual; + else + { + _chat.SendMessage(player, $"Unknown option '{parameters}'. Use: strict or casual."); + return; + } + + if (newMode == data.Mode) + { + _chat.SendMessage(player, $"Matchmaking preference is already set to: {newMode}"); + return; + } + + data.Mode = newMode; + _chat.SendMessage(player, $"Matchmaking preference: {newMode}"); + } + + private sealed class PreferenceData : IResettable + { + public MatchmakingMode Mode = MatchmakingMode.Casual; + + bool IResettable.TryReset() + { + Mode = MatchmakingMode.Casual; + return true; + } + } + } +} diff --git a/src/Matchmaking/Modules/RecklessPlayPenalty.cs b/src/Matchmaking/Modules/RecklessPlayPenalty.cs new file mode 100644 index 00000000..f78d4d2a --- /dev/null +++ b/src/Matchmaking/Modules/RecklessPlayPenalty.cs @@ -0,0 +1,267 @@ +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 penalizes players who get KO'd too quickly after a match starts. + /// + /// If a player loses all their lives within a configured time window after match start, + /// they receive a queue hold that prevents them from playing in another match for a + /// duration that scales with how quickly they were eliminated. + /// + /// For use with the module. + /// + [ModuleInfo($""" + Penalizes players that get KO'd too quickly after a match starts. + For use with the {nameof(TeamVersusMatch)} module. + """)] + public sealed class RecklessPlayPenalty : IModule, IArenaAttachableModule, IRecklessPlayPenalty + { + private readonly IChat _chat; + private readonly IConfigManager _configManager; + private readonly ILogManager _logManager; + private readonly IPlayerData _playerData; + + private IMatchmakingQueues? _matchmakingQueues; + private InterfaceRegistrationToken? _iRecklessPlayPenaltyToken; + + private readonly Dictionary _arenaDataDictionary = new(Constants.TargetArenaCount); + + public RecklessPlayPenalty( + IChat chat, + IConfigManager configManager, + ILogManager logManager, + IPlayerData playerData) + { + _chat = chat ?? throw new ArgumentNullException(nameof(chat)); + _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager)); + _logManager = logManager ?? throw new ArgumentNullException(nameof(logManager)); + _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(RecklessPlayPenalty), $"Unable to get {nameof(IMatchmakingQueues)}."); + return false; + } + + _iRecklessPlayPenaltyToken = broker.RegisterInterface(this); + return true; + } + + bool IModule.Unload(IComponentBroker broker) + { + if (broker.UnregisterInterface(ref _iRecklessPlayPenaltyToken) != 0) + return false; + + broker.ReleaseInterface(ref _matchmakingQueues); + return true; + } + + #endregion + + #region IArenaAttachableModule members + + [ConfigHelp("SS.Matchmaking.RecklessPlayPenalty", "Enabled", ConfigScope.Arena, Default = false, + Description = "Set to 1 to enable the reckless play penalty feature.")] + [ConfigHelp("SS.Matchmaking.RecklessPlayPenalty", "ThresholdSeconds", ConfigScope.Arena, Default = 180, + Description = "KO within this many seconds of match start counts as reckless.")] + [ConfigHelp("SS.Matchmaking.RecklessPlayPenalty", "PenaltyMinimumSeconds", ConfigScope.Arena, Default = 120, + Description = "Hold duration (seconds) when KO'd right at the threshold boundary.")] + [ConfigHelp("SS.Matchmaking.RecklessPlayPenalty", "PenaltyMaximumSeconds", ConfigScope.Arena, Default = 600, + Description = "Hold duration (seconds) when KO'd almost instantly after match start.")] + bool IArenaAttachableModule.AttachModule(Arena arena) + { + ArenaData arenaData = new(); + + arenaData.Enabled = _configManager.GetBool(arena.Cfg!, "SS.Matchmaking.RecklessPlayPenalty", "Enabled", false); + arenaData.Threshold = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.RecklessPlayPenalty", "ThresholdSeconds", 180)); + arenaData.PenaltyMinimum = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.RecklessPlayPenalty", "PenaltyMinimumSeconds", 120)); + arenaData.PenaltyMaximum = TimeSpan.FromSeconds(_configManager.GetInt(arena.Cfg!, "SS.Matchmaking.RecklessPlayPenalty", "PenaltyMaximumSeconds", 600)); + + if (arenaData.Enabled) + { + if (arenaData.Threshold <= TimeSpan.Zero) + { + _logManager.LogM(LogLevel.Warn, nameof(RecklessPlayPenalty), + $"[{arena.Name}] ThresholdSeconds must be positive. Reckless play penalty will never trigger."); + } + + if (arenaData.PenaltyMinimum > arenaData.PenaltyMaximum) + { + _logManager.LogM(LogLevel.Warn, nameof(RecklessPlayPenalty), + $"[{arena.Name}] PenaltyMinimumSeconds ({arenaData.PenaltyMinimum.TotalSeconds}) is greater than PenaltyMaximumSeconds ({arenaData.PenaltyMaximum.TotalSeconds}). Penalty will increase with elapsed time instead of decrease."); + } + } + + _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); + + if (!_arenaDataDictionary.Remove(arena, out ArenaData? arenaData)) + return false; + + foreach (Dictionary matchPenalties in arenaData.PendingPenalties.Values) + matchPenalties.Clear(); + arenaData.PendingPenalties.Clear(); + + return true; + } + + #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) + { + if (!isKnockout) + return; + + IMatchData matchData = killedSlot.MatchData; + Arena? arena = matchData.Arena; + if (arena is null) + return; + + if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData)) + return; + + if (!arenaData.Enabled) + return; + + if (arenaData.Threshold <= TimeSpan.Zero) + return; + + if (matchData.Started is not { } started) + return; + + TimeSpan elapsed = DateTime.UtcNow - started; + + if (elapsed >= arenaData.Threshold) + return; + + // Penalty scales linearly from PenaltyMaximum (instant KO) down to PenaltyMinimum (KO just at threshold). + double t = elapsed.TotalSeconds / arenaData.Threshold.TotalSeconds; + double penaltySeconds = arenaData.PenaltyMaximum.TotalSeconds + + t * (arenaData.PenaltyMinimum.TotalSeconds - arenaData.PenaltyMaximum.TotalSeconds); + TimeSpan penalty = TimeSpan.FromSeconds(penaltySeconds); + + // Clamp as a safety net against floating-point edge cases. + if (penalty < arenaData.PenaltyMinimum) penalty = arenaData.PenaltyMinimum; + if (penalty > arenaData.PenaltyMaximum) penalty = arenaData.PenaltyMaximum; + + string? playerName = killedSlot.PlayerName; + if (string.IsNullOrEmpty(playerName)) + return; + + if (!arenaData.PendingPenalties.TryGetValue(matchData, out Dictionary? matchPenalties)) + { + matchPenalties = new Dictionary(StringComparer.OrdinalIgnoreCase); + arenaData.PendingPenalties[matchData] = matchPenalties; + } + + // A player can only be KO'd once per match, but guard defensively and keep the larger penalty. + if (!matchPenalties.TryGetValue(playerName, out (TimeSpan Penalty, TimeSpan ElapsedAtKo) existing) || penalty > existing.Penalty) + matchPenalties[playerName] = (penalty, elapsed); + + _logManager.LogM(LogLevel.Info, nameof(RecklessPlayPenalty), + $"[{arena.Name}] [{playerName}] Reckless KO at {elapsed.TotalSeconds:F1}s into match (threshold: {arenaData.Threshold.TotalSeconds}s). Pending penalty: {penalty.TotalSeconds:F0}s."); + } + + private void Callback_TeamVersusMatchEnded(IMatchData matchData, MatchEndReason reason, ITeam? winnerTeam) + { + Arena? arena = matchData.Arena; + if (arena is null) + return; + + if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData)) + return; + + // Always remove from the dictionary to prevent memory leaks, regardless of whether penalties are applied. + if (!arenaData.PendingPenalties.Remove(matchData, out Dictionary? matchPenalties)) + return; + + if (!arenaData.Enabled) + return; + + // A cancelled match never reached InProgress, so no reckless KOs could have occurred. + // (This also protects against any hypothetical edge cases where data was recorded before cancellation.) + if (reason == MatchEndReason.Cancelled) + return; + + foreach ((string playerName, (TimeSpan penalty, TimeSpan elapsedAtKo)) in matchPenalties) + { + // Apply the hold. Safe even if the player was already removed from the Playing state + // by another mechanism — UnsetPlayingWithHold early-returns when the name is not found. + _matchmakingQueues!.UnsetPlayingWithHold(playerName, penalty); + + Player? player = _playerData.FindPlayer(playerName); + if (player is not null) + { + _chat.SendMessage(player, + $"You were KO'd too quickly ({FormatDuration(elapsedAtKo)} into the match). " + + $"You must wait {FormatDuration(penalty)} before queuing again."); + } + + _logManager.LogM(LogLevel.Info, nameof(RecklessPlayPenalty), + $"[{arena.Name}] [{playerName}] Reckless play penalty applied: {penalty.TotalSeconds:F0}s."); + } + } + + #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 Threshold; + public TimeSpan PenaltyMinimum; + public TimeSpan PenaltyMaximum; + + // Outer key: IMatchData (reference equality — match objects live for the match duration) + // Inner key: player name (OrdinalIgnoreCase); value: penalty duration and elapsed time at the moment of KO + public readonly Dictionary> PendingPenalties = []; + } + } +} diff --git a/src/Matchmaking/Modules/TeamVersusMatch.cs b/src/Matchmaking/Modules/TeamVersusMatch.cs index 2e8afa52..bd8fab75 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"; @@ -81,11 +81,13 @@ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IF // optional private ITeamVersusStatsBehavior? _teamVersusStatsBehavior; + private IMatchmakingPreferences? _matchmakingPreferences; private ILeagueManager? _leagueManager; private IReplayController? _replayController; private AdvisorRegistrationToken? _iMatchFocusAdvisorToken; private AdvisorRegistrationToken? _iMatchmakingQueueAdvisorToken; + private InterfaceRegistrationToken? _iKoEarlyRequeueToken; private ConfigHandle? _teamVersusConfig; @@ -161,6 +163,16 @@ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IF /// private readonly Dictionary _arenaBaseDataDictionary = []; + /// + /// Match configurations that have an active look-ahead wait timer running. + /// + private readonly HashSet _activeWaitTimers = []; + + /// + /// Match configurations whose look-ahead wait timer has fired and are ready to proceed. + /// + private readonly HashSet _firedWaitTimers = []; + /// /// Data per-arena (not all arenas, only those configured for matches). /// @@ -173,6 +185,9 @@ public sealed class TeamVersusMatch : IAsyncModule, IMatchmakingQueueAdvisor, IF private readonly DefaultObjectPool _teamLineupPool = new(new DefaultPooledObjectPolicy(), Constants.TargetPlayerCount); private readonly DefaultObjectPool> _teamLineupListPool = new(new ListPooledObjectPolicy(), 8); private readonly DefaultObjectPool> _playerListPool = new(new ListPooledObjectPolicy() { InitialCapacity = Constants.TargetPlayerCount }, 8); + private readonly DefaultObjectPool> _candidateListPool = new(new ListPooledObjectPolicy() { InitialCapacity = Constants.TargetPlayerCount }, 8); + private readonly DefaultObjectPool> _handleListPool = new(new ListPooledObjectPolicy<(Player? Player, IPlayerGroup? Group)>() { InitialCapacity = Constants.TargetPlayerCount }, 8); + private readonly DefaultObjectPool> _stringListPool = new(new ListPooledObjectPolicy() { InitialCapacity = Constants.TargetPlayerCount }, 8); public TeamVersusMatch( IComponentBroker broker, @@ -223,6 +238,7 @@ public TeamVersusMatch( async Task IAsyncModule.LoadAsync(IComponentBroker broker, CancellationToken cancellationToken) { _teamVersusStatsBehavior = broker.GetInterface(); + _matchmakingPreferences = broker.GetInterface(); _leagueManager = broker.GetInterface(); _replayController = broker.GetInterface(); @@ -262,6 +278,7 @@ async Task IAsyncModule.LoadAsync(IComponentBroker broker, CancellationTok _iMatchFocusAdvisorToken = broker.RegisterAdvisor(this); _iMatchmakingQueueAdvisorToken = broker.RegisterAdvisor(this); + _iKoEarlyRequeueToken = broker.RegisterInterface(this); return true; @@ -295,6 +312,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); @@ -320,6 +340,9 @@ Task IAsyncModule.UnloadAsync(IComponentBroker broker, CancellationToken c if (_teamVersusStatsBehavior is not null) broker.ReleaseInterface(ref _teamVersusStatsBehavior); + if (_matchmakingPreferences is not null) + broker.ReleaseInterface(ref _matchmakingPreferences); + if (_leagueManager is not null) broker.ReleaseInterface(ref _leagueManager); @@ -759,6 +782,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, @@ -4347,6 +4389,24 @@ private bool LoadMatchType(string matchType, Player? player) if (!double.TryParse(_configManager.GetStr(ch, matchType, "OpenSkillDisplayOrdinalTarget"), out double displayOrdinalTarget)) displayOrdinalTarget = 0; + int lookAheadWindow = _configManager.GetInt(ch, matchType, "LookAheadWindow", 0); + if (lookAheadWindow < 0) + lookAheadWindow = 0; + + int lookAheadWaitSeconds = _configManager.GetInt(ch, matchType, "LookAheadWaitSeconds", 60); + if (lookAheadWaitSeconds < 0) + lookAheadWaitSeconds = 0; + + string? skipNudgeRateStr = _configManager.GetStr(ch, matchType, "SkipNudgeRate"); + double skipNudgeRate = 0.2; + if (!string.IsNullOrWhiteSpace(skipNudgeRateStr)) + double.TryParse(skipNudgeRateStr, out skipNudgeRate); + + string? strictMaxDisparityStr = _configManager.GetStr(ch, matchType, "StrictMatchmakingMaxDisparity"); + double strictMatchmakingMaxDisparity = 0.0; + if (!string.IsNullOrWhiteSpace(strictMaxDisparityStr)) + double.TryParse(strictMaxDisparityStr, out strictMatchmakingMaxDisparity); + MatchConfiguration matchConfiguration = new() { MatchType = matchType, @@ -4385,6 +4445,10 @@ private bool LoadMatchType(string matchType, Player? player) OpenSkillSigmaDecayPerDay = sigmaDecayPerDay, OpenSkillUseScoresWhenPossible = useScoresWhenPossible, OpenSkillDisplayOrdinal = new OrdinalArgs(displayOrdinalZ, displayOrdinalAlpha, displayOrdinalTarget), + LookAheadWindow = lookAheadWindow, + LookAheadWaitSeconds = lookAheadWaitSeconds, + SkipNudgeRate = skipNudgeRate, + StrictMatchmakingMaxDisparity = strictMatchmakingMaxDisparity, Boxes = new MatchBoxConfiguration[numBoxes], }; @@ -4787,107 +4851,353 @@ private bool MakeMatch(TeamVersusMatchmakingQueue queue) // Found an available location for a game to be played in. Next, try to find players. // - List teamList = _teamLineupListPool.Get(); - for (int teamIdx = 0; teamIdx < matchConfiguration.NumTeams; teamIdx++) + int N = matchConfiguration.NumTeams * matchConfiguration.PlayersPerTeam; + int lookAheadWindow = matchConfiguration.LookAheadWindow; + bool useLookAhead = lookAheadWindow > 0 && _teamVersusStatsBehavior is not null; + + if (useLookAhead) { - teamList.Add(_teamLineupPool.Get()); - } + // + // Look-ahead matchmaking: peek at N+W candidates and select the best N. + // - List participantList = _playerListPool.Get(); + int windowSize = N + lookAheadWindow; + List candidates = _candidateListPool.Get(); + List<(Player? Player, IPlayerGroup? Group)> handles = _handleListPool.Get(); - try - { - if (!queue.GetParticipants(matchData.Configuration, teamList, participantList)) + try { - foreach (TeamLineup teamLineup in teamList) + int totalPeeked = queue.PeekCandidates(windowSize, candidates, handles); + + if (totalPeeked < N) + { + // Not enough players. Cancel any active wait timer. + if (_activeWaitTimers.Remove(matchConfiguration)) + _mainloopTimer.ClearTimer(Timer_LookAheadWait, matchConfiguration); + _firedWaitTimers.Remove(matchConfiguration); + continue; + } + + // Check if any candidate is a premade group — fall back to FIFO if so. + bool hasGroup = false; + foreach ((Player? player, IPlayerGroup? group) in handles) { - _teamLineupPool.Return(teamLineup); + if (group is not null) + { + hasGroup = true; + break; + } } - _teamLineupListPool.Return(teamList); + if (hasGroup) + { + // Groups present — fall through to FIFO path. + } + else + { + bool windowFull = totalPeeked >= windowSize; + bool timerFired = _firedWaitTimers.Contains(matchConfiguration); - continue; + if (!windowFull && !timerFired) + { + // Minimum players available but window not yet full — start/keep the wait timer. + if (!_activeWaitTimers.Contains(matchConfiguration) && matchConfiguration.LookAheadWaitSeconds > 0) + { + _activeWaitTimers.Add(matchConfiguration); + _mainloopTimer.SetTimer( + Timer_LookAheadWait, + matchConfiguration.LookAheadWaitSeconds * 1000, + Timeout.Infinite, + new WaitTimerState { Config = matchConfiguration, Queue = queue }, + matchConfiguration); + } + continue; // waiting for window to fill or timer to fire + } + + // Window is full, timer fired, or wait is 0 — proceed with look-ahead. + _firedWaitTimers.Remove(matchConfiguration); + if (_activeWaitTimers.Remove(matchConfiguration)) + _mainloopTimer.ClearTimer(Timer_LookAheadWait, matchConfiguration); + + // Reserve the match. + matchData.Status = MatchStatus.Initializing; + + List teamList = _teamLineupListPool.Get(); + for (int teamIdx = 0; teamIdx < matchConfiguration.NumTeams; teamIdx++) + { + teamList.Add(_teamLineupPool.Get()); + } + + // Synchronously dequeue all peeked candidates and mark them as playing. + // This prevents a race where concurrent MakeMatch calls could peek the same players. + List peekedPlayers = _playerListPool.Get(); + foreach ((Player? player, IPlayerGroup? group) in handles) + { + if (player is not null) + { + queue.DequeueByReference(player, null); + peekedPlayers.Add(player); + } + else if (group is not null) + { + queue.DequeueByReference(null, group); + foreach (Player member in group.Members) + peekedPlayers.Add(member); + } + } + + HashSet peekedPlayerSet = _objectPoolManager.PlayerSetPool.Get(); + try + { + peekedPlayerSet.UnionWith(peekedPlayers); + _matchmakingQueues.SetPlaying(peekedPlayerSet); + } + finally + { + _objectPoolManager.PlayerSetPool.Return(peekedPlayerSet); + } + + // Fire async look-ahead initialization (ownership of candidates, handles, teamList, peekedPlayers transfers). + _ = InitializeMatchWithLookAhead(queue, matchData, teamList, candidates, handles, peekedPlayers); + candidates = null!; // ownership transferred + handles = null!; // ownership transferred + + return true; + } + } + finally + { + if (candidates is not null) + _candidateListPool.Return(candidates); + if (handles is not null) + _handleListPool.Return(handles); } + } - // - // Reserve the match. - // + // + // FIFO path (LookAheadWindow == 0, groups present, or stats behavior not available). + // - matchData.Status = MatchStatus.Initializing; + // Clear any stale wait timer state for this config. + if (_activeWaitTimers.Remove(matchConfiguration)) + _mainloopTimer.ClearTimer(Timer_LookAheadWait, matchConfiguration); + _firedWaitTimers.Remove(matchConfiguration); - // - // Mark the players as playing. - // + { + List teamList = _teamLineupListPool.Get(); + for (int teamIdx = 0; teamIdx < matchConfiguration.NumTeams; teamIdx++) + { + teamList.Add(_teamLineupPool.Get()); + } + + List participantList = _playerListPool.Get(); - HashSet players = _objectPoolManager.PlayerSetPool.Get(); try { - foreach (Player player in participantList) + if (!queue.GetParticipants(matchData.Configuration, teamList, participantList)) { - players.Add(player); + foreach (TeamLineup teamLineup in teamList) + { + _teamLineupPool.Return(teamLineup); + } - // Add the participants in the order provided (which is the order they were queued up in). - matchData.ParticipationList.Add(new PlayerParticipationRecord(player.Name!, false, false)); + _teamLineupListPool.Return(teamList); + + continue; } - _matchmakingQueues.SetPlaying(players); - _chat.SendAnyMessage(players, ChatMessageType.RemotePrivate, ChatSound.None, null, $"{_matchmakingQueues.NextCommandName}: Placing you into a {matchData.MatchIdentifier.MatchType} match."); + // + // Reserve the match. + // + + matchData.Status = MatchStatus.Initializing; + + // + // Mark the players as playing. + // + + HashSet players = _objectPoolManager.PlayerSetPool.Get(); + try + { + foreach (Player player in participantList) + { + players.Add(player); + + // Add the participants in the order provided (which is the order they were queued up in). + matchData.ParticipationList.Add(new PlayerParticipationRecord(player.Name!, false, false)); + } + + _matchmakingQueues.SetPlaying(players); + _chat.SendAnyMessage(players, ChatMessageType.RemotePrivate, ChatSound.None, null, $"{_matchmakingQueues.NextCommandName}: Placing you into a {matchData.MatchIdentifier.MatchType} match."); + } + finally + { + _objectPoolManager.PlayerSetPool.Return(players); + } } finally { - _objectPoolManager.PlayerSetPool.Return(players); + _playerListPool.Return(participantList); } - } - finally - { - _playerListPool.Return(participantList); - } - // - // Initialize the match. - // + // + // Initialize the match. + // - _ = InitializeMatch(matchData, teamList); + _ = InitializeMatch(matchData, teamList, balancingAlreadyDone: false); - return true; + return true; + } } return false; - - // local function that performs the steps required to initialize a match - async Task InitializeMatch(MatchData matchData, List teamLineups) + async Task InitializeMatchWithLookAhead( + TeamVersusMatchmakingQueue queue, + MatchData matchData, + List teamList, + List candidates, + List<(Player? Player, IPlayerGroup? Group)> handles, + List peekedPlayers) { try { - // Balance or randomize teams. - List mutableTeams = _teamLineupListPool.Get(); + List skippedPlayerNames = _stringListPool.Get(); try { - foreach (TeamLineup team in teamLineups) + bool selected = await _teamVersusStatsBehavior!.GetBalancedParticipantsAsync( + matchData.Configuration, candidates, teamList, _matchmakingPreferences, skippedPlayerNames); + + if (!selected) { - if (!team.IsPremade) - mutableTeams.Add(team); + // Ratings unavailable — cancel and restore all peeked players. + EndMatch(matchData, MatchEndReason.Cancelled, null); + + _matchmakingQueues.UnsetPlayingDueToCancel(peekedPlayers); + + foreach (TeamLineup teamLineup in teamList) + { + teamLineup.Players.Clear(); + _teamLineupPool.Return(teamLineup); + } + _teamLineupListPool.Return(teamList); + return; } - if (mutableTeams.Count >= 2) + // Build a set of selected player names from the filled teamList. + HashSet selectedNames = new(StringComparer.OrdinalIgnoreCase); + foreach (TeamLineup team in teamList) { - bool balanced = false; + foreach ((string playerName, _) in team.Players) + { + selectedNames.Add(playerName); + } + } - if (_teamVersusStatsBehavior is not null) + // Split peeked players into selected participants and skipped players. + List participantList = _playerListPool.Get(); + List skippedPlayers = _playerListPool.Get(); + try + { + foreach (Player player in peekedPlayers) { - balanced = await _teamVersusStatsBehavior.BalanceTeamsAsync(matchData.Configuration, mutableTeams); + if (selectedNames.Contains(player.Name!)) + participantList.Add(player); + else + skippedPlayers.Add(player); } - if (!balanced) + // Restore skipped players back to their queues. + if (skippedPlayers.Count > 0) + _matchmakingQueues.UnsetPlayingDueToCancel(skippedPlayers); + + // Increment skip counts for skipped players (they are back in the queue now). + queue.IncrementSkipCounts(skippedPlayerNames); + + // Add participation records and send notification to selected players. + HashSet players = _objectPoolManager.PlayerSetPool.Get(); + try + { + foreach (Player player in participantList) + { + players.Add(player); + matchData.ParticipationList.Add(new PlayerParticipationRecord(player.Name!, false, false)); + } + + _chat.SendAnyMessage(players, ChatMessageType.RemotePrivate, ChatSound.None, null, $"{_matchmakingQueues.NextCommandName}: Placing you into a {matchData.MatchIdentifier.MatchType} match."); + } + finally { - RandomizeTeams(matchData.Configuration, mutableTeams); + _objectPoolManager.PlayerSetPool.Return(players); } } + finally + { + _playerListPool.Return(skippedPlayers); + _playerListPool.Return(participantList); + } } finally { - _teamLineupListPool.Return(mutableTeams); + _stringListPool.Return(skippedPlayerNames); + } + + // Continue with the rest of match initialization (find players, assign slots, warp to arena). + // BalanceTeamsAsync is NOT called — GetBalancedParticipantsAsync already did both selection and team assignment. + await InitializeMatch(matchData, teamList, balancingAlreadyDone: true); + } + catch (Exception ex) + { + _logManager.LogM(LogLevel.Error, nameof(TeamVersusMatch), $"Error during look-ahead match initialization: {ex}"); + EndMatch(matchData, MatchEndReason.Cancelled, null); + + // Restore all peeked players on unexpected failure. + _matchmakingQueues.UnsetPlayingDueToCancel(peekedPlayers); + } + finally + { + _candidateListPool.Return(candidates); + _handleListPool.Return(handles); + _playerListPool.Return(peekedPlayers); + } + } + + // local function that performs the steps required to initialize a match + async Task InitializeMatch(MatchData matchData, List teamLineups, bool balancingAlreadyDone) + { + try + { + if (!balancingAlreadyDone) + { + // Balance or randomize teams. + List mutableTeams = _teamLineupListPool.Get(); + try + { + foreach (TeamLineup team in teamLineups) + { + if (!team.IsPremade) + mutableTeams.Add(team); + } + + if (mutableTeams.Count >= 2) + { + bool balanced = false; + + if (_teamVersusStatsBehavior is not null) + { + balanced = await _teamVersusStatsBehavior.BalanceTeamsAsync(matchData.Configuration, mutableTeams); + } + + if (!balanced) + { + RandomizeTeams(matchData.Configuration, mutableTeams); + } + } + } + finally + { + _teamLineupListPool.Return(mutableTeams); + } } // Get the Player objects of all the players in the match. @@ -5412,6 +5722,17 @@ private void SendSubAvailabilityNotificationToQueuedPlayers(MatchData matchData) } } + private bool Timer_LookAheadWait(WaitTimerState state) + { + _activeWaitTimers.Remove(state.Config); + _firedWaitTimers.Add(state.Config); + + // Re-attempt match formation — will now proceed past the wait check. + MakeMatch(state.Queue); + + return false; // single-fire timer + } + private bool MainloopTimer_ProcessInactiveSlot(PlayerSlot slot) { if (slot is null) @@ -6705,9 +7026,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; } @@ -6944,6 +7266,10 @@ private class MatchConfiguration : IMatchConfiguration public required double OpenSkillSigmaDecayPerDay { get; init; } public required bool OpenSkillUseScoresWhenPossible { get; init; } public required OrdinalArgs OpenSkillDisplayOrdinal { get; init; } + public required int LookAheadWindow { get; init; } + public required int LookAheadWaitSeconds { get; init; } + public required double SkipNudgeRate { get; init; } + public required double StrictMatchmakingMaxDisparity { get; init; } public required MatchBoxConfiguration[] Boxes; @@ -7189,7 +7515,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 { @@ -7655,6 +7981,12 @@ private readonly struct ShipSettings public short MaximumEnergy { get; init; } } + private sealed class WaitTimerState + { + public required MatchConfiguration Config { get; init; } + public required TeamVersusMatchmakingQueue Queue { get; init; } + } + private class ArenaBaseData { /// diff --git a/src/Matchmaking/Modules/TeamVersusStats.cs b/src/Matchmaking/Modules/TeamVersusStats.cs index a6f2eded..003938f2 100644 --- a/src/Matchmaking/Modules/TeamVersusStats.cs +++ b/src/Matchmaking/Modules/TeamVersusStats.cs @@ -465,6 +465,372 @@ void BalanceTeamsSnakeDraft(IReadOnlyList teamList, Dictionary ITeamVersusStatsBehavior.GetBalancedParticipantsAsync( + IMatchConfiguration matchConfiguration, + IReadOnlyList candidates, + IReadOnlyList teamList, + IMatchmakingPreferences? preferences, + List skippedPlayerNames) + { + if (matchConfiguration is null) + return false; + + if (matchConfiguration.GameTypeId is null) + return false; + + if (_gameStatsRepository is null) + return false; + + int M = matchConfiguration.NumTeams * matchConfiguration.PlayersPerTeam; + if (candidates.Count < M) + return false; + + Dictionary ratings = _playerRatingDictionaryPool.Get(); + try + { + // Start all candidates with the default rating from the model. + foreach (PlayerCandidate candidate in candidates) + { + ratings[candidate.PlayerName] = new PlayerRating + { + PlayerName = candidate.PlayerName, + Mu = matchConfiguration.OpenSkillModel.Mu, + Sigma = matchConfiguration.OpenSkillModel.Sigma, + }; + } + + // Fetch ratings from DB. + try + { + await _gameStatsRepository.GetPlayerOpenSkillRatingsAsync(matchConfiguration.GameTypeId.Value, ratings); + } + catch + { + return false; + } + + // Adjust ratings for decay. + AdjustOpenSkillRatingsForDecay(matchConfiguration, ratings, DateTime.UtcNow); + + // Compute base ordinals. + Dictionary baseOrdinal = new(candidates.Count, StringComparer.OrdinalIgnoreCase); + double poolSum = 0; + foreach (PlayerCandidate candidate in candidates) + { + double ordinal = ratings[candidate.PlayerName].GetOrdinal(); + baseOrdinal[candidate.PlayerName] = ordinal; + poolSum += ordinal; + } + double poolMean = poolSum / candidates.Count; + + // Compute effective ordinals (mean-nudge using skip count). + Dictionary effectiveOrdinal = new(candidates.Count, StringComparer.OrdinalIgnoreCase); + foreach (PlayerCandidate candidate in candidates) + { + double nudgeFraction = Math.Min(1.0, candidate.SkipCount * matchConfiguration.SkipNudgeRate); + double baseOrd = baseOrdinal[candidate.PlayerName]; + effectiveOrdinal[candidate.PlayerName] = baseOrd + (poolMean - baseOrd) * nudgeFraction; + } + + // Get best subsets using LTS. + (List> bestSubsets, bool combinationLimitHit) = GetBestSubsets(candidates, M, effectiveOrdinal, k: 5); + if (bestSubsets.Count == 0) + return false; + + if (combinationLimitHit) + { + _logManager.LogM(LogLevel.Warn, nameof(TeamVersusStats), + $"Combination limit reached during look-ahead selection (candidates={candidates.Count}, playersNeeded={M}, lookAheadWindow={matchConfiguration.LookAheadWindow}). Consider tuning LookAheadWindow or team size."); + } + + // Filter subsets for strict mode compliance. + double strictMaxDisparity = matchConfiguration.StrictMatchmakingMaxDisparity; + List> validSubsets = []; + bool notifyStrictPlayers = false; + + if (strictMaxDisparity > 0 && preferences is not null) + { + foreach (List subset in bestSubsets) + { + if (!HasStrictViolation(subset, baseOrdinal, preferences, strictMaxDisparity)) + { + validSubsets.Add(subset); + } + } + + if (validSubsets.Count == 0) + { + // No subset satisfies strict mode — use all and notify. + validSubsets = bestSubsets; + notifyStrictPlayers = true; + } + } + else + { + validSubsets = bestSubsets; + } + + // For each valid subset, run snake draft and evaluate match balance. + List? bestSubset = null; + List? bestAssignment = null; + double bestMatchBalance = double.MaxValue; + + List tempTeamList = new(teamList.Count); + + foreach (List subset in validSubsets) + { + // Create a temporary team list for drafting. + tempTeamList.Clear(); + for (int t = 0; t < teamList.Count; t++) + { + tempTeamList.Add(new TeamLineup()); + } + + // Build the rating list for this subset using BASE ordinals. + List<(string PlayerName, double Ordinal)> subsetRatings = _playerOrdinalListPool.Get(); + try + { + foreach (string name in subset) + { + subsetRatings.Add((name, baseOrdinal[name])); + } + + // Sort by ordinal descending. + subsetRatings.Sort(static (x, y) => -x.Ordinal.CompareTo(y.Ordinal)); + + // Snake draft. + bool ascending = true; + int playerIndex = 0; + int teamIndex = 0; + while (playerIndex < subsetRatings.Count) + { + tempTeamList[teamIndex].Players.Add(subsetRatings[playerIndex++].PlayerName, null); + + if (ascending) + { + if (teamIndex == tempTeamList.Count - 1) + ascending = false; + else + teamIndex++; + } + else + { + if (teamIndex == 0) + ascending = true; + else + teamIndex--; + } + } + + // Evaluate match balance: playerSpread + 0.25 * teamSpread. + double subsetMean = 0; + foreach (string name in subset) + subsetMean += baseOrdinal[name]; + subsetMean /= subset.Count; + + double playerSpread = 0; + foreach (string name in subset) + { + double diff = baseOrdinal[name] - subsetMean; + playerSpread += diff * diff; + } + + double overallAvg = subsetMean; + double teamSpread = 0; + for (int t = 0; t < tempTeamList.Count; t++) + { + double teamSum = 0; + foreach ((string name, _) in tempTeamList[t].Players) + teamSum += baseOrdinal[name]; + double teamAvg = teamSum / tempTeamList[t].Players.Count; + double teamDiff = teamAvg - overallAvg; + teamSpread += teamDiff * teamDiff; + } + + double matchBalance = playerSpread + 0.25 * teamSpread; + + if (matchBalance < bestMatchBalance) + { + bestMatchBalance = matchBalance; + bestSubset = subset; + + // Clone the assignment. + bestAssignment ??= new List(tempTeamList.Count); + // Reuse or clear existing entries. + while (bestAssignment.Count < tempTeamList.Count) + bestAssignment.Add(new TeamLineup()); + while (bestAssignment.Count > tempTeamList.Count) + bestAssignment.RemoveAt(bestAssignment.Count - 1); + + for (int t = 0; t < tempTeamList.Count; t++) + { + bestAssignment[t].Players.Clear(); + foreach ((string name, int? premadeGroupId) in tempTeamList[t].Players) + bestAssignment[t].Players.Add(name, premadeGroupId); + } + } + } + finally + { + _playerOrdinalListPool.Return(subsetRatings); + } + } + + if (bestSubset is null || bestAssignment is null) + return false; + + // Copy the winning team assignment into the actual teamList. + for (int t = 0; t < teamList.Count; t++) + { + teamList[t].Players.Clear(); + foreach ((string name, int? premadeGroupId) in bestAssignment[t].Players) + teamList[t].Players.Add(name, premadeGroupId); + } + + // Populate skipped player names. + HashSet selectedNames = new(bestSubset, StringComparer.OrdinalIgnoreCase); + foreach (PlayerCandidate candidate in candidates) + { + if (!selectedNames.Contains(candidate.PlayerName)) + skippedPlayerNames.Add(candidate.PlayerName); + } + + // Notify strict-mode players if their preference was violated. + if (notifyStrictPlayers && preferences is not null) + { + foreach (string name in bestSubset) + { + if (preferences.GetMatchmakingMode(name) == MatchmakingMode.Strict) + { + Player? player = _playerData.FindPlayer(name); + if (player is not null) + { + _chat.SendMessage(player, "Strict matchmaking: unable to adhere to your preference, the best match was formed using players with a wider skill distribution"); + } + } + } + } + + return true; + } + finally + { + _playerRatingDictionaryPool.Return(ratings); + } + + static bool HasStrictViolation( + List subset, + Dictionary baseOrdinal, + IMatchmakingPreferences preferences, + double strictMaxDisparity) + { + double minOrdinal = double.MaxValue; + foreach (string name in subset) + { + double ord = baseOrdinal[name]; + if (ord < minOrdinal) + minOrdinal = ord; + } + + foreach (string name in subset) + { + if (preferences.GetMatchmakingMode(name) == MatchmakingMode.Strict) + { + double gap = baseOrdinal[name] - minOrdinal; + if (gap > strictMaxDisparity) + return true; + } + } + + return false; + } + + static (List> Subsets, bool LimitHit) GetBestSubsets( + IReadOnlyList candidates, + int M, + Dictionary effectiveOrdinal, + int k) + { + const int MaxCombinations = 100_000; + + int N = candidates.Count; + + // Enumerate C(N, M) subsets and compute LTS score for each. + // For small N (typically <=15) and M (typically 4-8), this is tractable. + // A hard limit prevents combinatorial explosion with large configs. + List<(double Score, List Subset)> scored = []; + + // Use iterative combination generation. + int[] indices = new int[M]; + for (int j = 0; j < M; j++) + indices[j] = j; + + bool limitHit = false; + int combinationsEvaluated = 0; + + while (true) + { + // Compute LTS score directly from indices (no allocation). + double sum = 0; + for (int j = 0; j < M; j++) + sum += effectiveOrdinal[candidates[indices[j]].PlayerName]; + double mean = sum / M; + + double ltsScore = 0; + for (int j = 0; j < M; j++) + { + double diff = effectiveOrdinal[candidates[indices[j]].PlayerName] - mean; + ltsScore += diff * diff; + } + + // Insert into scored list, maintaining at most k entries (lowest scores). + // Only allocate the List when the score qualifies. + if (scored.Count < k) + { + List subset = new(M); + for (int j = 0; j < M; j++) + subset.Add(candidates[indices[j]].PlayerName); + + scored.Add((ltsScore, subset)); + scored.Sort(static (a, b) => a.Score.CompareTo(b.Score)); + } + else if (ltsScore < scored[^1].Score) + { + List subset = new(M); + for (int j = 0; j < M; j++) + subset.Add(candidates[indices[j]].PlayerName); + + scored[^1] = (ltsScore, subset); + scored.Sort(static (a, b) => a.Score.CompareTo(b.Score)); + } + + // Check combination limit. + if (++combinationsEvaluated >= MaxCombinations) + { + limitHit = true; + break; + } + + // Generate next combination. + int i = M - 1; + while (i >= 0 && indices[i] == N - M + i) + i--; + + if (i < 0) + break; + + indices[i]++; + for (int j = i + 1; j < M; j++) + indices[j] = indices[j - 1] + 1; + } + + List> result = new(scored.Count); + foreach ((_, List subset) in scored) + result.Add(subset); + return (result, limitHit); + } + } + async Task ITeamVersusStatsBehavior.InitializeAsync(IMatchData matchData) { // @@ -1436,7 +1802,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/Matchmaking/Queues/TeamVersusMatchmakingQueue.cs b/src/Matchmaking/Queues/TeamVersusMatchmakingQueue.cs index eb964352..cc5c3e4a 100644 --- a/src/Matchmaking/Queues/TeamVersusMatchmakingQueue.cs +++ b/src/Matchmaking/Queues/TeamVersusMatchmakingQueue.cs @@ -387,6 +387,90 @@ public bool GetParticipants( } } + #region Look-ahead + + /// + /// Peeks at up to queue entries (front-first) without removing them, + /// filling with entries and + /// with corresponding player/group references for later dequeue. + /// + /// Total player count represented by the peeked entries. + public int PeekCandidates(int maxEntries, List candidates, List<(Player? Player, IPlayerGroup? Group)> handles) + { + int totalPlayers = 0; + int entriesAdded = 0; + LinkedListNode? node = _queue.First; + while (node is not null && entriesAdded < maxEntries) + { + ref readonly QueuedPlayerOrGroup pog = ref node.ValueRef; + if (pog.Player is not null) + { + candidates.Add(new PlayerCandidate { PlayerName = pog.Player.Name!, SkipCount = pog.SkipCount }); + handles.Add((pog.Player, null)); + totalPlayers += 1; + } + else if (pog.Group is not null) + { + foreach (Player member in pog.Group.Members) + { + candidates.Add(new PlayerCandidate { PlayerName = member.Name!, SkipCount = pog.SkipCount }); + } + handles.Add((null, pog.Group)); + totalPlayers += pog.Group.Members.Count; + } + entriesAdded++; + node = node.Next; + } + return totalPlayers; + } + + /// + /// Removes a specific queue entry identified by player or group reference. + /// + /// if found and removed; otherwise, . + public bool DequeueByReference(Player? player, IPlayerGroup? group) + { + LinkedListNode? node = _queue.First; + while (node is not null) + { + if ((player is not null && node.ValueRef.Player == player) + || (group is not null && node.ValueRef.Group == group)) + { + _queue.Remove(node); + s_nodePool.Return(node); + return true; + } + node = node.Next; + } + return false; + } + + /// + /// For each player name in , finds the corresponding queue node + /// and increments its . + /// + public void IncrementSkipCounts(IReadOnlyList skippedPlayerNames) + { + foreach (string name in skippedPlayerNames) + { + LinkedListNode? node = _queue.First; + while (node is not null) + { + ref readonly QueuedPlayerOrGroup pog = ref node.ValueRef; + bool match = (pog.Player?.Name is string pn && string.Equals(pn, name, StringComparison.OrdinalIgnoreCase)) + || (pog.Group is not null && pog.Group.Members.Any(m => string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase))); + if (match) + { + node.ValueRef = node.ValueRef with { SkipCount = node.ValueRef.SkipCount + 1 }; + break; + } + node = node.Next; + } + } + } + + #endregion + /// /// Represents either a single or a of players. /// @@ -397,6 +481,7 @@ public QueuedPlayerOrGroup(Player player, DateTime timestamp) Player = player ?? throw new ArgumentNullException(nameof(player)); Group = null; Timestamp = timestamp; + SkipCount = 0; } public QueuedPlayerOrGroup(IPlayerGroup group, DateTime timestamp) @@ -404,11 +489,13 @@ public QueuedPlayerOrGroup(IPlayerGroup group, DateTime timestamp) Player = null; Group = group ?? throw new ArgumentNullException(nameof(group)); Timestamp = timestamp; + SkipCount = 0; } public Player? Player { get; } public IPlayerGroup? Group { get; } public DateTime Timestamp { get; } + public int SkipCount { get; init; } } } } diff --git a/src/Matchmaking/TeamVersus/IMatchConfiguration.cs b/src/Matchmaking/TeamVersus/IMatchConfiguration.cs index 0d689543..8a6c7555 100644 --- a/src/Matchmaking/TeamVersus/IMatchConfiguration.cs +++ b/src/Matchmaking/TeamVersus/IMatchConfiguration.cs @@ -68,5 +68,27 @@ public interface IMatchConfiguration /// The arguments to pass when calculating the Ordinal value to display for a rating. /// public OrdinalArgs OpenSkillDisplayOrdinal { get; } + + /// + /// Additional players beyond the minimum (N) to consider for look-ahead balancing. 0 = disabled (FIFO). + /// + int LookAheadWindow => 0; + + /// + /// Seconds to wait for the look-ahead window to fill before forming with fewer than N+W candidates. + /// + int LookAheadWaitSeconds => 60; + + /// + /// Fraction per skip that nudges an outlier's effective ordinal toward the candidate pool mean. + /// After skipCount * rate >= 1.0, the player is effectively at the mean. + /// + double SkipNudgeRate => 0.2; + + /// + /// Maximum ordinal gap a strict-mode player tolerates between themselves and the lowest-rated + /// player in the selected set. 0 = no limit. + /// + double StrictMatchmakingMaxDisparity => 0; } } diff --git a/src/Matchmaking/TeamVersus/PlayerCandidate.cs b/src/Matchmaking/TeamVersus/PlayerCandidate.cs new file mode 100644 index 00000000..74825d05 --- /dev/null +++ b/src/Matchmaking/TeamVersus/PlayerCandidate.cs @@ -0,0 +1,16 @@ +namespace SS.Matchmaking.TeamVersus +{ + /// + /// A player candidate for look-ahead matchmaking selection. + /// + public readonly struct PlayerCandidate + { + public required string PlayerName { get; init; } + + /// + /// Number of times this player was in the look-ahead window but was not selected. + /// Used to compute a priority boost when running the selection algorithm. + /// + public int SkipCount { get; init; } + } +} diff --git a/src/SubspaceServer/Zone/arenas/2v2league/arena.conf b/src/SubspaceServer/Zone/arenas/2v2league/arena.conf index 7def3af5..9f5b38f7 100644 --- a/src/SubspaceServer/Zone/arenas/2v2league/arena.conf +++ b/src/SubspaceServer/Zone/arenas/2v2league/arena.conf @@ -15,6 +15,8 @@ LevelFiles = match.lvz AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -38,3 +40,7 @@ FilterKillPackets = 1 ;; for commands: ?schedule, ?standings, ?results, ?roster, etc... [SS.Matchmaking.League] DefaultSeasonId = 2 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf index c65ed8cc..47d88697 100644 --- a/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf +++ b/src/SubspaceServer/Zone/arenas/2v2pub/arena.conf @@ -13,6 +13,8 @@ LevelFiles = match.lvz AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -29,4 +31,8 @@ InitialSpec = 1 PublicPlayEnabled = 0 [SS.Matchmaking.MatchFocus] -FilterKillPackets = 1 \ No newline at end of file +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 413ce07b..04da2d96 100644 --- a/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf +++ b/src/SubspaceServer/Zone/arenas/3v3pub/arena.conf @@ -15,6 +15,8 @@ AttachModules = \ SS.Core.Modules.Scoring.KillPoints \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -32,4 +34,8 @@ InitialSpec = 1 PublicPlayEnabled = 1 [SS.Matchmaking.MatchFocus] -FilterKillPackets = 1 \ No newline at end of file +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 d9f5f23f..8bffc49c 100644 --- a/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4caps/arena.conf @@ -16,6 +16,7 @@ AttachModules = \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.MatchLvz \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ SS.Matchmaking.Modules.CaptainsMatch [ Misc ] @@ -55,4 +56,14 @@ Freq300StartLocation = 340,350 Freq400StartLocation = 710,360 [SS.Matchmaking.MatchFocus] -FilterKillPackets = 1 \ No newline at end of file +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 c3285582..54ee151a 100644 --- a/src/SubspaceServer/Zone/arenas/4v4league/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4league/arena.conf @@ -17,6 +17,8 @@ AttachModules = \ SS.Core.Modules.Scoring.KillPoints \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -41,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 756143d7..1dbc7133 100644 --- a/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4prac/arena.conf @@ -15,6 +15,8 @@ AttachModules = \ SS.Core.Modules.Scoring.KillPoints \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -32,4 +34,18 @@ InitialSpec = 1 PublicPlayEnabled = 1 [SS.Matchmaking.MatchFocus] -FilterKillPackets = 1 \ No newline at end of file +FilterKillPackets = 1 + +[SS.Matchmaking.RecklessPlayPenalty] +; Set to 1 to enable the reckless play penalty feature. +Enabled = 1 +; KO within this many seconds of match start counts as reckless. +ThresholdSeconds = 300 +; 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 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/arenas/4v4pub/arena.conf b/src/SubspaceServer/Zone/arenas/4v4pub/arena.conf index 2b4b7155..8b5ebd87 100644 --- a/src/SubspaceServer/Zone/arenas/4v4pub/arena.conf +++ b/src/SubspaceServer/Zone/arenas/4v4pub/arena.conf @@ -15,6 +15,8 @@ AttachModules = \ SS.Core.Modules.Scoring.KillPoints \ SS.Matchmaking.Modules.MatchFocus \ SS.Matchmaking.Modules.TeamVersusStats \ + SS.Matchmaking.Modules.RecklessPlayPenalty \ + SS.Matchmaking.Modules.KoRequeue \ SS.Matchmaking.Modules.MatchLvz [ Misc ] @@ -33,3 +35,7 @@ PublicPlayEnabled = 1 [SS.Matchmaking.MatchFocus] FilterKillPackets = 1 + +[SS.Matchmaking.KoRequeue] +Enabled = 0 +CooldownSeconds = 30 diff --git a/src/SubspaceServer/Zone/conf/Modules.config b/src/SubspaceServer/Zone/conf/Modules.config index c360eba0..7bf67aad 100644 --- a/src/SubspaceServer/Zone/conf/Modules.config +++ b/src/SubspaceServer/Zone/conf/Modules.config @@ -195,12 +195,15 @@ For plug-in modules (e.g. custom modules that you build): + + + diff --git a/src/SubspaceServer/Zone/conf/TeamVersus.conf b/src/SubspaceServer/Zone/conf/TeamVersus.conf index 5ef1da65..b7955a6a 100644 --- a/src/SubspaceServer/Zone/conf/TeamVersus.conf +++ b/src/SubspaceServer/Zone/conf/TeamVersus.conf @@ -80,6 +80,18 @@ Match3 = 4v4pub ;; Adjusts the overall scale of the ordinal value. ;; OpenSkillDisplayOrdinalTarget - A number used to shift the ordinal value towards a specific target. ;; The shift is adjusted by the OpenSkillDisplayOrdinalAlpha scaling factor. +;; LookAheadWindow - How many additional players beyond the minimum to consider when selecting participants. +;; 0 = disabled (current FIFO behavior). Default: 0. +;; LookAheadWaitSeconds - Seconds to wait for the window to fill when there are exactly N players available +;; and the window hasn't reached N+LookAheadWindow yet. 0 = form immediately. Default: 60. +;; SkipNudgeRate - Fraction per skip that pulls an outlier's effective ordinal toward the candidate pool mean. +;; After skipCount * SkipNudgeRate >= 1.0, the player is effectively at the mean and will never be excluded. +;; Example: 0.2 means a player is fully nudged to the mean after 5 consecutive skips. +;; 0 = no nudge (skipped players get no priority). Default: 0.2. +;; StrictMatchmakingMaxDisparity - Ordinal threshold for strict matchmaking mode (?matchmaking strict). +;; A player with strict mode enabled won't be placed in a match where the gap between their +;; ordinal and the lowest ordinal in the selected set exceeds this value. +;; 0 = no limit. Default: 0. [2v2pub] GameTypeId = 2 @@ -310,7 +322,7 @@ OpenSkillDisplayOrdinalAlpha = 24 OpenSkillDisplayOrdinalTarget = 1500 [4v4pub-Box1] -Team1StartLocation1 = 340,350 +Team1StartLocation1 = 340,350 Team1StartLocation2 = 430,710 Team2StartLocation1 = 710,360 Team2StartLocation2 = 760,550 @@ -356,13 +368,17 @@ OpenSkillUseScoresWhenPossible = false OpenSkillDisplayOrdinalZ = 3 OpenSkillDisplayOrdinalAlpha = 24 OpenSkillDisplayOrdinalTarget = 1500 +LookAheadWindow = 3 +LookAheadWaitSeconds = 60 +SkipNudgeRate = 0.2 +StrictMatchmakingMaxDisparity = 3.0 [4v4prac-Box1] -Team1StartLocation1 = 340,350 +Team1StartLocation1 = 340,350 Team1StartLocation2 = 430,710 Team2StartLocation1 = 710,360 Team2StartLocation2 = 760,550 -PlayAreaMapRegion = +PlayAreaMapRegion = [4v4league] GameTypeId = 12 @@ -406,7 +422,7 @@ OpenSkillDisplayOrdinalAlpha = 24 OpenSkillDisplayOrdinalTarget = 1500 [4v4league-Box1] -Team1StartLocation1 = 340,350 +Team1StartLocation1 = 340,350 Team1StartLocation2 = 430,710 Team2StartLocation1 = 710,360 Team2StartLocation2 = 760,550