diff --git a/MatchmakingServer.Tests/MatchmakerTests.cs b/MatchmakingServer.Tests/MatchmakerTests.cs index 4209767..fd5c749 100644 --- a/MatchmakingServer.Tests/MatchmakerTests.cs +++ b/MatchmakingServer.Tests/MatchmakerTests.cs @@ -2,6 +2,9 @@ using H2MLauncher.Core.Matchmaking.Models; +using MatchmakingServer.Matchmaking; +using MatchmakingServer.Matchmaking.Models; + namespace MatchmakingServer.Tests { public class MatchmakerTests diff --git a/MatchmakingServer/Api/EndpointRouteBuilderExtensions.cs b/MatchmakingServer/Api/EndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..faf5d6d --- /dev/null +++ b/MatchmakingServer/Api/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,20 @@ +using MatchmakingServer.Authentication; +using MatchmakingServer.Stats; + +namespace MatchmakingServer.Api +{ + public static class EndpointRouteBuilderExtensions + { + public static void MapEndpoints(this WebApplication app) + { + app.MapEndpoint(); + app.MapEndpoint(); + } + + private static IEndpointRouteBuilder MapEndpoint(this IEndpointRouteBuilder app) where TEndpoint : IEndpoint + { + TEndpoint.Map(app); + return app; + } + } +} diff --git a/MatchmakingServer/Api/IEndpoint.cs b/MatchmakingServer/Api/IEndpoint.cs new file mode 100644 index 0000000..936ae66 --- /dev/null +++ b/MatchmakingServer/Api/IEndpoint.cs @@ -0,0 +1,7 @@ +namespace MatchmakingServer.Api +{ + public interface IEndpoint + { + static abstract void Map(IEndpointRouteBuilder app); + } +} diff --git a/MatchmakingServer/Api/RouteHandlerBuilderExtensions.cs b/MatchmakingServer/Api/RouteHandlerBuilderExtensions.cs new file mode 100644 index 0000000..39733cf --- /dev/null +++ b/MatchmakingServer/Api/RouteHandlerBuilderExtensions.cs @@ -0,0 +1,12 @@ +namespace MatchmakingServer.Api +{ + public static class RouteHandlerBuilderExtensions + { + public static RouteHandlerBuilder WithValidation(this RouteHandlerBuilder builder) + { + return builder + .AddEndpointFilter>() + .ProducesValidationProblem(); + } + } +} diff --git a/MatchmakingServer/Api/ValidationFilter.cs b/MatchmakingServer/Api/ValidationFilter.cs new file mode 100644 index 0000000..27ffe0a --- /dev/null +++ b/MatchmakingServer/Api/ValidationFilter.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +namespace MatchmakingServer.Api; + +public class ValidationFilter(IValidator validator) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + T? request = context.Arguments.OfType().First(); + + if (request is null) + { + return Results.BadRequest(); + } + + var validationResult = await validator.ValidateAsync(request); + if (!validationResult.IsValid) + { + return Results.ValidationProblem(validationResult.ToDictionary()); + } + + return await next(context); + } +} diff --git a/MatchmakingServer/ApplicationSetup.cs b/MatchmakingServer/ApplicationSetup.cs new file mode 100644 index 0000000..0e9f3a4 --- /dev/null +++ b/MatchmakingServer/ApplicationSetup.cs @@ -0,0 +1,42 @@ +using MatchmakingServer.SignalR; + +using Serilog; + +namespace MatchmakingServer; + +public static class ApplicationSetup +{ + public static void UseRequestLogging(this IApplicationBuilder app) + { + app.UseSerilogRequestLogging(options => + { + options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms ({ClientAppName}/{ClientAppVersion})"; + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + if (httpContext.Request.Headers.TryGetValue("X-App-Name", out var appNameValues) && appNameValues.Count != 0) + { + diagnosticContext.Set("ClientAppName", appNameValues.FirstOrDefault()); + } + else + { + diagnosticContext.Set("ClientAppName", "Unknown"); + } + + if (httpContext.Request.Headers.TryGetValue("X-App-Version", out var appVersionValues) && appVersionValues.Count != 0) + { + diagnosticContext.Set("ClientAppVersion", appVersionValues.FirstOrDefault()); + } + else + { + diagnosticContext.Set("ClientAppVersion", "?"); + } + }; + }); + } + + public static void MapHubs(this IEndpointRouteBuilder app) + { + app.MapHub("/Queue"); + app.MapHub("/Party"); + } +} diff --git a/MatchmakingServer/Authentication/AuthenticationEndpoint.cs b/MatchmakingServer/Authentication/AuthenticationEndpoint.cs new file mode 100644 index 0000000..42906c8 --- /dev/null +++ b/MatchmakingServer/Authentication/AuthenticationEndpoint.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; + +using FluentValidation; + +using MatchmakingServer.Api; + +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Http.HttpResults; + +namespace MatchmakingServer.Authentication +{ + public class AuthenticationEndpoint : IEndpoint + { + public static void Map(IEndpointRouteBuilder app) => app + .MapGet("/login", Handle) + .WithSummary("Logs the user in with the UID and player name and returns a bearer token.") + .WithValidation(); + + public record Request(string Uid, string PlayerName); + public class RequestValidator : AbstractValidator + { + public RequestValidator() + { + RuleFor(x => x.Uid).NotEmpty().MaximumLength(36); + RuleFor(x => x.PlayerName).NotEmpty().MaximumLength(69); + } + } + + private static SignInHttpResult Handle([AsParameters] Request request) + { + ClaimsPrincipal claimsPrincipal = new( + new ClaimsIdentity( + [new Claim(ClaimTypes.Name, request.PlayerName), new Claim(ClaimTypes.NameIdentifier, request.Uid)], + BearerTokenDefaults.AuthenticationScheme + ) + ); + + return TypedResults.SignIn(claimsPrincipal); + } + } +} diff --git a/MatchmakingServer/Controllers/PlaylistsController.cs b/MatchmakingServer/Controllers/PlaylistsController.cs index cff21e1..169bb20 100644 --- a/MatchmakingServer/Controllers/PlaylistsController.cs +++ b/MatchmakingServer/Controllers/PlaylistsController.cs @@ -5,6 +5,7 @@ using H2MLauncher.Core.Networking.GameServer.HMW; using H2MLauncher.Core.Services; +using MatchmakingServer.Matchmaking; using MatchmakingServer.SignalR; using Microsoft.AspNetCore.Mvc; diff --git a/MatchmakingServer/Matchmaking/MMServerPriorityComparer.cs b/MatchmakingServer/Matchmaking/MMServerPriorityComparer.cs index 6bdd94e..b4fbc3e 100644 --- a/MatchmakingServer/Matchmaking/MMServerPriorityComparer.cs +++ b/MatchmakingServer/Matchmaking/MMServerPriorityComparer.cs @@ -1,38 +1,37 @@ -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking; + +class MMServerPriorityComparer : IComparer { - class MMServerPriorityComparer : IComparer + public int Compare(GameServer? x, GameServer? y) { - public int Compare(GameServer? x, GameServer? y) + if (x?.LastServerInfo is null || y?.LastServerInfo is null) { - if (x?.LastServerInfo is null || y?.LastServerInfo is null) - { - return 0; - } - - // Check if we should prioritize based on TotalScore < 1000 - bool xIsHalfFull = x.LastStatusResponse?.TotalScore < 1000 && x.LastServerInfo.RealPlayerCount < 6; - bool yIsHalfFull = y.LastStatusResponse?.TotalScore < 1000 && x.LastServerInfo.RealPlayerCount < 6; - - // Case 1: If both servers are half empty and under the score limit, - // prioritize by player count (servers with fewer players should be prioritized) - if (xIsHalfFull && yIsHalfFull) - { - return x.LastServerInfo.RealPlayerCount.CompareTo(y.LastServerInfo.RealPlayerCount); - } + return 0; + } - // Case 2: If one server is half full and under the score limit and the other one is not, - // prioritize the half full server - if (xIsHalfFull && !yIsHalfFull) - { - return -1; - } - if (!xIsHalfFull && yIsHalfFull) - { - return 1; - } + // Check if we should prioritize based on TotalScore < 1000 + bool xIsHalfFull = x.LastStatusResponse?.TotalScore < 1000 && x.LastServerInfo.RealPlayerCount < 6; + bool yIsHalfFull = y.LastStatusResponse?.TotalScore < 1000 && x.LastServerInfo.RealPlayerCount < 6; - // Case 3: If both servers are over the score limit, prioritize by fewer players + // Case 1: If both servers are half empty and under the score limit, + // prioritize by player count (servers with fewer players should be prioritized) + if (xIsHalfFull && yIsHalfFull) + { return x.LastServerInfo.RealPlayerCount.CompareTo(y.LastServerInfo.RealPlayerCount); } + + // Case 2: If one server is half full and under the score limit and the other one is not, + // prioritize the half full server + if (xIsHalfFull && !yIsHalfFull) + { + return -1; + } + if (!xIsHalfFull && yIsHalfFull) + { + return 1; + } + + // Case 3: If both servers are over the score limit, prioritize by fewer players + return x.LastServerInfo.RealPlayerCount.CompareTo(y.LastServerInfo.RealPlayerCount); } } diff --git a/MatchmakingServer/Matchmaking/Matchmaker.cs b/MatchmakingServer/Matchmaking/Matchmaker.cs index cd20fdc..10eb82f 100644 --- a/MatchmakingServer/Matchmaking/Matchmaker.cs +++ b/MatchmakingServer/Matchmaking/Matchmaker.cs @@ -4,406 +4,407 @@ using H2MLauncher.Core.Matchmaking.Models; using H2MLauncher.Core.Models; -namespace MatchmakingServer +using MatchmakingServer.Matchmaking.Models; + +namespace MatchmakingServer.Matchmaking; + +public class Matchmaker { - public class Matchmaker - { - private readonly ILogger _logger; - private readonly SemaphoreSlim _semaphore = new(1, 1); + private readonly ILogger _logger; + private readonly SemaphoreSlim _semaphore = new(1, 1); - /// - /// Holds the tickets in matchmaking for each server. - /// - private readonly ConcurrentDictionary> _serverQueues = []; + /// + /// Holds the tickets in matchmaking for each server. + /// + private readonly ConcurrentDictionary> _serverQueues = []; - /// - /// All players queued in matchmaking. - /// - private readonly ConcurrentLinkedQueue _queue = new(); + /// + /// All players queued in matchmaking. + /// + private readonly ConcurrentLinkedQueue _queue = new(); - public IReadOnlyCollection Tickets => _queue; - public IReadOnlyCollection QueuedServers => new ReadOnlyCollectionWrapper(_serverQueues.Keys); + public IReadOnlyCollection Tickets => _queue; + public IReadOnlyCollection QueuedServers => new ReadOnlyCollectionWrapper(_serverQueues.Keys); - public Matchmaker(ILogger logger) - { - _logger = logger; - } + public Matchmaker(ILogger logger) + { + _logger = logger; + } - public void AddTicketToQueue(MMTicket ticket) - { - _queue.Enqueue(ticket); + public void AddTicketToQueue(MMTicket ticket) + { + _queue.Enqueue(ticket); - foreach (ServerConnectionDetails server in ticket.PreferredServers.Keys) + foreach (ServerConnectionDetails server in ticket.PreferredServers.Keys) + { + if (!_serverQueues.ContainsKey(server)) { - if (!_serverQueues.ContainsKey(server)) - { - _serverQueues[server] = []; - } - - _serverQueues[server].Enqueue(ticket); + _serverQueues[server] = []; } - _logger.LogDebug("Ticket added to matchmaking queue: {ticket}", ticket); + _serverQueues[server].Enqueue(ticket); } - public bool RemoveTicket(MMTicket ticket) - { - bool removed = _queue.Remove(ticket); + _logger.LogDebug("Ticket added to matchmaking queue: {ticket}", ticket); + } + + public bool RemoveTicket(MMTicket ticket) + { + bool removed = _queue.Remove(ticket); - ticket.MatchCompletion.TrySetCanceled(); + ticket.MatchCompletion.TrySetCanceled(); - foreach (ServerConnectionDetails server in ticket.PreferredServers.Keys) + foreach (ServerConnectionDetails server in ticket.PreferredServers.Keys) + { + if (_serverQueues.TryGetValue(server, out ConcurrentLinkedQueue? playersForServer)) { - if (_serverQueues.TryGetValue(server, out ConcurrentLinkedQueue? playersForServer)) - { - playersForServer.Remove(ticket); + playersForServer.Remove(ticket); - if (playersForServer.Count == 0) - { - _serverQueues.TryRemove(server, out _); - } + if (playersForServer.Count == 0) + { + _serverQueues.TryRemove(server, out _); } } + } - _logger.LogDebug("Ticket removed from matchmaking queue: {ticket}", ticket); + _logger.LogDebug("Ticket removed from matchmaking queue: {ticket}", ticket); - return removed; - } + return removed; + } - public MMTicket? FindTicketById(Guid ticketId) - { - return _queue.FirstOrDefault(t => t.Id == ticketId); - } + public MMTicket? FindTicketById(Guid ticketId) + { + return _queue.FirstOrDefault(t => t.Id == ticketId); + } + + private MMMatch? CreateNextMatch(IEnumerable<(GameServer, double)> serversWithQuality) + { + List matches = []; - private MMMatch? CreateNextMatch(IEnumerable<(GameServer, double)> serversWithQuality) + // Iterate through prioritized servers + foreach ((GameServer server, double qualityScore) in serversWithQuality) { - List matches = []; + int availableSlots = Math.Max(0, server.LastServerInfo!.FreeSlots - server.UnavailableSlots); - // Iterate through prioritized servers - foreach ((GameServer server, double qualityScore) in serversWithQuality) + _logger.LogTrace("Server {server} has {numPlayers} players, {numAvailableSlots} available slots, {totalScore} total score => Quality {qualityScore}", + server, server.LastServerInfo.RealPlayerCount, availableSlots, server.LastStatusResponse?.TotalScore, qualityScore); + + if (availableSlots <= 0) + continue; // Skip if no free slots are available + + // Sort players based on their min player threshold (ascending order) and check whether servers meets their criteria + List<(MMTicket ticket, EligibilityResult eligibility)> ticketsForServerSorted = GetTicketsForServer(server); + if (ticketsForServerSorted.Count == 0) { - int availableSlots = Math.Max(0, server.LastServerInfo!.FreeSlots - server.UnavailableSlots); + // no players + continue; + } + + List eligibleTickets = ticketsForServerSorted + .Where(x => x.eligibility.IsEligibile) + .Select(x => x.ticket) + .ToList(); - _logger.LogTrace("Server {server} has {numPlayers} players, {numAvailableSlots} available slots, {totalScore} total score => Quality {qualityScore}", - server, server.LastServerInfo.RealPlayerCount, availableSlots, server.LastStatusResponse?.TotalScore, qualityScore); + _logger.LogTrace("{numTickets} tickets ({numEligible} eligible) in matchmaking queue for server {server}", + ticketsForServerSorted.Count, eligibleTickets.Count, server); - if (availableSlots <= 0) - continue; // Skip if no free slots are available + // find a valid match for all eligible players + if (TrySelectMatch(server, eligibleTickets, qualityScore, availableSlots, out MMMatch validMatch)) + { + matches.Add(validMatch); - // Sort players based on their min player threshold (ascending order) and check whether servers meets their criteria - List<(MMTicket ticket, EligibilityResult eligibility)> ticketsForServerSorted = GetTicketsForServer(server); - if (ticketsForServerSorted.Count == 0) - { - // no players - continue; - } + _logger.LogTrace("Potential match found: {validMatch}", + new + { + NumTickets = validMatch.SelectedTickets.Count, + NumPlayers = validMatch.SelectedTickets.Sum(t => t.Players.Count), + TotalPlayers = validMatch.SelectedTickets.Sum(t => t.Players.Count) + server.LastServerInfo.RealPlayerCount, + AdjustedQuality = validMatch.MatchQuality + }); + } - List eligibleTickets = ticketsForServerSorted - .Where(x => x.eligibility.IsEligibile) - .Select(x => x.ticket) - .ToList(); + // find overall best possible match for each non eligible player + foreach ((MMTicket ticket, EligibilityResult eligibility) in ticketsForServerSorted) + { + if (eligibility.IsEligibile) continue; - _logger.LogTrace("{numTickets} tickets ({numEligible} eligible) in matchmaking queue for server {server}", - ticketsForServerSorted.Count, eligibleTickets.Count, server); + _logger.LogTrace("Try finding match for ineligible ticket {ticket}, reason: {ineligibilityReason}", ticket, eligibility.Reason); - // find a valid match for all eligible players - if (TrySelectMatch(server, eligibleTickets, qualityScore, availableSlots, out MMMatch validMatch)) - { - matches.Add(validMatch); - - _logger.LogTrace("Potential match found: {validMatch}", - new - { - NumTickets = validMatch.SelectedTickets.Count, - NumPlayers = validMatch.SelectedTickets.Sum(t => t.Players.Count), - TotalPlayers = validMatch.SelectedTickets.Sum(t => t.Players.Count) + server.LastServerInfo.RealPlayerCount, - AdjustedQuality = validMatch.MatchQuality - }); - } + bool foundMatch = TrySelectMatch( + server, + ticketsForServerSorted.Select(x => x.ticket).ToList(), + qualityScore, + availableSlots, + out MMMatch match); - // find overall best possible match for each non eligible player - foreach ((MMTicket ticket, EligibilityResult eligibility) in ticketsForServerSorted) + if (!foundMatch) { - if (eligibility.IsEligibile) continue; - - _logger.LogTrace("Try finding match for ineligible ticket {ticket}, reason: {ineligibilityReason}", ticket, eligibility.Reason); + continue; + } - bool foundMatch = TrySelectMatch( - server, - ticketsForServerSorted.Select(x => x.ticket).ToList(), - qualityScore, - availableSlots, - out MMMatch match); + ticket.PossibleMatches.Add(match); - if (!foundMatch) + _logger.LogTrace("Possible match found for ticket {ticket}: {validMatch}", + ticket, + new { - continue; - } - - ticket.PossibleMatches.Add(match); - - _logger.LogTrace("Possible match found for ticket {ticket}: {validMatch}", - ticket, - new - { - NumTickets = match.SelectedTickets.Count, - NumPlayers = match.SelectedTickets.Sum(t => t.Players.Count), - TotalPlayers = match.SelectedTickets.Sum(t => t.Players.Count) + server.LastServerInfo.RealPlayerCount, - AdjustedQuality = match.MatchQuality - }); - } + NumTickets = match.SelectedTickets.Count, + NumPlayers = match.SelectedTickets.Sum(t => t.Players.Count), + TotalPlayers = match.SelectedTickets.Sum(t => t.Players.Count) + server.LastServerInfo.RealPlayerCount, + AdjustedQuality = match.MatchQuality + }); } + } + + if (matches.Count == 0) + { + // no more match + _logger.LogDebug("No more matches found"); + return null; + } - if (matches.Count == 0) + // Find match with best quality + MMMatch bestMatch = matches.OrderByDescending(x => x.MatchQuality).First(); + + _logger.LogDebug("Best match found: {bestMatch}", new + { + bestMatch.Server, + NumTickets = bestMatch.SelectedTickets.Count, + NumPlayers = bestMatch.SelectedTickets.Sum(t => t.Players.Count), + TotalPlayers = bestMatch.SelectedTickets.Sum(t => t.Players.Count) + bestMatch.Server.LastServerInfo!.RealPlayerCount, + AdjustedQuality = bestMatch.MatchQuality + }); + + // atomic completion + using (bestMatch.SelectedTickets.Select(t => t.LockObj).LockAll()) + { + if (bestMatch.SelectedTickets.Any(t => t.MatchCompletion.Task.IsCompleted)) { - // no more match - _logger.LogDebug("No more matches found"); + _logger.LogWarning("Invalid match: Selected ticket already completed"); return null; } - // Find match with best quality - MMMatch bestMatch = matches.OrderByDescending(x => x.MatchQuality).First(); - - _logger.LogDebug("Best match found: {bestMatch}", new - { - bestMatch.Server, - NumTickets = bestMatch.SelectedTickets.Count, - NumPlayers = bestMatch.SelectedTickets.Sum(t => t.Players.Count), - TotalPlayers = bestMatch.SelectedTickets.Sum(t => t.Players.Count) + bestMatch.Server.LastServerInfo!.RealPlayerCount, - AdjustedQuality = bestMatch.MatchQuality - }); - - // atomic completion - using (bestMatch.SelectedTickets.Select(t => t.LockObj).LockAll()) + // Complete and remove the tickets + foreach (MMTicket ticket in bestMatch.SelectedTickets) { - if (bestMatch.SelectedTickets.Any(t => t.MatchCompletion.Task.IsCompleted)) - { - _logger.LogWarning("Invalid match: Selected ticket already completed"); - return null; - } - - // Complete and remove the tickets - foreach (MMTicket ticket in bestMatch.SelectedTickets) - { - ticket.MatchCompletion.TrySetResult(bestMatch); - RemoveTicket(ticket); - } + ticket.MatchCompletion.TrySetResult(bestMatch); + RemoveTicket(ticket); } - - return bestMatch; } - public async IAsyncEnumerable CheckForMatchesAsync(IEnumerable servers, [EnumeratorCancellation] CancellationToken cancellationToken) + return bestMatch; + } + + public async IAsyncEnumerable CheckForMatchesAsync(IEnumerable servers, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); + try { - await _semaphore.WaitAsync(cancellationToken); - try + // Sort servers by quality score + List<(GameServer server, double qualityScore)> orderedServers = servers + .Select(s => (server: s, qualityScore: CalculateServerQuality(s))) + .OrderByDescending(x => x.qualityScore) + .ToList(); + + _logger.LogDebug("{numPlayers} players in matchmaking queue, selecting players for matchmaking...", _queue.Count); + + List matches = []; + foreach (MMTicket ticket in _queue) { - // Sort servers by quality score - List<(GameServer server, double qualityScore)> orderedServers = servers - .Select(s => (server: s, qualityScore: CalculateServerQuality(s))) - .OrderByDescending(x => x.qualityScore) - .ToList(); + ticket.PossibleMatches.Clear(); + } - _logger.LogDebug("{numPlayers} players in matchmaking queue, selecting players for matchmaking...", _queue.Count); + cancellationToken.ThrowIfCancellationRequested(); - List matches = []; - foreach (MMTicket ticket in _queue) + do + { + MMMatch? nextMatch = CreateNextMatch(orderedServers); + if (nextMatch.HasValue) { - ticket.PossibleMatches.Clear(); + yield return nextMatch.Value; } - - cancellationToken.ThrowIfCancellationRequested(); - - do + else { - MMMatch? nextMatch = CreateNextMatch(orderedServers); - if (nextMatch.HasValue) - { - yield return nextMatch.Value; - } - else - { - // no more matches in this pass - break; - } + // no more matches in this pass + break; + } - cancellationToken.ThrowIfCancellationRequested(); - } while (_queue.Count > 0); + cancellationToken.ThrowIfCancellationRequested(); + } while (_queue.Count > 0); - foreach (MMTicket ticket in _queue) - { - ticket.SearchAttempts++; - } - } - finally + foreach (MMTicket ticket in _queue) { - _semaphore.Release(); + ticket.SearchAttempts++; } } - - public List GetPlayersInServer(IServerConnectionDetails serverConnectionDetails) + finally { - List result = []; - if (serverConnectionDetails is not ServerConnectionDetails key) - { - key = new(serverConnectionDetails.Ip, serverConnectionDetails.Port); - } - if (_serverQueues.TryGetValue(key, out ConcurrentLinkedQueue? queue)) - { - result.AddRange(queue.SelectMany(t => t.Players)); - } - return result; + _semaphore.Release(); } + } - private List<(MMTicket ticket, EligibilityResult eligibility)> GetTicketsForServer(GameServer server) + public List GetPlayersInServer(IServerConnectionDetails serverConnectionDetails) + { + List result = []; + if (serverConnectionDetails is not ServerConnectionDetails key) { - if (!_serverQueues.TryGetValue((server.ServerIp, server.ServerPort), out ConcurrentLinkedQueue? ticketsForServer)) - return []; - - // Sort players based on their min player threshold (descending order) - // and check whether server meets their criteria - return ticketsForServer - .Where(t => t.SearchPreferences.MinPlayers <= server.LastServerInfo?.MaxClients) // rule out impossible treshold directly - .OrderByDescending(t => t.SearchPreferences.MinPlayers) - .Select(ticket => (ticket, eligibility: ticket.IsEligibleForServer(server, ticketsForServer.Sum(t => t.Players.Count)))) - .ToList(); + key = new(serverConnectionDetails.Ip, serverConnectionDetails.Port); } - - internal bool TrySelectMatch(GameServer server, IReadOnlyList tickets, double serverQuality, int availableSlots, out MMMatch match) + if (_serverQueues.TryGetValue(key, out ConcurrentLinkedQueue? queue)) { - List selectedTickets = SelectMaxPlayersForMatchDesc( - tickets, - server.LastServerInfo!.RealPlayerCount, - availableSlots); + result.AddRange(queue.SelectMany(t => t.Players)); + } + return result; + } - if (selectedTickets.Count > 0) - { - double adjustedQualityScore = AdjustedServerQuality(server, serverQuality, selectedTickets, _logger); - match = (server, adjustedQualityScore, selectedTickets); + private List<(MMTicket ticket, EligibilityResult eligibility)> GetTicketsForServer(GameServer server) + { + if (!_serverQueues.TryGetValue((server.ServerIp, server.ServerPort), out ConcurrentLinkedQueue? ticketsForServer)) + return []; + + // Sort players based on their min player threshold (descending order) + // and check whether server meets their criteria + return ticketsForServer + .Where(t => t.SearchPreferences.MinPlayers <= server.LastServerInfo?.MaxClients) // rule out impossible treshold directly + .OrderByDescending(t => t.SearchPreferences.MinPlayers) + .Select(ticket => (ticket, eligibility: ticket.IsEligibleForServer(server, ticketsForServer.Sum(t => t.Players.Count)))) + .ToList(); + } - return true; - } + internal bool TrySelectMatch(GameServer server, IReadOnlyList tickets, double serverQuality, int availableSlots, out MMMatch match) + { + List selectedTickets = SelectMaxPlayersForMatchDesc( + tickets, + server.LastServerInfo!.RealPlayerCount, + availableSlots); + + if (selectedTickets.Count > 0) + { + double adjustedQualityScore = AdjustedServerQuality(server, serverQuality, selectedTickets, _logger); + match = (server, adjustedQualityScore, selectedTickets); - match = default; - return false; + return true; } - private static double CalculateServerQuality(GameServer server) + match = default; + return false; + } + + private static double CalculateServerQuality(GameServer server) + { + if (server?.LastServerInfo is null) { - if (server?.LastServerInfo is null) - { - return 0; // Invalid server, assign lowest score - } + return 0; // Invalid server, assign lowest score + } - double baseQuality = 1000; // Start with a base score for every server + double baseQuality = 1000; // Start with a base score for every server - // Check if the server is "half full" and under the score limit and probably needs players - bool isEmpty = server.LastServerInfo.RealPlayerCount == 0; + // Check if the server is "half full" and under the score limit and probably needs players + bool isEmpty = server.LastServerInfo.RealPlayerCount == 0; - // Case 1: If server is empty, give it a high bonus - if (isEmpty) - { - baseQuality += 1000; + // Case 1: If server is empty, give it a high bonus + if (isEmpty) + { + baseQuality += 1000; - return baseQuality; - } + return baseQuality; + } - bool isHalfFull = server.LastStatusResponse?.TotalScore < 3000 && server.LastServerInfo.RealPlayerCount < 6; + bool isHalfFull = server.LastStatusResponse?.TotalScore < 3000 && server.LastServerInfo.RealPlayerCount < 6; - // Case 2: If server is under the score limit and half full, give it a significant bonus - if (isHalfFull) - { - baseQuality += 3000; - } + // Case 2: If server is under the score limit and half full, give it a significant bonus + if (isHalfFull) + { + baseQuality += 3000; + } - double totalScoreAssumption = server.LastStatusResponse?.TotalScore ?? 10000; // assume average score + double totalScoreAssumption = server.LastStatusResponse?.TotalScore ?? 10000; // assume average score - // Calculate proportional penalty based on TotalScore (higher score means lower quality) - double totalScorePenalty = Math.Min(totalScoreAssumption / 300, 600); // cut of at 20000 score + // Calculate proportional penalty based on TotalScore (higher score means lower quality) + double totalScorePenalty = Math.Min(totalScoreAssumption / 300, 600); // cut of at 20000 score - // Apply proportional penalties for TotalScore and available slots - baseQuality -= totalScorePenalty; // Higher TotalScore reduces the quality + // Apply proportional penalties for TotalScore and available slots + baseQuality -= totalScorePenalty; // Higher TotalScore reduces the quality - return baseQuality; - } + return baseQuality; + } - internal static double AdjustedServerQuality(GameServer server, double qualityScore, List potentialPlayers, ILogger logger) - { - DateTime now = DateTime.Now; + internal static double AdjustedServerQuality(GameServer server, double qualityScore, List potentialPlayers, ILogger logger) + { + DateTime now = DateTime.Now; - double avgWaitTime = potentialPlayers.Average(p => (now - p.JoinTime).TotalSeconds); - double waitTimeFactor = 40; + double avgWaitTime = potentialPlayers.Average(p => (now - p.JoinTime).TotalSeconds); + double waitTimeFactor = 40; - double avgMaxPing = potentialPlayers.Where(p => p.SearchPreferences.MaxPing > 0) - .Average(p => p.SearchPreferences.MaxPing); + double avgMaxPing = potentialPlayers.Where(p => p.SearchPreferences.MaxPing > 0) + .Average(p => p.SearchPreferences.MaxPing); - List pingDeviations = potentialPlayers - .Select(p => p.PreferredServers[(server.ServerIp, server.ServerPort)]) - .Where(ping => ping >= 0) - .Select(ping => ping - avgMaxPing) - .ToList(); + List pingDeviations = potentialPlayers + .Select(p => p.PreferredServers[(server.ServerIp, server.ServerPort)]) + .Where(ping => ping >= 0) + .Select(ping => ping - avgMaxPing) + .ToList(); - double avgPingDeviation = pingDeviations.Count != 0 ? pingDeviations.Average() : 0; - double pingFactor = 15; + double avgPingDeviation = pingDeviations.Count != 0 ? pingDeviations.Average() : 0; + double pingFactor = 15; - logger.LogTrace("Adjusting quality based on avg wait time ({avgWaitTime} s) and ping deviation ({avgPingDeviation} ms)", - Math.Round(avgWaitTime, 1), Math.Round(avgPingDeviation, 1)); + logger.LogTrace("Adjusting quality based on avg wait time ({avgWaitTime} s) and ping deviation ({avgPingDeviation} ms)", + Math.Round(avgWaitTime, 1), Math.Round(avgPingDeviation, 1)); - return qualityScore + (potentialPlayers.Count * 15) + (waitTimeFactor * avgWaitTime) - (pingFactor * avgPingDeviation); - } + return qualityScore + (potentialPlayers.Count * 15) + (waitTimeFactor * avgWaitTime) - (pingFactor * avgPingDeviation); + } - /// - /// Selects the upper max players for a match whose are satisfied, - /// given a list of players ordered by their min treshold in descending order. - /// - /// Players to select from ordered by min player treshold in descending order. - /// Number of players alredy on the server. - /// The number of free slots available on the server. - /// The biggest possible selection of players that can be joined. - internal static List SelectMaxPlayersForMatchDesc(IReadOnlyList tickets, int joinedPlayersCount, int freeSlots) - { - List selectedTickets = new(freeSlots); - int selectedPlayers = 0; + /// + /// Selects the upper max players for a match whose are satisfied, + /// given a list of players ordered by their min treshold in descending order. + /// + /// Players to select from ordered by min player treshold in descending order. + /// Number of players alredy on the server. + /// The number of free slots available on the server. + /// The biggest possible selection of players that can be joined. + internal static List SelectMaxPlayersForMatchDesc(IReadOnlyList tickets, int joinedPlayersCount, int freeSlots) + { + List selectedTickets = new(freeSlots); + int selectedPlayers = 0; - // outer loop is for the selection start index - // (first ticket with min players small enough to be satisfied) - for (int i = 0; i < tickets.Count; i++) + // outer loop is for the selection start index + // (first ticket with min players small enough to be satisfied) + for (int i = 0; i < tickets.Count; i++) + { + // adjust min players by subtracting already joined players + int minPlayers = tickets[i].SearchPreferences.MinPlayers - joinedPlayersCount; + if (minPlayers > freeSlots) { - // adjust min players by subtracting already joined players - int minPlayers = tickets[i].SearchPreferences.MinPlayers - joinedPlayersCount; - if (minPlayers > freeSlots) - { - // less free slots than min players - continue; - } + // less free slots than min players + continue; + } - // select the maximum amount of players possible, starting at i - for (int j = i; j < tickets.Count; j++) + // select the maximum amount of players possible, starting at i + for (int j = i; j < tickets.Count; j++) + { + selectedPlayers += tickets[j].Players.Count; + if (selectedPlayers > freeSlots) { - selectedPlayers += tickets[j].Players.Count; - if (selectedPlayers > freeSlots) - { - // including this ticket would overfill -> stop selecting - break; - } - - selectedTickets.Add(tickets[j]); + // including this ticket would overfill -> stop selecting + break; } - if (minPlayers <= selectedTickets.Count) - { - // we found the first (and best) match - return selectedTickets; - } + selectedTickets.Add(tickets[j]); + } - // not enough players selected - selectedTickets.Clear(); - selectedPlayers = 0; + if (minPlayers <= selectedTickets.Count) + { + // we found the first (and best) match + return selectedTickets; } - // no valid selection found, return empty - return selectedTickets; + // not enough players selected + selectedTickets.Clear(); + selectedPlayers = 0; } + + // no valid selection found, return empty + return selectedTickets; } } diff --git a/MatchmakingServer/Matchmaking/MatchmakingService.cs b/MatchmakingServer/Matchmaking/MatchmakingService.cs index 36bef24..b32547a 100644 --- a/MatchmakingServer/Matchmaking/MatchmakingService.cs +++ b/MatchmakingServer/Matchmaking/MatchmakingService.cs @@ -5,6 +5,7 @@ using H2MLauncher.Core.Models; using H2MLauncher.Core.Services; +using MatchmakingServer.Matchmaking.Models; using MatchmakingServer.Queueing; using MatchmakingServer.SignalR; @@ -12,602 +13,601 @@ using Nito.Disposables.Internals; -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking; + +public class MatchmakingService : BackgroundService { - public class MatchmakingService : BackgroundService - { - /// - /// The time in queue after which a fresh lobby is not exclusively created and - /// joined players are also calculated in the min players threshold. - /// - private const int FRESH_LOBBY_TIMEOUT_SECONDS = 20; - private const int MATCHMAKING_INTERVAL_MS = 3000; + /// + /// The time in queue after which a fresh lobby is not exclusively created and + /// joined players are also calculated in the min players threshold. + /// + private const int FRESH_LOBBY_TIMEOUT_SECONDS = 20; + private const int MATCHMAKING_INTERVAL_MS = 3000; - private readonly Matchmaker _matchmaker; + private readonly Matchmaker _matchmaker; - private readonly ServerStore _serverStore; - private readonly IHubContext _hubContext; - private readonly QueueingService _queueingService; - private readonly GameServerCommunicationService _gameServerCommunicationService; - private readonly IGameServerInfoService _gameServerInfoService; - private readonly ILogger _logger; + private readonly ServerStore _serverStore; + private readonly IHubContext _hubContext; + private readonly QueueingService _queueingService; + private readonly GameServerCommunicationService _gameServerCommunicationService; + private readonly IGameServerInfoService _gameServerInfoService; + private readonly ILogger _logger; - private readonly ConcurrentDictionary _metadata = []; + private readonly ConcurrentDictionary _metadata = []; - public readonly record struct TicketMetadata - { - public required Player ActiveSearcher { get; init; } + public readonly record struct TicketMetadata + { + public required Player ActiveSearcher { get; init; } - public Playlist? AssociatedPlaylist { get; init; } - } + public Playlist? AssociatedPlaylist { get; init; } + } - public MatchmakingService( - ServerStore serverStore, - IHubContext hubContext, - QueueingService queueingService, - GameServerCommunicationService gameServerCommunicationService, - ILogger logger, - IGameServerInfoService gameServerInfoService, - Matchmaker matchmaker) - { - _serverStore = serverStore; - _hubContext = hubContext; - _queueingService = queueingService; - _gameServerCommunicationService = gameServerCommunicationService; - _logger = logger; - _gameServerInfoService = gameServerInfoService; - _matchmaker = matchmaker; - } + public MatchmakingService( + ServerStore serverStore, + IHubContext hubContext, + QueueingService queueingService, + GameServerCommunicationService gameServerCommunicationService, + ILogger logger, + IGameServerInfoService gameServerInfoService, + Matchmaker matchmaker) + { + _serverStore = serverStore; + _hubContext = hubContext; + _queueingService = queueingService; + _gameServerCommunicationService = gameServerCommunicationService; + _logger = logger; + _gameServerInfoService = gameServerInfoService; + _matchmaker = matchmaker; + } - /// - /// Main loop that checks for matches. - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + /// + /// Main loop that checks for matches. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) { - while (!stoppingToken.IsCancellationRequested) + while (_matchmaker.Tickets.Count == 0) { - while (_matchmaker.Tickets.Count == 0) - { - // wait for players to enter matchmaking - await Task.Delay(500, stoppingToken); - } - - await CheckForMatches(stoppingToken); - await Task.Delay(MATCHMAKING_INTERVAL_MS, stoppingToken); + // wait for players to enter matchmaking + await Task.Delay(500, stoppingToken); } + + await CheckForMatches(stoppingToken); + await Task.Delay(MATCHMAKING_INTERVAL_MS, stoppingToken); } + } - private async Task CheckForMatches(CancellationToken cancellationToken) + private async Task CheckForMatches(CancellationToken cancellationToken) + { + try { - try - { - List serversToRequest = _matchmaker.QueuedServers - .Select(key => _serverStore.Servers.TryGetValue(key, out GameServer? server) ? server : null) - .WhereNotNull() - .ToList(); + List serversToRequest = _matchmaker.QueuedServers + .Select(key => _serverStore.Servers.TryGetValue(key, out GameServer? server) ? server : null) + .WhereNotNull() + .ToList(); - List respondingServers = await RefreshServerInfo(serversToRequest, cancellationToken); + List respondingServers = await RefreshServerInfo(serversToRequest, cancellationToken); - await foreach (MMMatch match in _matchmaker.CheckForMatchesAsync(respondingServers, cancellationToken)) - { - _ = await CreateMatchAsync(match); - } - - // Notify players of theoretically possible matches - List notifyTasks = new(_matchmaker.Tickets.Count); - foreach (MMTicket ticket in _matchmaker.Tickets) - { - notifyTasks.Add(SendMatchSearchResults(ticket, ticket.PossibleMatches)); - } - - await Task.WhenAll(notifyTasks); + await foreach (MMMatch match in _matchmaker.CheckForMatchesAsync(respondingServers, cancellationToken)) + { + _ = await CreateMatchAsync(match); } - catch (Exception ex) + + // Notify players of theoretically possible matches + List notifyTasks = new(_matchmaker.Tickets.Count); + foreach (MMTicket ticket in _matchmaker.Tickets) { - _logger.LogError(ex, "Error in matchmaking loop"); + notifyTasks.Add(SendMatchSearchResults(ticket, ticket.PossibleMatches)); } + + await Task.WhenAll(notifyTasks); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in matchmaking loop"); } + } - private async Task> RefreshServerInfo(IReadOnlyList servers, CancellationToken cancellationToken) + private async Task> RefreshServerInfo(IReadOnlyList servers, CancellationToken cancellationToken) + { + List respondingServers = new(servers.Count); + _logger.LogTrace("Requesting server info for {numServers} servers...", servers.Count); + try { - List respondingServers = new(servers.Count); - _logger.LogTrace("Requesting server info for {numServers} servers...", servers.Count); - try + // Request server info for all servers part of matchmaking rn + Task getInfoCompleted = await _gameServerInfoService.SendGetInfoAsync(servers, (e) => { - // Request server info for all servers part of matchmaking rn - Task getInfoCompleted = await _gameServerInfoService.SendGetInfoAsync(servers, (e) => - { - e.Server.LastServerInfo = e.ServerInfo; - e.Server.LastSuccessfulPingTimestamp = DateTimeOffset.Now; - - respondingServers.Add(e.Server); - }, timeoutInMs: 2000, cancellationToken: cancellationToken); + e.Server.LastServerInfo = e.ServerInfo; + e.Server.LastSuccessfulPingTimestamp = DateTimeOffset.Now; - // Immediately after send info requests send status requests - Task getStatusCompleted = await _gameServerCommunicationService.SendGetStatusAsync(servers, (e) => - { - e.Server.LastStatusResponse = e.ServerInfo; - }, timeoutInMs: 2000, cancellationToken: cancellationToken); + respondingServers.Add(e.Server); + }, timeoutInMs: 2000, cancellationToken: cancellationToken); - // Wait for all to complete / time out - await Task.WhenAll(getInfoCompleted, getStatusCompleted); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + // Immediately after send info requests send status requests + Task getStatusCompleted = await _gameServerCommunicationService.SendGetStatusAsync(servers, (e) => { - // expected timeout - return respondingServers; - } - - _logger.LogDebug("Server info received from {numServers}", respondingServers.Count); + e.Server.LastStatusResponse = e.ServerInfo; + }, timeoutInMs: 2000, cancellationToken: cancellationToken); + // Wait for all to complete / time out + await Task.WhenAll(getInfoCompleted, getStatusCompleted); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // expected timeout return respondingServers; } - private MMTicket? PrepareTicketWithMetadata( - IReadOnlySet players, TicketMetadata ticketMetadata, MatchSearchCriteria searchPreferences, List servers) - { - ValidateMetadata(players, servers, ticketMetadata); + _logger.LogDebug("Server info received from {numServers}", respondingServers.Count); - Dictionary serversParsed = []; - foreach (ServerConnectionDetails connDetails in servers) - { - if (serversParsed.TryAdd(connDetails, -1)) - { - // Make sure server is created - _serverStore.GetOrAddServer(connDetails.Ip, connDetails.Port); - } - } + return respondingServers; + } + + private MMTicket? PrepareTicketWithMetadata( + IReadOnlySet players, TicketMetadata ticketMetadata, MatchSearchCriteria searchPreferences, List servers) + { + ValidateMetadata(players, servers, ticketMetadata); - if (serversParsed.Count == 0) + Dictionary serversParsed = []; + foreach (ServerConnectionDetails connDetails in servers) + { + if (serversParsed.TryAdd(connDetails, -1)) { - return null; + // Make sure server is created + _serverStore.GetOrAddServer(connDetails.Ip, connDetails.Port); } + } + + if (serversParsed.Count == 0) + { + return null; + } - MMTicket ticket = new(players, serversParsed, searchPreferences); + MMTicket ticket = new(players, serversParsed, searchPreferences); - _metadata.TryAdd(ticket, ticketMetadata); + _metadata.TryAdd(ticket, ticketMetadata); - return ticket; - } + return ticket; + } - private static void ValidateMetadata(IReadOnlySet players, IEnumerable servers, TicketMetadata ticketMetadata) + private static void ValidateMetadata(IReadOnlySet players, IEnumerable servers, TicketMetadata ticketMetadata) + { + // validate metadata + if (!players.Contains(ticketMetadata.ActiveSearcher)) { - // validate metadata - if (!players.Contains(ticketMetadata.ActiveSearcher)) - { - throw new ArgumentException("Active searcher not part of provided player set.", nameof(ticketMetadata)); - } + throw new ArgumentException("Active searcher not part of provided player set.", nameof(ticketMetadata)); + } - if (ticketMetadata.AssociatedPlaylist?.Servers is not null && - servers.Any(s => !ticketMetadata.AssociatedPlaylist.Servers.Contains(s))) - { - throw new ArgumentException("Server is not in playlist", nameof(ticketMetadata)); - } + if (ticketMetadata.AssociatedPlaylist?.Servers is not null && + servers.Any(s => !ticketMetadata.AssociatedPlaylist.Servers.Contains(s))) + { + throw new ArgumentException("Server is not in playlist", nameof(ticketMetadata)); } + } - private bool QueueTicket(MMTicket ticket) + private bool QueueTicket(MMTicket ticket) + { + if (ticket.Players.Any(p => p.State is not (PlayerState.Connected or PlayerState.Joined))) { - if (ticket.Players.Any(p => p.State is not (PlayerState.Connected or PlayerState.Joined))) - { - return false; - } + return false; + } + + _matchmaker.AddTicketToQueue(ticket); - _matchmaker.AddTicketToQueue(ticket); + // update state + foreach (Player p in ticket.Players) + { + p.State = PlayerState.Matchmaking; + } - // update state - foreach (Player p in ticket.Players) + // notify other participants they entered + if (ticket.Players.Count > 1) + { + if (!_metadata.TryGetValue(ticket, out TicketMetadata ticketMetadata)) { - p.State = PlayerState.Matchmaking; + ticketMetadata = new() + { + ActiveSearcher = ticket.Players.First() + }; } - // notify other participants they entered - if (ticket.Players.Count > 1) + MatchmakingMetadata metadata = new() { - if (!_metadata.TryGetValue(ticket, out TicketMetadata ticketMetadata)) - { - ticketMetadata = new() - { - ActiveSearcher = ticket.Players.First() - }; - } + IsActiveSearcher = false, + TotalGroupSize = ticket.Players.Count, + QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, + JoinTime = ticket.JoinTime, + SearchPreferences = ticket.SearchPreferences, + Playlist = ticketMetadata.AssociatedPlaylist + }; - MatchmakingMetadata metadata = new() - { - IsActiveSearcher = false, - TotalGroupSize = ticket.Players.Count, - QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, - JoinTime = ticket.JoinTime, - SearchPreferences = ticket.SearchPreferences, - Playlist = ticketMetadata.AssociatedPlaylist - }; + _ = NotifyPlayersMachmakingEntered(ticket.Players.Where(p => p != ticketMetadata.ActiveSearcher), metadata); + } - _ = NotifyPlayersMachmakingEntered(ticket.Players.Where(p => p != ticketMetadata.ActiveSearcher), metadata); - } + return true; + } - return true; + private bool DequeueTicket(MMTicket ticket) + { + if (ticket.MatchCompletion.Task.IsCompleted) + { + // ticket is already completed and therfore not owned by the Matchmaker anymore + return false; } - private bool DequeueTicket(MMTicket ticket) + // remove whole ticket + if (!_matchmaker.RemoveTicket(ticket)) { - if (ticket.MatchCompletion.Task.IsCompleted) - { - // ticket is already completed and therfore not owned by the Matchmaker anymore - return false; - } + _logger.LogWarning("Matchmaking ticket {ticket} could not be removed.", ticket); + return false; + } - // remove whole ticket - if (!_matchmaker.RemoveTicket(ticket)) + _metadata.Remove(ticket, out _); + + // update state + foreach (Player p in ticket.Players) + { + if (p.State is not PlayerState.Matchmaking) { - _logger.LogWarning("Matchmaking ticket {ticket} could not be removed.", ticket); - return false; + // skip player with other state + continue; } - _metadata.Remove(ticket, out _); + p.State = PlayerState.Connected; + } - // update state - foreach (Player p in ticket.Players) - { - if (p.State is not PlayerState.Matchmaking) - { - // skip player with other state - continue; - } + // notify participants of removal + _ = NotifyPlayersRemovedFromMatchmaking(ticket.Players, MatchmakingError.UserLeave); - p.State = PlayerState.Connected; - } + return true; + } - // notify participants of removal - _ = NotifyPlayersRemovedFromMatchmaking(ticket.Players, MatchmakingError.UserLeave); + /// + /// Creates a matchmaking ticket with the and adds them to the matchmaking queue. + /// + /// The players to queue together. + /// Initial search criteria. + /// The matchmaking ticket, if successful. + public IMMTicket? EnterMatchmaking( + IReadOnlySet players, + MatchSearchCriteria searchPreferences, + List servers, + TicketMetadata ticketMetadata = default) + { + if (players.Any(p => p.State is not (PlayerState.Connected or PlayerState.Joined))) + { + return null; + } - return true; + MMTicket? ticket = PrepareTicketWithMetadata(players, ticketMetadata, searchPreferences, servers); + if (ticket is null) + { + return null; } - /// - /// Creates a matchmaking ticket with the and adds them to the matchmaking queue. - /// - /// The players to queue together. - /// Initial search criteria. - /// The matchmaking ticket, if successful. - public IMMTicket? EnterMatchmaking( - IReadOnlySet players, - MatchSearchCriteria searchPreferences, - List servers, - TicketMetadata ticketMetadata = default) + if (QueueTicket(ticket)) { - if (players.Any(p => p.State is not (PlayerState.Connected or PlayerState.Joined))) - { - return null; - } + return ticket; + } - MMTicket? ticket = PrepareTicketWithMetadata(players, ticketMetadata, searchPreferences, servers); - if (ticket is null) - { - return null; - } + return null; + } - if (QueueTicket(ticket)) - { - return ticket; - } + /// + /// Creates a matchmaking ticket with the and adds him to the matchmaking queue. + /// + /// The matchmaking ticket, if successful. + public IMMTicket? EnterMatchmaking( + Player player, + MatchSearchCriteria searchPreferences, + List servers, + TicketMetadata ticketMetadata = default) + { + if (player.State is not (PlayerState.Connected or PlayerState.Joined)) + { + // invalid player state + _logger.LogDebug("Cannot enter matchmaking: invalid state {player}", player); + return null; + } + + _logger.LogDebug("Entering matchmaking for player {player} (searchPreferences: {@searchPreferences}, servers: {numPreferredServers})", + player, searchPreferences, servers.Count); + MMTicket? ticket = PrepareTicketWithMetadata(new HashSet(), ticketMetadata, searchPreferences, servers); + if (ticket is null) + { return null; } - /// - /// Creates a matchmaking ticket with the and adds him to the matchmaking queue. - /// - /// The matchmaking ticket, if successful. - public IMMTicket? EnterMatchmaking( - Player player, - MatchSearchCriteria searchPreferences, - List servers, - TicketMetadata ticketMetadata = default) + if (QueueTicket(ticket)) { - if (player.State is not (PlayerState.Connected or PlayerState.Joined)) - { - // invalid player state - _logger.LogDebug("Cannot enter matchmaking: invalid state {player}", player); - return null; - } + return ticket; + } - _logger.LogDebug("Entering matchmaking for player {player} (searchPreferences: {@searchPreferences}, servers: {numPreferredServers})", - player, searchPreferences, servers.Count); + return null; + } - MMTicket? ticket = PrepareTicketWithMetadata(new HashSet(), ticketMetadata, searchPreferences, servers); - if (ticket is null) - { - return null; - } + /// + /// Removes the from the matchmaking queue. + /// + public bool LeaveMatchmaking(IMMTicket ticket) + { + MMTicket? internalTicket = _matchmaker.FindTicketById(ticket.Id); + if (internalTicket is null) + { + return false; + } - if (QueueTicket(ticket)) - { - return ticket; - } + // remove whole ticket + if (!DequeueTicket(internalTicket)) + { + return false; + } - return null; + return true; + } + + /// + /// Finds the ticket associated with the and either removes the player + /// or the whole ticket from the matchmaking queue. + /// + /// The player to remove. + /// Whether to remove the whole associated ticket. + public bool LeaveMatchmaking(Player player, bool removeTicket = false) + { + if (player.State is not PlayerState.Matchmaking) + { + // invalid player state + _logger.LogDebug("Cannot leave matchmaking: invalid state {player}", player); + return false; } - /// - /// Removes the from the matchmaking queue. - /// - public bool LeaveMatchmaking(IMMTicket ticket) + MMTicket? ticket = _matchmaker.Tickets.FirstOrDefault(t => t.Players.Contains(player)); + if (ticket is null) { - MMTicket? internalTicket = _matchmaker.FindTicketById(ticket.Id); - if (internalTicket is null) - { - return false; - } + _logger.LogWarning("Player {player} not queued in Matchmaking despite state. Correcting state to 'Connected'.", player); + player.State = PlayerState.Connected; + return false; + } + if (ticket.Players.Count == 1 || removeTicket) + { // remove whole ticket - if (!DequeueTicket(internalTicket)) + if (!DequeueTicket(ticket)) { return false; } - - return true; + } + else + { + // remove from ticket + ticket.RemovePlayer(player); + player.State = PlayerState.Connected; } - /// - /// Finds the ticket associated with the and either removes the player - /// or the whole ticket from the matchmaking queue. - /// - /// The player to remove. - /// Whether to remove the whole associated ticket. - public bool LeaveMatchmaking(Player player, bool removeTicket = false) + if (player.QueueingHubId is not null) { - if (player.State is not PlayerState.Matchmaking) - { - // invalid player state - _logger.LogDebug("Cannot leave matchmaking: invalid state {player}", player); - return false; - } + _hubContext.Clients.Client(player.QueueingHubId) + .OnRemovedFromMatchmaking(MatchmakingError.UserLeave); + } - MMTicket? ticket = _matchmaker.Tickets.FirstOrDefault(t => t.Players.Contains(player)); - if (ticket is null) - { - _logger.LogWarning("Player {player} not queued in Matchmaking despite state. Correcting state to 'Connected'.", player); - player.State = PlayerState.Connected; - return false; - } + _logger.LogInformation("Player {player} removed from matchmaking", player); + return true; + } - if (ticket.Players.Count == 1 || removeTicket) - { - // remove whole ticket - if (!DequeueTicket(ticket)) - { - return false; - } - } - else - { - // remove from ticket - ticket.RemovePlayer(player); - player.State = PlayerState.Connected; - } + /// + /// Updates the metadata for the given to . + /// + public void UpdateTicketMetadata(IMMTicket ticket, TicketMetadata ticketMetadata) + { + ValidateMetadata(ticket.Players, ticket.PreferredServers, ticketMetadata); - if (player.QueueingHubId is not null) - { - _hubContext.Clients.Client(player.QueueingHubId) - .OnRemovedFromMatchmaking(MatchmakingError.UserLeave); - } + if (_metadata.TryGetValue(ticket, out TicketMetadata oldTicketMetadata)) + { + _metadata[ticket] = ticketMetadata; - _logger.LogInformation("Player {player} removed from matchmaking", player); - return true; + _ = NotifyMatchmakingMetadata(ticket.Players.Where(p => p != oldTicketMetadata.ActiveSearcher), + new MatchmakingMetadata() + { + IsActiveSearcher = false, + JoinTime = ticket.JoinTime, + TotalGroupSize = ticket.Players.Count, + QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, + SearchPreferences = ticket.SearchPreferences, + Playlist = ticketMetadata.AssociatedPlaylist + }); } + } - /// - /// Updates the metadata for the given to . - /// - public void UpdateTicketMetadata(IMMTicket ticket, TicketMetadata ticketMetadata) + /// + /// Updates the current search criteria and server pings of the ticket for the active searcher . + /// + /// The active searcher of the ticket to update. + /// The new match search criteria. + /// Updated list of server pings. + /// True if the update was successful. + public bool UpdateSearchPreferences(Player player, MatchSearchCriteria matchSearchPreferences, List serverPings) + { + if (player.State is not PlayerState.Matchmaking) { - ValidateMetadata(ticket.Players, ticket.PreferredServers, ticketMetadata); - - if (_metadata.TryGetValue(ticket, out TicketMetadata oldTicketMetadata)) - { - _metadata[ticket] = ticketMetadata; - - _ = NotifyMatchmakingMetadata(ticket.Players.Where(p => p != oldTicketMetadata.ActiveSearcher), - new MatchmakingMetadata() - { - IsActiveSearcher = false, - JoinTime = ticket.JoinTime, - TotalGroupSize = ticket.Players.Count, - QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, - SearchPreferences = ticket.SearchPreferences, - Playlist = ticketMetadata.AssociatedPlaylist - }); - } + // invalid player state + _logger.LogDebug("Cannot update search session: invalid state {player}", player); + return false; } - /// - /// Updates the current search criteria and server pings of the ticket for the active searcher . - /// - /// The active searcher of the ticket to update. - /// The new match search criteria. - /// Updated list of server pings. - /// True if the update was successful. - public bool UpdateSearchPreferences(Player player, MatchSearchCriteria matchSearchPreferences, List serverPings) + MMTicket? ticket = _matchmaker.Tickets.FirstOrDefault(p => p.Players.Contains(player)); + if (ticket is null) { - if (player.State is not PlayerState.Matchmaking) - { - // invalid player state - _logger.LogDebug("Cannot update search session: invalid state {player}", player); - return false; - } + _logger.LogWarning("Player {player} not queued in Matchmaking despite state. Correcting state to 'Connected'.", player); + player.State = PlayerState.Connected; + return false; + } - MMTicket? ticket = _matchmaker.Tickets.FirstOrDefault(p => p.Players.Contains(player)); - if (ticket is null) - { - _logger.LogWarning("Player {player} not queued in Matchmaking despite state. Correcting state to 'Connected'.", player); - player.State = PlayerState.Connected; - return false; - } + TicketMetadata ticketMetadata = default; - TicketMetadata ticketMetadata = default; + if (ticket.Players.Count > 0 && + _metadata.TryGetValue(ticket, out ticketMetadata) && + ticketMetadata.ActiveSearcher != player) + { + _logger.LogDebug("Only the active searcher can update multi player ticket perferences"); + return false; + } - if (ticket.Players.Count > 0 && - _metadata.TryGetValue(ticket, out ticketMetadata) && - ticketMetadata.ActiveSearcher != player) - { - _logger.LogDebug("Only the active searcher can update multi player ticket perferences"); - return false; - } + _logger.LogTrace("Updating search preferences for {player}: {searchPreferences} ({numServerPings} server pings)", + player, matchSearchPreferences, serverPings.Count); - _logger.LogTrace("Updating search preferences for {player}: {searchPreferences} ({numServerPings} server pings)", - player, matchSearchPreferences, serverPings.Count); - - ticket.SearchPreferences = matchSearchPreferences; - - _ = NotifyMatchmakingMetadata(ticket.Players.Where(p => p != player), - new MatchmakingMetadata() - { - IsActiveSearcher = false, - JoinTime = ticket.JoinTime, - TotalGroupSize = ticket.Players.Count, - QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, - SearchPreferences = ticket.SearchPreferences, - Playlist = ticketMetadata.AssociatedPlaylist - }); - - foreach ((string serverIp, int serverPort, uint ping) in serverPings) - { - ticket.PreferredServers[(serverIp, serverPort)] = Math.Min(999, (int)ping); - } + ticket.SearchPreferences = matchSearchPreferences; + + _ = NotifyMatchmakingMetadata(ticket.Players.Where(p => p != player), + new MatchmakingMetadata() + { + IsActiveSearcher = false, + JoinTime = ticket.JoinTime, + TotalGroupSize = ticket.Players.Count, + QueueType = ticket.Players.Count > 0 ? MatchmakingQueueType.Party : MatchmakingQueueType.Solo, + SearchPreferences = ticket.SearchPreferences, + Playlist = ticketMetadata.AssociatedPlaylist + }); - return true; + foreach ((string serverIp, int serverPort, uint ping) in serverPings) + { + ticket.PreferredServers[(serverIp, serverPort)] = Math.Min(999, (int)ping); } + return true; + } - private static SearchMatchResult CreateMatchResult(MMMatch match) - { - return new SearchMatchResult() - { - ServerIp = match.Server.ServerIp, - ServerPort = match.Server.ServerPort, - MatchQuality = match.MatchQuality, - NumPlayers = match.SelectedTickets.Sum(t => t.Players.Count), - ServerScore = match.Server.LastStatusResponse?.TotalScore - }; - } - private async Task CreateMatchAsync(MMMatch match) + private static SearchMatchResult CreateMatchResult(MMMatch match) + { + return new SearchMatchResult() { - List selectedPlayers = match.SelectedTickets.SelectMany(p => p.Players).ToList(); + ServerIp = match.Server.ServerIp, + ServerPort = match.Server.ServerPort, + MatchQuality = match.MatchQuality, + NumPlayers = match.SelectedTickets.Sum(t => t.Players.Count), + ServerScore = match.Server.LastStatusResponse?.TotalScore + }; + } - _logger.LogInformation("Match created on server {server} for {numPlayers} players: {players}", - match.Server, - selectedPlayers.Count, - selectedPlayers.Select(p => p.Name)); + private async Task CreateMatchAsync(MMMatch match) + { + List selectedPlayers = match.SelectedTickets.SelectMany(p => p.Players).ToList(); - try - { - _logger.LogTrace("Notifying players with match result..."); + _logger.LogInformation("Match created on server {server} for {numPlayers} players: {players}", + match.Server, + selectedPlayers.Count, + selectedPlayers.Select(p => p.Name)); - await _hubContext.Clients.Clients(selectedPlayers.Select(p => p.QueueingHubId!)) - .OnMatchFound(match.Server.LastServerInfo!.HostName, CreateMatchResult(match)); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error while notifying players with match result"); - } + try + { + _logger.LogTrace("Notifying players with match result..."); - _logger.LogDebug("Joining players to server queue..."); + await _hubContext.Clients.Clients(selectedPlayers.Select(p => p.QueueingHubId!)) + .OnMatchFound(match.Server.LastServerInfo!.HostName, CreateMatchResult(match)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error while notifying players with match result"); + } - IEnumerable> queueTasks = selectedPlayers.Select(player => QueuePlayer(player, match.Server)); + _logger.LogDebug("Joining players to server queue..."); - bool[] results = await Task.WhenAll(queueTasks); - return results.Count(success => success); - } + IEnumerable> queueTasks = selectedPlayers.Select(player => QueuePlayer(player, match.Server)); - private async Task QueuePlayer(Player player, GameServer server) + bool[] results = await Task.WhenAll(queueTasks); + return results.Count(success => success); + } + + private async Task QueuePlayer(Player player, GameServer server) + { + try { - try + if (!await _queueingService.JoinQueue(server, player).ConfigureAwait(false)) { - if (!await _queueingService.JoinQueue(server, player).ConfigureAwait(false)) - { - await _hubContext.Clients.Client(player.QueueingHubId!) - .OnRemovedFromMatchmaking(MatchmakingError.QueueingFailed) - .ConfigureAwait(false); - - return false; - } + await _hubContext.Clients.Client(player.QueueingHubId!) + .OnRemovedFromMatchmaking(MatchmakingError.QueueingFailed) + .ConfigureAwait(false); - return true; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while queueing player {player}", player); return false; } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while queueing player {player}", player); + return false; } + } - private async Task SendMatchSearchResults(MMTicket ticket, List matchesForPlayer) + private async Task SendMatchSearchResults(MMTicket ticket, List matchesForPlayer) + { + try { - try - { - await _hubContext.Clients.Clients(ticket.Players.Select(p => p.QueueingHubId!)) - .OnSearchMatchUpdate(matchesForPlayer.Select(CreateMatchResult)) - .ConfigureAwait(false); - } - catch - { - _logger.LogWarning("Could not send match search results to ticket {ticket}", ticket); - } + await _hubContext.Clients.Clients(ticket.Players.Select(p => p.QueueingHubId!)) + .OnSearchMatchUpdate(matchesForPlayer.Select(CreateMatchResult)) + .ConfigureAwait(false); } + catch + { + _logger.LogWarning("Could not send match search results to ticket {ticket}", ticket); + } + } - private async Task NotifyPlayersMachmakingEntered(IEnumerable players, MatchmakingMetadata metadata) + private async Task NotifyPlayersMachmakingEntered(IEnumerable players, MatchmakingMetadata metadata) + { + try { - try - { - IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); + IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); - await _hubContext.Clients.Clients(connectionIds) - .OnMatchmakingEntered(metadata) - .ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while notifying player entered matchmaking"); - } + await _hubContext.Clients.Clients(connectionIds) + .OnMatchmakingEntered(metadata) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while notifying player entered matchmaking"); } + } - private async Task NotifyMatchmakingMetadata(IEnumerable players, MatchmakingMetadata metadata) + private async Task NotifyMatchmakingMetadata(IEnumerable players, MatchmakingMetadata metadata) + { + try { - try - { - _logger.LogDebug("Notifying players of matchmaking metadata update..."); + _logger.LogDebug("Notifying players of matchmaking metadata update..."); - IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); + IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); - await _hubContext.Clients.Clients(connectionIds) - .OnMetadataUpdate(metadata) - .ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while notifying players of metadata update"); - } + await _hubContext.Clients.Clients(connectionIds) + .OnMetadataUpdate(metadata) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while notifying players of metadata update"); } + } - private async Task NotifyPlayersRemovedFromMatchmaking(IEnumerable players, MatchmakingError reason) + private async Task NotifyPlayersRemovedFromMatchmaking(IEnumerable players, MatchmakingError reason) + { + try { - try - { - IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); + IEnumerable connectionIds = players.Select(p => p.QueueingHubId).WhereNotNull(); - await _hubContext.Clients.Clients(connectionIds) - .OnRemovedFromMatchmaking(reason) - .ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while notifying player removed from matchmaking"); - } + await _hubContext.Clients.Clients(connectionIds) + .OnRemovedFromMatchmaking(reason) + .ConfigureAwait(false); } - - public IReadOnlyList GetPlayersInServer(IServerConnectionDetails serverConnectionDetails) + catch (Exception ex) { - return _matchmaker.GetPlayersInServer(serverConnectionDetails); + _logger.LogError(ex, "Error while notifying player removed from matchmaking"); } } + + public IReadOnlyList GetPlayersInServer(IServerConnectionDetails serverConnectionDetails) + { + return _matchmaker.GetPlayersInServer(serverConnectionDetails); + } } diff --git a/MatchmakingServer/Matchmaking/EligibilityResult.cs b/MatchmakingServer/Matchmaking/Models/EligibilityResult.cs similarity index 64% rename from MatchmakingServer/Matchmaking/EligibilityResult.cs rename to MatchmakingServer/Matchmaking/Models/EligibilityResult.cs index 76338c2..acc25ae 100644 --- a/MatchmakingServer/Matchmaking/EligibilityResult.cs +++ b/MatchmakingServer/Matchmaking/Models/EligibilityResult.cs @@ -1,4 +1,4 @@ -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking.Models { public readonly record struct EligibilityResult(bool IsEligibile, string? Reason); } diff --git a/MatchmakingServer/Matchmaking/IMMTicket.cs b/MatchmakingServer/Matchmaking/Models/IMMTicket.cs similarity index 96% rename from MatchmakingServer/Matchmaking/IMMTicket.cs rename to MatchmakingServer/Matchmaking/Models/IMMTicket.cs index 14ce8fb..517b89f 100644 --- a/MatchmakingServer/Matchmaking/IMMTicket.cs +++ b/MatchmakingServer/Matchmaking/Models/IMMTicket.cs @@ -1,7 +1,7 @@ using H2MLauncher.Core.Matchmaking.Models; using H2MLauncher.Core.Models; -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking.Models { public interface IMMTicket { diff --git a/MatchmakingServer/Matchmaking/MMMatch.cs b/MatchmakingServer/Matchmaking/Models/MMMatch.cs similarity index 92% rename from MatchmakingServer/Matchmaking/MMMatch.cs rename to MatchmakingServer/Matchmaking/Models/MMMatch.cs index a2eb621..afc0f21 100644 --- a/MatchmakingServer/Matchmaking/MMMatch.cs +++ b/MatchmakingServer/Matchmaking/Models/MMMatch.cs @@ -1,4 +1,4 @@ -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking.Models { public record struct MMMatch(GameServer Server, double MatchQuality, List SelectedTickets) { diff --git a/MatchmakingServer/Matchmaking/MMTicket.cs b/MatchmakingServer/Matchmaking/Models/MMTicket.cs similarity index 98% rename from MatchmakingServer/Matchmaking/MMTicket.cs rename to MatchmakingServer/Matchmaking/Models/MMTicket.cs index 6141878..d0ff048 100644 --- a/MatchmakingServer/Matchmaking/MMTicket.cs +++ b/MatchmakingServer/Matchmaking/Models/MMTicket.cs @@ -1,7 +1,7 @@ using H2MLauncher.Core.Matchmaking.Models; using H2MLauncher.Core.Models; -namespace MatchmakingServer +namespace MatchmakingServer.Matchmaking.Models { public sealed class MMTicket : IMMTicket { diff --git a/MatchmakingServer/Matchmaking/ReadOnlyCollectionWrapper.cs b/MatchmakingServer/Matchmaking/ReadOnlyCollectionWrapper.cs deleted file mode 100644 index 37bce56..0000000 --- a/MatchmakingServer/Matchmaking/ReadOnlyCollectionWrapper.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections; - -namespace MatchmakingServer -{ - public class ReadOnlyCollectionWrapper : IReadOnlyCollection - { - private readonly ICollection _collection; - public ReadOnlyCollectionWrapper(ICollection collection) - { - _collection = collection; - } - - public int Count - { - get { return _collection.Count; } - } - - public IEnumerator GetEnumerator() - { - return _collection.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return _collection.GetEnumerator(); - } - } -} diff --git a/MatchmakingServer/MatchmakingServer.csproj b/MatchmakingServer/MatchmakingServer.csproj index a6d5968..d3a4946 100644 --- a/MatchmakingServer/MatchmakingServer.csproj +++ b/MatchmakingServer/MatchmakingServer.csproj @@ -9,6 +9,8 @@ + + diff --git a/MatchmakingServer/Parties/IParty.cs b/MatchmakingServer/Parties/IParty.cs new file mode 100644 index 0000000..bccbca0 --- /dev/null +++ b/MatchmakingServer/Parties/IParty.cs @@ -0,0 +1,10 @@ + +namespace MatchmakingServer.Parties +{ + public interface IParty + { + string Id { get; init; } + Player Leader { get; } + IReadOnlySet Members { get; } + } +} \ No newline at end of file diff --git a/MatchmakingServer/Parties/Party.cs b/MatchmakingServer/Parties/Party.cs index 2a67617..3f606ab 100644 --- a/MatchmakingServer/Parties/Party.cs +++ b/MatchmakingServer/Parties/Party.cs @@ -2,7 +2,7 @@ namespace MatchmakingServer.Parties; -public class Party +public class Party : IParty { private Player _leader; private readonly HashSet _members = []; diff --git a/MatchmakingServer/Parties/PartyMatchmakingService.cs b/MatchmakingServer/Parties/PartyMatchmakingService.cs index 64bc428..a881786 100644 --- a/MatchmakingServer/Parties/PartyMatchmakingService.cs +++ b/MatchmakingServer/Parties/PartyMatchmakingService.cs @@ -3,6 +3,8 @@ using H2MLauncher.Core.Matchmaking.Models; using H2MLauncher.Core.Models; +using MatchmakingServer.Matchmaking; +using MatchmakingServer.Matchmaking.Models; using MatchmakingServer.Queueing; using Microsoft.Extensions.Options; diff --git a/MatchmakingServer/Parties/PartyQueueingContext.cs b/MatchmakingServer/Parties/PartyQueueingContext.cs index 3ae64cc..5348ac7 100644 --- a/MatchmakingServer/Parties/PartyQueueingContext.cs +++ b/MatchmakingServer/Parties/PartyQueueingContext.cs @@ -1,4 +1,6 @@ -namespace MatchmakingServer.Parties +using MatchmakingServer.Matchmaking.Models; + +namespace MatchmakingServer.Parties { public sealed class PartyQueueingContext { diff --git a/MatchmakingServer/Parties/PartyService.cs b/MatchmakingServer/Parties/PartyService.cs index 8672b9e..f01be81 100644 --- a/MatchmakingServer/Parties/PartyService.cs +++ b/MatchmakingServer/Parties/PartyService.cs @@ -17,7 +17,8 @@ public sealed class PartyService private readonly IHubContext _hubContext; private readonly ConcurrentDictionary _parties = []; - private readonly SemaphoreSlim _semaphore = new(1, 1); + + public IReadOnlyCollection Parties => new ReadOnlyCollectionWrapper(_parties.Values); public event Action? PartyClosed; public event Action? PlayerRemovedFromParty; diff --git a/MatchmakingServer/Program.cs b/MatchmakingServer/Program.cs index 4383ee5..84a7600 100644 --- a/MatchmakingServer/Program.cs +++ b/MatchmakingServer/Program.cs @@ -1,5 +1,6 @@ -using System.Security.Claims; -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; + +using FluentValidation; using Flurl; @@ -11,17 +12,13 @@ using H2MLauncher.Core.Utilities; using MatchmakingServer; -using MatchmakingServer.Authentication; -using MatchmakingServer.Authentication.Player; +using MatchmakingServer.Api; +using MatchmakingServer.Matchmaking; using MatchmakingServer.Parties; using MatchmakingServer.Queueing; using MatchmakingServer.SignalR; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.BearerToken; -using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Options; -using Microsoft.OpenApi.Models; using Serilog; @@ -92,49 +89,10 @@ builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(c => -{ - c.SwaggerDoc("v1", new OpenApiInfo { Title = "MatchmakingServer", Version = "v1" }); - - OpenApiSecurityScheme apiKeyScheme = new() - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = ApiKeyDefaults.AuthenticationScheme, - }, - In = ParameterLocation.Header, - Name = ApiKeyDefaults.RequestHeaderKey, - Type = SecuritySchemeType.ApiKey, - }; - - c.AddSecurityDefinition(ApiKeyDefaults.AuthenticationScheme, apiKeyScheme); - c.AddSecurityRequirement(new OpenApiSecurityRequirement() { - { - apiKeyScheme, [] - } - }); -}); - -builder.Services.AddAuthentication(ApiKeyDefaults.AuthenticationScheme) - .AddScheme(ApiKeyDefaults.AuthenticationScheme, (options) => - { - options.ApiKey = builder.Configuration.GetValue("ApiKey"); - options.ForwardDefaultSelector = context => - { - Endpoint? endpoint = context.GetEndpoint(); - - // Only forward the authentication if the endpoint has the [Authorize] attribute - bool requiresAuth = endpoint?.Metadata?.GetMetadata() is not null; - - return requiresAuth ? ApiKeyDefaults.AuthenticationScheme : null; - }; - }); +builder.Services.AddValidatorsFromAssemblyContaining(); -builder.Services.AddAuthentication(BearerTokenDefaults.AuthenticationScheme) - .AddScheme("client", null) - .AddBearerToken(); +builder.AddSwagger(); +builder.AddAuthentication(); builder.Services.AddControllers() .AddJsonOptions(o => @@ -154,49 +112,13 @@ app.UseSwaggerUI(); } -app.UseSerilogRequestLogging(options => -{ - options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms ({ClientAppName}/{ClientAppVersion})"; - options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => - { - if (httpContext.Request.Headers.TryGetValue("X-App-Name", out var appNameValues) && appNameValues.Count != 0) - { - diagnosticContext.Set("ClientAppName", appNameValues.FirstOrDefault()); - } - else - { - diagnosticContext.Set("ClientAppName", "Unknown"); - } - - if (httpContext.Request.Headers.TryGetValue("X-App-Version", out var appVersionValues) && appVersionValues.Count != 0) - { - diagnosticContext.Set("ClientAppVersion", appVersionValues.FirstOrDefault()); - } - else - { - diagnosticContext.Set("ClientAppVersion", "?"); - } - }; -}); - +app.UseRequestLogging(); app.UseAuthentication(); + app.MapControllers(); app.MapHealthChecks("/health"); -app.MapHub("/Queue"); -app.MapHub("/Party"); - -app.MapGet("/login", (string uid, string playerName) => -{ - var claimsPrincipal = new ClaimsPrincipal( - new ClaimsIdentity( - [new Claim(ClaimTypes.Name, playerName), - new Claim(ClaimTypes.NameIdentifier, uid)], - BearerTokenDefaults.AuthenticationScheme - ) - ); - - return Results.SignIn(claimsPrincipal); -}); +app.MapHubs(); +app.MapEndpoints(); app.Run(); \ No newline at end of file diff --git a/MatchmakingServer/ReadOnlyCollectionWrapper.cs b/MatchmakingServer/ReadOnlyCollectionWrapper.cs new file mode 100644 index 0000000..722a974 --- /dev/null +++ b/MatchmakingServer/ReadOnlyCollectionWrapper.cs @@ -0,0 +1,12 @@ +using System.Collections; + +namespace MatchmakingServer; + +public class ReadOnlyCollectionWrapper(ICollection collection) : IReadOnlyCollection +{ + public int Count => collection.Count; + + public IEnumerator GetEnumerator() => collection.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => collection.GetEnumerator(); +} diff --git a/MatchmakingServer/SignalR/PlayerStore.cs b/MatchmakingServer/SignalR/PlayerStore.cs index 76726a3..9d90ec2 100644 --- a/MatchmakingServer/SignalR/PlayerStore.cs +++ b/MatchmakingServer/SignalR/PlayerStore.cs @@ -8,10 +8,15 @@ public class PlayerStore private readonly Dictionary _connectedPlayers = []; private readonly SemaphoreSlim _semaphore = new(1, 1); - private readonly struct PlayerConnectionInfo(string userId, Player player) - { - public string UserId { get; } = userId; + // Maps user id to last connection time + private readonly Dictionary _lastConnections = []; + + public int NumConnectedPlayers => _connectedPlayers.Count; + public int NumPlayersSeen => _lastConnections.Count; + public int NumPlayersSeenToday => _lastConnections.Values.Where(time => time.DayOfYear == DateTimeOffset.Now.DayOfYear).Count(); + private readonly struct PlayerConnectionInfo(Player player) + { public Player Player { get; } = player; public HashSet Connections { get; } = []; @@ -25,6 +30,7 @@ public async Task GetOrAdd(string userId, string connectionId, string pl if (_connectedPlayers.TryGetValue(userId, out PlayerConnectionInfo connectionInfo)) { connectionInfo.Connections.Add(connectionId); + _lastConnections[userId] = DateTimeOffset.Now; return connectionInfo.Player; } @@ -35,10 +41,11 @@ public async Task GetOrAdd(string userId, string connectionId, string pl State = PlayerState.Connected }; - connectionInfo = new(userId, player); + connectionInfo = new(player); connectionInfo.Connections.Add(connectionId); _connectedPlayers.TryAdd(userId, connectionInfo); + _lastConnections[userId] = DateTimeOffset.Now; return player; } @@ -72,4 +79,17 @@ public async Task GetOrAdd(string userId, string connectionId, string pl _semaphore.Release(); } } + + public async Task> GetAllPlayers() + { + await _semaphore.WaitAsync(); + try + { + return _connectedPlayers.Values.Select(connectionInfo => connectionInfo.Player).ToList(); + } + finally + { + _semaphore.Release(); + } + } } diff --git a/MatchmakingServer/SignalR/QueueingHub.cs b/MatchmakingServer/SignalR/QueueingHub.cs index 3bdb5b6..e7a4ab3 100644 --- a/MatchmakingServer/SignalR/QueueingHub.cs +++ b/MatchmakingServer/SignalR/QueueingHub.cs @@ -4,6 +4,7 @@ using H2MLauncher.Core.Matchmaking.Models; using H2MLauncher.Core.Models; +using MatchmakingServer.Matchmaking; using MatchmakingServer.Parties; using MatchmakingServer.Queueing; diff --git a/MatchmakingServer/Stats/StatsEndpoint.cs b/MatchmakingServer/Stats/StatsEndpoint.cs new file mode 100644 index 0000000..07e9868 --- /dev/null +++ b/MatchmakingServer/Stats/StatsEndpoint.cs @@ -0,0 +1,27 @@ +using MatchmakingServer.Api; +using MatchmakingServer.Matchmaking; +using MatchmakingServer.Parties; +using MatchmakingServer.SignalR; + +namespace MatchmakingServer.Stats; + +public class StatsEndpoint : IEndpoint +{ + public static void Map(IEndpointRouteBuilder app) => app + .MapGet("/stats", (PlayerStore playerStore, ServerStore serverStore, PartyService partyService, Matchmaker matchmaker) => + { + return new + { + ConnectedPlayers = playerStore.NumConnectedPlayers, + TotalPlayersSeen = playerStore.NumPlayersSeen, + TotalPlayersSeenToday = playerStore.NumPlayersSeenToday, + QueuedServers = serverStore.Servers.Where(s => s.Value.ProcessingState is QueueProcessingState.Running).Count(), + QueuedPlayers = serverStore.Servers.Values.Sum(s => s.PlayerQueue.Count), + MatchmakingTickets = matchmaker.Tickets.Count, + MatchmakingPlayers = matchmaker.Tickets.Sum(t => t.Players.Count), + MatchmakingServers = matchmaker.QueuedServers.Count, + Parties = partyService.Parties.Count, + PartyMembers = partyService.Parties.Sum(p => p.Members.Count) + }; + }); +} diff --git a/MatchmakingServer/WebApplicationBuilderExtensions.cs b/MatchmakingServer/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..7562214 --- /dev/null +++ b/MatchmakingServer/WebApplicationBuilderExtensions.cs @@ -0,0 +1,64 @@ +using MatchmakingServer.Authentication; +using MatchmakingServer.Authentication.Player; + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.BearerToken; +using Microsoft.AspNetCore.Authorization; +using Microsoft.OpenApi.Models; + +namespace MatchmakingServer; + +public static class WebApplicationBuilderExtensions +{ + public static void AddAuthentication(this WebApplicationBuilder builder) + { + builder.Services.AddAuthentication(BearerTokenDefaults.AuthenticationScheme) + + // api key + .AddScheme(ApiKeyDefaults.AuthenticationScheme, (options) => + { + options.ApiKey = builder.Configuration.GetValue("ApiKey"); + options.ForwardDefaultSelector = context => + { + Endpoint? endpoint = context.GetEndpoint(); + + // Only forward the authentication if the endpoint has the [Authorize] attribute + bool requiresAuth = endpoint?.Metadata?.GetMetadata() is not null; + + return requiresAuth ? ApiKeyDefaults.AuthenticationScheme : null; + }; + }) + + // player client (query params) + .AddScheme("client", null) + .AddBearerToken(); + } + + public static void AddSwagger(this WebApplicationBuilder builder) + { + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "MatchmakingServer", Version = "v1" }); + + OpenApiSecurityScheme apiKeyScheme = new() + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = ApiKeyDefaults.AuthenticationScheme, + }, + In = ParameterLocation.Header, + Name = ApiKeyDefaults.RequestHeaderKey, + Type = SecuritySchemeType.ApiKey, + }; + + c.AddSecurityDefinition(ApiKeyDefaults.AuthenticationScheme, apiKeyScheme); + c.AddSecurityRequirement(new OpenApiSecurityRequirement() { + { + apiKeyScheme, [] + } + }); + }); + } +} diff --git a/MatchmakingServer/appsettings.json b/MatchmakingServer/appsettings.json index bf492e0..2d89cb6 100644 --- a/MatchmakingServer/appsettings.json +++ b/MatchmakingServer/appsettings.json @@ -127,7 +127,12 @@ "172.93.107.62:27017", "172.93.107.62:27019", "47.186.228.188:27032", - "116.202.156.245:27022" + "116.202.156.245:27022", + "147.135.6.9:27020", + "47.186.228.188:27027", + "147.135.6.9:27023", + "38.45.100.46:27018", + "38.45.100.116:27016" ] }, { @@ -136,10 +141,11 @@ "Servers": [ "103.195.100.207:29737", "38.45.100.46:27016", + "38.45.100.46:27017", "162.156.117.3:27018", "47.186.228.188:27028", - "170.205.31.166:27023", - "38.45.100.46:27017" + "47.186.228.188:27030", + "170.205.31.166:27023" ] } ],