Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/Matchmaking/Interfaces/IKoEarlyRequeue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using SS.Core;
using SS.Matchmaking.TeamVersus;

namespace SS.Matchmaking.Interfaces
{
/// <summary>
/// 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 <c>UnsetPlayingByName</c> for them.
/// </summary>
public interface IKoEarlyRequeue : IComponentInterface
{
/// <summary>
/// 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.
/// </summary>
void MarkPlayerKoEarlyRequeued(IMatchData matchData, string playerName);
}
}
17 changes: 17 additions & 0 deletions src/Matchmaking/Interfaces/IRecklessPlayPenalty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using SS.Core;
using SS.Matchmaking.TeamVersus;

namespace SS.Matchmaking.Interfaces
{
/// <summary>
/// Interface for querying whether a player has a pending reckless play penalty for a match.
/// </summary>
public interface IRecklessPlayPenalty : IComponentInterface
{
/// <summary>
/// Returns <see langword="true"/> 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).
/// </summary>
bool HasPendingPenalty(IMatchData matchData, string playerName);
}
}
248 changes: 248 additions & 0 deletions src/Matchmaking/Modules/KoRequeue.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// <para>
/// 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 <c>?next</c> again.
/// </para>
/// <para>For use with the <see cref="TeamVersusMatch"/> module.</para>
/// </summary>
[ModuleInfo($"""
Allows KO'd players to re-enter the matchmaking queue after a configurable cooldown.
For use with the {nameof(TeamVersusMatch)} module.
""")]
public sealed class KoRequeue : IModule, IArenaAttachableModule
{
private readonly IChat _chat;
private readonly IConfigManager _configManager;
private readonly ILogManager _logManager;
private readonly IMainloopTimer _mainloopTimer;
private readonly IPlayerData _playerData;

private IPlayManager? _playManager;
private IKoEarlyRequeue? _koEarlyRequeue;
private IRecklessPlayPenalty? _recklessPlayPenalty; // optional

private readonly Dictionary<Arena, ArenaData> _arenaDataDictionary = new(Constants.TargetArenaCount);

public KoRequeue(
IChat chat,
IConfigManager configManager,
ILogManager logManager,
IMainloopTimer mainloopTimer,
IPlayerData playerData)
{
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
_logManager = logManager ?? throw new ArgumentNullException(nameof(logManager));
_mainloopTimer = mainloopTimer ?? throw new ArgumentNullException(nameof(mainloopTimer));
_playerData = playerData ?? throw new ArgumentNullException(nameof(playerData));
}

#region Module members

bool IModule.Load(IComponentBroker broker)
{
_playManager = broker.GetInterface<IPlayManager>();
if (_playManager is null)
{
_logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IPlayManager)}.");
return false;
}

_koEarlyRequeue = broker.GetInterface<IKoEarlyRequeue>();
if (_koEarlyRequeue is null)
{
_logManager.LogM(LogLevel.Error, nameof(KoRequeue), $"Unable to get {nameof(IKoEarlyRequeue)}.");
broker.ReleaseInterface(ref _playManager);
return false;
}

// Optional: if RecklessPlayPenalty is not loaded, all KO'd players are eligible for early requeue.
_recklessPlayPenalty = broker.GetInterface<IRecklessPlayPenalty>();

return true;
}

bool IModule.Unload(IComponentBroker broker)
{
if (_recklessPlayPenalty is not null)
broker.ReleaseInterface(ref _recklessPlayPenalty);

broker.ReleaseInterface(ref _koEarlyRequeue);
broker.ReleaseInterface(ref _playManager);
return true;
}

#endregion

#region IArenaAttachableModule members

[ConfigHelp<bool>("SS.Matchmaking.KoRequeue", "Enabled", ConfigScope.Arena, Default = false,
Description = "Set to 1 to enable early requeue for KO'd players.")]
[ConfigHelp<int>("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<KoTimerContext>(MainloopTimer_KoCooldown, matchData);
}

#endregion

#region Timer

private bool MainloopTimer_KoCooldown(KoTimerContext context)
{
IMatchData matchData = context.MatchData;
string playerName = context.PlayerName;

// Defensive check: verify the player's slot is still in KnockedOut status.
// (A sub could theoretically fill the slot before the timer fires.)
bool stillKnockedOut = false;
foreach (ITeam team in matchData.Teams)
{
foreach (IPlayerSlot slot in team.Slots)
{
if (string.Equals(slot.PlayerName, playerName, StringComparison.OrdinalIgnoreCase))
{
stillKnockedOut = slot.Status == PlayerSlotStatus.KnockedOut;
goto foundSlot;
}
}
}
foundSlot:

if (!stillKnockedOut)
return false;

// Defensive check: re-verify no reckless play penalty was recorded.
// The kill callback already checks this, but that check depends on RecklessPlayPenalty's
// callback having fired first. Checking again here makes correctness order-independent.
if (_recklessPlayPenalty?.HasPendingPenalty(matchData, playerName) == true)
return false;

// Free the player from the Playing state so they can queue again.
// allowRequeue: true respects the player's auto-requeue preference:
// - if auto-requeue is on → they are placed back in queue automatically
// - if auto-requeue is off → PreviousQueued is cleared; they must type ?next
_playManager!.UnsetPlayingByName(playerName, allowRequeue: true);

// Mark this in the old match's participation record so EndMatch skips them.
_koEarlyRequeue!.MarkPlayerKoEarlyRequeued(matchData, playerName);

Player? player = _playerData.FindPlayer(playerName);
if (player is not null)
{
_chat.SendMessage(player, "You are now free to queue for a new match.");
}

_logManager.LogM(LogLevel.Info, nameof(KoRequeue),
$"[{matchData.ArenaName}] [{playerName}] Early requeue cooldown elapsed; player freed from Playing state.");

return false; // one-shot timer
}

#endregion

private static string FormatDuration(TimeSpan duration)
{
int totalSeconds = Math.Max(0, (int)duration.TotalSeconds);
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
return minutes > 0 ? $"{minutes}m {seconds}s" : $"{seconds}s";
}

private sealed class ArenaData
{
public bool Enabled;
public TimeSpan Cooldown;
}

private sealed class KoTimerContext(IMatchData matchData, string playerName)
{
public IMatchData MatchData { get; } = matchData;
public string PlayerName { get; } = playerName;
}
}
}
23 changes: 22 additions & 1 deletion src/Matchmaking/Modules/RecklessPlayPenalty.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,25 +24,29 @@ public sealed class RecklessPlayPenalty(
IConfigManager configManager,
ILogManager logManager,
IPlayerData playerData,
IPlayManager playManager) : IModule, IArenaAttachableModule
IPlayManager playManager) : IModule, IArenaAttachableModule, IRecklessPlayPenalty
{
private readonly IChat _chat = chat ?? throw new ArgumentNullException(nameof(chat));
private readonly IConfigManager _configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
private readonly ILogManager _logManager = logManager ?? throw new ArgumentNullException(nameof(logManager));
private readonly IPlayerData _playerData = playerData ?? throw new ArgumentNullException(nameof(playerData));
private readonly IPlayManager _playManager = playManager ?? throw new ArgumentNullException(nameof(playManager));

private InterfaceRegistrationToken<IRecklessPlayPenalty>? _iRecklessPlayPenaltyToken;

private readonly Dictionary<Arena, ArenaData> _arenaDataDictionary = new(Constants.TargetArenaCount);

#region Module members

bool IModule.Load(IComponentBroker broker)
{
_iRecklessPlayPenaltyToken = broker.RegisterInterface<IRecklessPlayPenalty>(this);
return true;
}

bool IModule.Unload(IComponentBroker broker)
{
broker.UnregisterInterface(ref _iRecklessPlayPenaltyToken);
return true;
}

Expand Down Expand Up @@ -107,6 +111,23 @@ bool IArenaAttachableModule.DetachModule(Arena arena)

#endregion

#region IRecklessPlayPenalty members

bool IRecklessPlayPenalty.HasPendingPenalty(IMatchData matchData, string playerName)
{
Arena? arena = matchData.Arena;
if (arena is null)
return false;

if (!_arenaDataDictionary.TryGetValue(arena, out ArenaData? arenaData))
return false;

return arenaData.PendingPenalties.TryGetValue(matchData, out Dictionary<string, (TimeSpan Penalty, TimeSpan ElapsedAtKo)>? matchPenalties)
&& matchPenalties.ContainsKey(playerName);
}

#endregion

#region Callbacks

private void Callback_TeamVersusMatchPlayerKilled(IPlayerSlot killedSlot, IPlayerSlot killerSlot, bool isKnockout)
Expand Down
Loading