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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions GenOnlineService/Database/Database.MatchHistory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,44 @@ await db.MatchHistory
}
}

internal static async Task<MatchHistory_Entry?> 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<Func<SetPropertyCalls<MatchHistoryEntry>, SetPropertyCalls<MatchHistoryEntry>>>
BuildSlotSetter(int slotIndex, string updatedJson)
Expand Down
21 changes: 19 additions & 2 deletions GenOnlineService/Database/Database.User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -572,14 +572,31 @@ public static async Task<EloData> 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);
}

Expand Down
258 changes: 258 additions & 0 deletions GenOnlineService/ExternalLeaderboardsClient.cs
Original file line number Diff line number Diff line change
@@ -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<long, EloRefreshEntry> 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<string>("BaseUrl");
string? sectionPostToken = configSection.GetValue<string>("PostToken");
string? sectionGetToken = configSection.GetValue<string>("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<HttpRequestException>()
.Or<SocketException>()
.Or<TaskCanceledException>()
.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<EloRefreshResponse>(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<long>(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<EloData?> 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<EloRefreshResponse>(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;
}
}
}
}
1 change: 1 addition & 0 deletions GenOnlineService/GenOnlineService.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="MySql.Data" Version="9.5.0" />
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
<PackageReference Include="Polly" Version="8.5.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Sentry" Version="6.0.0" />
</ItemGroup>
Expand Down
4 changes: 4 additions & 0 deletions GenOnlineService/LobbyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1553,6 +1553,10 @@ public async Task<bool> 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;
Expand Down
7 changes: 6 additions & 1 deletion GenOnlineService/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,10 @@
"jwks_endpoint": null,
"audience": null,
"issuer": null
},
"ExternalLeaderboards": {
"BaseUrl": null,
"GetToken": null,
"PostToken": null,
}
}
}
Loading