diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 1ee0eba..8bc45b1 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -18,6 +18,7 @@ using Amazon.S3.Model; using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.EntityFrameworkCore; using Org.BouncyCastle.Tls; using System; using System.Collections.Concurrent; @@ -220,6 +221,13 @@ public static async Task CreateSession(AppDbContext _db, // how many other sessions do they have online? bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; + // kill any existing sessions for this user of same session type + if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) + { + Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); + await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); + } + // get and cache social container UserSocialContainer socialContainer = new(); socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); @@ -262,13 +270,6 @@ public static async Task CreateSession(AppDbContext _db, } } - // kill any existing sessions for this user of same session type - if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) - { - Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); - await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); - } - // now create a websocket, we always do this whether its reconnect or not, only data is persistent UserWebSocketInstance newSess = new UserWebSocketInstance(sessionType, ownerID); m_dictWebsockets[sessionType][ownerID] = newSess; @@ -276,13 +277,15 @@ public static async Task CreateSession(AppDbContext _db, // update last login and last ip await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); - int numSessions = m_dictWebsockets.Count; + // TODO_EFCORE: Optimize this, dont iterate all the time + int numSessions = WebSocketManager.GetNumberOfUsersOnline(); + if (numSessions > g_PeakConnectionCount) { g_PeakConnectionCount = numSessions; } - Console.Title = String.Format("GenOnline - {0} players", m_dictWebsockets.Count); + Console.Title = String.Format("GenOnline - {0} players", numSessions); SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(ownerID); @@ -324,6 +327,17 @@ public static async Task Tick() await Task.WhenAll(m_dictUserSessions.Values.SelectMany(inner => inner.Values).Select(sess => sess.TickWebsocket(cts.Token))); } + public static int GetNumberOfUsersOnline() + { + int numSessions = 0; + foreach (var kvPair in m_dictUserSessions) + { + numSessions += kvPair.Value.Count; + } + + return numSessions; + } + public static async Task CheckForTimeouts() { List lstSessionsToDestroy = new(); @@ -388,6 +402,11 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy var item = m_dictWebsockets[sessionType].First(kvp => kvp.Value == oldWS); // safe to lookup by sessionType here since we only ever remove old WS of the same type m_dictWebsockets[sessionType].Remove(item.Key, out UserWebSocketInstance? destroyedSess); + if (destroyedSess != null) + { + destroyedSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [A]"); + } + // decrement ref count on shared data if (m_dictSharedUserData.TryGetValue(user_id, out SharedUserData? sharedData)) { @@ -442,13 +461,15 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy } } - Console.Title = String.Format("GenOnline - {0} players", m_dictWebsockets.Count); + int numSessions = WebSocketManager.GetNumberOfUsersOnline(); ; + Console.Title = String.Format("GenOnline - {0} players", numSessions); try { - if (sourceData != null) + if (oldWS != null) { - await sourceData.CloseWebsocket(WebSocketCloseStatus.NormalClosure, "Session being deleted"); + // Close the WS directly, dont rely on session data as it may be linked to something else at this point + await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); } } catch @@ -503,7 +524,7 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy private static ConcurrentDictionary m_dictSharedUserData = new(); - public static ConcurrentDictionary> GetUserDataCache() + public static ConcurrentDictionary> GetUserDataCache() { return m_dictUserSessions; } @@ -654,7 +675,14 @@ public static async Task TickRoomMemberList() { if (sessType == EUserSessionType.GameLauncher) { - strDisplayName += " [LAUNCHER]"; + if (sessionData.Value.m_client_id == KnownClients.EKnownClients.genhub) + { + strDisplayName += " [GENHUB]"; + } + else + { + strDisplayName += " [LAUNCHER]"; + } } else if (sessType == EUserSessionType.ChatClient) { diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 65aadff..626a396 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -141,11 +141,17 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr } user_id = highestIDFound + 1; + + bool bTestSPOP = false; + if (bTestSPOP) + { + user_id = 0; + } strDisplayName = String.Format("DEV_ACCOUNT_{0}", Math.Abs(user_id) - 1); // make user - await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, result.display_name); + await Database.Users.CreateUserIfNotExists_DevAccount(db, user_id, strDisplayName); } bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); diff --git a/GenOnlineService/Controllers/MOTD/MOTDController.cs b/GenOnlineService/Controllers/MOTD/MOTDController.cs index f0aad01..8aea192 100644 --- a/GenOnlineService/Controllers/MOTD/MOTDController.cs +++ b/GenOnlineService/Controllers/MOTD/MOTDController.cs @@ -66,7 +66,7 @@ public async Task Get() if (System.IO.File.Exists(Path.Combine("data", "motd.txt"))) { string strFileData = await System.IO.File.ReadAllTextAsync(Path.Combine("data", "motd.txt")); - int numPlayers = GenOnlineService.WebSocketManager.GetUserDataCache().Count; + int numPlayers = GenOnlineService.WebSocketManager.GetNumberOfUsersOnline(); result.MOTD = String.Format(strFileData, numPlayers); } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index d7f5250..70c80d3 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -125,10 +125,12 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) return; } + EUserSessionType sessType = TokenHelper.GetSessionType(this); + await using var db = await _dbFactory.CreateDbContextAsync(); UserWebSocketInstance wsSess = await WebSocketManager.CreateSession( db, - EUserSessionType.GameClient, + sessType, bIsReconnect, user_id, client_id, @@ -170,11 +172,11 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) receiveResult = await webSocket.ReceiveAsync( new ArraySegment(buffer), cts.Token); } - catch (OperationCanceledException) - { - // No message received in 30s — send a keep-alive pong and continue waiting - wsSess.SendPong(); - continue; + catch (OperationCanceledException) + { + // No message received in 30s — send a keep-alive pong and continue waiting + wsSess.SendPong(); + continue; } catch (Exception ex) { @@ -195,6 +197,14 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) var segment = new ArraySegment(buffer, 0, receiveResult.Count); UserSession? sourceUserData = WebSocketManager.GetSessionFromUser(wsSess.m_UserID, wsSess.m_SessionType); + + // if we lost session data, close WS + if (sourceUserData == null) + { + wsSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [B]"); + break; + } + await ProcessWSMessage(wsSess, sourceUserData, receiveResult, segment); } diff --git a/GenOnlineService/Database/Database.ConnectionOutcomes.cs b/GenOnlineService/Database/Database.ConnectionOutcomes.cs index 9fc16ef..5f90445 100644 --- a/GenOnlineService/Database/Database.ConnectionOutcomes.cs +++ b/GenOnlineService/Database/Database.ConnectionOutcomes.cs @@ -61,65 +61,73 @@ namespace Database { public static class ConnectionOutcomes { - public static async Task StoreConnectionOutcome( - AppDbContext db, - EIPVersion protocol, - EConnectionState outcome) - { - // Only track these states - if (outcome != EConnectionState.CONNECTED_DIRECT && - outcome != EConnectionState.CONNECTED_RELAY && - outcome != EConnectionState.CONNECTION_FAILED) - return; - - int dayOfYear = DateTime.UtcNow.DayOfYear; - - // Load existing row (if any) - var existing = await db.ConnectionOutcomes - .Where(c => c.DayOfYear == dayOfYear) - .FirstOrDefaultAsync(); - - // If no row exists → create one - if (existing == null) - { - existing = new ConnectionOutcome - { - DayOfYear = dayOfYear, - Ipv4Count = 0, - Ipv6Count = 0, - SuccessCount = 0, - FailedCount = 0 - }; - - db.ConnectionOutcomes.Add(existing); - } - - // Increment protocol counters - if (protocol == EIPVersion.IPV4) - existing.Ipv4Count = (existing.Ipv4Count ?? 0) + 1; - else if (protocol == EIPVersion.IPV6) - existing.Ipv6Count = (existing.Ipv6Count ?? 0) + 1; - - // Increment outcome counters - if (outcome == EConnectionState.CONNECTED_DIRECT || - outcome == EConnectionState.CONNECTED_RELAY) - { - existing.SuccessCount = (existing.SuccessCount ?? 0) + 1; - } - else if (outcome == EConnectionState.CONNECTION_FAILED) - { - existing.FailedCount = (existing.FailedCount ?? 0) + 1; - } - - // Persist insert/update - await db.SaveChangesAsync(); - - // Cleanup: delete rows older than 30 days - int cutoff = dayOfYear - 30; - - await db.ConnectionOutcomes - .Where(c => c.DayOfYear < cutoff) - .ExecuteDeleteAsync(); + public static async Task StoreConnectionOutcome( + AppDbContext db, + EIPVersion protocol, + EConnectionState outcome) + { + // Only track these states + if (outcome != EConnectionState.CONNECTED_DIRECT && + outcome != EConnectionState.CONNECTED_RELAY && + outcome != EConnectionState.CONNECTION_FAILED) + return; + + try + { + int dayOfYear = DateTime.UtcNow.DayOfYear; + + // Load existing row (if any) + var existing = await db.ConnectionOutcomes + .Where(c => c.DayOfYear == dayOfYear) + .FirstOrDefaultAsync(); + + // If no row exists → create one + if (existing == null) + { + existing = new ConnectionOutcome + { + DayOfYear = dayOfYear, + Ipv4Count = 0, + Ipv6Count = 0, + SuccessCount = 0, + FailedCount = 0 + }; + + db.ConnectionOutcomes.Add(existing); + } + + // Increment protocol counters + if (protocol == EIPVersion.IPV4) + existing.Ipv4Count = (existing.Ipv4Count ?? 0) + 1; + else if (protocol == EIPVersion.IPV6) + existing.Ipv6Count = (existing.Ipv6Count ?? 0) + 1; + + // Increment outcome counters + if (outcome == EConnectionState.CONNECTED_DIRECT || + outcome == EConnectionState.CONNECTED_RELAY) + { + existing.SuccessCount = (existing.SuccessCount ?? 0) + 1; + } + else if (outcome == EConnectionState.CONNECTION_FAILED) + { + existing.FailedCount = (existing.FailedCount ?? 0) + 1; + } + + // Persist insert/update + await db.SaveChangesAsync(); + + // Cleanup: delete rows older than 30 days + int cutoff = dayOfYear - 30; + + await db.ConnectionOutcomes + .Where(c => c.DayOfYear < cutoff) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] StoreConnectionOutcome failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs index 5bd047c..8d4490c 100644 --- a/GenOnlineService/Database/Database.DailyStats.cs +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -69,12 +69,21 @@ public static class DailyStatsManager public static async Task LoadFromDB(AppDbContext db) { - int day_of_year = DateTime.Now.DayOfYear; - g_StatsContainer = await db.DailyStats.FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); + try + { + int day_of_year = DateTime.Now.DayOfYear; + g_StatsContainer = await db.DailyStats.FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); - // if null, instantiate, but dont save immediately, let the normal save timer handle it - if (g_StatsContainer == null) + // if null, instantiate, but dont save immediately, let the normal save timer handle it + if (g_StatsContainer == null) + { + g_StatsContainer = new DailyStat(); + } + } + catch (Exception ex) { + Console.WriteLine($"[ERROR] DailyStats.LoadFromDB failed: {ex.Message}"); + SentrySdk.CaptureException(ex); g_StatsContainer = new DailyStat(); } } @@ -82,24 +91,32 @@ public static async Task LoadFromDB(AppDbContext db) // TODO_EFCORE: This can be optimized public static async Task SaveToDB(AppDbContext db) { - int day_of_year = DateTime.Now.DayOfYear; + try + { + int day_of_year = DateTime.Now.DayOfYear; - var entity = await db.DailyStats.AsTracking() - .FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); + var entity = await db.DailyStats.AsTracking() + .FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); - // Insert if new, otherwise update - if (entity == null) - { - entity = g_StatsContainer; - db.DailyStats.Add(entity); + // Insert if new, otherwise update + if (entity == null) + { + entity = g_StatsContainer; + db.DailyStats.Add(entity); + } + else + { + entity.Stats = g_StatsContainer.Stats; + db.DailyStats.Update(entity); + } + + await db.SaveChangesAsync(); } - else + catch (Exception ex) { - entity.Stats = g_StatsContainer.Stats; - db.DailyStats.Update(entity); + Console.WriteLine($"[ERROR] DailyStats.SaveToDB failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } - - await db.SaveChangesAsync(); } public static void RegisterOutcome(int army, bool bWon) @@ -128,6 +145,7 @@ public static void RegisterOutcome(int army, bool bWon) catch (Exception ex) { Console.WriteLine($"[ERROR] RegisterOutcome failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } } \ No newline at end of file diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index e2621c2..69c41df 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -164,35 +164,6 @@ public sealed class LeaderboardRow public int Matches { get; set; } } - public class LeaderboardDaily - { - public long UserId { get; set; } - public int? Points { get; set; } - public int DayOfYear { get; set; } - public int Year { get; set; } - public int? Wins { get; set; } - public int? Losses { get; set; } - } - - public class LeaderboardMonthly - { - public long UserId { get; set; } - public int? Points { get; set; } - public int MonthOfYear { get; set; } - public int Year { get; set; } - public int? Wins { get; set; } - public int? Losses { get; set; } - } - - public class LeaderboardYearly - { - public long UserId { get; set; } - public int? Points { get; set; } - public int Year { get; set; } - public int? Wins { get; set; } - public int? Losses { get; set; } - } - public static class LeaderboardQueries { public static readonly Func, int, int, IAsyncEnumerable> DailyBulk = @@ -242,52 +213,60 @@ public static class LeaderboardQueries public static async Task CreateUserEntriesIfNotExists(AppDbContext db, long playerId) { - int dayOfYear = DateTime.UtcNow.DayOfYear; - int monthOfYear = DateTime.UtcNow.Month; - int year = DateTime.UtcNow.Year; - - var daily = new LeaderboardDaily - { - UserId = playerId, - Points = EloConfig.BaseRating, - DayOfYear = dayOfYear, - Year = year, - Wins = 0, - Losses = 0 - }; - - var monthly = new LeaderboardMonthly - { - UserId = playerId, - Points = EloConfig.BaseRating, - MonthOfYear = monthOfYear, - Year = year, - Wins = 0, - Losses = 0 - }; - - var yearly = new LeaderboardYearly - { - UserId = playerId, - Points = EloConfig.BaseRating, - Year = year, - Wins = 0, - Losses = 0 - }; - - db.Add(daily); - db.Add(monthly); - db.Add(yearly); - try { - await db.SaveChangesAsync(); + int dayOfYear = DateTime.UtcNow.DayOfYear; + int monthOfYear = DateTime.UtcNow.Month; + int year = DateTime.UtcNow.Year; + + var daily = new LeaderboardDaily + { + UserId = playerId, + Points = EloConfig.BaseRating, + DayOfYear = dayOfYear, + Year = year, + Wins = 0, + Losses = 0 + }; + + var monthly = new LeaderboardMonthly + { + UserId = playerId, + Points = EloConfig.BaseRating, + MonthOfYear = monthOfYear, + Year = year, + Wins = 0, + Losses = 0 + }; + + var yearly = new LeaderboardYearly + { + UserId = playerId, + Points = EloConfig.BaseRating, + Year = year, + Wins = 0, + Losses = 0 + }; + + db.Add(daily); + db.Add(monthly); + db.Add(yearly); + + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) + { + // Ignore duplicate key errors (INSERT IGNORE behavior) + if (!IsDuplicateKeyException(ex)) + throw; + } } - catch (DbUpdateException ex) + catch (Exception ex) { - // Ignore duplicate key errors (INSERT IGNORE behavior) - if (!IsDuplicateKeyException(ex)) - throw; + Console.WriteLine($"[ERROR] CreateUserEntriesIfNotExists failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -308,9 +287,6 @@ private static async Task> MaterializeAsync(IAsyncEnumerabl } - // Reusable buffer to avoid allocating a new Task[] every call - private static readonly Task[] _taskBuffer = new Task[3]; - public async static ValueTask> GetBulkLeaderboardData( AppDbContext db, List playerIDs, @@ -326,41 +302,44 @@ public async static ValueTask> GetBulkLeader foreach (var id in playerIDs) results[id] = new LeaderboardPoints(); - var dailyTask = MaterializeAsync(LeaderboardQueries.DailyBulk(db, playerIDs, dayOfYear, year)); - var monthlyTask = MaterializeAsync(LeaderboardQueries.MonthlyBulk(db, playerIDs, monthOfYear, year)); - var yearlyTask = MaterializeAsync(LeaderboardQueries.YearlyBulk(db, playerIDs, year)); - - _taskBuffer[0] = dailyTask; - _taskBuffer[1] = monthlyTask; - _taskBuffer[2] = yearlyTask; - - await Task.WhenAll(_taskBuffer).ConfigureAwait(false); - - // DAILY - foreach (var row in dailyTask.Result) + try { - var entry = results[row.UserId]; - entry.daily = row.Points; - entry.daily_matches = row.Matches; - results[row.UserId] = entry; - } + // Queries must run sequentially — DbContext does not support concurrent operations. + var daily = await MaterializeAsync(LeaderboardQueries.DailyBulk(db, playerIDs, dayOfYear, year)); + var monthly = await MaterializeAsync(LeaderboardQueries.MonthlyBulk(db, playerIDs, monthOfYear, year)); + var yearly = await MaterializeAsync(LeaderboardQueries.YearlyBulk(db, playerIDs, year)); - // MONTHLY - foreach (var row in monthlyTask.Result) - { - var entry = results[row.UserId]; - entry.monthly = row.Points; - entry.monthly_matches = row.Matches; - results[row.UserId] = entry; + // DAILY + foreach (var row in daily) + { + var entry = results[row.UserId]; + entry.daily = row.Points; + entry.daily_matches = row.Matches; + results[row.UserId] = entry; + } + + // MONTHLY + foreach (var row in monthly) + { + var entry = results[row.UserId]; + entry.monthly = row.Points; + entry.monthly_matches = row.Matches; + results[row.UserId] = entry; + } + + // YEARLY + foreach (var row in yearly) + { + var entry = results[row.UserId]; + entry.yearly = row.Points; + entry.yearly_matches = row.Matches; + results[row.UserId] = entry; + } } - - // YEARLY - foreach (var row in yearlyTask.Result) + catch (Exception ex) { - var entry = results[row.UserId]; - entry.yearly = row.Points; - entry.yearly_matches = row.Matches; - results[row.UserId] = entry; + Console.WriteLine($"[ERROR] GetBulkLeaderboardData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return results; diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index ceab442..e701093 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -144,22 +144,22 @@ public void Configure(EntityTypeBuilder entity) // TODO_EFCORE: put everything in below namespace namespace GenOnlineService -{ - - public enum EScreenshotType - { - NONE = -1, - SCREENSHOT_TYPE_LOADSCREEN = 0, - SCREENSHOT_TYPE_GAMEPLAY = 1, - SCREENSHOT_TYPE_SCORESCREEN = 2 - } - - - public enum EMetadataFileType - { - UNKNOWN = -1, - FILE_TYPE_SCREENSHOT = 0, - FILE_TYPE_REPLAY = 1 +{ + + public enum EScreenshotType + { + NONE = -1, + SCREENSHOT_TYPE_LOADSCREEN = 0, + SCREENSHOT_TYPE_GAMEPLAY = 1, + SCREENSHOT_TYPE_SCORESCREEN = 2 + } + + + public enum EMetadataFileType + { + UNKNOWN = -1, + FILE_TYPE_SCREENSHOT = 0, + FILE_TYPE_REPLAY = 1 }; public struct MemberMetadataModel @@ -168,6 +168,20 @@ public struct MemberMetadataModel public EMetadataFileType file_type { get; set; } } + // Handles JSON booleans stored as integers (0/1) from legacy MySQL tinyint serialization. + public sealed class BoolFromIntConverter : JsonConverter + { + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + return reader.GetInt32() != 0; + return reader.GetBoolean(); + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + => writer.WriteBooleanValue(value); + } + public struct MatchdataMemberModel { public Int64 user_id { get; set; } = -1; // bigint(20) NOT NULL @@ -185,6 +199,7 @@ public struct MatchdataMemberModel public int units_lost { get; set; } = 0; // int(11) DEFAULT NULL public int total_money { get; set; } = 0; // int(11) DEFAULT NULL + [JsonConverter(typeof(BoolFromIntConverter))] public bool won { get; set; } = false; // tinyint(4) DEFAULT NULL public List metadata { get; set; } = new List(); @@ -195,11 +210,11 @@ public MatchdataMemberModel() } namespace Database -{ - // TODO_EFCORE: Consider moving to zero-serialization model +{ + // TODO_EFCORE: Consider moving to zero-serialization model public static class MatchHistory - { - private static readonly Expression>[] _slotSelectors = + { + private static readonly Expression>[] _slotSelectors = { m => m.MemberSlot0, m => m.MemberSlot1, @@ -211,46 +226,41 @@ public static class MatchHistory m => m.MemberSlot7 }; - private static readonly Func> _getAllMemberSlots = - EF.CompileAsyncQuery( - (AppDbContext db, long matchId) => - db.MatchHistory - .Where(m => m.MatchId == matchId) - .Select(m => new string?[] - { - m.MemberSlot0, - m.MemberSlot1, - m.MemberSlot2, - m.MemberSlot3, - m.MemberSlot4, - m.MemberSlot5, - m.MemberSlot6, - m.MemberSlot7 - }) - .FirstOrDefault() + private static readonly Func> _getAllMemberSlots = + EF.CompileAsyncQuery( + (AppDbContext db, long matchId) => + db.MatchHistory + .Where(m => m.MatchId == matchId) + .Select(m => new string?[] + { + m.MemberSlot0, + m.MemberSlot1, + m.MemberSlot2, + m.MemberSlot3, + m.MemberSlot4, + m.MemberSlot5, + m.MemberSlot6, + m.MemberSlot7 + }) + .FirstOrDefault() ); private static Expression, SetPropertyCalls>> BuildSetter(int slotIndex, string? json) { - var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); - - var call = Expression.Call( - param, - nameof(SetPropertyCalls.SetProperty), - typeArguments: null, - arguments: new Expression[] - { - _slotSelectors[slotIndex], - Expression.Constant(json, typeof(string)) - } - ); - - return Expression.Lambda, SetPropertyCalls>>( - call, - param - ); + return slotIndex switch + { + 0 => s => s.SetProperty(m => m.MemberSlot0, json), + 1 => s => s.SetProperty(m => m.MemberSlot1, json), + 2 => s => s.SetProperty(m => m.MemberSlot2, json), + 3 => s => s.SetProperty(m => m.MemberSlot3, json), + 4 => s => s.SetProperty(m => m.MemberSlot4, json), + 5 => s => s.SetProperty(m => m.MemberSlot5, json), + 6 => s => s.SetProperty(m => m.MemberSlot6, json), + 7 => s => s.SetProperty(m => m.MemberSlot7, json), + _ => throw new ArgumentOutOfRangeException(nameof(slotIndex)) + }; } @@ -267,37 +277,34 @@ private static Expression, SetPropertyC }; - private static Expression, SetPropertyCalls>> - BuildWinnerSetter(int slotIndex, string updatedJson) - { - var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); - - var call = Expression.Call( - param, - nameof(SetPropertyCalls.SetProperty), - typeArguments: null, - arguments: new Expression[] - { - _slotSelectors[slotIndex], - Expression.Constant(updatedJson, typeof(string)) - } - ); - - return Expression.Lambda, SetPropertyCalls>>( - call, - param - ); - } - - - private static readonly Func> _getMemberSlot = - EF.CompileAsyncQuery( - (AppDbContext db, long matchId, int slotIndex) => - db.MatchHistory - .Where(m => m.MatchId == matchId) - .Select(_slotSelectors[slotIndex]) - .FirstOrDefault() - ); + private static Expression, SetPropertyCalls>> + BuildWinnerSetter(int slotIndex, string updatedJson) + { + return slotIndex switch + { + 0 => s => s.SetProperty(m => m.MemberSlot0, updatedJson), + 1 => s => s.SetProperty(m => m.MemberSlot1, updatedJson), + 2 => s => s.SetProperty(m => m.MemberSlot2, updatedJson), + 3 => s => s.SetProperty(m => m.MemberSlot3, updatedJson), + 4 => s => s.SetProperty(m => m.MemberSlot4, updatedJson), + 5 => s => s.SetProperty(m => m.MemberSlot5, updatedJson), + 6 => s => s.SetProperty(m => m.MemberSlot6, updatedJson), + 7 => s => s.SetProperty(m => m.MemberSlot7, updatedJson), + _ => throw new ArgumentOutOfRangeException(nameof(slotIndex)) + }; + } + + + private static async Task _getMemberSlot(AppDbContext db, long matchId, int slotIndex) + { + if (slotIndex < 0 || slotIndex > 7) + return null; + + return await db.MatchHistory + .Where(m => m.MatchId == matchId) + .Select(_slotSelectors[slotIndex]) + .FirstOrDefaultAsync(); + } @@ -308,41 +315,7 @@ private static Expression, SetPropertyC .Max(m => (long?)m.MatchId) ); - private static readonly Func _insertMatch = - EF.CompileAsyncQuery( - (AppDbContext db, MatchHistoryEntry m) => - db.MatchHistory.Add(m) - ); - - - - private static readonly Func> _getMatchesInRange = - EF.CompileAsyncQuery( - (AppDbContext db, long startId, long endId) => - db.MatchHistory - .Where(m => m.MatchId >= startId && - m.MatchId <= endId && - m.Finished) - .Select(m => new MatchHistory_Entry( - m.MatchId, - m.Owner, - m.Name, - m.Finished, - m.Started.ToString("O"), - m.TimeFinished.ToString("O"), - m.MapName, - m.MapPath!, - m.MatchRosterType, - m.MapOfficial, - m.VanillaTeams, - m.StartingCash, - m.LimitSuperweapons, - m.TrackStats, - m.AllowObservers, - m.MaxCamHeight - )) - ); public static async Task CommitPlayerOutcome( AppDbContext db, @@ -358,34 +331,42 @@ public static async Task CommitPlayerOutcome( bool won) { if (slotIndex < 0 || slotIndex > 7) - return; - - // 1. Load JSON for this slot - string? json = await _getMemberSlot(db, (long)matchId, slotIndex); - if (string.IsNullOrEmpty(json)) - return; - - // 2. Deserialize - MatchdataMemberModel? modelNullable = JsonSerializer.Deserialize(json); - if (modelNullable == null) - return; - - // 3. Update fields - MatchdataMemberModel model = modelNullable.Value; - model.buildings_built = buildingsBuilt; - model.buildings_killed = buildingsKilled; - model.buildings_lost = buildingsLost; - model.units_built = unitsBuilt; - model.units_killed = unitsKilled; - model.units_lost = unitsLost; - model.total_money = totalMoney; - model.won = won; - - // 4. Serialize back - string updatedJson = JsonSerializer.Serialize(model); - - // 5. Update DB (single SQL UPDATE) - await _updateMemberSlot(db, (long)matchId, slotIndex, updatedJson); + return; + + try + { + // 1. Load JSON for this slot + string? json = await _getMemberSlot(db, (long)matchId, slotIndex); + if (string.IsNullOrEmpty(json)) + return; + + // 2. Deserialize + MatchdataMemberModel? modelNullable = JsonSerializer.Deserialize(json); + if (modelNullable == null) + return; + + // 3. Update fields + MatchdataMemberModel model = modelNullable.Value; + model.buildings_built = buildingsBuilt; + model.buildings_killed = buildingsKilled; + model.buildings_lost = buildingsLost; + model.units_built = unitsBuilt; + model.units_killed = unitsKilled; + model.units_lost = unitsLost; + model.total_money = totalMoney; + model.won = won; + + // 4. Serialize back + string updatedJson = JsonSerializer.Serialize(model); + + // 5. Update DB (single SQL UPDATE) + await _updateMemberSlot(db, (long)matchId, slotIndex, updatedJson); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CommitPlayerOutcome failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task _updateMemberSlot( @@ -399,29 +380,32 @@ await db.MatchHistory } - private static string ComputeRosterType(int playersSeen, Dictionary playersPerTeam) - { - // FFA check - bool isFFA = playersSeen > 2 && - playersPerTeam.All(kv => kv.Key == -1 || kv.Value == 1); + private static string ComputeRosterType(Dictionary playersPerTeam) + { + int noTeamCount = playersPerTeam.TryGetValue(-1, out int n) ? n : 0; - if (isFFA) - return $"{playersSeen} Player FFA"; - - // Team roster type - string roster = ""; + var teamedGroups = playersPerTeam + .Where(kv => kv.Key != -1) + .Select(kv => kv.Value) + .OrderBy(c => c) + .ToList(); - foreach (var kv in playersPerTeam) + int activePlayers = noTeamCount + teamedGroups.Sum(); + + if (activePlayers == 0) { - int count = kv.Value; + return "Unknown"; + } - if (string.IsNullOrEmpty(roster)) - roster = count.ToString(); - else - roster += $"v{count}"; + bool isFFA = activePlayers > 2 && + (noTeamCount == activePlayers || teamedGroups.All(c => c == 1)); + + if (isFFA) + { + return $"{activePlayers} Player FFA"; } - return roster; + return string.Join("v", teamedGroups); } @@ -430,216 +414,249 @@ public static async Task CreatePlaceholderMatchHistory( GenOnlineService.Lobby lobby) { if (lobby == null) - return 0; - - // Build member JSON array - string?[] jsonSlots = new string?[8]; + return 0; - Dictionary playersPerTeam = new(); - int playersSeen = 0; - - foreach (var member in lobby.Members) + try { - if (member.SlotState == EPlayerType.SLOT_OPEN || - member.SlotState == EPlayerType.SLOT_CLOSED) - continue; + // Build member JSON array + string?[] jsonSlots = new string?[8]; + + Dictionary playersPerTeam = new(); - var model = new MatchdataMemberModel + foreach (var member in lobby.Members) { - user_id = member.UserID, - display_name = member.DisplayName, - slot_state = member.SlotState, - side = member.Side, - color = member.Color, - team = member.Team, - startpos = member.StartingPosition, - buildings_built = 0, - buildings_killed = 0, - buildings_lost = 0, - units_built = 0, - units_killed = 0, - units_lost = 0, - total_money = 0, - won = false + if (member.SlotState == EPlayerType.SLOT_OPEN || + member.SlotState == EPlayerType.SLOT_CLOSED) + continue; + + var model = new MatchdataMemberModel + { + user_id = member.UserID, + display_name = member.DisplayName, + slot_state = member.SlotState, + side = member.Side, + color = member.Color, + team = member.Team, + startpos = member.StartingPosition, + buildings_built = 0, + buildings_killed = 0, + buildings_lost = 0, + units_built = 0, + units_killed = 0, + units_lost = 0, + total_money = 0, + won = false + }; + + jsonSlots[member.SlotIndex] = JsonSerializer.Serialize(model); + + // Observers (side == -2) are not active players + if (model.side != -2) + { + if (playersPerTeam.ContainsKey(model.team)) + { + playersPerTeam[model.team]++; + } + else + { + playersPerTeam[model.team] = 1; + } + } + } + + // Determine roster type + string rosterType = ComputeRosterType(playersPerTeam); + + // Build EF entity + var entity = new MatchHistoryEntry + { + Owner = lobby.Owner, + Name = lobby.Name, + MapName = lobby.MapName, + MapPath = lobby.MapPath, + MapOfficial = lobby.IsMapOfficial, + MatchRosterType = rosterType, + VanillaTeams = lobby.IsVanillaTeamsOnly, + StartingCash = lobby.StartingCash, + LimitSuperweapons = lobby.IsLimitSuperweapons, + TrackStats = lobby.IsTrackingStats, + AllowObservers = lobby.AllowObservers, + MaxCamHeight = lobby.MaximumCameraHeight, + + MemberSlot0 = jsonSlots[0], + MemberSlot1 = jsonSlots[1], + MemberSlot2 = jsonSlots[2], + MemberSlot3 = jsonSlots[3], + MemberSlot4 = jsonSlots[4], + MemberSlot5 = jsonSlots[5], + MemberSlot6 = jsonSlots[6], + MemberSlot7 = jsonSlots[7] }; - jsonSlots[member.SlotIndex] = JsonSerializer.Serialize(model); - - playersSeen++; - - if (playersPerTeam.ContainsKey(model.team)) - playersPerTeam[model.team]++; - else - playersPerTeam[model.team] = 1; - } - - // Determine roster type - string rosterType = ComputeRosterType(playersSeen, playersPerTeam); - - // Build EF entity - var entity = new MatchHistoryEntry + // Add entity to DbSet + db.MatchHistory.Add(entity); + + // Save + await db.SaveChangesAsync(); + + ulong id = (ulong)entity.MatchId; + lobby.SetMatchID(id); + + return id; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CreatePlaceholderMatchHistory failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return 0; + } + } + + public static async Task DetermineLobbyWinnerIfNotPresent( + AppDbContext db, + GenOnlineService.Lobby lobby) + { + if (lobby == null || lobby.MatchID == 0) + return; + + try + { + // 1. Load all JSON slots + string?[]? slots = await _getAllMemberSlots(db, (long)lobby.MatchID); + if (slots == null) + return; + + // 2. Deserialize only non-null slots + Dictionary members = new(); + + for (int i = 0; i < 8; i++) + { + if (!string.IsNullOrEmpty(slots[i])) + { + MatchdataMemberModel? model = JsonSerializer.Deserialize(slots[i]!); + if (model != null) + members[i] = model.Value; + } + } + + // 3. Check if a winner already exists + bool hasWinner = false; + int winnerTeam = -1; + + foreach (var kv in members) + { + if (kv.Value.won) + { + hasWinner = true; + winnerTeam = kv.Value.team; + break; + } + } + + // 4. If winner exists, propagate to teammates and return + if (hasWinner) + { + if (winnerTeam != -1) + { + foreach (var kv in members) + { + if (kv.Value.team == winnerTeam) + { + await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); + } + } + } + + return; + } + + // 5. No winner — pick last player to leave + DateTime latestLeave = DateTime.UnixEpoch; + MatchdataMemberModel? lastPlayerNullable = null; + int lastSlot = -1; + + foreach (var kv in members) + { + var model = kv.Value; + + if (lobby.TimeMemberLeft.TryGetValue(model.user_id, out DateTime leftAt)) + { + if (leftAt >= latestLeave) + { + latestLeave = leftAt; + lastPlayerNullable = model; + lastSlot = kv.Key; + } + } + } + + if (lastPlayerNullable == null) + return; + + MatchdataMemberModel lastPlayer = lastPlayerNullable.Value; + int winningTeam = lastPlayer.team; + + // 6. Mark last player + teammates as winners + foreach (var kv in members) + { + var model = kv.Value; + + if (model.user_id == lastPlayer.user_id || + (winningTeam != -1 && model.team == winningTeam)) + { + await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] DetermineLobbyWinnerIfNotPresent failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + public static async Task UpdateMatchHistoryMakeWinner( + AppDbContext db, + ulong matchId, + int slotIndex) + { + if (matchId == 0 || slotIndex < 0 || slotIndex > 7) + return; + + try + { + // 1. Load the JSON for this slot + string? json = await _getMemberSlot(db, (long)matchId, slotIndex); + if (string.IsNullOrEmpty(json)) + return; + + // 2. Deserialize + MatchdataMemberModel? modelNullable = JsonSerializer.Deserialize(json); + if (modelNullable == null) + return; + + // 3. Update winner flag + MatchdataMemberModel model = modelNullable.Value; + model.won = true; + + // 4. Serialize back + string updatedJson = JsonSerializer.Serialize(model); + + // 5. Build setter expression + var setter = BuildWinnerSetter(slotIndex, updatedJson); + + // 6. Execute update (single SQL UPDATE) + await db.MatchHistory + .Where(m => m.MatchId == (long)matchId) + .ExecuteUpdateAsync(setter); + } + catch (Exception ex) { - Owner = lobby.Owner, - Name = lobby.Name, - MapName = lobby.MapName, - MapPath = lobby.MapPath, - MapOfficial = lobby.IsMapOfficial, - MatchRosterType = rosterType, - VanillaTeams = lobby.IsVanillaTeamsOnly, - StartingCash = lobby.StartingCash, - LimitSuperweapons = lobby.IsLimitSuperweapons, - TrackStats = lobby.IsTrackingStats, - AllowObservers = lobby.AllowObservers, - MaxCamHeight = lobby.MaximumCameraHeight, - - MemberSlot0 = jsonSlots[0], - MemberSlot1 = jsonSlots[1], - MemberSlot2 = jsonSlots[2], - MemberSlot3 = jsonSlots[3], - MemberSlot4 = jsonSlots[4], - MemberSlot5 = jsonSlots[5], - MemberSlot6 = jsonSlots[6], - MemberSlot7 = jsonSlots[7] - }; - - // Precompiled Add() - await _insertMatch(db, entity); - - // Save - await db.SaveChangesAsync(); - - ulong id = (ulong)entity.MatchId; - lobby.SetMatchID(id); - - return id; + Console.WriteLine($"[ERROR] UpdateMatchHistoryMakeWinner failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task DetermineLobbyWinnerIfNotPresent( - AppDbContext db, - GenOnlineService.Lobby lobby) - { - if (lobby == null || lobby.MatchID == 0) - return; - - // 1. Load all JSON slots - string?[]? slots = await _getAllMemberSlots(db, (long)lobby.MatchID); - if (slots == null) - return; - - // 2. Deserialize only non-null slots - Dictionary members = new(); - - for (int i = 0; i < 8; i++) - { - if (!string.IsNullOrEmpty(slots[i])) - { - MatchdataMemberModel? model = JsonSerializer.Deserialize(slots[i]!); - if (model != null) - members[i] = model.Value; - } - } - - // 3. Check if a winner already exists - bool hasWinner = false; - int winnerTeam = -1; - - foreach (var kv in members) - { - if (kv.Value.won) - { - hasWinner = true; - winnerTeam = kv.Value.team; - break; - } - } - - // 4. If winner exists, propagate to teammates - if (hasWinner && winnerTeam != -1) - { - foreach (var kv in members) - { - if (kv.Value.team == winnerTeam) - { - await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); - } - } - - return; - } - - // 5. No winner — pick last player to leave - DateTime latestLeave = DateTime.UnixEpoch; - MatchdataMemberModel? lastPlayerNullable = null; - int lastSlot = -1; - - foreach (var kv in members) - { - var model = kv.Value; - - if (lobby.TimeMemberLeft.TryGetValue(model.user_id, out DateTime leftAt)) - { - if (leftAt >= latestLeave) - { - latestLeave = leftAt; - lastPlayerNullable = model; - lastSlot = kv.Key; - } - } - } - - if (lastPlayerNullable == null) - return; - - MatchdataMemberModel lastPlayer = lastPlayerNullable.Value; - int winningTeam = lastPlayer.team; - - // 6. Mark last player + teammates as winners - foreach (var kv in members) - { - var model = kv.Value; - - if (model.user_id == lastPlayer.user_id || - (winningTeam != -1 && model.team == winningTeam)) - { - await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); - } - } - } - - public static async Task UpdateMatchHistoryMakeWinner( - AppDbContext db, - ulong matchId, - int slotIndex) - { - if (matchId == 0 || slotIndex < 0 || slotIndex > 7) - return; - - // 1. Load the JSON for this slot - string? json = await _getMemberSlot(db, (long)matchId, slotIndex); - if (string.IsNullOrEmpty(json)) - return; - - // 2. Deserialize - MatchdataMemberModel? modelNullable = JsonSerializer.Deserialize(json); - if (modelNullable == null) - return; - - // 3. Update winner flag - MatchdataMemberModel model = modelNullable.Value; - model.won = true; - - // 4. Serialize back - string updatedJson = JsonSerializer.Serialize(model); - - // 5. Build setter expression - var setter = BuildWinnerSetter(slotIndex, updatedJson); - - // 6. Execute update (single SQL UPDATE) - await db.MatchHistory - .Where(m => m.MatchId == (long)matchId) - .ExecuteUpdateAsync(setter); - } - public static async Task GetMatchesInRange( @@ -647,13 +664,29 @@ public static async Task GetMatchesInRange( { MatchHistoryCollection collection = new(); - await foreach (var entry in _getMatchesInRange(db, startID, endID)) - { - // Load JSON members (optional optimization below) - var entity = await db.MatchHistory - .Where(m => m.MatchId == entry.match_id) + try + { + // Single query fetches metadata + all slot columns — no concurrent reader issue. + var rows = await db.MatchHistory + .Where(m => m.MatchId >= startID && m.MatchId <= endID && m.Finished) .Select(m => new { + m.MatchId, + m.Owner, + m.Name, + m.Finished, + m.Started, + m.TimeFinished, + m.MapName, + m.MapPath, + m.MatchRosterType, + m.MapOfficial, + m.VanillaTeams, + m.StartingCash, + m.LimitSuperweapons, + m.TrackStats, + m.AllowObservers, + m.MaxCamHeight, m.MemberSlot0, m.MemberSlot1, m.MemberSlot2, @@ -663,19 +696,45 @@ public static async Task GetMatchesInRange( m.MemberSlot6, m.MemberSlot7 }) - .FirstAsync(); - - // Deserialize only if not null - AddMemberIfNotNull(entry, entity.MemberSlot0); - AddMemberIfNotNull(entry, entity.MemberSlot1); - AddMemberIfNotNull(entry, entity.MemberSlot2); - AddMemberIfNotNull(entry, entity.MemberSlot3); - AddMemberIfNotNull(entry, entity.MemberSlot4); - AddMemberIfNotNull(entry, entity.MemberSlot5); - AddMemberIfNotNull(entry, entity.MemberSlot6); - AddMemberIfNotNull(entry, entity.MemberSlot7); - - collection.matches.Add(entry); + .ToListAsync(); + + foreach (var row in rows) + { + 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); + + collection.matches.Add(entry); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetMatchesInRange failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return collection; @@ -695,296 +754,330 @@ private static void AddMemberIfNotNull(MatchHistory_Entry entry, string? json) public static async Task GetHighestMatchID(AppDbContext db) { - long? result = await _getHighestMatchId(db); - return result ?? -1; - } - - // Called when a lobby is deleted, thats the true end of a match - public static async Task CommitLobbyToMatchHistory(AppDbContext db, GenOnlineService.Lobby lobby) - { - if (lobby.MatchID == 0) - return; - - await db.MatchHistory - .Where(m => m.MatchId == (long)lobby.MatchID && !m.Finished) - .ExecuteUpdateAsync(s => s - .SetProperty(m => m.Finished, true) - .SetProperty(m => m.TimeFinished, DateTime.UtcNow)); - } - - // METADATA - private static Expression, SetPropertyCalls>> - BuildSlotSetter(int slotIndex, string updatedJson) - { - var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); - - var call = Expression.Call( - param, - nameof(SetPropertyCalls.SetProperty), - typeArguments: null, - arguments: new Expression[] - { - _slotSelectors[slotIndex], - Expression.Constant(updatedJson, typeof(string)) - } - ); - - return Expression.Lambda, SetPropertyCalls>>( - call, - param - ); - } - - - public static async Task AttachMatchHistoryMetadata( - AppDbContext db, - ulong matchId, - int slotIndex, - string fileName, - EMetadataFileType fileType) - { - if (matchId == 0 || slotIndex < 0 || slotIndex > 7) - return; - - // 1. Load JSON for this slot - string? json = await _getMemberSlot(db, (long)matchId, slotIndex); - if (string.IsNullOrEmpty(json)) - return; - - // 2. Deserialize - MatchdataMemberModel? modelN = JsonSerializer.Deserialize(json); - if (modelN == null) - return; - - MatchdataMemberModel model = modelN.Value; - - // 3. Ensure metadata list exists - model.metadata ??= new List(); - - // 4. Append metadata entry - model.metadata.Add(new MemberMetadataModel - { - file_name = fileName, - file_type = (EMetadataFileType)fileType - }); - - // 5. Serialize back - string updatedJson = JsonSerializer.Serialize(model); - - // 6. Build setter expression - var setter = BuildSlotSetter(slotIndex, updatedJson); - - // 7. Execute update (single SQL UPDATE) - await db.MatchHistory - .Where(m => m.MatchId == (long)matchId) - .ExecuteUpdateAsync(setter); - } - - // ELO - public static async Task UpdateLeaderboardAndElo( - AppDbContext db, - GenOnlineService.Lobby lobby) - { - if (lobby.LobbyType != ELobbyType.QuickMatch) - return; - - int dayOfYear = lobby.TimeCreated.DayOfYear; - int monthOfYear = lobby.TimeCreated.Month; - int year = lobby.TimeCreated.Year; - - var members = await LoadMatchMembersAsync(db, (long)lobby.MatchID); - if (members.Count == 0) - return; - - await UpdateCurrentEloAsync(db, members); - await UpdatePeriodEloAndLeaderboardsAsync( - db, members, dayOfYear, monthOfYear, year); - } - private static async Task> LoadMatchMembersAsync( - AppDbContext db, long matchId) - { - var slots = await _getAllMemberSlots(db, matchId); - var list = new List(); - - if (slots == null) - return list; - - for (int i = 0; i < slots.Length; i++) - { - if (!string.IsNullOrEmpty(slots[i])) - { - MatchdataMemberModel? model = JsonSerializer.Deserialize(slots[i]!); - if (model != null) - list.Add(model.Value); - } - } - - return list; - } - - private static async Task UpdateCurrentEloAsync( - AppDbContext db, - List members) - { - var userIds = members.Select(m => (long)m.user_id).ToList(); - var dictElo = await Database.Users.GetBulkELOData(db, userIds); - - // --- ELO pairwise loop (ref-safe) --- - foreach (var a in members) - { - foreach (var b in members) - { - if (a.user_id == b.user_id) - continue; - - if (b.team == a.team && a.team != -1) - continue; - - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault( - dictElo, a.user_id, out _); - - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault( - dictElo, b.user_id, out _); - - Elo.ApplyResult( - ref A, - ref B, - a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - - // --- Increment matches (still ref-safe) --- - foreach (var m in members) - { - ref EloData data = ref CollectionsMarshal.GetValueRefOrAddDefault( - dictElo, m.user_id, out _); - data.NumMatches++; - } - - // --- Persist (copy out of ref before EF) --- - foreach (var pair in dictElo) - { - long userId = pair.Key; - EloData data = pair.Value; // <-- COPY OUT OF REF HERE - - // Update live user if online - var shared = GenOnlineService.WebSocketManager.GetSharedDataForUser(userId); - if (shared != null) - { - shared.GameStats.EloRating = data.Rating; - shared.GameStats.EloMatches = data.NumMatches; - } - - // EF Core persistence (no ref locals allowed) - await Database.Users.SaveELOData(db, userId, data); - } - } - - - private static async Task UpdatePeriodEloAndLeaderboardsAsync( - AppDbContext db, - List members, - int dayOfYear, - int monthOfYear, - int year) - { - var userIds = members.Select(m => (long)m.user_id).ToList(); - var bulk = await Database.Leaderboards.GetBulkLeaderboardData( - db, userIds, dayOfYear, monthOfYear, year); - - var daily = new Dictionary(); - var monthly = new Dictionary(); - var yearly = new Dictionary(); - - // Initialize from DB - foreach (var m in members) - { - var lb = bulk[m.user_id]; - daily[m.user_id] = new EloData(lb.daily, lb.daily_matches); - monthly[m.user_id] = new EloData(lb.monthly, lb.monthly_matches); - yearly[m.user_id] = new EloData(lb.yearly, lb.yearly_matches); - } - - // --- Pairwise ELO (ref-safe) --- - foreach (var a in members) - { - foreach (var b in members) - { - if (a.user_id == b.user_id) - continue; - - if (b.team == a.team && a.team != -1) - continue; - - // Daily - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - - // Monthly - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - - // Yearly - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - } - - // --- Persist (copy out of ref before EF) --- - foreach (var m in members) - { - long userId = m.user_id; - - EloData d = daily[userId]; // <-- COPY OUT OF REF - EloData mo = monthly[userId]; - EloData y = yearly[userId]; - - int wins = m.won ? 1 : 0; - int losses = m.won ? 0 : 1; - - // Daily - await db.LeaderboardDaily - .Where(x => x.UserId == userId && - x.DayOfYear == dayOfYear && - x.Year == year) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.Points, d.Rating) - .SetProperty(x => x.Wins, x => x.Wins + wins) - .SetProperty(x => x.Losses, x => x.Losses + losses)); - - // Monthly - await db.LeaderboardMonthly - .Where(x => x.UserId == userId && - x.MonthOfYear == monthOfYear && - x.Year == year) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.Points, mo.Rating) - .SetProperty(x => x.Wins, x => x.Wins + wins) - .SetProperty(x => x.Losses, x => x.Losses + losses)); - - // Yearly - await db.LeaderboardYearly - .Where(x => x.UserId == userId && - x.Year == year) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.Points, y.Rating) - .SetProperty(x => x.Wins, x => x.Wins + wins) - .SetProperty(x => x.Losses, x => x.Losses + losses)); - } - } - - - - - + try + { + long? result = await _getHighestMatchId(db); + return result ?? -1; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetHighestMatchID failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return -1; + } + } + + // Called when a lobby is deleted, thats the true end of a match + public static async Task CommitLobbyToMatchHistory(AppDbContext db, GenOnlineService.Lobby lobby) + { + if (lobby.MatchID == 0) + return; + + try + { + await db.MatchHistory + .Where(m => m.MatchId == (long)lobby.MatchID && !m.Finished) + .ExecuteUpdateAsync(s => s + .SetProperty(m => m.Finished, true) + .SetProperty(m => m.TimeFinished, DateTime.UtcNow)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CommitLobbyToMatchHistory failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + // METADATA + private static Expression, SetPropertyCalls>> + BuildSlotSetter(int slotIndex, string updatedJson) + { + return slotIndex switch + { + 0 => s => s.SetProperty(m => m.MemberSlot0, updatedJson), + 1 => s => s.SetProperty(m => m.MemberSlot1, updatedJson), + 2 => s => s.SetProperty(m => m.MemberSlot2, updatedJson), + 3 => s => s.SetProperty(m => m.MemberSlot3, updatedJson), + 4 => s => s.SetProperty(m => m.MemberSlot4, updatedJson), + 5 => s => s.SetProperty(m => m.MemberSlot5, updatedJson), + 6 => s => s.SetProperty(m => m.MemberSlot6, updatedJson), + 7 => s => s.SetProperty(m => m.MemberSlot7, updatedJson), + _ => throw new ArgumentOutOfRangeException(nameof(slotIndex)) + }; + } + + + public static async Task AttachMatchHistoryMetadata( + AppDbContext db, + ulong matchId, + int slotIndex, + string fileName, + EMetadataFileType fileType) + { + if (matchId == 0 || slotIndex < 0 || slotIndex > 7) + return; + + try + { + // 1. Load JSON for this slot + string? json = await _getMemberSlot(db, (long)matchId, slotIndex); + if (string.IsNullOrEmpty(json)) + return; + + // 2. Deserialize + MatchdataMemberModel? modelN = JsonSerializer.Deserialize(json); + if (modelN == null) + return; + + MatchdataMemberModel model = modelN.Value; + + // 3. Ensure metadata list exists + model.metadata ??= new List(); + + // 4. Append metadata entry + model.metadata.Add(new MemberMetadataModel + { + file_name = fileName, + file_type = (EMetadataFileType)fileType + }); + + // 5. Serialize back + string updatedJson = JsonSerializer.Serialize(model); + + // 6. Build setter expression + var setter = BuildSlotSetter(slotIndex, updatedJson); + + // 7. Execute update (single SQL UPDATE) + await db.MatchHistory + .Where(m => m.MatchId == (long)matchId) + .ExecuteUpdateAsync(setter); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] AttachMatchHistoryMetadata failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + + // ELO + public static async Task UpdateLeaderboardAndElo( + AppDbContext db, + GenOnlineService.Lobby lobby) + { + if (lobby.LobbyType != ELobbyType.QuickMatch) + return; + + try + { + int dayOfYear = lobby.TimeCreated.DayOfYear; + int monthOfYear = lobby.TimeCreated.Month; + int year = lobby.TimeCreated.Year; + + var members = await LoadMatchMembersAsync(db, (long)lobby.MatchID); + if (members.Count == 0) + return; + + await UpdateCurrentEloAsync(db, members); + await UpdatePeriodEloAndLeaderboardsAsync( + db, members, dayOfYear, monthOfYear, year); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] UpdateLeaderboardAndElo failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + } + private static async Task> LoadMatchMembersAsync( + AppDbContext db, long matchId) + { + var slots = await _getAllMemberSlots(db, matchId); + var list = new List(); + + if (slots == null) + return list; + + for (int i = 0; i < slots.Length; i++) + { + if (!string.IsNullOrEmpty(slots[i])) + { + MatchdataMemberModel? model = JsonSerializer.Deserialize(slots[i]!); + if (model != null) + list.Add(model.Value); + } + } + + return list; + } + + private static async Task UpdateCurrentEloAsync( + AppDbContext db, + List members) + { + var userIds = members.Select(m => (long)m.user_id).ToList(); + var dictElo = await Database.Users.GetBulkELOData(db, userIds); + + // --- ELO pairwise loop (ref-safe) --- + foreach (var a in members) + { + foreach (var b in members) + { + if (a.user_id == b.user_id) + continue; + + if (b.team == a.team && a.team != -1) + continue; + + if (a.user_id >= b.user_id) + continue; + + ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault( + dictElo, a.user_id, out _); + + ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault( + dictElo, b.user_id, out _); + + Elo.ApplyResult( + ref A, + ref B, + a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); + } + } + + // --- Increment matches (still ref-safe) --- + foreach (var m in members) + { + ref EloData data = ref CollectionsMarshal.GetValueRefOrAddDefault( + dictElo, m.user_id, out _); + data.NumMatches++; + } + + // --- Persist (copy out of ref before EF) --- + foreach (var pair in dictElo) + { + long userId = pair.Key; + EloData data = pair.Value; // <-- COPY OUT OF REF HERE + + // Update live user if online + var shared = GenOnlineService.WebSocketManager.GetSharedDataForUser(userId); + if (shared != null) + { + shared.GameStats.EloRating = data.Rating; + shared.GameStats.EloMatches = data.NumMatches; + } + + // EF Core persistence (no ref locals allowed) + await Database.Users.SaveELOData(db, userId, data); + } + } + + + private static async Task UpdatePeriodEloAndLeaderboardsAsync( + AppDbContext db, + List members, + int dayOfYear, + int monthOfYear, + int year) + { + var userIds = members.Select(m => (long)m.user_id).ToList(); + var bulk = await Database.Leaderboards.GetBulkLeaderboardData( + db, userIds, dayOfYear, monthOfYear, year); + + var daily = new Dictionary(); + var monthly = new Dictionary(); + var yearly = new Dictionary(); + + // Initialize from DB + foreach (var m in members) + { + var lb = bulk[m.user_id]; + daily[m.user_id] = new EloData(lb.daily, lb.daily_matches); + monthly[m.user_id] = new EloData(lb.monthly, lb.monthly_matches); + yearly[m.user_id] = new EloData(lb.yearly, lb.yearly_matches); + } + + // --- Pairwise ELO (ref-safe) --- + foreach (var a in members) + { + foreach (var b in members) + { + if (a.user_id == b.user_id) + continue; + + if (b.team == a.team && a.team != -1) + continue; + + if (a.user_id >= b.user_id) + continue; + + // Daily + { + ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, a.user_id, out _); + ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, b.user_id, out _); + Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); + } + + // Monthly + { + ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, a.user_id, out _); + ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, b.user_id, out _); + Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); + } + + // Yearly + { + ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, a.user_id, out _); + ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, b.user_id, out _); + Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); + } + } + } + + // --- Persist (copy out of ref before EF) --- + foreach (var m in members) + { + long userId = m.user_id; + + EloData d = daily[userId]; // <-- COPY OUT OF REF + EloData mo = monthly[userId]; + EloData y = yearly[userId]; + + int wins = m.won ? 1 : 0; + int losses = m.won ? 0 : 1; + + // Daily + await db.LeaderboardDaily + .Where(x => x.UserId == userId && + x.DayOfYear == dayOfYear && + x.Year == year) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Points, d.Rating) + .SetProperty(x => x.Wins, x => x.Wins + wins) + .SetProperty(x => x.Losses, x => x.Losses + losses)); + + // Monthly + await db.LeaderboardMonthly + .Where(x => x.UserId == userId && + x.MonthOfYear == monthOfYear && + x.Year == year) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Points, mo.Rating) + .SetProperty(x => x.Wins, x => x.Wins + wins) + .SetProperty(x => x.Losses, x => x.Losses + losses)); + + // Yearly + await db.LeaderboardYearly + .Where(x => x.UserId == userId && + x.Year == year) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Points, y.Rating) + .SetProperty(x => x.Wins, x => x.Wins + wins) + .SetProperty(x => x.Losses, x => x.Losses + losses)); + } + } + + + + + } -} \ No newline at end of file +} diff --git a/GenOnlineService/Database/Database.PlayerStats.cs b/GenOnlineService/Database/Database.PlayerStats.cs index 8a40383..7b97634 100644 --- a/GenOnlineService/Database/Database.PlayerStats.cs +++ b/GenOnlineService/Database/Database.PlayerStats.cs @@ -78,91 +78,117 @@ public static class UserStats .FirstOrDefault() ); - public static async Task GetPlayerStats( - AppDbContext db, - long userId) - { - // Load ELO (already EF-based) - EloData elo = await Database.Users.GetELOData(db, userId); - - PlayerStats ps = new PlayerStats(userId, elo.Rating, elo.NumMatches); - - // Load stats JSON via EF - string? json = await _getUserStatsJson(db, userId); - - if (string.IsNullOrEmpty(json)) - return ps; // no stats row → return ELO-only stats - - // Deserialize dictionary - Dictionary? dict = - JsonSerializer.Deserialize>(json); - - if (dict == null) - return ps; - - // Feed into PlayerStats - foreach (var kv in dict) - { - EStatIndex statId = (EStatIndex)kv.Key; - int statValue = kv.Value; - - ps.ProcessFromDB(statId, statValue); - } - - return ps; + public static async Task GetPlayerStats( + AppDbContext db, + long userId) + { + EloData elo; + try + { + // Load ELO (already EF-based) + elo = await Database.Users.GetELOData(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return new PlayerStats(userId, EloConfig.BaseRating, 0); + } + + PlayerStats ps = new PlayerStats(userId, elo.Rating, elo.NumMatches); + + try + { + // Load stats JSON via EF + string? json = await _getUserStatsJson(db, userId); + + if (string.IsNullOrEmpty(json)) + return ps; // no stats row → return ELO-only stats + + // Deserialize dictionary + Dictionary? dict = + JsonSerializer.Deserialize>(json); + + if (dict == null) + return ps; + + // Feed into PlayerStats + foreach (var kv in dict) + { + EStatIndex statId = (EStatIndex)kv.Key; + int statValue = kv.Value; + + ps.ProcessFromDB(statId, statValue); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return ps; } - public static async Task UpdatePlayerStat( - AppDbContext db, - long userId, - int statId, - int statVal) - { - // 1. Load existing JSON (if any) - string? json = await _getUserStats(db, userId); - - Dictionary stats; - - if (string.IsNullOrEmpty(json)) - { - // No row exists → create new dictionary - stats = new Dictionary(); - } - else - { - // Deserialize existing stats - stats = JsonSerializer.Deserialize>(json) - ?? new Dictionary(); - } - - // 2. Update the stat - stats[statId.ToString()] = statVal; - - // 3. Serialize back - string updatedJson = JsonSerializer.Serialize(stats); - - // 4. Check if row exists - bool exists = json != null; - - if (!exists) - { - // INSERT - db.UserStats.Add(new UserStatsEntry - { - UserId = userId, - Stats = updatedJson - }); - - await db.SaveChangesAsync(); - return; - } - - // 5. UPDATE using ExecuteUpdateAsync (fast, no tracking) - await db.UserStats - .Where(s => s.UserId == userId) - .ExecuteUpdateAsync(s => s - .SetProperty(x => x.Stats, updatedJson)); + public static async Task UpdatePlayerStat( + AppDbContext db, + long userId, + int statId, + int statVal) + { + try + { + // 1. Load existing JSON (if any) + string? json = await _getUserStats(db, userId); + + Dictionary stats; + + if (string.IsNullOrEmpty(json)) + { + // No row exists → create new dictionary + stats = new Dictionary(); + } + else + { + // Deserialize existing stats + stats = JsonSerializer.Deserialize>(json) + ?? new Dictionary(); + } + + // 2. Update the stat + stats[statId.ToString()] = statVal; + + // 3. Serialize back + string updatedJson = JsonSerializer.Serialize(stats); + + // 4. Check if row exists + bool exists = json != null; + + if (!exists) + { + // INSERT + db.UserStats.Add(new UserStatsEntry + { + UserId = userId, + Stats = updatedJson + }); + + await db.SaveChangesAsync(); + return; + } + + // 5. UPDATE using ExecuteUpdateAsync (fast, no tracking) + await db.UserStats + .Where(s => s.UserId == userId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.Stats, updatedJson)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] UpdatePlayerStat failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs index 8251626..44ef93b 100644 --- a/GenOnlineService/Database/Database.ServiceStats.cs +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -76,39 +76,47 @@ public static async Task CommitStats( int player_peak, int lobbies_peak) { - // UPSERT logic using precompiled query - var existing = await FindStatTracked(db, day_of_year, hour_of_day); - - if (existing == null) + try { - // Insert new - var stat = new ServiceStat - { - DayOfYear = day_of_year, - HourOfDay = hour_of_day, - PlayerPeak = player_peak, - LobbiesPeak = lobbies_peak - }; + // UPSERT logic using precompiled query + var existing = await FindStatTracked(db, day_of_year, hour_of_day); - db.ServiceStats.Add(stat); - } - else - { - // Update using GREATEST() semantics - existing.PlayerPeak = Math.Max(existing.PlayerPeak, player_peak); - existing.LobbiesPeak = Math.Max(existing.LobbiesPeak, lobbies_peak); - } + if (existing == null) + { + // Insert new + var stat = new ServiceStat + { + DayOfYear = day_of_year, + HourOfDay = hour_of_day, + PlayerPeak = player_peak, + LobbiesPeak = lobbies_peak + }; + + db.ServiceStats.Add(stat); + } + else + { + // Update using GREATEST() semantics + existing.PlayerPeak = Math.Max(existing.PlayerPeak, player_peak); + existing.LobbiesPeak = Math.Max(existing.LobbiesPeak, lobbies_peak); + } - // NOTE: duplicate, unnecessary - //await db.SaveChangesAsync(); + // NOTE: duplicate, unnecessary + //await db.SaveChangesAsync(); - // DELETE old rows (precompiled) - int cutoff = day_of_year - 30; + // DELETE old rows (precompiled) + int cutoff = day_of_year - 30; - await foreach (var old in FindOldStats(db, cutoff)) - db.ServiceStats.Remove(old); + await foreach (var old in FindOldStats(db, cutoff)) + db.ServiceStats.Remove(old); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CommitStats failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } diff --git a/GenOnlineService/Database/Database.Social.cs b/GenOnlineService/Database/Database.Social.cs index 3a08e62..887a79c 100644 --- a/GenOnlineService/Database/Database.Social.cs +++ b/GenOnlineService/Database/Database.Social.cs @@ -118,97 +118,169 @@ public static class Social - public static async Task> GetFriends(AppDbContext db, long userId) - { - HashSet result = new(); - - await foreach (var f in _getFriends(db, userId)) - { - result.Add(f.UserId1 == userId ? f.UserId2 : f.UserId1); - } - - return result; + public static async Task> GetFriends(AppDbContext db, long userId) + { + HashSet result = new(); + + try + { + await foreach (var f in _getFriends(db, userId)) + { + result.Add(f.UserId1 == userId ? f.UserId2 : f.UserId1); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetFriends failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return result; } - public static async Task> GetBlocked(AppDbContext db, long sourceUserId) - { - HashSet result = new(); - - await foreach (var id in _getBlocked(db, sourceUserId)) - result.Add(id); - - return result; + public static async Task> GetBlocked(AppDbContext db, long sourceUserId) + { + HashSet result = new(); + + try + { + await foreach (var id in _getBlocked(db, sourceUserId)) + result.Add(id); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetBlocked failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return result; } - public static async Task> GetPendingFriendsRequests(AppDbContext db, long targetUserId) - { - HashSet result = new(); - - await foreach (var id in _getPendingRequests(db, targetUserId)) - result.Add(id); - - return result; + public static async Task> GetPendingFriendsRequests(AppDbContext db, long targetUserId) + { + HashSet result = new(); + + try + { + await foreach (var id in _getPendingRequests(db, targetUserId)) + result.Add(id); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetPendingFriendsRequests failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return result; } - public static async Task RemovePendingFriendRequest(AppDbContext db, long sourceUserId, long targetUserId) - { - await db.FriendRequests - .Where(r => - (r.SourceUserId == sourceUserId && r.TargetUserId == targetUserId) || - (r.SourceUserId == targetUserId && r.TargetUserId == sourceUserId)) - .ExecuteDeleteAsync(); + public static async Task RemovePendingFriendRequest(AppDbContext db, long sourceUserId, long targetUserId) + { + try + { + await db.FriendRequests + .Where(r => + (r.SourceUserId == sourceUserId && r.TargetUserId == targetUserId) || + (r.SourceUserId == targetUserId && r.TargetUserId == sourceUserId)) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] RemovePendingFriendRequest failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task CreateFriendship(AppDbContext db, long userId1, long userId2) - { - db.Friends.Add(new FriendEntry - { - UserId1 = userId1, - UserId2 = userId2 - }); - - await db.SaveChangesAsync(); + public static async Task CreateFriendship(AppDbContext db, long userId1, long userId2) + { + try + { + db.Friends.Add(new FriendEntry + { + UserId1 = userId1, + UserId2 = userId2 + }); + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CreateFriendship failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task RemoveFriendship(AppDbContext db, long userId1, long userId2) - { - await db.Friends - .Where(f => - (f.UserId1 == userId1 && f.UserId2 == userId2) || - (f.UserId1 == userId2 && f.UserId2 == userId1)) - .ExecuteDeleteAsync(); + public static async Task RemoveFriendship(AppDbContext db, long userId1, long userId2) + { + try + { + await db.Friends + .Where(f => + (f.UserId1 == userId1 && f.UserId2 == userId2) || + (f.UserId1 == userId2 && f.UserId2 == userId1)) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] RemoveFriendship failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task AddBlock(AppDbContext db, long sourceUserId, long targetUserId) - { - db.BlockedUsers.Add(new BlockedUserEntry - { - SourceUserId = sourceUserId, - TargetUserId = targetUserId - }); - - await db.SaveChangesAsync(); + public static async Task AddBlock(AppDbContext db, long sourceUserId, long targetUserId) + { + try + { + db.BlockedUsers.Add(new BlockedUserEntry + { + SourceUserId = sourceUserId, + TargetUserId = targetUserId + }); + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] AddBlock failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task RemoveBlock(AppDbContext db, long sourceUserId, long targetUserId) - { - await db.BlockedUsers - .Where(b => b.SourceUserId == sourceUserId && b.TargetUserId == targetUserId) - .ExecuteDeleteAsync(); + public static async Task RemoveBlock(AppDbContext db, long sourceUserId, long targetUserId) + { + try + { + await db.BlockedUsers + .Where(b => b.SourceUserId == sourceUserId && b.TargetUserId == targetUserId) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] RemoveBlock failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } - public static async Task AddPendingFriendRequest(AppDbContext db, long sourceUserId, long targetUserId) - { - db.FriendRequests.Add(new FriendRequestEntry - { - SourceUserId = sourceUserId, - TargetUserId = targetUserId - }); - - await db.SaveChangesAsync(); + public static async Task AddPendingFriendRequest(AppDbContext db, long sourceUserId, long targetUserId) + { + try + { + db.FriendRequests.Add(new FriendRequestEntry + { + SourceUserId = sourceUserId, + TargetUserId = targetUserId + }); + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] AddPendingFriendRequest failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8b44101..357db02 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -50,26 +50,26 @@ public class User public EAccountType AccountType { get; set; } = EAccountType.Unknown; // Steam, only present if AccountType is Steam - public Int64 SteamID { get; set; } = -1; + public Int64? SteamID { get; set; } = -1; // Discord, only present if AccountType is Discord - public Int64 DiscordID { get; set; } = -1; - public string DiscordUsername { get; set; } = String.Empty; + public Int64? DiscordID { get; set; } = -1; + public string? DiscordUsername { get; set; } = String.Empty; // GameReplays, only present if AccountType is GameReplays - public Int64 GameReplaysID { get; set; } = -1; - public string GameReplaysUsername { get; set; } = String.Empty; + public Int64? GameReplaysID { get; set; } = -1; + public string? GameReplaysUsername { get; set; } = String.Empty; - public string DisplayName { get; set; } = ""; + public string? DisplayName { get; set; } = ""; public DateTime LastLogin { get; set; } = DateTime.UnixEpoch; - public string LastIPAddress { get; set; } = String.Empty; - public int ClientID { get; set; } = -1; + public string? LastIPAddress { get; set; } = String.Empty; + public KnownClients.EKnownClients ClientID { get; set; } = KnownClients.EKnownClients.custom_third_party_client; // Gameplay Favorites public int FavoriteColor { get; set; } = -1; public int FavoriteSide { get; set; } = -1; - public string FavoriteMap { get; set; } = String.Empty; + public string? FavoriteMap { get; set; } = String.Empty; public int FavoriteStartingMoney { get; set; } = -1; public bool LimitSuperweapons { get; set; } = false; @@ -82,10 +82,10 @@ public class User public int EloNumberOfMatches { get; set; } = 0; // Bans - public string BanReason { get; set; } = String.Empty; - public string BannedBy { get; set; } = String.Empty; - public string BanVerifiedBy { get; set; } = String.Empty; - public string BanAliases { get; set; } = String.Empty; + public string? BanReason { get; set; } = String.Empty; + public string? BannedBy { get; set; } = String.Empty; + public string? BanVerifiedBy { get; set; } = String.Empty; + public string? BanAliases { get; set; } = String.Empty; } public class UserLobbyPreferences @@ -174,21 +174,30 @@ namespace Database { public static class PendingLogins { - private static readonly Func> _getPendingLoginState = - EF.CompileAsyncQuery( - (AppDbContext db, string code) => - db.PendingLogins - .Where(p => p.LoginCode == code) - .Select(p => (EPendingLoginState?)p.State) - .FirstOrDefault() - ); - - public static async Task GetPendingLoginState(AppDbContext db, string gameCode) - { - string code = gameCode.ToUpper(); - return await _getPendingLoginState(db, code); - } - + private static readonly Func> _getPendingLoginState = + EF.CompileAsyncQuery( + (AppDbContext db, string code) => + db.PendingLogins + .Where(p => p.LoginCode == code) + .Select(p => (EPendingLoginState?)p.State) + .FirstOrDefault() + ); + + public static async Task GetPendingLoginState(AppDbContext db, string gameCode) + { + try + { + string code = gameCode.ToUpper(); + return await _getPendingLoginState(db, code); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetPendingLoginState failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } + } + private static readonly Func> GetUserIdFromCode = @@ -202,31 +211,56 @@ public static class PendingLogins public static async Task Cleanup(AppDbContext db, bool startup) { - TimeSpan threshold = startup ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(5); + try + { + TimeSpan threshold = startup ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(5); - DateTime cutoff = DateTime.UtcNow - threshold; + DateTime cutoff = DateTime.UtcNow - threshold; - await db.PendingLogins - .Where(p => p.Created <= cutoff) - .ExecuteDeleteAsync(); + await db.PendingLogins + .Where(p => p.Created <= cutoff) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] PendingLogins.Cleanup failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task GetUserIDFromPendingLogin(AppDbContext db, string gameCode) { - gameCode = gameCode.ToUpper(); + try + { + gameCode = gameCode.ToUpper(); - var result = await GetUserIdFromCode(db, gameCode); + var result = await GetUserIdFromCode(db, gameCode); - return result ?? -1; + return result ?? -1; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserIDFromPendingLogin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return -1; + } } public static async Task CleanupPendingLogin(AppDbContext db, string gameCode) { - gameCode = gameCode.ToUpper(); + try + { + gameCode = gameCode.ToUpper(); - await db.PendingLogins - .Where(p => p.LoginCode == gameCode) - .ExecuteDeleteAsync(); + await db.PendingLogins + .Where(p => p.LoginCode == gameCode) + .ExecuteDeleteAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] CleanupPendingLogin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } @@ -251,36 +285,44 @@ public static async Task RegisterUserDevice( string hwid_2, string ipAddr) { - // raw versions - string hwid_3 = hwid_0.ToUpper(); - string hwid_4 = hwid_1.ToUpper(); - string hwid_5 = hwid_2.ToUpper(); - - // hashed versions - string h0 = Helpers.ComputeMD5Hash(hwid_0).ToUpper(); - string h1 = Helpers.ComputeMD5Hash(hwid_1).ToUpper(); - string h2 = Helpers.ComputeMD5Hash(hwid_2).ToUpper(); - - // check if exists (precompiled query) - var existing = await FindDevice(db, userId, h0, h1, h2); - if (existing != null) - return; - - // insert new (if doesnt exist) - var device = new UserDevice - { - UserID = userId, - HWID_0 = h0, - HWID_1 = h1, - HWID_2 = h2, - HWID_3 = hwid_3, - HWID_4 = hwid_4, - HWID_5 = hwid_5, - IPAddress = ipAddr - }; - - db.UserDevices.Add(device); - await db.SaveChangesAsync(); + try + { + // raw versions + string hwid_3 = hwid_0.ToUpper(); + string hwid_4 = hwid_1.ToUpper(); + string hwid_5 = hwid_2.ToUpper(); + + // hashed versions + string h0 = Helpers.ComputeMD5Hash(hwid_0).ToUpper(); + string h1 = Helpers.ComputeMD5Hash(hwid_1).ToUpper(); + string h2 = Helpers.ComputeMD5Hash(hwid_2).ToUpper(); + + // check if exists (precompiled query) + var existing = await FindDevice(db, userId, h0, h1, h2); + if (existing != null) + return; + + // insert new (if doesnt exist) + var device = new UserDevice + { + UserID = userId, + HWID_0 = h0, + HWID_1 = h1, + HWID_2 = h2, + HWID_3 = hwid_3, + HWID_4 = hwid_4, + HWID_5 = hwid_5, + IPAddress = ipAddr + }; + + db.UserDevices.Add(device); + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] RegisterUserDevice failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } @@ -321,16 +363,16 @@ public static class Users .FirstOrDefault() ); - private static readonly Func, IAsyncEnumerable> _getUsersByIds = - EF.CompileAsyncQuery( - (AppDbContext db, List ids) => - db.Users - .Where(u => ids.Contains(u.ID)) - .Select(u => new User - { - ID = u.ID, - DisplayName = u.DisplayName - }) + private static readonly Func, IAsyncEnumerable> _getUsersByIds = + EF.CompileAsyncQuery( + (AppDbContext db, List ids) => + db.Users + .Where(u => ids.Contains(u.ID)) + .Select(u => new User + { + ID = u.ID, + DisplayName = u.DisplayName + }) ); private static readonly Func> _getUserLobbyPreferencesQuery = @@ -342,7 +384,7 @@ public static class Users { favorite_color = u.FavoriteColor, favorite_side = u.FavoriteSide, - favorite_map = u.FavoriteMap, + favorite_map = u.FavoriteMap ?? String.Empty, favorite_starting_money = u.FavoriteStartingMoney, favorite_limit_superweapons = u.LimitSuperweapons }) @@ -364,17 +406,25 @@ public static async Task> GetBulkELOData( if (userIds == null || userIds.Count == 0) return results; - // Execute compiled query - await foreach (var u in _compiledBulkQuery(db, userIds)) + try { - results[u.ID] = new EloData(u.EloRating, u.EloNumberOfMatches); - } + // Execute compiled query + await foreach (var u in _compiledBulkQuery(db, userIds)) + { + results[u.ID] = new EloData(u.EloRating, u.EloNumberOfMatches); + } - // Fill missing users with defaults - foreach (var id in userIds) + // Fill missing users with defaults + foreach (var id in userIds) + { + if (!results.ContainsKey(id)) + results[id] = new EloData(EloConfig.BaseRating, 0); + } + } + catch (Exception ex) { - if (!results.ContainsKey(id)) - results[id] = new EloData(EloConfig.BaseRating, 0); + Console.WriteLine($"[ERROR] GetBulkELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return results; @@ -399,53 +449,105 @@ internal static async Task CreateUserIfNotExists_DevAccount( if (!exists) { - db.Users.Add(new User + // needs try catch because debug client spams this + try + { + db.Users.Add(new User + { + ID = userId, + AccountType = EAccountType.DevAccount, + DisplayName = displayName + }); + + await db.SaveChangesAsync(); + } + catch { - ID = userId, - AccountType = EAccountType.DevAccount, - DisplayName = displayName - }); - await db.SaveChangesAsync(); + } } } #endif - public static Task IsUserAdmin(AppDbContext db, long userId) + public static async Task IsUserAdmin(AppDbContext db, long userId) { - return _isUserAdminQuery(db, userId); + try + { + return await _isUserAdminQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] IsUserAdmin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } } - public static async Task> GetDisplayNameBulk(AppDbContext db, List lstUserIDs) - { - var dict = new Dictionary(lstUserIDs.Count); - - await foreach (var user in _getUsersByIds(db, lstUserIDs)) - { - if (user.DisplayName is not null) - { - dict[user.ID] = user.DisplayName; - } - } - - return dict; + public static async Task> GetDisplayNameBulk(AppDbContext db, List lstUserIDs) + { + var dict = new Dictionary(lstUserIDs.Count); + + try + { + await foreach (var user in _getUsersByIds(db, lstUserIDs)) + { + if (user.DisplayName is not null) + { + dict[user.ID] = user.DisplayName; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetDisplayNameBulk failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } + + return dict; } - public static Task IsUserBanned(AppDbContext db, long userId) + public static async Task IsUserBanned(AppDbContext db, long userId) { - return _isUserBannedQuery(db, userId); + try + { + return await _isUserBannedQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] IsUserBanned failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } } public static async Task GetDisplayName(AppDbContext db, long userId) { - return await _getDisplayNameQuery(db, userId) ?? string.Empty; + try + { + return await _getDisplayNameQuery(db, userId) ?? string.Empty; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return string.Empty; + } } - public static Task GetUserLobbyPreferences(AppDbContext db, long userId) + public static async Task GetUserLobbyPreferences(AppDbContext db, long userId) { - return _getUserLobbyPreferencesQuery(db, userId); + try + { + return await _getUserLobbyPreferencesQuery(db, userId); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetUserLobbyPreferences failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return null; + } } // TODO_EFCORE: check all queries, determine which ones should be moved to precompiled query @@ -454,16 +556,32 @@ public static async Task SetFavorite_LimitSuperweapons( long userId, bool bLimitSuperweapons) { - // TODO_EFCORE: Check all sets, some may want to be execute update instead of db.SaveChangesAsync(); as this requires a lookup first - await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.LimitSuperweapons, bLimitSuperweapons)); + try + { + // TODO_EFCORE: Check all sets, some may want to be execute update instead of db.SaveChangesAsync(); as this requires a lookup first + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.LimitSuperweapons, bLimitSuperweapons)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetFavorite_LimitSuperweapons failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task GetELOData(AppDbContext db, long userId) { - var result = await GetEloData(db, userId); + try + { + var result = await GetEloData(db, userId); - if (result != null) - return result; + if (result != null) + return result; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } return new EloData(EloConfig.BaseRating, 0); } @@ -474,17 +592,33 @@ public static async Task SetFavorite_Map( long userId, string strMap) { - await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteMap, strMap)); + try + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteMap, strMap)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetFavorite_Map failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task UpdateLastLoginData(AppDbContext db, long userId, string ipAddr) { - await db.Users - .Where(u => u.ID == userId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(u => u.LastLogin, DateTime.UtcNow) - .SetProperty(u => u.LastIPAddress, ipAddr) - ); + try + { + await db.Users + .Where(u => u.ID == userId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.LastLogin, DateTime.UtcNow) + .SetProperty(u => u.LastIPAddress, ipAddr) + ); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] UpdateLastLoginData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } @@ -493,7 +627,15 @@ public static async Task SetFavorite_StartingMoney( long userId, int startingMoney) { - await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteStartingMoney, startingMoney)); + try + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteStartingMoney, startingMoney)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetFavorite_StartingMoney failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task SetFavorite_Side( @@ -501,16 +643,32 @@ public static async Task SetFavorite_Side( long userId, int side) { - await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteSide, side)); + try + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteSide, side)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetFavorite_Side failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task SetDisplayName(AppDbContext db, long userId, string newName) { - await db.Users - .Where(u => u.ID == userId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(u => u.DisplayName, newName) - ); + try + { + await db.Users + .Where(u => u.ID == userId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.DisplayName, newName) + ); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task SetFavorite_Color( @@ -518,17 +676,33 @@ public static async Task SetFavorite_Color( long userId, int color) { - await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteColor, color)); + try + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteColor, color)); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SetFavorite_Color failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } public static async Task SaveELOData(AppDbContext db, long userId, EloData newEloData) { - await db.Users - .Where(u => u.ID == userId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(u => u.EloRating, newEloData.Rating) - .SetProperty(u => u.EloNumberOfMatches, newEloData.NumMatches) - ); + try + { + await db.Users + .Where(u => u.ID == userId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(u => u.EloRating, newEloData.Rating) + .SetProperty(u => u.EloNumberOfMatches, newEloData.NumMatches) + ); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] SaveELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } } } diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 7c4d762..6e9c598 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -350,7 +350,7 @@ private async Task OnMessageReceived(SocketMessage message) if (message.Content.ToLower() == "!playercount" || message.Content.ToLower() == "!players") { - int numPlayers = GenOnlineService.WebSocketManager.GetUserDataCache().Count; + int numPlayers = GenOnlineService.WebSocketManager.GetNumberOfUsersOnline(); string strMessage = String.Format("There are currently {0} players online.", numPlayers); if (enumChannelID == EDiscordChannelIDs.DirectMessage) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 334f771..fcf4cb0 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -899,11 +899,19 @@ public async Task UpdateState(ELobbyState state) // lobby cant have AI and must have at least 2 human players at some point if (WasPVPAtStart() && !HadAIAtStart()) { - // create placeholder - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - await Database.MatchHistory.CreatePlaceholderMatchHistory(db, this); + try + { + // create placeholder + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.MatchHistory.CreatePlaceholderMatchHistory(db, this); + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] UpdateState placeholder creation failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + } // calculate first probe time CalculateNextProbeTime(true); @@ -1145,7 +1153,7 @@ public async Task Cleanup() foreach (Lobby lobbyToRemove in lstLobbiesToRemove) { // remove it, also commit it + update leaderboard - DeleteLobby(lobbyToRemove); + await DeleteLobby(lobbyToRemove); } } @@ -1399,40 +1407,49 @@ public async Task LeaveAnyLobby(Int64 userID) public async Task DeleteLobby(Lobby lobby) { - using var scope = _services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - - if (lobby.State != ELobbyState.COMPLETE) + try { - // make done - await lobby.UpdateState(ELobbyState.COMPLETE); + using var scope = _services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); - // attempt to commit it - await Database.MatchHistory.CommitLobbyToMatchHistory(db, lobby); - } - - // delete - bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); - await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); + if (lobby.State != ELobbyState.COMPLETE) + { + // make done + await lobby.UpdateState(ELobbyState.COMPLETE); - // only do this once - if (bRemoved) - { - // unsubscribe from self-destruct event - lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; + // attempt to commit it + await Database.MatchHistory.CommitLobbyToMatchHistory(db, lobby); + } - // make sure we have a winner - await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); + // delete + bool bRemoved = m_dictLobbies.Remove(lobby.LobbyID, out _); + await WebSocketManager.SendNewOrDeletedLobbyToAllNetworkRoomMembers(lobby.NetworkRoomID); - // if its a quickmatch, update our leaderboards - if (lobby.LobbyType == ELobbyType.QuickMatch) + // only do this once + if (bRemoved) { - await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); - } - } + // unsubscribe from self-destruct event + lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; + + // make sure we have a winner + await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); - return bRemoved; + // if its a quickmatch, update our leaderboards + if (lobby.LobbyType == ELobbyType.QuickMatch) + { + await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); + } + } + + return bRemoved; + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] DeleteLobby failed: {ex.Message}"); + SentrySdk.CaptureException(ex); + return false; + } } public bool IsUserInLobby(Lobby lobby, Int64 user_id) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 0e77526..d073f4b 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -260,7 +260,11 @@ public static Int64 GetUserID(ControllerBase controller) return -1; } - return Convert.ToInt64(controller.User.Claims.First().Value); + var claim = controller.User.FindFirst(ClaimTypes.NameIdentifier); + if (claim == null || !Int64.TryParse(claim.Value, out Int64 userId)) + return -1; + + return userId; } public static List GetRoles(ControllerBase controller) @@ -278,6 +282,9 @@ public static KnownClients.EKnownClients GetClientID(ControllerBase controller) { var first = controller.User.FindFirst("client_id"); + if (first == null) + return KnownClients.EKnownClients.unknown; + if (int.TryParse(first.Value, out int clientIDInt32)) { // Validate if the int corresponds to a defined enum value @@ -295,6 +302,9 @@ public static EUserSessionType GetSessionType(ControllerBase controller) { var first = controller.User.FindFirst("session_type"); + if (first == null) + return EUserSessionType.None; + if (int.TryParse(first.Value, out int sessionTypeInt32)) { // Validate if the int corresponds to a defined enum value @@ -419,6 +429,12 @@ private static async Task InitializeDatabase(WebApplicationBuilder builder) options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); +#if RELEASE + options.UseLoggerFactory(LoggerFactory.Create(builder => { })); // Empty logger + options.EnableSensitiveDataLogging(false); // Ensure sensitive data is not logged + options.EnableDetailedErrors(false); // Disable detailed error messages +#endif + }); } } @@ -459,6 +475,7 @@ private static Task AdditionalValidation(TokenValidatedContext context) if (firstType == null || string.IsNullOrEmpty(firstType.Value)) { context.Fail("Failed Validation #8"); + return Task.CompletedTask; } string strTypeClaim = firstType.Value; @@ -489,6 +506,7 @@ private static Task AdditionalValidation(TokenValidatedContext context) if (context.Principal.FindFirst(JwtRegisteredClaimNames.Address) == null) { context.Fail("Failed Validation #7"); + return Task.CompletedTask; } string strExpectedIP = context.Principal.FindFirst(JwtRegisteredClaimNames.Address).Value; @@ -1021,7 +1039,7 @@ public static async Task Main(string[] args) var lobbyManager = ServiceLocator.Services.GetRequiredService(); int numLobbies = lobbyManager.GetNumLobbies(); - await StatsTracker.Update(numLobbies, WebSocketManager.GetUserDataCache().Count); + await StatsTracker.Update(numLobbies, WebSocketManager.GetNumberOfUsersOnline()); await lobbyManager.Cleanup(); }