From 2e9a1712bca48a0146b8e93399b79d09469541f6 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:29:27 -0400 Subject: [PATCH 01/12] - Fix an update query that was using precomp --- GenOnlineService/Database/Database.MatchHistory.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index ceab442..209cb83 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -308,15 +308,6 @@ 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) => @@ -502,8 +493,8 @@ public static async Task CreatePlaceholderMatchHistory( MemberSlot7 = jsonSlots[7] }; - // Precompiled Add() - await _insertMatch(db, entity); + // Add entity to DbSet + db.MatchHistory.Add(entity); // Save await db.SaveChangesAsync(); From ee528c8f40aa3eb1eb33ef13b85d865de7530f75 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:47:52 -0400 Subject: [PATCH 02/12] - Improvements to dev auth flow --- .../CheckLogin/CheckLoginController.cs | 2 +- GenOnlineService/Database/Database.User.cs | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 65aadff..573ef77 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -145,7 +145,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr // 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/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8b44101..7a706f7 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -64,7 +64,7 @@ public class User 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 KnownClients.EKnownClients ClientID { get; set; } = KnownClients.EKnownClients.custom_third_party_client; // Gameplay Favorites public int FavoriteColor { get; set; } = -1; @@ -399,14 +399,22 @@ internal static async Task CreateUserIfNotExists_DevAccount( if (!exists) { - db.Users.Add(new User - { - ID = userId, - AccountType = EAccountType.DevAccount, - DisplayName = displayName - }); - - await db.SaveChangesAsync(); + // 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 + { + + } } } From ce13f3cfac68800056f2bcf961a9a5ffc7e10f6e Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:39:57 -0400 Subject: [PATCH 03/12] - Exception handling for EFCore --- .../Database/Database.ConnectionOutcomes.cs | 125 +- .../Database/Database.DailyStats.cs | 49 +- .../Database/Database.Leaderboards.cs | 158 +- .../Database/Database.MatchHistory.cs | 1301 +++++++++-------- .../Database/Database.PlayerStats.cs | 187 +-- .../Database/Database.ServiceStats.cs | 61 +- GenOnlineService/Database/Database.Social.cs | 207 ++- GenOnlineService/Database/Database.User.cs | 346 +++-- 8 files changed, 1388 insertions(+), 1046 deletions(-) diff --git a/GenOnlineService/Database/Database.ConnectionOutcomes.cs b/GenOnlineService/Database/Database.ConnectionOutcomes.cs index 9fc16ef..c32a996 100644 --- a/GenOnlineService/Database/Database.ConnectionOutcomes.cs +++ b/GenOnlineService/Database/Database.ConnectionOutcomes.cs @@ -61,65 +61,72 @@ 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}"); + } } } diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs index 5bd047c..2325a1e 100644 --- a/GenOnlineService/Database/Database.DailyStats.cs +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -69,12 +69,20 @@ 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}"); g_StatsContainer = new DailyStat(); } } @@ -82,24 +90,31 @@ 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}"); } - - await db.SaveChangesAsync(); } public static void RegisterOutcome(int army, bool bWon) diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index e2621c2..5a01fc0 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -242,52 +242,59 @@ 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}"); } } @@ -326,41 +333,48 @@ 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; + try + { + 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)); - await Task.WhenAll(_taskBuffer).ConfigureAwait(false); + _taskBuffer[0] = dailyTask; + _taskBuffer[1] = monthlyTask; + _taskBuffer[2] = yearlyTask; - // DAILY - foreach (var row in dailyTask.Result) - { - var entry = results[row.UserId]; - entry.daily = row.Points; - entry.daily_matches = row.Matches; - results[row.UserId] = entry; - } + await Task.WhenAll(_taskBuffer).ConfigureAwait(false); - // 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 dailyTask.Result) + { + var entry = results[row.UserId]; + entry.daily = row.Points; + entry.daily_matches = row.Matches; + results[row.UserId] = entry; + } + + // 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; + } + + // YEARLY + foreach (var row in yearlyTask.Result) + { + 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}"); } return results; diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 209cb83..0a85e87 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -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 @@ -195,11 +195,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,23 +211,23 @@ 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() ); @@ -267,37 +267,39 @@ 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 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)) + } + ); - private static readonly Func> _getMemberSlot = - EF.CompileAsyncQuery( - (AppDbContext db, long matchId, int slotIndex) => - db.MatchHistory - .Where(m => m.MatchId == matchId) - .Select(_slotSelectors[slotIndex]) - .FirstOrDefault() - ); + return Expression.Lambda, SetPropertyCalls>>( + call, + param + ); + } + + + 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(); + } @@ -349,34 +351,41 @@ 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}"); + } } public static async Task _updateMemberSlot( @@ -391,15 +400,15 @@ await db.MatchHistory private static string ComputeRosterType(int playersSeen, Dictionary playersPerTeam) - { - // FFA check + { + // FFA check bool isFFA = playersSeen > 2 && playersPerTeam.All(kv => kv.Key == -1 || kv.Value == 1); if (isFFA) - return $"{playersSeen} Player FFA"; - - // Team roster type + return $"{playersSeen} Player FFA"; + + // Team roster type string roster = ""; foreach (var kv in playersPerTeam) @@ -421,216 +430,238 @@ public static async Task CreatePlaceholderMatchHistory( GenOnlineService.Lobby lobby) { if (lobby == null) - return 0; - - // Build member JSON array - string?[] jsonSlots = new string?[8]; - - Dictionary playersPerTeam = new(); - int playersSeen = 0; + return 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(); + int playersSeen = 0; + + foreach (var member in lobby.Members) + { + 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); + + playersSeen++; + + if (playersPerTeam.ContainsKey(model.team)) + playersPerTeam[model.team]++; + else + playersPerTeam[model.team] = 1; + } + + // Determine roster type + string rosterType = ComputeRosterType(playersSeen, playersPerTeam); - var model = new MatchdataMemberModel + // Build EF entity + var entity = new MatchHistoryEntry { - 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 + 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); + // Add entity to DbSet + db.MatchHistory.Add(entity); - playersSeen++; + // Save + await db.SaveChangesAsync(); - 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 + ulong id = (ulong)entity.MatchId; + lobby.SetMatchID(id); + + return id; + } + 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] - }; - - // Add entity to DbSet - db.MatchHistory.Add(entity); - - // Save - await db.SaveChangesAsync(); + Console.WriteLine($"[ERROR] CreatePlaceholderMatchHistory failed: {ex.Message}"); + 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(); - ulong id = (ulong)entity.MatchId; - lobby.SetMatchID(id); + 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 id; + 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}"); + } + } + + 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) + { + Console.WriteLine($"[ERROR] UpdateMatchHistoryMakeWinner failed: {ex.Message}"); + } } - 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( @@ -638,35 +669,42 @@ 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) - .Select(m => new - { - m.MemberSlot0, - m.MemberSlot1, - m.MemberSlot2, - m.MemberSlot3, - m.MemberSlot4, - m.MemberSlot5, - 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); + try + { + 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) + .Select(m => new + { + m.MemberSlot0, + m.MemberSlot1, + m.MemberSlot2, + m.MemberSlot3, + m.MemberSlot4, + m.MemberSlot5, + 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); + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] GetMatchesInRange failed: {ex.Message}"); } return collection; @@ -686,296 +724,325 @@ 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}"); + 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}"); + } + } + + // 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; + + 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}"); + } + } + + // 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}"); + } + } + 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)); + } + } + + + + + } } \ No newline at end of file diff --git a/GenOnlineService/Database/Database.PlayerStats.cs b/GenOnlineService/Database/Database.PlayerStats.cs index 8a40383..8de28bc 100644 --- a/GenOnlineService/Database/Database.PlayerStats.cs +++ b/GenOnlineService/Database/Database.PlayerStats.cs @@ -78,91 +78,114 @@ 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}"); + 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}"); + } + + 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}"); + } } } diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs index 8251626..d71e5a4 100644 --- a/GenOnlineService/Database/Database.ServiceStats.cs +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -76,39 +76,46 @@ 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}"); + } } } diff --git a/GenOnlineService/Database/Database.Social.cs b/GenOnlineService/Database/Database.Social.cs index 3a08e62..c63b113 100644 --- a/GenOnlineService/Database/Database.Social.cs +++ b/GenOnlineService/Database/Database.Social.cs @@ -118,97 +118,160 @@ 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}"); + } + + 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}"); + } + + 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}"); + } + + 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}"); + } } - 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}"); + } } - 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}"); + } } - 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}"); + } } - 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}"); + } } - 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}"); + } } diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 7a706f7..a51224c 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -183,10 +183,18 @@ public static class PendingLogins .FirstOrDefault() ); - public static async Task GetPendingLoginState(AppDbContext db, string gameCode) - { - string code = gameCode.ToUpper(); - return await _getPendingLoginState(db, code); + 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}"); + return null; + } } @@ -202,31 +210,53 @@ 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}"); + } } 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}"); + 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}"); + } } } @@ -251,36 +281,43 @@ 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}"); + } } } @@ -364,17 +401,24 @@ 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}"); } return results; @@ -420,40 +464,79 @@ internal static async Task CreateUserIfNotExists_DevAccount( #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}"); + 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}"); + } + + 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}"); + 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}"); + 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}"); + return null; + } } // TODO_EFCORE: check all queries, determine which ones should be moved to precompiled query @@ -462,16 +545,30 @@ 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}"); + } } 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}"); + } return new EloData(EloConfig.BaseRating, 0); } @@ -482,17 +579,31 @@ 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}"); + } } 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}"); + } } @@ -501,7 +612,14 @@ 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}"); + } } public static async Task SetFavorite_Side( @@ -509,16 +627,30 @@ 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}"); + } } 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}"); + } } public static async Task SetFavorite_Color( @@ -526,17 +658,31 @@ 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}"); + } } 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}"); + } } } From b527684ef3077f8116b4d42e33e0067b097a67b8 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:49:43 -0400 Subject: [PATCH 04/12] - Further EFCore safety --- .../Database/Database.MatchHistory.cs | 5 +- GenOnlineService/LobbyManager.cs | 81 +++++++++++-------- GenOnlineService/Program.cs | 14 +++- 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 0a85e87..fe6f2f0 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -687,7 +687,10 @@ public static async Task GetMatchesInRange( m.MemberSlot6, m.MemberSlot7 }) - .FirstAsync(); + .FirstOrDefaultAsync(); + + if (entity == null) + continue; // Deserialize only if not null AddMemberIfNotNull(entry, entity.MemberSlot0); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 334f771..453e3e8 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -899,11 +899,18 @@ 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}"); + } // calculate first probe time CalculateNextProbeTime(true); @@ -1145,7 +1152,7 @@ public async Task Cleanup() foreach (Lobby lobbyToRemove in lstLobbiesToRemove) { // remove it, also commit it + update leaderboard - DeleteLobby(lobbyToRemove); + await DeleteLobby(lobbyToRemove); } } @@ -1399,40 +1406,48 @@ 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}"); + return false; + } } public bool IsUserInLobby(Lobby lobby, Int64 user_id) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 0e77526..d5030bb 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 @@ -459,6 +469,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 +500,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; From e75932c5c9e67d33b63f07df088bc8f2bb0969aa Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:55:42 -0400 Subject: [PATCH 05/12] - Log EFCore exceptions to Sentry --- .../Database/Database.ConnectionOutcomes.cs | 1 + .../Database/Database.DailyStats.cs | 5 ++++- .../Database/Database.Leaderboards.cs | 4 +++- .../Database/Database.MatchHistory.cs | 9 ++++++++ .../Database/Database.PlayerStats.cs | 3 +++ .../Database/Database.ServiceStats.cs | 3 ++- GenOnlineService/Database/Database.Social.cs | 9 ++++++++ GenOnlineService/Database/Database.User.cs | 22 ++++++++++++++++++- GenOnlineService/LobbyManager.cs | 2 ++ 9 files changed, 54 insertions(+), 4 deletions(-) diff --git a/GenOnlineService/Database/Database.ConnectionOutcomes.cs b/GenOnlineService/Database/Database.ConnectionOutcomes.cs index c32a996..5f90445 100644 --- a/GenOnlineService/Database/Database.ConnectionOutcomes.cs +++ b/GenOnlineService/Database/Database.ConnectionOutcomes.cs @@ -126,6 +126,7 @@ await db.ConnectionOutcomes 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 2325a1e..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 ** @@ -83,6 +83,7 @@ public static async Task LoadFromDB(AppDbContext db) catch (Exception ex) { Console.WriteLine($"[ERROR] DailyStats.LoadFromDB failed: {ex.Message}"); + SentrySdk.CaptureException(ex); g_StatsContainer = new DailyStat(); } } @@ -114,6 +115,7 @@ public static async Task SaveToDB(AppDbContext db) catch (Exception ex) { Console.WriteLine($"[ERROR] DailyStats.SaveToDB failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -143,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 5a01fc0..072928e 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 ** @@ -295,6 +295,7 @@ public static async Task CreateUserEntriesIfNotExists(AppDbContext db, long play catch (Exception ex) { Console.WriteLine($"[ERROR] CreateUserEntriesIfNotExists failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -375,6 +376,7 @@ public async static ValueTask> GetBulkLeader catch (Exception ex) { 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 fe6f2f0..3a703a1 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -385,6 +385,7 @@ public static async Task CommitPlayerOutcome( catch (Exception ex) { Console.WriteLine($"[ERROR] CommitPlayerOutcome failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -518,6 +519,7 @@ public static async Task CreatePlaceholderMatchHistory( catch (Exception ex) { Console.WriteLine($"[ERROR] CreatePlaceholderMatchHistory failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return 0; } } @@ -618,6 +620,7 @@ public static async Task DetermineLobbyWinnerIfNotPresent( catch (Exception ex) { Console.WriteLine($"[ERROR] DetermineLobbyWinnerIfNotPresent failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -659,6 +662,7 @@ await db.MatchHistory catch (Exception ex) { Console.WriteLine($"[ERROR] UpdateMatchHistoryMakeWinner failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -708,6 +712,7 @@ public static async Task GetMatchesInRange( catch (Exception ex) { Console.WriteLine($"[ERROR] GetMatchesInRange failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return collection; @@ -735,6 +740,7 @@ public static async Task GetHighestMatchID(AppDbContext db) catch (Exception ex) { Console.WriteLine($"[ERROR] GetHighestMatchID failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return -1; } } @@ -756,6 +762,7 @@ await db.MatchHistory catch (Exception ex) { Console.WriteLine($"[ERROR] CommitLobbyToMatchHistory failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -831,6 +838,7 @@ await db.MatchHistory catch (Exception ex) { Console.WriteLine($"[ERROR] AttachMatchHistoryMetadata failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -859,6 +867,7 @@ await UpdatePeriodEloAndLeaderboardsAsync( catch (Exception ex) { Console.WriteLine($"[ERROR] UpdateLeaderboardAndElo failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } private static async Task> LoadMatchMembersAsync( diff --git a/GenOnlineService/Database/Database.PlayerStats.cs b/GenOnlineService/Database/Database.PlayerStats.cs index 8de28bc..7b97634 100644 --- a/GenOnlineService/Database/Database.PlayerStats.cs +++ b/GenOnlineService/Database/Database.PlayerStats.cs @@ -91,6 +91,7 @@ public static async Task GetPlayerStats( catch (Exception ex) { Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return new PlayerStats(userId, EloConfig.BaseRating, 0); } @@ -123,6 +124,7 @@ public static async Task GetPlayerStats( catch (Exception ex) { Console.WriteLine($"[ERROR] GetPlayerStats failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return ps; @@ -185,6 +187,7 @@ await db.UserStats 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 d71e5a4..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 ** @@ -115,6 +115,7 @@ public static async Task CommitStats( 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 c63b113..887a79c 100644 --- a/GenOnlineService/Database/Database.Social.cs +++ b/GenOnlineService/Database/Database.Social.cs @@ -132,6 +132,7 @@ public static async Task> GetFriends(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] GetFriends failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return result; @@ -150,6 +151,7 @@ public static async Task> GetBlocked(AppDbContext db, long sourceU catch (Exception ex) { Console.WriteLine($"[ERROR] GetBlocked failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return result; @@ -168,6 +170,7 @@ public static async Task> GetPendingFriendsRequests(AppDbContext d catch (Exception ex) { Console.WriteLine($"[ERROR] GetPendingFriendsRequests failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return result; @@ -187,6 +190,7 @@ await db.FriendRequests catch (Exception ex) { Console.WriteLine($"[ERROR] RemovePendingFriendRequest failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -205,6 +209,7 @@ public static async Task CreateFriendship(AppDbContext db, long userId1, long us catch (Exception ex) { Console.WriteLine($"[ERROR] CreateFriendship failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -221,6 +226,7 @@ await db.Friends catch (Exception ex) { Console.WriteLine($"[ERROR] RemoveFriendship failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -239,6 +245,7 @@ public static async Task AddBlock(AppDbContext db, long sourceUserId, long targe catch (Exception ex) { Console.WriteLine($"[ERROR] AddBlock failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -253,6 +260,7 @@ await db.BlockedUsers catch (Exception ex) { Console.WriteLine($"[ERROR] RemoveBlock failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -271,6 +279,7 @@ public static async Task AddPendingFriendRequest(AppDbContext db, long sourceUse 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 a51224c..9a2ce6d 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -193,6 +193,7 @@ public static class PendingLogins catch (Exception ex) { Console.WriteLine($"[ERROR] GetPendingLoginState failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return null; } } @@ -223,6 +224,7 @@ await db.PendingLogins catch (Exception ex) { Console.WriteLine($"[ERROR] PendingLogins.Cleanup failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -239,6 +241,7 @@ public static async Task GetUserIDFromPendingLogin(AppDbContext db, string catch (Exception ex) { Console.WriteLine($"[ERROR] GetUserIDFromPendingLogin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return -1; } } @@ -256,6 +259,7 @@ await db.PendingLogins catch (Exception ex) { Console.WriteLine($"[ERROR] CleanupPendingLogin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -317,6 +321,7 @@ public static async Task RegisterUserDevice( catch (Exception ex) { Console.WriteLine($"[ERROR] RegisterUserDevice failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } } @@ -419,6 +424,7 @@ public static async Task> GetBulkELOData( catch (Exception ex) { Console.WriteLine($"[ERROR] GetBulkELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return results; @@ -473,6 +479,7 @@ public static async Task IsUserAdmin(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] IsUserAdmin failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return false; } } @@ -494,6 +501,7 @@ public static async Task> GetDisplayNameBulk(AppDbConte catch (Exception ex) { Console.WriteLine($"[ERROR] GetDisplayNameBulk failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return dict; @@ -508,6 +516,7 @@ public static async Task IsUserBanned(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] IsUserBanned failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return false; } } @@ -522,6 +531,7 @@ public static async Task GetDisplayName(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] GetDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return string.Empty; } } @@ -535,6 +545,7 @@ public static async Task GetDisplayName(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] GetUserLobbyPreferences failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return null; } } @@ -553,6 +564,7 @@ public static async Task SetFavorite_LimitSuperweapons( catch (Exception ex) { Console.WriteLine($"[ERROR] SetFavorite_LimitSuperweapons failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -568,6 +580,7 @@ public static async Task GetELOData(AppDbContext db, long userId) catch (Exception ex) { Console.WriteLine($"[ERROR] GetELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } return new EloData(EloConfig.BaseRating, 0); @@ -586,6 +599,7 @@ public static async Task SetFavorite_Map( catch (Exception ex) { Console.WriteLine($"[ERROR] SetFavorite_Map failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -603,6 +617,7 @@ await db.Users catch (Exception ex) { Console.WriteLine($"[ERROR] UpdateLastLoginData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -619,6 +634,7 @@ public static async Task SetFavorite_StartingMoney( catch (Exception ex) { Console.WriteLine($"[ERROR] SetFavorite_StartingMoney failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -634,6 +650,7 @@ public static async Task SetFavorite_Side( catch (Exception ex) { Console.WriteLine($"[ERROR] SetFavorite_Side failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -650,6 +667,7 @@ await db.Users catch (Exception ex) { Console.WriteLine($"[ERROR] SetDisplayName failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -665,6 +683,7 @@ public static async Task SetFavorite_Color( catch (Exception ex) { Console.WriteLine($"[ERROR] SetFavorite_Color failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } @@ -682,6 +701,7 @@ await db.Users catch (Exception ex) { Console.WriteLine($"[ERROR] SaveELOData failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 453e3e8..fcf4cb0 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -910,6 +910,7 @@ public async Task UpdateState(ELobbyState state) catch (Exception ex) { Console.WriteLine($"[ERROR] UpdateState placeholder creation failed: {ex.Message}"); + SentrySdk.CaptureException(ex); } // calculate first probe time @@ -1446,6 +1447,7 @@ public async Task DeleteLobby(Lobby lobby) catch (Exception ex) { Console.WriteLine($"[ERROR] DeleteLobby failed: {ex.Message}"); + SentrySdk.CaptureException(ex); return false; } } From 04b1c2547e4edfe91dc8321ad03cd548b11e851d Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:02:31 -0400 Subject: [PATCH 06/12] - Improved db processing of user instance - Reduced DB logging in retail --- GenOnlineService/Constants.cs | 23 ++++- .../Controllers/MOTD/MOTDController.cs | 2 +- GenOnlineService/Database/Database.User.cs | 92 +++++++++---------- GenOnlineService/Discord.cs | 2 +- GenOnlineService/Program.cs | 8 +- 5 files changed, 74 insertions(+), 53 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 1ee0eba..4fb6d1e 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; @@ -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(); @@ -442,7 +456,8 @@ 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 { @@ -503,7 +518,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; } 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/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 9a2ce6d..357db02 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -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 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,15 +174,15 @@ 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() - ); - + 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 @@ -196,8 +196,8 @@ public static class PendingLogins SentrySdk.CaptureException(ex); return null; } - } - + } + private static readonly Func> GetUserIdFromCode = @@ -363,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 = @@ -384,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 }) @@ -451,19 +451,19 @@ internal static async Task CreateUserIfNotExists_DevAccount( { // needs try catch because debug client spams this try - { - db.Users.Add(new User - { - ID = userId, - AccountType = EAccountType.DevAccount, - DisplayName = displayName - }); - - await db.SaveChangesAsync(); + { + db.Users.Add(new User + { + ID = userId, + AccountType = EAccountType.DevAccount, + DisplayName = displayName + }); + + await db.SaveChangesAsync(); } catch - { - + { + } } } 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/Program.cs b/GenOnlineService/Program.cs index d5030bb..d073f4b 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -429,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 + }); } } @@ -1033,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(); } From d32af5a460a84fc90600ddf490003565e0985f31 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:44:57 +0100 Subject: [PATCH 07/12] Improve ComputeRosterType to exclude observers, normalization Signed-off-by: tintinhamans <5984296+tintinhamans@users.noreply.github.com> --- .../Database/Database.MatchHistory.cs | 74 ++++++++++--------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 3a703a1..1882552 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -290,15 +290,15 @@ private static Expression, SetPropertyC } - 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(); + 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(); } @@ -400,29 +400,32 @@ await db.MatchHistory } - private static string ComputeRosterType(int playersSeen, Dictionary playersPerTeam) + private static string ComputeRosterType(Dictionary playersPerTeam) { - // FFA check - bool isFFA = playersSeen > 2 && - playersPerTeam.All(kv => kv.Key == -1 || kv.Value == 1); + int noTeamCount = playersPerTeam.TryGetValue(-1, out int n) ? n : 0; - if (isFFA) - return $"{playersSeen} Player FFA"; + var teamedGroups = playersPerTeam + .Where(kv => kv.Key != -1) + .Select(kv => kv.Value) + .OrderBy(c => c) + .ToList(); - // Team roster type - string roster = ""; + int activePlayers = noTeamCount + teamedGroups.Sum(); - foreach (var kv in playersPerTeam) + if (activePlayers == 0) { - int count = kv.Value; + return "Unknown"; + } + + bool isFFA = activePlayers > 2 && + (noTeamCount == activePlayers || teamedGroups.All(c => c == 1)); - if (string.IsNullOrEmpty(roster)) - roster = count.ToString(); - else - roster += $"v{count}"; + if (isFFA) + { + return $"{activePlayers} Player FFA"; } - return roster; + return string.Join("v", teamedGroups); } @@ -439,7 +442,6 @@ public static async Task CreatePlaceholderMatchHistory( string?[] jsonSlots = new string?[8]; Dictionary playersPerTeam = new(); - int playersSeen = 0; foreach (var member in lobby.Members) { @@ -468,16 +470,22 @@ public static async Task CreatePlaceholderMatchHistory( jsonSlots[member.SlotIndex] = JsonSerializer.Serialize(model); - playersSeen++; - - if (playersPerTeam.ContainsKey(model.team)) - playersPerTeam[model.team]++; - else - playersPerTeam[model.team] = 1; + // 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(playersSeen, playersPerTeam); + string rosterType = ComputeRosterType(playersPerTeam); // Build EF entity var entity = new MatchHistoryEntry @@ -1057,4 +1065,4 @@ await db.LeaderboardYearly } -} \ No newline at end of file +} From 8e7efe605fe24a69f946070087e5fc00492bb5a9 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:01:35 -0400 Subject: [PATCH 08/12] - Remove duplicate LB definition --- .../Database/Database.Leaderboards.cs | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index 072928e..645169d 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -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 = From a43333c1bdd7aa0e6c3400bac5ef4fcf9b091e6b Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:54:51 -0400 Subject: [PATCH 09/12] - Fix SPOP session issue seen in new deployment - Fix for match history API endpoint --- GenOnlineService/Constants.cs | 19 +- .../WebSocket/WebSocketController.cs | 18 +- .../Database/Database.Leaderboards.cs | 22 +- .../Database/Database.MatchHistory.cs | 218 +++++++++--------- 4 files changed, 144 insertions(+), 133 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 4fb6d1e..fa1f823 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -221,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); @@ -263,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; @@ -402,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)) { diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index d7f5250..b2b7b55 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -170,11 +170,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 +195,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.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index 645169d..69c41df 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -287,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, @@ -307,18 +304,13 @@ public async static ValueTask> GetBulkLeader try { - 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); + // 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)); // DAILY - foreach (var row in dailyTask.Result) + foreach (var row in daily) { var entry = results[row.UserId]; entry.daily = row.Points; @@ -327,7 +319,7 @@ public async static ValueTask> GetBulkLeader } // MONTHLY - foreach (var row in monthlyTask.Result) + foreach (var row in monthly) { var entry = results[row.UserId]; entry.monthly = row.Points; @@ -336,7 +328,7 @@ public async static ValueTask> GetBulkLeader } // YEARLY - foreach (var row in yearlyTask.Result) + foreach (var row in yearly) { var entry = results[row.UserId]; entry.yearly = row.Points; diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 3a703a1..26619f1 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -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(); @@ -234,23 +249,18 @@ public static class MatchHistory 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)) + }; } @@ -270,23 +280,18 @@ 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 - ); + 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)) + }; } @@ -310,32 +315,7 @@ private static Expression, SetPropertyC .Max(m => (long?)m.MatchId) ); - 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, @@ -675,36 +655,67 @@ public static async Task GetMatchesInRange( try { - await foreach (var entry in _getMatchesInRange(db, startID, endID)) + // 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, + m.MemberSlot3, + m.MemberSlot4, + m.MemberSlot5, + m.MemberSlot6, + m.MemberSlot7 + }) + .ToListAsync(); + + foreach (var row in rows) { - // Load JSON members (optional optimization below) - var entity = await db.MatchHistory - .Where(m => m.MatchId == entry.match_id) - .Select(m => new - { - m.MemberSlot0, - m.MemberSlot1, - m.MemberSlot2, - m.MemberSlot3, - m.MemberSlot4, - m.MemberSlot5, - m.MemberSlot6, - m.MemberSlot7 - }) - .FirstOrDefaultAsync(); - - if (entity == null) - continue; - - // 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); + 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); } @@ -770,23 +781,18 @@ await db.MatchHistory 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 - ); + 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)) + }; } From 4046e757a6fe892faa94bc217cbb714292a9cc5a Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:03:10 -0400 Subject: [PATCH 10/12] - Added SPOP test for DEBUG builds + dev auth flow - Fix a bug where quickmatches could get 2 winners - Fix bug where ELO could be calculated twice --- .../CheckLogin/CheckLoginController.cs | 6 ++++++ .../Database/Database.MatchHistory.cs | 21 +++++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 573ef77..626a396 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -141,6 +141,12 @@ 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); diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 26619f1..6326203 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -1,4 +1,4 @@ -/* +/* ** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour ** Copyright (C) 2025 GeneralsOnline Development Team ** @@ -545,14 +545,17 @@ public static async Task DetermineLobbyWinnerIfNotPresent( } } - // 4. If winner exists, propagate to teammates - if (hasWinner && winnerTeam != -1) + // 4. If winner exists, propagate to teammates and return + if (hasWinner) { - foreach (var kv in members) + if (winnerTeam != -1) { - if (kv.Value.team == winnerTeam) + foreach (var kv in members) { - await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); + if (kv.Value.team == winnerTeam) + { + await UpdateMatchHistoryMakeWinner(db, lobby.MatchID, kv.Key); + } } } @@ -916,6 +919,9 @@ private static async Task UpdateCurrentEloAsync( 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 _); @@ -992,6 +998,9 @@ private static async Task UpdatePeriodEloAndLeaderboardsAsync( 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 _); From eb7e922543e0156da9f06ebbc5fe27a498e47bdb Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:19:22 -0400 Subject: [PATCH 11/12] - Fix bug with multiple points of presence signing users out incorrectly --- GenOnlineService/Controllers/WebSocket/WebSocketController.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index b2b7b55..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, From 68ea26585fa3735acadc0b7af1f9174272b7170f Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:05:04 -0400 Subject: [PATCH 12/12] - Fixed a bug where incorrect WS could be closed when user is rapidly signing in from different clients/devices - Added chat postfix for GENHUB --- GenOnlineService/Constants.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index fa1f823..8bc45b1 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -466,9 +466,10 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy 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 @@ -674,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) {