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