diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 53b19d0..9e18723 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -883,6 +883,44 @@ await db.MatchHistory } } + internal static async Task LoadMatchHistoryEntryAsync( + AppDbContext db, long matchId) + { + var row = await db.MatchHistory.FirstOrDefaultAsync(m => m.MatchId == matchId); + if (row == null) + return null; + + var entry = new MatchHistory_Entry( + row.MatchId, + row.Owner, + row.Name, + row.Finished, + row.Started.ToString("O"), + row.TimeFinished.ToString("O"), + row.MapName, + row.MapPath ?? string.Empty, + row.MatchRosterType, + row.MapOfficial, + row.VanillaTeams, + row.StartingCash, + row.LimitSuperweapons, + row.TrackStats, + row.AllowObservers, + row.MaxCamHeight + ); + + AddMemberIfNotNull(entry, row.MemberSlot0); + AddMemberIfNotNull(entry, row.MemberSlot1); + AddMemberIfNotNull(entry, row.MemberSlot2); + AddMemberIfNotNull(entry, row.MemberSlot3); + AddMemberIfNotNull(entry, row.MemberSlot4); + AddMemberIfNotNull(entry, row.MemberSlot5); + AddMemberIfNotNull(entry, row.MemberSlot6); + AddMemberIfNotNull(entry, row.MemberSlot7); + + return entry; + } + // METADATA private static Expression, SetPropertyCalls>> BuildSlotSetter(int slotIndex, string updatedJson) diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index feb90ec..d6a3e38 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -572,14 +572,31 @@ public static async Task GetELOData(AppDbContext db, long userId) { try { - var result = await GetEloData(db, userId); + // Try fetching from the external API + var apiElo = await ExternalLeaderboardsClient.GetEloFromApi(userId); + if (apiElo != null) + { + // Persist retrieved rating to DB asynchronously for fallback + // await SaveELOData(db, userId, apiElo); + // return apiElo; + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetELOData API call failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + try + { + // Fall back to database ELO + var result = await GetEloData(db, userId); if (result != null) return result; } catch (Exception ex) { - Console.WriteLine($"[ERROR] GetELOData failed: {ex.Message}"); + Console.WriteLine($"[ERROR] GetELOData fallback failed: {ex.Message}"); SentrySdk.CaptureException(ex); } diff --git a/GenOnlineService/ExternalLeaderboardsClient.cs b/GenOnlineService/ExternalLeaderboardsClient.cs new file mode 100644 index 0000000..ca8078f --- /dev/null +++ b/GenOnlineService/ExternalLeaderboardsClient.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Polly; + +namespace GenOnlineService +{ + public class EloRefreshResponse + { + public Dictionary data { get; set; } + } + + public class EloRefreshEntry + { + public int rating { get; set; } + public int matches { get; set; } + public int? rank { get; set; } + } + + public static class ExternalLeaderboardsClient + { + private static void GetExternalLeaderboardsConfig(out string baseUrl, out string postToken, out string getToken) + { + baseUrl = string.Empty; + postToken = string.Empty; + getToken = string.Empty; + + if (Program.g_Config == null) + { + throw new Exception("Config not loaded"); + } + + IConfigurationSection? configSection = Program.g_Config.GetSection("ExternalLeaderboards"); + if (configSection == null) + { + throw new Exception("ExternalLeaderboards section missing in config"); + } + + string? sectionBaseUrl = configSection.GetValue("BaseUrl"); + string? sectionPostToken = configSection.GetValue("PostToken"); + string? sectionGetToken = configSection.GetValue("GetToken"); + + if (string.IsNullOrEmpty(sectionBaseUrl)) + { + throw new Exception("ExternalLeaderboards BaseUrl missing in config"); + } + + if (string.IsNullOrEmpty(sectionPostToken)) + { + throw new Exception("ExternalLeaderboards PostToken missing in config"); + } + + if (string.IsNullOrEmpty(sectionGetToken)) + { + throw new Exception("ExternalLeaderboards GetToken missing in config"); + } + + baseUrl = sectionBaseUrl; + postToken = sectionPostToken; + getToken = sectionGetToken; + } + + private static SocketsHttpHandler CreateLeaderboardsHandler() + { + return new SocketsHttpHandler() + { + ConnectCallback = async (context, cancellationToken) => + { + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, AddressFamily.InterNetwork, cancellationToken); + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) + { + NoDelay = true + }; + + try + { + await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken); + return new NetworkStream(socket, ownsSocket: true); + } + catch + { + socket.Dispose(); + throw; + } + } + }; + } + + public static async Task PostMatchResultAsync(AppDbContext db, Lobby lobby) + { + if (lobby.MatchID == 0) + return; + + try + { + GetExternalLeaderboardsConfig(out string baseUrl, out string postToken, out _); + + string postUrl = $"{baseUrl.TrimEnd('/')}/matches/ingest"; + + // Load the match payload + var matchEntry = await Database.MatchHistory.LoadMatchHistoryEntryAsync(db, (long)lobby.MatchID); + if (matchEntry == null) + { + Console.WriteLine($"[WARNING] MatchHistory entry not found for match ID {lobby.MatchID}"); + return; + } + + // Serialize payload to JSON + string payloadJson = JsonSerializer.Serialize(matchEntry); + + // Configure Polly wait-and-retry policy with exponential backoff on HTTP/Socket errors + var retryPolicy = Policy + .Handle() + .Or() + .Or() + .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), (exception, timeSpan, retryCount, context) => + { + Console.WriteLine($"[WARNING] External Match ingest POST failed (attempt {retryCount}). Retrying in {timeSpan.TotalSeconds}s. Error: {exception.Message}"); + }); + + HttpResponseMessage? response = null; + + await retryPolicy.ExecuteAsync(async () => + { + using (var handler = CreateLeaderboardsHandler()) + using (var client = new HttpClient(handler)) + { + client.Timeout = TimeSpan.FromSeconds(10); + + using (var request = new HttpRequestMessage(HttpMethod.Post, postUrl)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", postToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + + var sw = Stopwatch.StartNew(); + response = await client.SendAsync(request); + sw.Stop(); + + Console.WriteLine($"[INFO] External Match Ingest POST Response for match {lobby.MatchID} was received in {sw.ElapsedMilliseconds}ms (status: {response.StatusCode})."); + + // Explicitly verify response success inside execution block to ensure retry triggers on HTTP error statuses + response.EnsureSuccessStatusCode(); + } + } + }); + + if (response == null || !response.IsSuccessStatusCode) + { + Console.WriteLine($"[ERROR] External Match Ingest POST failed for match {lobby.MatchID}."); + return; + } + + // Only QuickMatch responses are expected to carry a ratings body. + if (lobby.LobbyType == ELobbyType.QuickMatch) + { + string responseBody = await response.Content.ReadAsStringAsync(); + var refreshResponse = JsonSerializer.Deserialize(responseBody); + if (refreshResponse?.data == null) + { + Console.WriteLine($"[WARNING] External Match Ingest response body contains no data or could not be deserialized: {responseBody}"); + return; + } + + // Only player IDs that were actually part of this match are valid recipients of an ELO update. + var expectedPlayerIds = new HashSet(matchEntry.members.Where(m => m.HasValue).Select(m => m.Value.user_id)); + + foreach (var (userId, updatedPlayer) in refreshResponse.data) + { + if (!expectedPlayerIds.Contains(userId)) + { + Console.WriteLine($"[WARNING] External Match Ingest response for match {lobby.MatchID} contained unexpected player_id {userId}; skipping (ELO left unchanged)."); + continue; + } + + int newRating = updatedPlayer.rating; + int newMatches = updatedPlayer.matches; + + // Update in-memory session cache if the player is online + var sharedData = WebSocketManager.GetSharedDataForUser(userId); + if (sharedData?.GameStats != null) + { + // sharedData.GameStats.EloRating = newRating; + // sharedData.GameStats.EloMatches = newMatches; + } + + // Call SaveELOData to persist as fallback + // await Database.Users.SaveELOData(db, userId, new EloData(newRating, newMatches)); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Exception during External Match Ingest POST: {ex.Message}"); + } + } + + public static async Task GetEloFromApi(long playerId) + { + try + { + GetExternalLeaderboardsConfig(out string baseUrl, out _, out string getToken); + + string requestUrl = $"{baseUrl.TrimEnd('/')}/players/{playerId}/leagues/1/rating"; + + using (var handler = CreateLeaderboardsHandler()) + using (var client = new HttpClient(handler)) + { + client.Timeout = TimeSpan.FromSeconds(10); + + using (var request = new HttpRequestMessage(HttpMethod.Get, requestUrl)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", getToken); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var sw = Stopwatch.StartNew(); + using (var response = await client.SendAsync(request)) + { + sw.Stop(); + Console.WriteLine($"[INFO] External ELO API call for player {playerId} took {sw.ElapsedMilliseconds}ms (status: {response.StatusCode})."); + + if (!response.IsSuccessStatusCode) + { + Console.WriteLine($"[ERROR] External ELO API call failed for player {playerId} with status: {response.StatusCode}"); + return null; + } + + string responseBody = await response.Content.ReadAsStringAsync(); + var result = JsonSerializer.Deserialize(responseBody); + if (result?.data == null || !result.data.TryGetValue(playerId, out var entry)) + { + Console.WriteLine($"[ERROR] External ELO API response for player {playerId} did not contain that player_id or could not be deserialized: {responseBody}"); + return null; + } + + return new EloData(entry.rating, entry.matches); + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] Exception during external ELO API call for player {playerId}: {ex.Message}"); + return null; + } + } + } +} diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj index cb2d5e0..da0a86d 100644 --- a/GenOnlineService/GenOnlineService.csproj +++ b/GenOnlineService/GenOnlineService.csproj @@ -50,6 +50,7 @@ + diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 7a9ee3a..856e46d 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1553,6 +1553,10 @@ public async Task DeleteLobby(Lobby lobby) { await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); } + + // Post match result to external leaderboard API for every lobby type. + // Only QuickMatch responses are expected to carry a ratings body. + await ExternalLeaderboardsClient.PostMatchResultAsync(db, lobby); } return bRemoved; diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 0aa6d5d..4e53e57 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -75,5 +75,10 @@ "jwks_endpoint": null, "audience": null, "issuer": null + }, + "ExternalLeaderboards": { + "BaseUrl": null, + "GetToken": null, + "PostToken": null, } -} \ No newline at end of file +}