From c0b77cfd2ad1aab6a6b9de2d235bd7132384fcbf Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:04:32 -0500 Subject: [PATCH 01/33] - Begin EFCore migration --- GenOnlineService/Database/MySQL.cs | 142 ++++++++++++++++++++++- GenOnlineService/GenOnlineService.csproj | 1 + GenOnlineService/Program.cs | 2 +- 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index df7db8c..942b3cb 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -23,6 +23,7 @@ using GenOnlineService; using GenOnlineService.Controllers; using Microsoft.AspNetCore.Connections.Features; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; using MySql.Data.MySqlClient; using MySqlX.XDevAPI; @@ -35,6 +36,7 @@ using System.IO; using System.Net; using System.Net.WebSockets; +using System.Numerics; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; @@ -48,6 +50,109 @@ using static Database.Functions.Auth; using static Database.Functions.Lobby; +public class AppDbContext : DbContext +{ + public DbSet Users => Set(); + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Fluent configuration here + base.OnModelCreating(modelBuilder); + + // Fluent config for user + modelBuilder.Entity(entity => + { + entity.ToTable("users"); + + entity.Property(e => e.ID).HasColumnName("user_id"); + + entity.Property(e => e.AccountType).HasColumnName("account_type"); + entity.Property(e => e.SteamID).HasColumnName("steam_id"); + entity.Property(e => e.DiscordID).HasColumnName("discord_id"); + entity.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; + entity.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); + entity.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; + entity.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; + entity.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); + entity.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; + entity.Property(e => e.ClientID).HasColumnName("client_id"); + entity.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); + entity.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); + entity.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; + entity.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); + entity.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); + entity.Property(e => e.IsAdmin).HasColumnName("admin"); + entity.Property(e => e.IsBanned).HasColumnName("banned"); + entity.Property(e => e.EloRating).HasColumnName("elo_rating"); + entity.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); + entity.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; + entity.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; + entity.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; + entity.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; + }); + } +} + +public enum EAccountType +{ + Unknown = -1, + Steam = 0, + Discord = 1, + Reserved = 2, + Reserved2 = 3, + GameReplays = 4, +} + +public class User +{ + public Int64 ID { get; set; } + public EAccountType AccountType { get; set; } = EAccountType.Unknown; + + // Steam, only present if AccountType is Steam + 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; + + // GameReplays, only present if AccountType is GameReplays + public Int64 GameReplaysID { get; set; } = -1; + public string GameReplaysUsername { get; set; } = String.Empty; + + + 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; + + // Gameplay Favorites + public int FavoriteColor { get; set; } = -1; + public int FavoriteSide { get; set; } = -1; + public string FavoriteMap { get; set; } = String.Empty; + public int FavoriteStartingMoney { get; set; } = -1; + public bool LimitSuperweapons { get; set; } = false; + + // User Permissions + public bool IsAdmin { get; set; } = false; + public bool IsBanned { get; set; } = false; + + // ELO + public int EloRating { get; set; } = EloConfig.BaseRating; + 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 class DailyStats { public const int numSides = 12; @@ -1893,7 +1998,7 @@ public async static Task TestQuery(MySQLInstance m_Inst) await m_Inst.Query("SELECT * FROM users LIMIT 1", null); } - public bool Initialize(bool bIsStartup = true) + public bool Initialize(WebApplicationBuilder builder, bool bIsStartup = true) { if (Program.g_Config == null) { @@ -1913,6 +2018,13 @@ public bool Initialize(bool bIsStartup = true) string? password = dbSettings.GetValue("db_password"); UInt16? port = dbSettings.GetValue("db_port"); + int? db_min_poolsize = dbSettings.GetValue("db_min_poolsize"); + int? db_max_poolsize = dbSettings.GetValue("db_max_poolsize"); + bool? db_use_pooling = dbSettings.GetValue("db_use_pooling"); + bool? db_conn_reset = dbSettings.GetValue("db_conn_reset"); + int? db_connect_timeout = dbSettings.GetValue("db_connect_timeout"); + int? db_command_timeout = dbSettings.GetValue("db_command_timeout"); + if (hostname == null) { throw new Exception("DB Hostname is null / not set in config"); @@ -1944,6 +2056,32 @@ public bool Initialize(bool bIsStartup = true) Directory.CreateDirectory("Exceptions"); } + // TODO_EFCORE: Use more config params here + // EFCore connect + { + //var builder = WebApplication.CreateBuilder(args); + + var csb = new MySqlConnectionStringBuilder + { + Server = hostname, + Port = (uint)port, + Database = dbname, + UserID = username, + Password = password, + ConnectionTimeout = (uint)db_connect_timeout, + DefaultCommandTimeout = (uint)db_command_timeout, + SslMode = MySqlSslMode.Preferred + }; + + builder.Services.AddDbContext(options => + { + options.UseMySql( + csb.ConnectionString, + ServerVersion.AutoDetect(csb.ConnectionString)); + }); + + } + try { Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); @@ -2160,7 +2298,7 @@ public async Task Query(string commandStr, Dictionary + diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 8288280..3fe5d91 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -445,7 +445,7 @@ public static void Main(string[] args) g_Discord = new DiscordBot(); } - GlobalDatabaseInstance.g_Database.Initialize(); + GlobalDatabaseInstance.g_Database.Initialize(builder); // do a cleanup on startup DoCleanup(true); From 7fb87ae48b5107272108c5caa75d0419dde0f3c1 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:27:39 -0500 Subject: [PATCH 02/33] JSON formatting fix --- GenOnlineService/appsettings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/GenOnlineService/appsettings.json b/GenOnlineService/appsettings.json index 1d5ad80..0aa6d5d 100644 --- a/GenOnlineService/appsettings.json +++ b/GenOnlineService/appsettings.json @@ -71,7 +71,6 @@ "enabled": false, "dsn": "" }, - , "Middleware": { "jwks_endpoint": null, "audience": null, From aecc3f49db95d44d3cf344a537f8bf24c3a97a79 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:20:19 -0500 Subject: [PATCH 03/33] Migrated daily stats to EFcore --- .../GlobalStats/GlobalStatsController.cs | 4 +- .../LoginWithTokenController.cs | 5 +- .../Monitoring/MonitoringController.cs | 3 +- .../Database/Database.DailyStats.cs | 91 +++++++++++ GenOnlineService/Database/Database.cs | 75 +++++++++ GenOnlineService/Database/MySQL.cs | 142 +----------------- GenOnlineService/Program.cs | 13 +- 7 files changed, 187 insertions(+), 146 deletions(-) create mode 100644 GenOnlineService/Database/Database.DailyStats.cs create mode 100644 GenOnlineService/Database/Database.cs diff --git a/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs b/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs index 1fb957d..36d4ae3 100644 --- a/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs +++ b/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs @@ -32,7 +32,7 @@ public override Type GetReturnType() return this.GetType(); } - public DailyStats? globalstats { get; set; } = null; + public DailyStatsStructure? globalstats { get; set; } = null; } [ApiController] @@ -52,7 +52,7 @@ public APIResult Get() { RouteHandler_GET_GlobalStats_Result result = new RouteHandler_GET_GlobalStats_Result(); - result.globalstats = DailyStatsManager.g_Stats; + result.globalstats = DailyStatsManager.g_StatsContainer.Stats; return result; } diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 443d151..ea1ec94 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -49,10 +49,11 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class LoginWithToken : ControllerBase { + private readonly AppDbContext _db; - public LoginWithToken() + public LoginWithToken(AppDbContext db) { - + _db = db; } [HttpPost(Name = "PostLoginWithToken")] diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 51245b5..cfa070f 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -175,7 +175,8 @@ public async Task Monitor_Database() // db call try { - GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(); + // TODO_EFCORE: Pass DB properly + GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(null); GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), true); return internalResult; } diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs new file mode 100644 index 0000000..215f42d --- /dev/null +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; + +// TODO_EFCORE: When updating this, make sure we preserve old ON DUPLICATE behavior, to overwrite the old data since day_of_year key will be re-used +public class DailyStat +{ + public DailyStat() + { + DayOfYear = DateTime.Now.DayOfYear; + Stats = new(); + } + + public int DayOfYear { get; set; } = -1; + public DailyStatsStructure Stats { get; set; } = null; +} + +public class DailyStatsStructure +{ + public const int numSides = 12; + public int[] matches { get; set; } = new int[12] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; + public int[] wins { get; set; } = new int[12] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; +} + +public static class DailyStatsManager +{ + public static DailyStat g_StatsContainer = new(); + + 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); + + // if null, instantiate, but dont save immediately, let the normal save timer handle it + if (g_StatsContainer == null) + { + g_StatsContainer = new DailyStat(); + } + } + + public static async Task SaveToDB(AppDbContext db) + { + //await Database.Functions.Auth.StoreDailyStats(GlobalDatabaseInstance.g_Database, g_Stats); + + int day_of_year = DateTime.Now.DayOfYear; + + var entity = await db.DailyStats + .FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); + + // 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(); + } + + public static void RegisterOutcome(int army, bool bWon) + { + try + { + int armyIndex = army - 2; // teams start at 2, so substract for array indices + + if (armyIndex >= 0 && armyIndex <= 11) + { + ++g_StatsContainer.Stats.matches[armyIndex]; + + if (bWon) + { + ++g_StatsContainer.Stats.wins[armyIndex]; + } + + // clamp to a sane value, just incase (wins can never be more than matches) + if (g_StatsContainer.Stats.wins[armyIndex] > g_StatsContainer.Stats.matches[armyIndex]) + { + g_StatsContainer.Stats.wins[armyIndex] = g_StatsContainer.Stats.matches[armyIndex]; + + } + } + } + catch (Exception ex) + { + Console.WriteLine($"[ERROR] RegisterOutcome failed: {ex.Message}"); + } + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs new file mode 100644 index 0000000..c363014 --- /dev/null +++ b/GenOnlineService/Database/Database.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using System.Text.Json; + +public class AppDbContext : DbContext +{ + public DbSet Users => Set(); + public DbSet DailyStats => Set(); + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Fluent configuration here + base.OnModelCreating(modelBuilder); + + // Fluent config for user + modelBuilder.Entity(entity => + { + entity.ToTable("users"); + + // prim key + entity.HasKey(e => e.ID); + + entity.Property(e => e.ID).HasColumnName("user_id"); + + entity.Property(e => e.AccountType).HasColumnName("account_type"); + entity.Property(e => e.SteamID).HasColumnName("steam_id"); + entity.Property(e => e.DiscordID).HasColumnName("discord_id"); + entity.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; + entity.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); + entity.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; + entity.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; + entity.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); + entity.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; + entity.Property(e => e.ClientID).HasColumnName("client_id"); + entity.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); + entity.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); + entity.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; + entity.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); + entity.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); + entity.Property(e => e.IsAdmin).HasColumnName("admin"); + entity.Property(e => e.IsBanned).HasColumnName("banned"); + entity.Property(e => e.EloRating).HasColumnName("elo_rating"); + entity.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); + entity.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; + entity.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; + entity.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; + entity.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; + }); + + // Fluent config for daily stats + modelBuilder.Entity(entity => + { + entity.ToTable("daily_stats"); + + // prim key + entity.HasKey(e => e.DayOfYear); + + entity.Property(e => e.DayOfYear).HasColumnName("day_of_year"); + + // TODO_EFCORE: use column type json later (needs db update) + + entity.Property(e => e.Stats) + .HasColumnName("stats_structure") + .HasColumnType("longtext") + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions)null) + ); + }); + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 4b0edf9..52ca9a9 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -50,54 +50,6 @@ using static Database.Functions.Auth; using static Database.Functions.Lobby; -public class AppDbContext : DbContext -{ - public DbSet Users => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - // Fluent configuration here - base.OnModelCreating(modelBuilder); - - // Fluent config for user - modelBuilder.Entity(entity => - { - entity.ToTable("users"); - - entity.Property(e => e.ID).HasColumnName("user_id"); - - entity.Property(e => e.AccountType).HasColumnName("account_type"); - entity.Property(e => e.SteamID).HasColumnName("steam_id"); - entity.Property(e => e.DiscordID).HasColumnName("discord_id"); - entity.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; - entity.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); - entity.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; - entity.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; - entity.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); - entity.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; - entity.Property(e => e.ClientID).HasColumnName("client_id"); - entity.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); - entity.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); - entity.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; - entity.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); - entity.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); - entity.Property(e => e.IsAdmin).HasColumnName("admin"); - entity.Property(e => e.IsBanned).HasColumnName("banned"); - entity.Property(e => e.EloRating).HasColumnName("elo_rating"); - entity.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); - entity.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; - entity.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; - entity.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; - entity.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; - }); - } -} - public enum EAccountType { Unknown = -1, @@ -108,6 +60,8 @@ public enum EAccountType GameReplays = 4, } + + public class User { public Int64 ID { get; set; } @@ -153,12 +107,7 @@ public class User } -public class DailyStats -{ - public const int numSides = 12; - public int[] matches { get; set; } = new int[12] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; - public int[] wins { get; set; } = new int[12] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; -} + /* * 2, // USA @@ -175,49 +124,7 @@ public class DailyStats 13 // GLA Stealth */ -public static class DailyStatsManager -{ - public static DailyStats g_Stats = new(); - - public static async Task LoadFromDB() - { - g_Stats = await Database.Functions.Auth.LoadDailyStats(GlobalDatabaseInstance.g_Database); - } - - public static async Task SaveToDB() - { - await Database.Functions.Auth.StoreDailyStats(GlobalDatabaseInstance.g_Database, g_Stats); - } - - public static void RegisterOutcome(int army, bool bWon) - { - try - { - int armyIndex = army - 2; // teams start at 2, so substract for array indices - - if (armyIndex >= 0 && armyIndex <= 11) - { - ++g_Stats.matches[armyIndex]; - - if (bWon) - { - ++g_Stats.wins[armyIndex]; - } - - // clamp to a sane value, just incase (wins can never be more than matches) - if (g_Stats.wins[armyIndex] > g_Stats.matches[armyIndex]) - { - g_Stats.wins[armyIndex] = g_Stats.matches[armyIndex]; - } - } - } - catch (Exception ex) - { - Console.WriteLine($"[ERROR] RegisterOutcome failed: {ex.Message}"); - } - } -} namespace Database { @@ -1322,48 +1229,7 @@ public async static Task UpdatePlayerStat(MySQLInstance m_Inst, Int64 user_id, i ); } - public async static Task LoadDailyStats(MySQLInstance m_Inst) - { - DailyStats ds = new(); - - int day_of_year = DateTime.Now.DayOfYear; - var res = await m_Inst.Query("SELECT stats_structure FROM daily_stats WHERE day_of_year=@day_of_year LIMIT 1;", - new() - { - { "@day_of_year", day_of_year } - } - ); - - if (res.NumRows() == 0) - { - return ds; - } - - try - { - string? jsonData = Convert.ToString(res.GetRow(0)["stats_structure"]); - if (jsonData != null) - { - DailyStats? statsDeserialized = JsonSerializer.Deserialize(jsonData); - - if (statsDeserialized != null) - { - ds = statsDeserialized; - } - - return ds; - } - } - catch - { - return new DailyStats(); - } - - - return new DailyStats(); - } - - public async static Task StoreDailyStats(MySQLInstance m_Inst, DailyStats stats) + public async static Task StoreDailyStats(MySQLInstance m_Inst, DailyStatsStructure stats) { string strJSON = JsonSerializer.Serialize(stats); diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index dfe403a..69b9023 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -949,7 +949,11 @@ public static async Task Main(string[] args) { try { - await DailyStatsManager.SaveToDB(); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await DailyStatsManager.SaveToDB(db); + } } catch (Exception ex) { @@ -973,8 +977,11 @@ public static async Task Main(string[] args) g_tokenGenerator = new JwtTokenGenerator(builder.Configuration); // load daily stats - await DailyStatsManager.LoadFromDB(); - + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await DailyStatsManager.LoadFromDB(db); + } app.Run(); From 0e7293c9e355fb67eee6aba40bde246d4c554306 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 02:49:45 -0500 Subject: [PATCH 04/33] - Cleanup model creation - Begin leaderboard migration --- .../Database/Database.DailyStats.cs | 45 +++++- .../Database/Database.Leaderboards.cs | 138 ++++++++++++++++++ GenOnlineService/Database/Database.cs | 116 ++++++++------- 3 files changed, 242 insertions(+), 57 deletions(-) create mode 100644 GenOnlineService/Database/Database.Leaderboards.cs diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs index 215f42d..949f1ff 100644 --- a/GenOnlineService/Database/Database.DailyStats.cs +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -1,4 +1,24 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Text.Json; // TODO_EFCORE: When updating this, make sure we preserve old ON DUPLICATE behavior, to overwrite the old data since day_of_year key will be re-used public class DailyStat @@ -20,6 +40,29 @@ public class DailyStatsStructure public int[] wins { get; set; } = new int[12] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; } +public class DailyStatsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("daily_stats"); + + // prim key + builder.HasKey(e => e.DayOfYear); + + builder.Property(e => e.DayOfYear).HasColumnName("day_of_year"); + + // TODO_EFCORE: use column type json later (needs db update) + + builder.Property(e => e.Stats) + .HasColumnName("stats_structure") + .HasColumnType("longtext") + .HasConversion( + v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null), + v => JsonSerializer.Deserialize(v, (JsonSerializerOptions)null) + ); + } +} + public static class DailyStatsManager { public static DailyStat g_StatsContainer = new(); @@ -38,8 +81,6 @@ public static async Task LoadFromDB(AppDbContext db) public static async Task SaveToDB(AppDbContext db) { - //await Database.Functions.Auth.StoreDailyStats(GlobalDatabaseInstance.g_Database, g_Stats); - int day_of_year = DateTime.Now.DayOfYear; var entity = await db.DailyStats diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs new file mode 100644 index 0000000..29d9c6a --- /dev/null +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -0,0 +1,138 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +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 class LeaderboardDailyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leaderboard_daily"); + + builder.HasKey(x => new { x.UserId, x.DayOfYear, x.Year }); + + builder.Property(x => x.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(x => x.Points) + .HasColumnName("points"); + + builder.Property(x => x.DayOfYear) + .HasColumnName("day_of_year") + .IsRequired(); + + builder.Property(x => x.Year) + .HasColumnName("year") + .IsRequired(); + + builder.Property(x => x.Wins) + .HasColumnName("wins"); + + builder.Property(x => x.Losses) + .HasColumnName("losses"); + } +} + +public class LeaderboardMonthlyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leaderboard_monthly"); + + builder.HasKey(x => new { x.UserId, x.MonthOfYear, x.Year }); + + builder.Property(x => x.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(x => x.Points) + .HasColumnName("points"); + + builder.Property(x => x.MonthOfYear) + .HasColumnName("month_of_year") + .IsRequired(); + + builder.Property(x => x.Year) + .HasColumnName("year") + .IsRequired(); + + builder.Property(x => x.Wins) + .HasColumnName("wins"); + + builder.Property(x => x.Losses) + .HasColumnName("losses"); + } +} + +public class LeaderboardYearlyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("leaderboard_yearly"); + + builder.HasKey(x => new { x.UserId, x.Year }); + + builder.Property(x => x.UserId) + .HasColumnName("user_id") + .IsRequired(); + + builder.Property(x => x.Points) + .HasColumnName("points"); + + builder.Property(x => x.Year) + .HasColumnName("year") + .IsRequired(); + + builder.Property(x => x.Wins) + .HasColumnName("wins"); + + builder.Property(x => x.Losses) + .HasColumnName("losses"); + } +} diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index c363014..4581add 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -1,6 +1,62 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + // prim key + builder.HasKey(e => e.ID); + + builder.Property(e => e.ID).HasColumnName("user_id"); + + builder.Property(e => e.AccountType).HasColumnName("account_type"); + builder.Property(e => e.SteamID).HasColumnName("steam_id"); + builder.Property(e => e.DiscordID).HasColumnName("discord_id"); + builder.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; + builder.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); + builder.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; + builder.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; + builder.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); + builder.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; + builder.Property(e => e.ClientID).HasColumnName("client_id"); + builder.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); + builder.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); + builder.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; + builder.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); + builder.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); + builder.Property(e => e.IsAdmin).HasColumnName("admin"); + builder.Property(e => e.IsBanned).HasColumnName("banned"); + builder.Property(e => e.EloRating).HasColumnName("elo_rating"); + builder.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); + builder.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; + builder.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; + builder.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; + builder.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; + } +} + public class AppDbContext : DbContext { public DbSet Users => Set(); @@ -16,60 +72,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Fluent configuration here base.OnModelCreating(modelBuilder); - // Fluent config for user - modelBuilder.Entity(entity => - { - entity.ToTable("users"); - - // prim key - entity.HasKey(e => e.ID); - - entity.Property(e => e.ID).HasColumnName("user_id"); - - entity.Property(e => e.AccountType).HasColumnName("account_type"); - entity.Property(e => e.SteamID).HasColumnName("steam_id"); - entity.Property(e => e.DiscordID).HasColumnName("discord_id"); - entity.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; - entity.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); - entity.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; - entity.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; - entity.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); - entity.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; - entity.Property(e => e.ClientID).HasColumnName("client_id"); - entity.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); - entity.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); - entity.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; - entity.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); - entity.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); - entity.Property(e => e.IsAdmin).HasColumnName("admin"); - entity.Property(e => e.IsBanned).HasColumnName("banned"); - entity.Property(e => e.EloRating).HasColumnName("elo_rating"); - entity.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); - entity.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; - entity.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; - entity.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; - entity.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; - }); - - // Fluent config for daily stats - modelBuilder.Entity(entity => - { - entity.ToTable("daily_stats"); - - // prim key - entity.HasKey(e => e.DayOfYear); - - entity.Property(e => e.DayOfYear).HasColumnName("day_of_year"); - - // TODO_EFCORE: use column type json later (needs db update) - - entity.Property(e => e.Stats) - .HasColumnName("stats_structure") - .HasColumnType("longtext") - .HasConversion( - v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null), - v => JsonSerializer.Deserialize(v, (JsonSerializerOptions)null) - ); - }); + modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new DailyStatsConfiguration()); + modelBuilder.ApplyConfiguration(new LeaderboardDailyConfiguration()); + modelBuilder.ApplyConfiguration(new LeaderboardMonthlyConfiguration()); + modelBuilder.ApplyConfiguration(new LeaderboardYearlyConfiguration()); } } \ No newline at end of file From e2a70966d14c054663fa797337501d76b8488fca Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:01:18 -0500 Subject: [PATCH 05/33] - Migrated lobbymanager away from static class to DI'd singleton --- GenOnlineService/Constants.cs | 3 +- .../ConnectionOutcomeController.cs | 6 +- .../Controllers/Lobbies/LobbiesController.cs | 13 +- .../Controllers/Lobby/LobbyController.cs | 20 +- .../MatchReplay/MatchReplayController.cs | 6 +- .../MatchUpdate/MatchUpdateController.cs | 6 +- .../WebSocket/WebSocketController.cs | 27 ++- .../Database/Database.Leaderboards.cs | 174 ++++++++++++++++++ GenOnlineService/Database/Database.cs | 3 + GenOnlineService/Database/MySQL.cs | 136 +------------- GenOnlineService/Discord.cs | 3 +- GenOnlineService/GenOnlineService.csproj | 7 +- GenOnlineService/LobbyManager.cs | 66 ++++--- GenOnlineService/MatchmakingManager.cs | 13 +- GenOnlineService/Program.cs | 18 +- 15 files changed, 311 insertions(+), 190 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index ca1ecfc..0e3e259 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1908,7 +1908,8 @@ public static string DetermineUserStatus(UserSession? userData) } else { - Lobby? plrLobby = LobbyManager.GetLobby(userData.currentLobbyID); + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + Lobby? plrLobby = lobbyManager.GetLobby(userData.currentLobbyID); if (plrLobby == null) { diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index 39e1997..f371313 100644 --- a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs +++ b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs @@ -45,10 +45,12 @@ public override Type GetReturnType() public class ConnectionOutcomeController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; - public ConnectionOutcomeController(ILogger logger) + public ConnectionOutcomeController(LobbyManager lobbyManager, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; } [HttpPost] @@ -82,7 +84,7 @@ public async Task Post() Int64 source_user = TokenHelper.GetUserID(this); if (source_user != -1) { - Lobby? playerLobby = LobbyManager.GetPlayerParticipantLobby(source_user); + Lobby? playerLobby = _lobbyManager.GetPlayerParticipantLobby(source_user); if (playerLobby != null) { diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 5fd6d62..f9821f9 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -63,9 +63,12 @@ public class LobbiesController : ControllerBase private static List? s_cachedRooms = null; private static readonly object s_roomsLock = new object(); - public LobbiesController(ILogger logger) + private readonly LobbyManager _lobbyManager; + + public LobbiesController(LobbyManager lobbyManager, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; } // Cache rooms.json data to avoid disk I/O on every request @@ -182,7 +185,7 @@ public async Task Get() bIncludeAllNetworkRooms = true; } - lstLobbies = LobbyManager.GetAllLobbies(networkRoomID, true, true, false, false, bIncludeAllNetworkRooms); + lstLobbies = _lobbyManager.GetAllLobbies(networkRoomID, true, true, false, false, bIncludeAllNetworkRooms); List lstLobbiesToRemove = new(); @@ -252,7 +255,7 @@ public async Task Get() networkRoomID = 0; bIncludeAllNetworkRooms = true; - lstLobbies = LobbyManager.GetAllLobbies(networkRoomID, true, true, true, true, bIncludeAllNetworkRooms); + lstLobbies = _lobbyManager.GetAllLobbies(networkRoomID, true, true, true, true, bIncludeAllNetworkRooms); } else { @@ -364,10 +367,10 @@ public async Task Put() if (playerSession != null) { // cleanup any zombie lobbies - await LobbyManager.CleanupUserLobbiesNotStarted(user_id); + await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); - Int64 newLobbyID = await LobbyManager.CreateLobby(playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, + Int64 newLobbyID = await _lobbyManager.CreateLobby(playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame); if (newLobbyID >= 0) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 1664707..325785e 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -142,10 +142,12 @@ public override Type GetReturnType() public class LobbyController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; - public LobbyController(ILogger logger) + public LobbyController(LobbyManager lobbyManager, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; } [HttpGet("{lobby_id}")] @@ -167,7 +169,7 @@ public async Task Get(string lobby_id) // need a lobby ID if (Int64.TryParse(lobby_id, out Int64 lobbyID)) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(lobbyID); result.lobby = lobby; } @@ -212,7 +214,7 @@ public async Task Delete(Int64 lobbyID) Int64 user_id = TokenHelper.GetUserID(this); if (user_id != -1) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) { foreach (var member in lobby.Members) @@ -228,7 +230,7 @@ public async Task Delete(Int64 lobbyID) } Console.WriteLine("[Source 1] User {0} Leave Any Lobby", user_id); - LobbyManager.LeaveAnyLobby(user_id); + _lobbyManager.LeaveAnyLobby(user_id); // cleanup TURN credentials TURNCredentialManager.DeleteCredentialsForUser(user_id); @@ -382,7 +384,7 @@ public async Task Post(Int64 lobbyID) Int64 user_id = TokenHelper.GetUserID(this); if (user_id != -1) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) { @@ -509,7 +511,7 @@ public async Task Post(Int64 lobbyID) // TODO: we should communicate the kick to the user... Int64 KickedUserID = data["userid"].GetInt64(); - LobbyManager.LeaveSpecificLobby(KickedUserID, lobbyID); + _lobbyManager.LeaveSpecificLobby(KickedUserID, lobbyID); // cleanup TURN credentials TURNCredentialManager.DeleteCredentialsForUser(KickedUserID); @@ -668,7 +670,7 @@ public async Task Put(Int64 lobbyID) ) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) { @@ -712,10 +714,10 @@ public async Task Put(Int64 lobbyID) if (playerSession != null) { // leave any lobby - LobbyManager.LeaveAnyLobby(user_id); + _lobbyManager.LeaveAnyLobby(user_id); string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); - bool bJoinedSuccessfully = await LobbyManager.JoinLobby(lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); + bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); result.success = bJoinedSuccessfully; diff --git a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs index 67cb0a0..ee623b7 100644 --- a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs +++ b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs @@ -48,10 +48,12 @@ public override Type GetReturnType() public class MatchReplayController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; - public MatchReplayController(ILogger logger) + public MatchReplayController(LobbyManager lobbyManager, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; } [HttpPut] @@ -98,7 +100,7 @@ public async Task Post() // TODO_QUICKMATCH: We need a way of checking if player is really in a match or not, so they cant just upload all the time, and also dont let them keep uploading replays if they already did, etc // lobby cant have AI and must have at least 2 human players at some point - Lobby? lobby = LobbyManager.GetLobby(sourceData.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceData.currentLobbyID); if (lobby == null || !lobby.WasPVPAtStart() || lobby.HadAIAtStart()) { Response.StatusCode = (int)HttpStatusCode.NotAcceptable; diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index c3b48c8..6b66fd9 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -171,10 +171,12 @@ public async Task GetHighestMatchID([FromHeader(Name = "X-Api-Key")] public class MatchUpdateController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; - public MatchUpdateController(ILogger logger) + public MatchUpdateController(LobbyManager lobbyManager, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; } [HttpPut] @@ -221,7 +223,7 @@ public async Task Post() if (sourceData != null) { // lobby cant have AI and must have at least 2 human players at some point - Lobby? lobby = LobbyManager.GetLobby(sourceData.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceData.currentLobbyID); if (lobby == null || !lobby.WasPVPAtStart() || lobby.HadAIAtStart()) { Response.StatusCode = (int)HttpStatusCode.NotAcceptable; diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index a716864..3c3ad54 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -31,6 +31,13 @@ namespace GenOnlineService.Controllers { public class WebSocketController : ControllerBase { + private readonly LobbyManager _lobbyManager; + + public WebSocketController(LobbyManager lobbyManager) + { + _lobbyManager = lobbyManager; + } + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, @@ -395,7 +402,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession { bool bReady = data["ready"].GetBoolean(); - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { LobbyMember? member = lobby.GetMemberFromUserID(sourceUserSession.m_UserID); @@ -444,7 +451,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession else if (msgID == EWebSocketMessageID.LOBBY_CHANGE_PASSWORD) { // must be in a lobby - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { // must be owner too @@ -463,7 +470,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession else if (msgID == EWebSocketMessageID.LOBBY_REMOVE_PASSWORD) { // must be in a lobby - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { // must be owner too @@ -487,7 +494,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (chatMessage != null) { // get lobby - Lobby? playerLobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? playerLobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (playerLobby != null) { @@ -549,7 +556,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (sourceUserSession.currentLobbyID != -1) { // must be lobby owner too - lobbyInfo = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + lobbyInfo = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobbyInfo == null || lobbyInfo.Owner != sourceUserSession.m_UserID) { @@ -572,7 +579,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (sourceUserSession.currentLobbyID != -1) { // must be lobby owner too - lobbyInfo = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + lobbyInfo = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobbyInfo == null || lobbyInfo.Owner != sourceUserSession.m_UserID) { @@ -615,7 +622,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (sourceUserSession.currentLobbyID != -1) { // must be lobby owner too - lobbyInfo = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + lobbyInfo = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobbyInfo == null || lobbyInfo.Owner != sourceUserSession.m_UserID) { @@ -659,7 +666,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // store response if (fullMeshMsg != null) { - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { await lobby.StoreFullMeshConnectivityResponse(sourceUserSession.m_UserID, fullMeshMsg.connectivity_map); @@ -682,7 +689,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession UserSession? targetSession = WebSocketManager.GetDataFromUser(signalingRequest.target_user_id); if (targetSession != null) { - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { @@ -726,7 +733,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession UserSession? targetSession = WebSocketManager.GetDataFromUser(signal.target_user_id); if (targetSession != null) { - Lobby? lobby = LobbyManager.GetLobby(sourceUserSession.currentLobbyID); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index 29d9c6a..a45ac60 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -18,6 +18,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; public class LeaderboardDaily { @@ -136,3 +140,173 @@ public void Configure(EntityTypeBuilder builder) .HasColumnName("losses"); } } + + +namespace Database +{ + public static class Leaderboards + { + + public struct LeaderboardPoints + { + public int daily; + public int daily_matches; + public int monthly; + public int monthly_matches; + public int yearly; + public int yearly_matches; + } + + public sealed class LeaderboardRow + { + public long UserId { get; set; } + public int Points { get; set; } + 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 = + EF.CompileAsyncQuery((AppDbContext db, List ids, int day, int year) => + db.LeaderboardDaily + .AsNoTracking() + .Where(x => ids.Contains(x.UserId) + && x.DayOfYear == day + && x.Year == year) + .Select(x => new LeaderboardRow + { + UserId = x.UserId, + Points = x.Points ?? 0, + Matches = (x.Wins ?? 0) + (x.Losses ?? 0) + }) + ); + + public static readonly Func, int, int, IAsyncEnumerable> MonthlyBulk = + EF.CompileAsyncQuery((AppDbContext db, List ids, int month, int year) => + db.LeaderboardMonthly + .AsNoTracking() + .Where(x => ids.Contains(x.UserId) + && x.MonthOfYear == month + && x.Year == year) + .Select(x => new LeaderboardRow + { + UserId = x.UserId, + Points = x.Points ?? 0, + Matches = (x.Wins ?? 0) + (x.Losses ?? 0) + }) + ); + + public static readonly Func, int, IAsyncEnumerable> YearlyBulk = + EF.CompileAsyncQuery((AppDbContext db, List ids, int year) => + db.LeaderboardYearly + .AsNoTracking() + .Where(x => ids.Contains(x.UserId) + && x.Year == year) + .Select(x => new LeaderboardRow + { + UserId = x.UserId, + Points = x.Points ?? 0, + Matches = (x.Wins ?? 0) + (x.Losses ?? 0) + }) + ); + } + + private static async Task> MaterializeAsync(IAsyncEnumerable source) + { + var list = new List(); + + await foreach (var item in source.ConfigureAwait(false)) + list.Add(item); + + return list; + } + + + // 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, + int dayOfYear, + int monthOfYear, + int year) + { + var results = new Dictionary(); + + if (playerIDs == null || playerIDs.Count == 0) + return results; + + foreach (var id in playerIDs) + results[id] = new LeaderboardPoints(); + + var dailyTask = MaterializeAsync(LeaderboardQueries.DailyBulk(db, playerIDs, dayOfYear, year)); + var monthlyTask = MaterializeAsync(LeaderboardQueries.MonthlyBulk(db, playerIDs, monthOfYear, year)); + var yearlyTask = MaterializeAsync(LeaderboardQueries.YearlyBulk(db, playerIDs, year)); + + _taskBuffer[0] = dailyTask; + _taskBuffer[1] = monthlyTask; + _taskBuffer[2] = yearlyTask; + + await Task.WhenAll(_taskBuffer).ConfigureAwait(false); + + // DAILY + foreach (var row in dailyTask.Result) + { + 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; + } + + return results; + } + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 4581add..faba06b 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -61,6 +61,9 @@ public class AppDbContext : DbContext { public DbSet Users => Set(); public DbSet DailyStats => Set(); + public DbSet LeaderboardDaily => Set(); + public DbSet LeaderboardMonthly => Set(); + public DbSet LeaderboardYearly => Set(); public AppDbContext(DbContextOptions options) : base(options) diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 52ca9a9..863024b 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -270,127 +270,6 @@ public async static Task GetMatchesInRange(MySQLInstance public static class Leaderboards { - public class LeaderboardPoints - { - public int daily = 0; - public int daily_matches = 0; - public int monthly = 0; - public int monthly_matches = 0; - public int yearly = 0; - public int yearly_matches = 0; - } - - public async static Task GetLeaderboardDataForUser(MySQLInstance m_Inst, Int64 playerID, int dayOfYear, int monthOfYear, int year) - { - LeaderboardPoints retVal = new(); - - // daily - var resDaily = await m_Inst.Query("SELECT points, wins+losses as `matches` FROM leaderboard_daily WHERE user_id=@user_id AND day_of_year=@day_of_year AND year=@year LIMIT 1;", - new() - { - { "@user_id", playerID }, - { "@day_of_year", dayOfYear }, - { "@year", year } - } - ); - if (resDaily.NumRows() > 0) - { - CMySQLRow row = resDaily.GetRow(0); - retVal.daily = Convert.ToInt32(row["points"]); - retVal.daily_matches = Convert.ToInt32(row["matches"]); - } - - // monthly - var resMonthly = await m_Inst.Query("SELECT points, wins+losses as `matches` FROM leaderboard_monthly WHERE user_id=@user_id AND month_of_year=@month_of_year AND year=@year LIMIT 1;", - new() - { - { "@user_id", playerID }, - { "@month_of_year", monthOfYear }, - { "@year", year } - } - ); - if (resMonthly.NumRows() > 0) - { - CMySQLRow row = resMonthly.GetRow(0); - retVal.monthly = Convert.ToInt32(row["points"]); - retVal.monthly_matches = Convert.ToInt32(row["matches"]); - } - - // yearly - var resYearly = await m_Inst.Query("SELECT points, wins+losses as `matches` FROM leaderboard_yearly WHERE user_id=@user_id AND year=@year LIMIT 1;", - new() - { - { "@user_id", playerID }, - { "@year", year } - } - ); - if (resYearly.NumRows() > 0) - { - CMySQLRow row = resYearly.GetRow(0); - retVal.yearly = Convert.ToInt32(row["points"]); - retVal.yearly_matches = Convert.ToInt32(row["matches"]); - } - - return retVal; - } - - public async static Task> GetBulkLeaderboardData(MySQLInstance m_Inst, List playerIDs, int dayOfYear, int monthOfYear, int year) - { - Dictionary results = new(); - - if (playerIDs == null || playerIDs.Count == 0) - { - return results; - } - - // Initialize all users with default values - foreach (Int64 playerId in playerIDs) - { - results[playerId] = new LeaderboardPoints(); - } - - // Build IN clause - string inClause = string.Join(",", playerIDs); - - // Bulk daily - var resDaily = await m_Inst.Query($"SELECT user_id, points, wins+losses as `matches` FROM leaderboard_daily WHERE user_id IN ({inClause}) AND day_of_year={dayOfYear} AND year={year};", null); - foreach (var row in resDaily.GetRows()) - { - Int64 userId = Convert.ToInt64(row["user_id"]); - if (results.ContainsKey(userId)) - { - results[userId].daily = Convert.ToInt32(row["points"]); - results[userId].daily_matches = Convert.ToInt32(row["matches"]); - } - } - - // Bulk monthly - var resMonthly = await m_Inst.Query($"SELECT user_id, points, wins+losses as `matches` FROM leaderboard_monthly WHERE user_id IN ({inClause}) AND month_of_year={monthOfYear} AND year={year};", null); - foreach (var row in resMonthly.GetRows()) - { - Int64 userId = Convert.ToInt64(row["user_id"]); - if (results.ContainsKey(userId)) - { - results[userId].monthly = Convert.ToInt32(row["points"]); - results[userId].monthly_matches = Convert.ToInt32(row["matches"]); - } - } - - // Bulk yearly - var resYearly = await m_Inst.Query($"SELECT user_id, points, wins+losses as `matches` FROM leaderboard_yearly WHERE user_id IN ({inClause}) AND year={year};", null); - foreach (var row in resYearly.GetRows()) - { - Int64 userId = Convert.ToInt64(row["user_id"]); - if (results.ContainsKey(userId)) - { - results[userId].yearly = Convert.ToInt32(row["points"]); - results[userId].yearly_matches = Convert.ToInt32(row["matches"]); - } - } - - return results; - } - public async static Task DetermineLobbyWinnerIfNotPresent(MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) { // NOTE: this works only when you call this function BEFORE updating ELO, as elo will read it all to award points @@ -518,7 +397,7 @@ public async static Task DetermineLobbyWinnerIfNotPresent(MySQLInstance m_Inst, } } - public async static Task UpdateLeaderboardAndElo(MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) + public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) { // must be in a QM if (lobbyInst.LobbyType != ELobbyType.QuickMatch) @@ -634,11 +513,12 @@ public async static Task UpdateLeaderboardAndElo(MySQLInstance m_Inst, GenOnline // initialize data with bulk query (3 queries instead of N*3) List userIds = lstMembers.Select(m => m.user_id).ToList(); - Dictionary bulkLbData = await GetBulkLeaderboardData(m_Inst, userIds, dayOfYear, monthOfYear, year); - + + var bulkLbData = await Database.Leaderboards.GetBulkLeaderboardData(db, userIds, dayOfYear, monthOfYear, year); + foreach (MatchdataMemberModel member in lstMembers) { - LeaderboardPoints userLBPoints = bulkLbData[member.user_id]; + Database.Leaderboards.LeaderboardPoints userLBPoints = bulkLbData[member.user_id]; dictEloData_Daily[member.user_id] = new EloData(userLBPoints.daily, userLBPoints.daily_matches); dictEloData_Monthly[member.user_id] = new EloData(userLBPoints.monthly, userLBPoints.monthly_matches); dictEloData_Yearly[member.user_id] = new EloData(userLBPoints.yearly, userLBPoints.yearly_matches); @@ -1453,10 +1333,12 @@ public static async Task FullyDestroyPlayerSession(MySQLInstance m_Inst, Int64 u // leave any lobby Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); - LobbyManager.LeaveAnyLobby(user_id); + + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + lobbyManager.LeaveAnyLobby(user_id); - await LobbyManager.CleanupUserLobbiesNotStarted(user_id); + await lobbyManager.CleanupUserLobbiesNotStarted(user_id); // remove from any matchmaking if (userData != null) diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index f57e11c..7d3b1de 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -364,7 +364,8 @@ private async Task OnMessageReceived(SocketMessage message) } else if (message.Content.ToLower() == "!lobbies") { - int numLobbies = LobbyManager.GetNumLobbies(); + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + int numLobbies = lobbyManager.GetNumLobbies(); string strMessage = String.Format("There are currently {0} lobbies.", numLobbies); if (enumChannelID == EDiscordChannelIDs.DirectMessage) diff --git a/GenOnlineService/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj index 28ae444..cb2d5e0 100644 --- a/GenOnlineService/GenOnlineService.csproj +++ b/GenOnlineService/GenOnlineService.csproj @@ -12,7 +12,12 @@ False - + + + + + + 0 False diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 9a43137..356bba0 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -332,6 +332,8 @@ public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, } } + public event Action? OnLobbyNeedsDestroyed; + public async Task OnAfterPlayerLeft(Int64 leavingUserID) { // NOTE: By the time this is called, the member is no longer in the members list @@ -346,7 +348,7 @@ public async Task OnAfterPlayerLeft(Int64 leavingUserID) Console.WriteLine("DeleteLobby: Source A"); Console.ForegroundColor = ConsoleColor.Gray; - await LobbyManager.DeleteLobby(this); + OnLobbyNeedsDestroyed?.Invoke(this); } else { @@ -1105,13 +1107,20 @@ public enum ELobbyType QuickMatch = 1 } - public static class LobbyManager + public class LobbyManager { - private static ConcurrentDictionary m_dictLobbies = new(); + private ConcurrentDictionary m_dictLobbies = new(); + + private Int64 m_NextLobbyID = 0; - private static Int64 m_NextLobbyID = 0; + private readonly IServiceProvider _services; - public static async Task Cleanup() + public LobbyManager(IServiceProvider services) + { + _services = services; + } + + public async Task Cleanup() { // Remove any lobby that has 0 members and has been around for a bit (enough time for host to join) List lstLobbiesToRemove = new List(); @@ -1136,7 +1145,12 @@ public static async Task Cleanup() } } - public static async Task CreateLobby(UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, + private async void HandleLobbyNeedsDestroyed(Lobby lobby) + { + await DeleteLobby(lobby); + } + + public async Task CreateLobby(UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType) { @@ -1145,7 +1159,7 @@ public static async Task CreateLobby(UserSession owningSession, string st await CleanupUserLobbiesNotStarted(owningSession.m_UserID); Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); - LobbyManager.LeaveAnyLobby(owningSession.m_UserID); + this.LeaveAnyLobby(owningSession.m_UserID); int rng_seed = new Random().Next(); @@ -1170,6 +1184,11 @@ public static async Task CreateLobby(UserSession owningSession, string st Lobby newLobby = new Lobby(newLobbyID, owningSession, strName, ELobbyState.GAME_SETUP, strMapName, strMapPath, bVanillaTeams, starting_cash, bLimitSuperweapons, bTrackStats, bPassworded, strPassword, bMapOfficial, rng_seed, parentNetworkRoom, bAllowObservers, maxCamHeight, exe_crc, ini_crc, maxPlayers, lobbyType); m_dictLobbies[newLobbyID] = newLobby; + + + // subscribe for self-destruct event + newLobby.OnLobbyNeedsDestroyed += HandleLobbyNeedsDestroyed; + // and join if (lobbyType != ELobbyType.QuickMatch) // quickmatch requires a manual join, because the service creates the lobby for them, so the client knows nothing about it without a manual join { @@ -1184,7 +1203,7 @@ public static async Task CreateLobby(UserSession owningSession, string st return newLobbyID; } - public static async Task Tick() + public async Task Tick() { foreach (var kvPair in m_dictLobbies) { @@ -1192,7 +1211,7 @@ public static async Task Tick() } } - public static async Task JoinLobby(Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) + public async Task JoinLobby(Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) { UserLobbyPreferences? lobbyPrefs = await Database.Functions.Auth.GetUserLobbyPreferences(GlobalDatabaseInstance.g_Database, playerSession.m_UserID); @@ -1205,12 +1224,12 @@ public static async Task JoinLobby(Lobby lobby, UserSession playerSession, return false; } - public static int GetNumLobbies() + public int GetNumLobbies() { return m_dictLobbies.Count; } - public static async Task CleanupUserLobbiesNotStarted(Int64 UserID) + public async Task CleanupUserLobbiesNotStarted(Int64 UserID) { List ownedLobbies = GetPlayerOwnedLobbies(UserID); foreach (Lobby ownedLobby in ownedLobbies) @@ -1222,7 +1241,7 @@ public static async Task CleanupUserLobbiesNotStarted(Int64 UserID) } } - public static List GetAllLobbies(Int16 networkRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) + public List GetAllLobbies(Int16 networkRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) { List listLobbies = new List(); foreach (var kvp in m_dictLobbies) @@ -1269,7 +1288,7 @@ public static List GetAllLobbies(Int16 networkRoomID, bool bIncludePasswo return listLobbies; } - public static Lobby? GetLobby(Int64 lobbyID) + public Lobby? GetLobby(Int64 lobbyID) { if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) { @@ -1279,7 +1298,7 @@ public static List GetAllLobbies(Int16 networkRoomID, bool bIncludePasswo return null; } - public static Lobby? GetLobbyFiltered(Int64 lobbyID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted) + public Lobby? GetLobbyFiltered(Int64 lobbyID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted) { if (m_dictLobbies.TryGetValue(lobbyID, out Lobby? lobby)) { @@ -1317,7 +1336,7 @@ public static List GetAllLobbies(Int16 networkRoomID, bool bIncludePasswo return null; } - public static Lobby? GetPlayerParticipantLobby(Int64 userID) + public Lobby? GetPlayerParticipantLobby(Int64 userID) { // TODO_LOBBY: Optimize this, maintain a dictionary of userid foreach (Lobby lobbyInst in m_dictLobbies.Values) @@ -1331,7 +1350,7 @@ public static List GetAllLobbies(Int16 networkRoomID, bool bIncludePasswo return null; } - public static List GetPlayerOwnedLobbies(Int64 userID) + public List GetPlayerOwnedLobbies(Int64 userID) { // NOTE: This function doesnt account for games in progress, the callee must process those (the owner can have left and orphaned the session if in-game) // TODO_LOBBY: Optimize this, maintain a dictionary of userid @@ -1347,7 +1366,7 @@ public static List GetPlayerOwnedLobbies(Int64 userID) return lstLobbies; } - public static async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) + public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) { Lobby? targetLobby = GetLobby(lobbyID); if (targetLobby != null) @@ -1361,7 +1380,7 @@ public static async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) } } - public static async Task LeaveAnyLobby(Int64 userID) + public async Task LeaveAnyLobby(Int64 userID) { foreach (Lobby lobbyInst in m_dictLobbies.Values) { @@ -1374,7 +1393,7 @@ public static async Task LeaveAnyLobby(Int64 userID) } } - public static async Task DeleteLobby(Lobby lobby) + public async Task DeleteLobby(Lobby lobby) { if (lobby.State != ELobbyState.COMPLETE) { @@ -1392,20 +1411,25 @@ public static async Task DeleteLobby(Lobby lobby) // only do this once if (bRemoved) { + // unsubscribe from self-destruct event + lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; + // make sure we have a winner await Database.Functions.Leaderboards.DetermineLobbyWinnerIfNotPresent(GlobalDatabaseInstance.g_Database, lobby); // if its a quickmatch, update our leaderboards if (lobby.LobbyType == ELobbyType.QuickMatch) { - await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(GlobalDatabaseInstance.g_Database, lobby); + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(db, GlobalDatabaseInstance.g_Database, lobby); } } return bRemoved; } - public static bool IsUserInLobby(Lobby lobby, Int64 user_id) + public bool IsUserInLobby(Lobby lobby, Int64 user_id) { LobbyMember? member = lobby.GetMemberFromUserID(user_id); return member != null; diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 5ba3c62..b391abb 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -666,6 +666,8 @@ public Int64 GetLobbyID() Int64 m_StartTime = -1; public async Task Tick() { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + // TODO_QUICKMATCH: What if the playlist is null? is this even possible since we validated before creating the bucket if (g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) { @@ -760,7 +762,7 @@ await SendMatchmakingMessage(memberSession, // make a lobby DetermineMap(out string strMapName, out string strMapPath); - m_LobbyID = await LobbyManager.CreateLobby(dummyHostUser, dummyHostUser.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", + m_LobbyID = await lobbyManager.CreateLobby(dummyHostUser, dummyHostUser.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch); // tell both to join our lobby @@ -818,7 +820,7 @@ await SendMatchmakingMessage(memberSession, if (m_bWaitingOnLobbyJoins) { // done? start time etc - Lobby? lobby = LobbyManager.GetLobby(m_LobbyID); + Lobby? lobby = lobbyManager.GetLobby(m_LobbyID); if (lobby != null) { if (lobby.NumCurrentPlayers == CurrentMemberCount()) // everyone is in, lets start for real @@ -891,7 +893,7 @@ await SendMatchmakingMessage(memberSession, } // start match + create placeholder match - Lobby? lobby = LobbyManager.GetLobby(m_LobbyID); + Lobby? lobby = lobbyManager.GetLobby(m_LobbyID); if (lobby != null) { await lobby.UpdateState(ELobbyState.INGAME); @@ -1157,6 +1159,7 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List public static void DeregisterPlayer(UserSession plr) { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); lstSessions.Remove(new WeakReference(plr)); // TODO_QUICKMATCH: What happens if the game is going to start? we should handle that, right now people probably goto game solo @@ -1169,7 +1172,7 @@ public static void DeregisterPlayer(UserSession plr) if (mmBucket.HasPlayer(plr)) { // remove from QM lobby too - Lobby? lobby = LobbyManager.GetLobby(mmBucket.GetLobbyID()); + Lobby? lobby = lobbyManager.GetLobby(mmBucket.GetLobbyID()); if (lobby != null) { LobbyMember? lobbyMember = lobby.GetMemberFromUserID(plr.m_UserID); @@ -1194,6 +1197,6 @@ public static void DeregisterPlayer(UserSession plr) // leave QM lobby too Console.WriteLine("[Source 4] User {0} Leave Any Lobby", plr.m_UserID); - LobbyManager.LeaveAnyLobby(plr.m_UserID); + lobbyManager.LeaveAnyLobby(plr.m_UserID); } } \ No newline at end of file diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 69b9023..c2556c5 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -530,7 +530,7 @@ public static async Task Main(string[] args) // do a cleanup on startup await DoCleanup(true); - + builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { @@ -805,6 +805,7 @@ public static async Task Main(string[] args) }); var app = builder.Build(); + ServiceLocator.Services = app.Services; app.UseRateLimiter(); @@ -855,10 +856,12 @@ public static async Task Main(string[] args) { await WebSocketManager.CheckForTimeouts(); - int numLobbies = LobbyManager.GetNumLobbies(); + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + + int numLobbies = lobbyManager.GetNumLobbies(); await StatsTracker.Update(numLobbies, WebSocketManager.GetUserDataCache().Count); - await LobbyManager.Cleanup(); + await lobbyManager.Cleanup(); } catch (Exception ex) { @@ -882,7 +885,8 @@ public static async Task Main(string[] args) { try { - await LobbyManager.Tick(); + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + await lobbyManager.Tick(); await WebSocketManager.Tick(); } catch (Exception ex) @@ -1071,4 +1075,10 @@ public static void GlobalExceptionHandler(object sender, UnhandledExceptionEvent } } } + + public static class ServiceLocator + { + public static IServiceProvider Services { get; set; } = default!; + } + } From 179e19aa0152dc35d23e1fbcdd3eb5949fc5a995 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:03:24 -0500 Subject: [PATCH 06/33] Cleanup of User --- GenOnlineService/Database/Database.User.cs | 101 +++++++++++++++++++++ GenOnlineService/Database/Database.cs | 37 -------- GenOnlineService/Database/MySQL.cs | 44 +-------- 3 files changed, 102 insertions(+), 80 deletions(-) create mode 100644 GenOnlineService/Database/Database.User.cs diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs new file mode 100644 index 0000000..b642e67 --- /dev/null +++ b/GenOnlineService/Database/Database.User.cs @@ -0,0 +1,101 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Text.Json; +public class User +{ + public Int64 ID { get; set; } + public EAccountType AccountType { get; set; } = EAccountType.Unknown; + + // Steam, only present if AccountType is Steam + 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; + + // GameReplays, only present if AccountType is GameReplays + public Int64 GameReplaysID { get; set; } = -1; + public string GameReplaysUsername { get; set; } = String.Empty; + + + 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; + + // Gameplay Favorites + public int FavoriteColor { get; set; } = -1; + public int FavoriteSide { get; set; } = -1; + public string FavoriteMap { get; set; } = String.Empty; + public int FavoriteStartingMoney { get; set; } = -1; + public bool LimitSuperweapons { get; set; } = false; + + // User Permissions + public bool IsAdmin { get; set; } = false; + public bool IsBanned { get; set; } = false; + + // ELO + public int EloRating { get; set; } = EloConfig.BaseRating; + 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 class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + + // prim key + builder.HasKey(e => e.ID); + + builder.Property(e => e.ID).HasColumnName("user_id"); + + builder.Property(e => e.AccountType).HasColumnName("account_type"); + builder.Property(e => e.SteamID).HasColumnName("steam_id"); + builder.Property(e => e.DiscordID).HasColumnName("discord_id"); + builder.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; + builder.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); + builder.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; + builder.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; + builder.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); + builder.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; + builder.Property(e => e.ClientID).HasColumnName("client_id"); + builder.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); + builder.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); + builder.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; + builder.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); + builder.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); + builder.Property(e => e.IsAdmin).HasColumnName("admin"); + builder.Property(e => e.IsBanned).HasColumnName("banned"); + builder.Property(e => e.EloRating).HasColumnName("elo_rating"); + builder.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); + builder.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; + builder.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; + builder.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; + builder.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index faba06b..0ef588f 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -20,43 +20,6 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; -public class UserConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("users"); - - // prim key - builder.HasKey(e => e.ID); - - builder.Property(e => e.ID).HasColumnName("user_id"); - - builder.Property(e => e.AccountType).HasColumnName("account_type"); - builder.Property(e => e.SteamID).HasColumnName("steam_id"); - builder.Property(e => e.DiscordID).HasColumnName("discord_id"); - builder.Property(e => e.DiscordUsername).HasColumnName("discord_username").HasColumnType("varchar(32)"); ; - builder.Property(e => e.GameReplaysID).HasColumnName("gamereplays_id"); - builder.Property(e => e.GameReplaysUsername).HasColumnName("gamereplays_username").HasColumnType("varchar(32)"); ; - builder.Property(e => e.DisplayName).HasColumnName("displayname").HasColumnType("varchar(32)"); ; - builder.Property(e => e.LastLogin).HasColumnName("lastlogin").HasColumnType("datetime(6)"); - builder.Property(e => e.LastIPAddress).HasColumnName("last_ip").HasColumnType("varchar(45)"); ; - builder.Property(e => e.ClientID).HasColumnName("client_id"); - builder.Property(e => e.FavoriteColor).HasColumnName("favorite_color"); - builder.Property(e => e.FavoriteSide).HasColumnName("favorite_side"); - builder.Property(e => e.FavoriteMap).HasColumnName("favorite_map").HasColumnType("varchar(128)"); ; - builder.Property(e => e.FavoriteStartingMoney).HasColumnName("favorite_starting_money"); - builder.Property(e => e.LimitSuperweapons).HasColumnName("favorite_limit_superweapons"); - builder.Property(e => e.IsAdmin).HasColumnName("admin"); - builder.Property(e => e.IsBanned).HasColumnName("banned"); - builder.Property(e => e.EloRating).HasColumnName("elo_rating"); - builder.Property(e => e.EloNumberOfMatches).HasColumnName("elo_num_matches"); - builder.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; - builder.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; - builder.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; - builder.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; - } -} - public class AppDbContext : DbContext { public DbSet Users => Set(); diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 863024b..000bbab 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -62,49 +62,7 @@ public enum EAccountType -public class User -{ - public Int64 ID { get; set; } - public EAccountType AccountType { get; set; } = EAccountType.Unknown; - - // Steam, only present if AccountType is Steam - 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; - - // GameReplays, only present if AccountType is GameReplays - public Int64 GameReplaysID { get; set; } = -1; - public string GameReplaysUsername { get; set; } = String.Empty; - - - 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; - - // Gameplay Favorites - public int FavoriteColor { get; set; } = -1; - public int FavoriteSide { get; set; } = -1; - public string FavoriteMap { get; set; } = String.Empty; - public int FavoriteStartingMoney { get; set; } = -1; - public bool LimitSuperweapons { get; set; } = false; - - // User Permissions - public bool IsAdmin { get; set; } = false; - public bool IsBanned { get; set; } = false; - - // ELO - public int EloRating { get; set; } = EloConfig.BaseRating; - 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; -} + From daa698e4249a2ca7e30aacb8af38ecd4ee73dc8c Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:14:19 -0500 Subject: [PATCH 07/33] Moved IsUserAdmin and IsUserBanned to EFCore --- .../CheckLogin/CheckLoginController.cs | 10 ++-- .../LoginWithTokenController.cs | 4 +- .../Monitoring/MonitoringController.cs | 6 +- GenOnlineService/Database/Database.User.cs | 33 +++++++++++ GenOnlineService/Database/MySQL.cs | 59 ------------------- 5 files changed, 45 insertions(+), 67 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 5c25e91..97c5bbb 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -48,9 +48,11 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class CheckLoginController : ControllerBase { - public CheckLoginController() - { + private readonly AppDbContext _db; + public CheckLoginController(AppDbContext db) + { + _db = db; } [HttpPost] @@ -140,7 +142,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await Database.Functions.Auth.CreateUserIfNotExists_DevAccount(GlobalDatabaseInstance.g_Database, user_id, result.display_name); } - bool bIsAdmin = await Database.Functions.Auth.IsUserAdmin(GlobalDatabaseInstance.g_Database, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); #else CMySQLResult sqlRes = await GlobalDatabaseInstance.g_Database.Query("SELECT state FROM pending_logins WHERE code=@game_code LIMIT 1;", new() @@ -171,7 +173,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr if (clientID != null && Program.g_tokenGenerator != null) { // ban check - bool bIsBanned = await Database.Functions.Auth.IsUserBanned(GlobalDatabaseInstance.g_Database, user_id); + bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); if (bIsBanned) { result.result = EPendingLoginState.LoginFailed; diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index ea1ec94..57ac868 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -122,7 +122,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await Database.Functions.Auth.RegisterUserDevice(GlobalDatabaseInstance.g_Database, user_id, hwid_0, hwid_1, hwid_2, ipAddr); // ban check - bool bIsBanned = await Database.Functions.Auth.IsUserBanned(GlobalDatabaseInstance.g_Database, user_id); + bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); if (bIsBanned) { result.result = EPendingLoginState.LoginFailed; @@ -136,7 +136,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID); - bool bIsAdmin = await Database.Functions.Auth.IsUserAdmin(GlobalDatabaseInstance.g_Database, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); result.result = EPendingLoginState.LoginSuccess; diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index cfa070f..54c919e 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -91,11 +91,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class MonitoringController : ControllerBase { + private readonly AppDbContext _db; private readonly ILogger _logger; - public MonitoringController(ILogger logger) + public MonitoringController(AppDbContext db, ILogger logger) { _logger = logger; + _db = db; } [Route("ActiveUsers")] @@ -254,7 +256,7 @@ public APIResult Monitor_Uptime() { try { - GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(); + GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(_db); APIResult internalResult = await checkLoginController.Post_InternalHandler("{\"challenge\": \"abc\", \"nonce\": \"def\", \"code\": \"iamatest\", \"client_id\": \"gen_online_30hz\"}", IPAddress.Loopback.ToString(), true); return internalResult; } diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index b642e67..2195556 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -98,4 +98,37 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; builder.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; } +} + +namespace Database +{ + public static class Users + { + private static readonly Func> _isUserAdminQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.IsAdmin) + .FirstOrDefault()); + + private static readonly Func> _isUserBannedQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.IsBanned) + .FirstOrDefault()); + + + public static Task IsUserAdmin(AppDbContext db, long userId) + { + return _isUserAdminQuery(db, userId); + } + + public static Task IsUserBanned(AppDbContext db, long userId) + { + return _isUserBannedQuery(db, userId); + } + } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 000bbab..283d221 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -1380,65 +1380,6 @@ public static async Task CleanupPendingLogin(MySQLInstance m_Inst, string strGam ); } - - public async static Task GetAccountType(MySQLInstance m_Inst, Int64 userID) - { - var res = await m_Inst.Query("SELECT account_type FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", userID} - } - ); - - if (res != null && res.NumRows() > 0) - { - var row = res.GetRow(0); - - EAccountType account_type = (EAccountType)Convert.ToInt32(row["account_type"]); - return account_type; - } - - return EAccountType.Unknown; - } - - public async static Task IsUserAdmin(MySQLInstance m_Inst, Int64 userID) - { - var res = await m_Inst.Query("SELECT admin FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", userID} - } - ); - - if (res != null && res.NumRows() > 0) - { - var row = res.GetRow(0); - - return Convert.ToBoolean(row["admin"]); - } - - return false; - } - - public async static Task IsUserBanned(MySQLInstance m_Inst, Int64 userID) - { - var res = await m_Inst.Query("SELECT banned FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", userID} - } - ); - - if (res != null && res.NumRows() > 0) - { - var row = res.GetRow(0); - - return Convert.ToBoolean(row["banned"]); - } - - return false; - } - public async static Task RegisterUserDevice(MySQLInstance m_Inst, Int64 userID, string hwid_0, string hwid_1, string hwid_2, string ipAddr) { // raw version From 77b25a0f6033d2c0f2b720eded680536a71a003c Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:22:56 -0500 Subject: [PATCH 08/33] Migrated more of User to efcore --- GenOnlineService/Constants.cs | 4 ++-- .../Controllers/Lobbies/LobbiesController.cs | 7 ++++-- .../Controllers/Lobby/LobbyController.cs | 6 +++-- .../LoginWithTokenController.cs | 2 +- .../Monitoring/MonitoringController.cs | 2 +- .../Controllers/User/UserController.cs | 6 +++-- .../WebSocket/WebSocketController.cs | 5 +++- GenOnlineService/Database/Database.User.cs | 14 +++++++++++ GenOnlineService/Database/MySQL.cs | 23 ++----------------- 9 files changed, 37 insertions(+), 32 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 0e3e259..5ee78b5 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -150,9 +150,9 @@ public class UserSocialContainer static class WebSocketManager { public static int g_PeakConnectionCount = 0; - public static async Task CreateSession(bool bIsReconnect, Int64 ownerID, string client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + public static async Task CreateSession(AppDbContext _db, bool bIsReconnect, Int64 ownerID, string client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) { - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, ownerID); + string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data UserSession? userCacheData = WebSocketManager.GetDataFromUser(ownerID); diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index f9821f9..27a1f7c 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -64,11 +64,14 @@ public class LobbiesController : ControllerBase private static readonly object s_roomsLock = new object(); private readonly LobbyManager _lobbyManager; + private readonly AppDbContext _db; - public LobbiesController(LobbyManager lobbyManager, ILogger logger) + + public LobbiesController(LobbyManager lobbyManager, AppDbContext db, ILogger logger) { _logger = logger; _lobbyManager = lobbyManager; + _db = db; } // Cache rooms.json data to avoid disk I/O on every request @@ -369,7 +372,7 @@ public async Task Put() // cleanup any zombie lobbies await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); Int64 newLobbyID = await _lobbyManager.CreateLobby(playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame); diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 325785e..e128507 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -143,11 +143,13 @@ public class LobbyController : ControllerBase { private readonly ILogger _logger; private readonly LobbyManager _lobbyManager; + private readonly AppDbContext _db; - public LobbyController(LobbyManager lobbyManager, ILogger logger) + public LobbyController(LobbyManager lobbyManager, AppDbContext db, ILogger logger) { _logger = logger; _lobbyManager = lobbyManager; + _db = db; } [HttpGet("{lobby_id}")] @@ -716,7 +718,7 @@ public async Task Put(Int64 lobbyID) // leave any lobby _lobbyManager.LeaveAnyLobby(user_id); - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); result.success = bJoinedSuccessfully; diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 57ac868..a8ad7f1 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -133,7 +133,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID); bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 54c919e..9e7777d 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -151,7 +151,7 @@ public async Task Monitor_Database() // db call try { - string strDontCare = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, 0); + string strDontCare = await Database.Users.GetDisplayName(_db, 0); result.ok = true; } catch diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index ea98802..a62af96 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -42,11 +42,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { + private readonly AppDbContext _db; private readonly ILogger _logger; - public UsersController(ILogger logger) + public UsersController(AppDbContext db, ILogger logger) { _logger = logger; + _db = db; } [Authorize(Roles = "Player")] @@ -59,7 +61,7 @@ public async Task MyUser() if (user_id != -1) { - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); result.display_name = strDisplayName; result.user_id = user_id; diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 3c3ad54..8571f98 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -32,10 +32,12 @@ namespace GenOnlineService.Controllers public class WebSocketController : ControllerBase { private readonly LobbyManager _lobbyManager; + private readonly AppDbContext _db; - public WebSocketController(LobbyManager lobbyManager) + public WebSocketController(LobbyManager lobbyManager, AppDbContext db) { _lobbyManager = lobbyManager; + _db = db; } private static readonly JsonSerializerOptions JsonOpts = new() @@ -107,6 +109,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) string client_id = firstEntryClientID.Value; UserWebSocketInstance wsSess = await WebSocketManager.CreateSession( + _db, bIsReconnect, user_id, client_id, diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 2195556..8d45074 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -120,6 +120,15 @@ public static class Users .Select(u => u.IsBanned) .FirstOrDefault()); + private static readonly Func> _getDisplayNameQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.DisplayName) + .FirstOrDefault() + ); + public static Task IsUserAdmin(AppDbContext db, long userId) { @@ -130,5 +139,10 @@ public static Task IsUserBanned(AppDbContext db, long userId) { return _isUserBannedQuery(db, userId); } + + public static async Task GetDisplayName(AppDbContext db, long userId) + { + return await _getDisplayNameQuery(db, userId) ?? string.Empty; + } } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 283d221..65b8edc 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -1407,25 +1407,6 @@ public async static Task RegisterUserDevice(MySQLInstance m_Inst, Int64 userID, ); } - public async static Task GetDisplayName(MySQLInstance m_Inst, Int64 userID) - { - var res = await m_Inst.Query("SELECT displayname FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", userID} - } - ); - - if (res != null && res.NumRows() > 0) - { - var row = res.GetRow(0); - - string? displayname = Convert.ToString(row["displayname"]); - return displayname ?? String.Empty; - } - - return String.Empty; - } public async static Task> GetFriends(MySQLInstance m_Inst, Int64 user_id) { @@ -1664,7 +1645,7 @@ public static class DisplayNameCache private static readonly System.Collections.Concurrent.ConcurrentDictionary s_cache = new(); private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(24); - public static async Task GetCachedDisplayName(MySQLInstance m_Inst, Int64 userID) + public static async Task GetCachedDisplayName(AppDbContext _db, MySQLInstance m_Inst, Int64 userID) { if (s_cache.TryGetValue(userID, out var cached)) { @@ -1675,7 +1656,7 @@ public static async Task GetCachedDisplayName(MySQLInstance m_Inst, Int6 s_cache.TryRemove(userID, out _); } - string displayName = await GetDisplayName(m_Inst, userID); + string displayName = await Database.Users.GetDisplayName(_db, userID); s_cache.TryAdd(userID, (displayName, DateTime.UtcNow)); return displayName; } From 45f13dcd321515594df128b96eba040a88a76cc9 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:31:49 -0500 Subject: [PATCH 09/33] Moved user lobby prefs to efcore --- GenOnlineService/Constants.cs | 30 ---- .../Controllers/Lobbies/LobbiesController.cs | 2 +- .../Controllers/Lobby/LobbyController.cs | 2 +- GenOnlineService/Database/Database.User.cs | 31 ++++ GenOnlineService/Database/MySQL.cs | 133 ------------------ GenOnlineService/LobbyManager.cs | 12 +- GenOnlineService/MatchmakingManager.cs | 4 +- 7 files changed, 42 insertions(+), 172 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 5ee78b5..a459951 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -63,36 +63,6 @@ public enum EPendingLoginState LoginFailed = 2 }; - public enum EQoSRegions - { - UNKNOWN = -1, - WestUS = 0, - CentralUS = 1, - WestEurope = 2, - SouthCentralUS = 3, - NorthEurope = 4, - NorthCentralUS = 5, - EastUS = 6, - BrazilSouth = 7, - AustraliaEast = 8, - JapanWest = 9, - AustraliaSoutheast = 10, - EastAsia = 11, - JapanEast = 12, - SoutheastAsia = 13, - SouthAfricaNorth = 14, - UaeNorth = 15 - }; - - public enum EMappingTech - { - NONE = -1, - PCP, - UPNP, - NATPMP, - MANUAL - }; - public enum EIPVersion { IPV4 = 0, diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 27a1f7c..a3ec33e 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -373,7 +373,7 @@ public async Task Put() await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - Int64 newLobbyID = await _lobbyManager.CreateLobby(playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, + Int64 newLobbyID = await _lobbyManager.CreateLobby(_db, playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame); if (newLobbyID >= 0) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index e128507..c7921e9 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -719,7 +719,7 @@ public async Task Put(Int64 lobbyID) _lobbyManager.LeaveAnyLobby(user_id); string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); + bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(_db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); result.success = bJoinedSuccessfully; diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8d45074..2bcd722 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -19,6 +19,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; +using static Database.Functions.Auth; public class User { public Int64 ID { get; set; } @@ -63,6 +64,15 @@ public class User public string BanAliases { get; set; } = String.Empty; } +public class UserLobbyPreferences +{ + public int favorite_color = -1; + public int favorite_side = -1; + public string favorite_map = String.Empty; + public int favorite_starting_money = -1; + public bool favorite_limit_superweapons = false; +} + public class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -129,6 +139,22 @@ public static class Users .FirstOrDefault() ); + private static readonly Func> _getUserLobbyPreferencesQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => new UserLobbyPreferences + { + favorite_color = u.FavoriteColor, + favorite_side = u.FavoriteSide, + favorite_map = u.FavoriteMap, + favorite_starting_money = u.FavoriteStartingMoney, + favorite_limit_superweapons = u.LimitSuperweapons + }) + .FirstOrDefault() + ); + public static Task IsUserAdmin(AppDbContext db, long userId) { @@ -144,5 +170,10 @@ public static async Task GetDisplayName(AppDbContext db, long userId) { return await _getDisplayNameQuery(db, userId) ?? string.Empty; } + + public static Task GetUserLobbyPreferences(AppDbContext db, long userId) + { + return _getUserLobbyPreferencesQuery(db, userId); + } } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 65b8edc..d084d09 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -964,40 +964,7 @@ public async static Task Cleanup(MySQLInstance m_Inst, bool bStartup) } ); } - public class UserLobbyPreferences - { - public int favorite_color = -1; - public int favorite_side = -1; - public string favorite_map = String.Empty; - public int favorite_starting_money = -1; - public int favorite_limit_superweapons = -1; - } - - public async static Task GetUserLobbyPreferences(MySQLInstance m_Inst, Int64 user_id) - { - var res = await m_Inst.Query("SELECT favorite_color, favorite_side, favorite_map, favorite_starting_money, favorite_limit_superweapons FROM users WHERE user_id=@user_id;", - new() - { - { "@user_id", user_id } - } - ); - if (res.NumRows() > 0) - { - CMySQLRow row = res.GetRow(0); - - UserLobbyPreferences lobbyPrefs = new UserLobbyPreferences(); - lobbyPrefs.favorite_color = Convert.ToInt32(row["favorite_color"]); - lobbyPrefs.favorite_side = Convert.ToInt32(row["favorite_side"]); - lobbyPrefs.favorite_map = Convert.ToString(row["favorite_map"]) ?? String.Empty; - lobbyPrefs.favorite_starting_money = Convert.ToInt32(row["favorite_starting_money"]); - lobbyPrefs.favorite_limit_superweapons = Convert.ToInt32(row["favorite_limit_superweapons"]); - - return lobbyPrefs; - } - - return null; - } public async static Task SetFavorite_Color(MySQLInstance m_Inst, Int64 user_id, int favorite_color) { @@ -1625,106 +1592,6 @@ internal static async Task CreateUserIfNotExists_DevAccount(MySQLInstance m_Inst ); } } - - internal static async Task SetUserPortMappingTech(MySQLInstance m_Inst, Int64 user_id, EMappingTech mappingTech, bool bIPV4, bool bIPV6) - { - await m_Inst.Query("UPDATE users SET portmapping_tech=@mappingTech, ipv4=@ipv4, ipv6=@ipv6 WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", user_id}, - { "@mapping_tech", mappingTech}, - { "@ipv4", bIPV4}, - { "@ipv6", bIPV6} - } - ); - } - - // Cache for display names (24-hour TTL - names rarely change) - public static class DisplayNameCache - { - private static readonly System.Collections.Concurrent.ConcurrentDictionary s_cache = new(); - private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(24); - - public static async Task GetCachedDisplayName(AppDbContext _db, MySQLInstance m_Inst, Int64 userID) - { - if (s_cache.TryGetValue(userID, out var cached)) - { - if (DateTime.UtcNow - cached.CachedAt < s_cacheDuration) - { - return cached.DisplayName; - } - s_cache.TryRemove(userID, out _); - } - - string displayName = await Database.Users.GetDisplayName(_db, userID); - s_cache.TryAdd(userID, (displayName, DateTime.UtcNow)); - return displayName; - } - - public static async Task> GetCachedDisplayNameBulk(MySQLInstance m_Inst, List lstUserIDs) - { - Dictionary result = new(); - List uncachedIDs = new(); - - foreach (Int64 userID in lstUserIDs) - { - if (s_cache.TryGetValue(userID, out var cached) && DateTime.UtcNow - cached.CachedAt < s_cacheDuration) - { - result[userID] = cached.DisplayName; - } - else - { - s_cache.TryRemove(userID, out _); - uncachedIDs.Add(userID); - } - } - - if (uncachedIDs.Count > 0) - { - Dictionary dbResults = await GetDisplayNameBulk(m_Inst, uncachedIDs); - foreach (var kvp in dbResults) - { - s_cache.TryAdd(kvp.Key, (kvp.Value, DateTime.UtcNow)); - result[kvp.Key] = kvp.Value; - } - } - - return result; - } - - public static void InvalidateCache(Int64 userID) - { - s_cache.TryRemove(userID, out _); - } - } - - // Cache for user lobby preferences (1-hour TTL) - public static class UserPreferencesCache - { - private static readonly System.Collections.Concurrent.ConcurrentDictionary s_cache = new(); - private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(1); - - public static async Task GetCachedPreferences(MySQLInstance m_Inst, Int64 userID) - { - if (s_cache.TryGetValue(userID, out var cached)) - { - if (DateTime.UtcNow - cached.CachedAt < s_cacheDuration) - { - return cached.Prefs; - } - s_cache.TryRemove(userID, out _); - } - - UserLobbyPreferences prefs = await GetUserLobbyPreferences(m_Inst, userID); - s_cache.TryAdd(userID, (prefs, DateTime.UtcNow)); - return prefs; - } - - public static void InvalidateCache(Int64 userID) - { - s_cache.TryRemove(userID, out _); - } - } } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 356bba0..3090212 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1150,7 +1150,7 @@ private async void HandleLobbyNeedsDestroyed(Lobby lobby) await DeleteLobby(lobby); } - public async Task CreateLobby(UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, + public async Task CreateLobby(AppDbContext _db, UserSession owningSession, string strOwnerDisplayName, string strName, string strMapName, string strMapPath, bool bMapOfficial, int maxPlayers, string HostIPAddr, UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType) { @@ -1171,8 +1171,8 @@ public async Task CreateLobby(UserSession owningSession, string strOwnerD UInt32 starting_cash = default_starting_cash; if (lobbyType == ELobbyType.CustomGame) { - UserLobbyPreferences? lobbyPrefs = await Database.Functions.Auth.GetUserLobbyPreferences(GlobalDatabaseInstance.g_Database, owningSession.m_UserID); - bLimitSuperweapons = lobbyPrefs != null ? lobbyPrefs.favorite_limit_superweapons == 1 : false; // limit superweapons (NOTE: not present in clientside create lobby UI) + UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, owningSession.m_UserID); + bLimitSuperweapons = lobbyPrefs != null ? lobbyPrefs.favorite_limit_superweapons : false; // limit superweapons (NOTE: not present in clientside create lobby UI) // sane defaults if (lobbyPrefs != null && lobbyPrefs.favorite_starting_money > 0) @@ -1192,7 +1192,7 @@ public async Task CreateLobby(UserSession owningSession, string strOwnerD // and join if (lobbyType != ELobbyType.QuickMatch) // quickmatch requires a manual join, because the service creates the lobby for them, so the client knows nothing about it without a manual join { - bool bJoined = await JoinLobby(newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + bool bJoined = await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); } newLobby.DirtyRetransmit(); @@ -1211,9 +1211,9 @@ public async Task Tick() } } - public async Task JoinLobby(Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) + public async Task JoinLobby(AppDbContext _db, Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) { - UserLobbyPreferences? lobbyPrefs = await Database.Functions.Auth.GetUserLobbyPreferences(GlobalDatabaseInstance.g_Database, playerSession.m_UserID); + UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, playerSession.m_UserID); if (lobbyPrefs != null) { diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index b391abb..19f8f4f 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -762,7 +762,9 @@ await SendMatchmakingMessage(memberSession, // make a lobby DetermineMap(out string strMapName, out string strMapPath); - m_LobbyID = await lobbyManager.CreateLobby(dummyHostUser, dummyHostUser.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUser.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch); // tell both to join our lobby From 4d76d3e9de52eca06e05fa961db769bdc4462eeb Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:55:35 -0500 Subject: [PATCH 10/33] Reworked sessions, shared data and websockets to allow for multiple instances of client (one per class) to be logged in simultaneously --- GenOnlineService/Constants.cs | 498 +++++++++++++----- .../CheckLogin/CheckLoginController.cs | 31 +- .../Controllers/Friends/SocialController.cs | 170 +++--- .../Controllers/Lobbies/LobbiesController.cs | 12 +- .../Controllers/Lobby/LobbyController.cs | 60 ++- .../LoginWithTokenController.cs | 91 ++-- .../MatchReplay/MatchReplayController.cs | 5 +- .../MatchUpdate/MatchUpdateController.cs | 5 +- .../Matchmaking/MatchmakingController.cs | 15 +- .../Monitoring/MonitoringController.cs | 30 +- .../Controllers/OID/OIDController.cs | 6 +- .../PlayerStats/PlayerStatsController.cs | 41 +- .../Controllers/User/UserController.cs | 33 +- .../WebSocket/WebSocketController.cs | 126 +++-- GenOnlineService/Database/MySQL.cs | 30 +- GenOnlineService/Discord.cs | 58 +- GenOnlineService/LobbyManager.cs | 16 +- GenOnlineService/MatchmakingManager.cs | 177 ++++--- GenOnlineService/Program.cs | 88 +++- 19 files changed, 931 insertions(+), 561 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index a459951..deb3b1a 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -21,6 +21,7 @@ using Org.BouncyCastle.Tls; using System; using System.Collections.Concurrent; +using System.Diagnostics.Metrics; using System.Globalization; using System.Net; using System.Net.Sockets; @@ -116,23 +117,88 @@ public class UserSocialContainer public HashSet Blocked { get; set; } = new HashSet(); } + // NOTE: If you add to the below, make sure you initialize the dictionary + public enum EUserSessionType + { + None = -1, + GameClient = 0, + ChatClient = 1, + GameLauncher = 2 + } + + public static class SocialHelper + { + public static void NotifyFriendslistDirty(Int64 userID) + { + // serialize + WebSocketMessage_Social_FriendsListDirty friendsListDirtyEvent = new(); + friendsListDirtyEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIENDS_LIST_DIRTY; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendsListDirtyEvent)); + + // send it to all sessions that are subscribed for realtime updates + WebSocketManager.GetAllDataFromUser(userID).ForEach(session => + { + if (session.IsSubscribedToRealtimeSocialUpdates()) + { + session.QueueWebsocketSend(bytesJSON); + } + }); + } + } + + public static class WebsocketHelper + { + public static void SendToAllSessionsOfUser(Int64 userID, byte[] bytesData) + { + WebSocketManager.GetAllDataFromUser(userID).ForEach(session => + { + session.QueueWebsocketSend(bytesData); + }); + } + } + + + + + public static class KnownClients + { + public enum EKnownClients + { + unknown = -1, + gen_online_30hz = 0, + gen_online_60hz = 1, + genhub = 2, + communityoutpost_chat = 3 + } + + public static ConcurrentDictionary KnownClientSessionTypes = new() + { + [EKnownClients.gen_online_30hz] = EUserSessionType.GameClient, + [EKnownClients.gen_online_60hz] = EUserSessionType.GameClient, + [EKnownClients.genhub] = EUserSessionType.GameLauncher, + [EKnownClients.communityoutpost_chat] = EUserSessionType.ChatClient + }; + } + + + // TODO static class WebSocketManager { public static int g_PeakConnectionCount = 0; - public static async Task CreateSession(AppDbContext _db, bool bIsReconnect, Int64 ownerID, string client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + public static async Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) { string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data - UserSession? userCacheData = WebSocketManager.GetDataFromUser(ownerID); + UserSession? userCacheData = WebSocketManager.GetSessionFromUser(ownerID, sessionType); if (bIsReconnect) { // this is a reconnect, re-use cache Console.WriteLine("--> WEBSOCKET RECONNECT"); - // if its a reconnect, and we dont have cache, its probably a server restart, so return null - if (userCacheData == null) + // if its a reconnect, and we dont have cache OR shared data, its probably a server restart, so return null + if (userCacheData == null || !m_dictSharedUserData.ContainsKey(ownerID)) { return null; } @@ -141,11 +207,16 @@ public static async Task CreateSession(AppDbContext _db, // clear abandoned flag userCacheData.MarkNotAbandoned(); } + + // nothing to do here for shared user data, since the session was abandoned but not fully destroyed, it should still have user data } else { Console.WriteLine("--> WEBSOCKET CONNECT"); + // how many other sessions do they have online? + bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; + // get and cache social container UserSocialContainer socialContainer = new(); socialContainer.Friends = await Database.Functions.Auth.GetFriends(GlobalDatabaseInstance.g_Database, ownerID); @@ -155,23 +226,52 @@ public static async Task CreateSession(AppDbContext _db, // get stats PlayerStats GameStats = await Database.Functions.Auth.GetPlayerStats(GlobalDatabaseInstance.g_Database, ownerID); - userCacheData = new UserSession(ownerID, socialContainer, client_id, strDisplayName, strContinent, strCountry, dLatitude, dLongitude, bIsAdmin, GameStats); - m_dictUserSessions[ownerID] = userCacheData; + userCacheData = new UserSession(ownerID, sessionType, client_id, strContinent, strCountry, dLatitude, dLongitude); + m_dictUserSessions[sessionType][ownerID] = userCacheData; + + // TODO_SOCIAL: Move this to a class + // inform any friends who are online that this person just came online (if they had no other sessions prior) + if (bIsFirstSessionForUser) + { + WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); + friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; + friendStatusChangedEvent.display_name = strDisplayName; + friendStatusChangedEvent.online = true; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); + + // friends are reciprocal so we can just iterate our friends + foreach (Int64 friendID in socialContainer.Friends) + { + WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); + } + } + + // TODO_EFCORE: check reconnect again, reconnect shouldnt increment ref count (nothing is done above) + // create or increment shared data + if (m_dictSharedUserData.TryGetValue(ownerID, out SharedUserData? sharedData)) + { + // increment + sharedData.IncrementRefCount(); + } + else + { + m_dictSharedUserData[ownerID] = new SharedUserData(ownerID, socialContainer, strDisplayName, bIsAdmin, GameStats); + } } - // kill any existing sessions for this user - if (m_dictWebsockets.TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) + // 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, existingSession, !bIsReconnect); + await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); } - // now create a session - UserWebSocketInstance newSess = new UserWebSocketInstance(ownerID, strDisplayName, userCacheData.GetSocialContainer(), userCacheData.GameStats); - m_dictWebsockets[ownerID] = newSess; + // 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; - // update last login and last ip - await Database.Functions.Auth.UpdateLastLoginData(GlobalDatabaseInstance.g_Database, ownerID, ipAddr); + // update last login and last ip + await Database.Functions.Auth.UpdateLastLoginData(GlobalDatabaseInstance.g_Database, ownerID, ipAddr); int numSessions = m_dictWebsockets.Count; if (numSessions > g_PeakConnectionCount) @@ -181,14 +281,16 @@ public static async Task CreateSession(AppDbContext _db, Console.Title = String.Format("GenOnline - {0} players", m_dictWebsockets.Count); + SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(ownerID); + // inform the user of any pending friends activities { int numOnline = 0; - int numPending = userCacheData.GetSocialContainer().PendingRequests.Count; + int numPending = sharedUserData.GetSocialContainer().PendingRequests.Count; - foreach (Int64 friendID in userCacheData.GetSocialContainer().Friends) + foreach (Int64 friendID in sharedUserData.GetSocialContainer().Friends) { - if (WebSocketManager.GetDataFromUser(friendID) != null) + if (WebSocketManager.GetSessionFromUser(friendID, sessionType) != null) { ++numOnline; } @@ -216,65 +318,86 @@ public static async Task Tick() // into the dequeue loop guard, so the stuck user is skipped and their unsent // messages stay in the queue for the next tick. using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(20)); - await Task.WhenAll(m_dictUserSessions.Values.Select(sess => sess.TickWebsocket(cts.Token))); + await Task.WhenAll(m_dictUserSessions.Values.SelectMany(inner => inner.Values).Select(sess => sess.TickWebsocket(cts.Token))); } public static async Task CheckForTimeouts() { List lstSessionsToDestroy = new(); - foreach (KeyValuePair sessionData in m_dictWebsockets) + foreach (var sessionDataByClient in m_dictWebsockets) { + foreach (var sessionData in sessionDataByClient.Value) + { #if DEBUG - const int timeoutVal = 60000 * 10; + const int timeoutVal = 60000 * 10; #else - const int timeoutVal = 20000; + const int timeoutVal = 20000; #endif - if (sessionData.Value.GetTimeSinceLastPing() >= timeoutVal) - { - lstSessionsToDestroy.Add(sessionData.Value); - } - else - { - await sessionData.Value.SendPong(); + if (sessionData.Value.GetTimeSinceLastPing() >= timeoutVal) + { + lstSessionsToDestroy.Add(sessionData.Value); + } + else + { + await sessionData.Value.SendPong(); + } } } foreach (UserWebSocketInstance wsSess in lstSessionsToDestroy) { Console.WriteLine("Timing out WS session for {0}", wsSess.m_UserID); - await DeleteSession(wsSess.m_UserID, wsSess, false); + await DeleteSession(wsSess.m_UserID, wsSess.m_SessionType, wsSess, false); } // do we need to clear out cache entries? - List lstCacheEntriesToDestroy = new(); - foreach (var kvPair in m_dictUserSessions) + List> lstCacheEntriesToDestroy = new(); + foreach (var sessionDataPerClientType in m_dictUserSessions) { - if (kvPair.Value.IsAbandoned()) + foreach (var sessionData in sessionDataPerClientType.Value) { - if (kvPair.Value.NeedsCleanup()) + if (sessionData.Value.IsAbandoned()) { - lstCacheEntriesToDestroy.Add(kvPair.Key); + if (sessionData.Value.NeedsCleanup()) + { + lstCacheEntriesToDestroy.Add(new Tuple(sessionData.Key, sessionData.Value.GetSessionType())); + } } } } - foreach (Int64 userID in lstCacheEntriesToDestroy) + foreach (Tuple userData in lstCacheEntriesToDestroy) { - ClearDataFromUser(userID); + ClearDataFromUser(userData.Item1, userData.Item2); } } - public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) + public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) { - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); + SharedUserData? sourceSharedData = WebSocketManager.GetSharedDataForUser(user_id); if (oldWS != null) { try { // dont remove by ID, user could have re-opened another websocket open via reconnection, remove by instance, if its not there, thats OK, it was already closed and the new instance is a reconnect - var item = m_dictWebsockets.First(kvp => kvp.Value == oldWS); - m_dictWebsockets.Remove(item.Key, out UserWebSocketInstance? destroyedSess); + 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); + + // decrement ref count on shared data + if (m_dictSharedUserData.TryGetValue(user_id, out SharedUserData? sharedData)) + { + sharedData.DecrementRefCount(); + if (sharedData.NeedsGC()) // cleanup if necessary + { + m_dictSharedUserData.Remove(user_id, out var removedSharedData); + } + } + else + { + Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", user_id); + } } catch { @@ -284,7 +407,7 @@ public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? old if (bShouldInvalidatePlayerCacheToBlockReconnect) { - WebSocketManager.ClearDataFromUser(user_id); + WebSocketManager.ClearDataFromUser(user_id, sessionType); } else { @@ -295,29 +418,23 @@ public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? old } } + // NOTE: They only went offline if ref count became 0, otherwise they're still online somewhere else + if (sourceData != null && sourceSharedData != null && sourceSharedData.NeedsGC()) { // TODO_SOCIAL: Move this to a class // inform any friends who are online that this person just came online WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; - friendStatusChangedEvent.display_name = sourceData.m_strDisplayName; + friendStatusChangedEvent.display_name = sourceSharedData.m_strDisplayName; friendStatusChangedEvent.online = false; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); if (sourceData != null) { // friends are reciprocal so we can just iterate our friends - foreach (Int64 friendID in sourceData.GetSocialContainer().Friends) + foreach (Int64 friendID in sourceSharedData.GetSocialContainer().Friends) { - UserSession? friendSession = WebSocketManager.GetDataFromUser(friendID); - - if (friendSession != null) - { - // TODO_SOCIAL: Await? -#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - friendSession.QueueWebsocketSend(bytesJSON); -#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - } + WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); } } } @@ -351,9 +468,10 @@ public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? old } */ + // TODO_EFCORE: Just use a weakref to the websocket from the user session instead of lookups public static UserWebSocketInstance? GetWebSocketForSession(UserSession session) { - if (m_dictWebsockets.TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) + if (m_dictWebsockets[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) { return retVal; } @@ -364,18 +482,58 @@ public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? old } - private static ConcurrentDictionary m_dictWebsockets = new(); + private static ConcurrentDictionary> m_dictWebsockets = new() + { + // Initialize everything ahead of time so we don't have to keep doing lookups to see if it exists + [EUserSessionType.GameClient] = new(), + [EUserSessionType.GameLauncher] = new(), + [EUserSessionType.ChatClient] = new(), + }; + + private static ConcurrentDictionary> m_dictUserSessions = new() + { + // Initialize everything ahead of time so we don't have to keep doing lookups to see if it exists + [EUserSessionType.GameClient] = new (), + [EUserSessionType.GameLauncher] = new (), + [EUserSessionType.ChatClient] = new (), + }; + + private static ConcurrentDictionary m_dictSharedUserData = new(); - private static ConcurrentDictionary m_dictUserSessions = new(); - - public static ConcurrentDictionary GetUserDataCache() + public static ConcurrentDictionary> GetUserDataCache() { return m_dictUserSessions; } - public static UserSession? GetDataFromUser(Int64 userID) + public static SharedUserData? GetSharedDataForUser(string strDisplayName) + { + foreach (var kvPair in m_dictSharedUserData) + { + if (String.Equals(kvPair.Value.m_strDisplayName, strDisplayName, StringComparison.OrdinalIgnoreCase)) + { + return kvPair.Value; + } + } + + return null; + } + + + public static SharedUserData? GetSharedDataForUser(Int64 userID) + { + if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? retVal)) + { + return retVal; + } + else + { + return null; + } + } + + public static UserSession? GetSessionFromUser(Int64 userID, EUserSessionType sessionType) { - if (m_dictUserSessions.TryGetValue(userID, out UserSession? retVal)) + if (m_dictUserSessions[sessionType].TryGetValue(userID, out UserSession? retVal)) { return retVal; } @@ -385,15 +543,31 @@ public static ConcurrentDictionary GetUserDataCache() } } - public static async Task ClearDataFromUser(Int64 userID) + public static List GetAllDataFromUser(Int64 userID) + { + List lstRet = new(); + + foreach (var sessionByClient in m_dictUserSessions) + { + if (sessionByClient.Value.TryGetValue(userID, out UserSession? sess)) + { + lstRet.Add(sess); + } + } + + return lstRet; + } + + public static async Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) { // NOTE: This is when a player is truly disconnected and we can destroy session, remove form lobby etc, websocket disconnect doesnt mean that because the clietn reconnects try { UserSession? userData = null; - if (m_dictUserSessions.ContainsKey(userID)) + + if (m_dictUserSessions[sessionType].ContainsKey(userID)) { - userData = m_dictUserSessions[userID]; + userData = m_dictUserSessions[sessionType][userID]; } await Database.Functions.Auth.FullyDestroyPlayerSession(GlobalDatabaseInstance.g_Database, userID, userData, true); } @@ -402,7 +576,7 @@ public static async Task ClearDataFromUser(Int64 userID) } - return m_dictUserSessions.Remove(userID, out var itemRemoved); + return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); } @@ -417,13 +591,16 @@ public static async Task SendNewOrDeletedLobbyToAllNetworkRoomMembers(int networ byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(lobbyListUpdate)); // populate list of everyone in the room - foreach (KeyValuePair sessionData in m_dictUserSessions) + foreach (var sessionDataByClient in m_dictUserSessions) { - if (sessionData.Value != null) + foreach (var sessionData in sessionDataByClient.Value) { - if (sessionData.Value.networkRoomID == networkRoomID || sessionData.Value.networkRoomID == 0) + if (sessionData.Value != null) { - sessionData.Value.QueueWebsocketSend(bytesJSON); + if (sessionData.Value.networkRoomID == networkRoomID || sessionData.Value.networkRoomID == 0) + { + sessionData.Value.QueueWebsocketSend(bytesJSON); + } } } } @@ -441,29 +618,53 @@ public static async Task TickRoomMemberList() memberListUpdate.msg_id = (int)EWebSocketMessageID.NETWORK_ROOM_MEMBER_LIST_UPDATE; memberListUpdate.members = new(); - SortedDictionary usersAlreadyProcessed = new(); + Dictionary> usersAlreadyProcessed = new(); + // create base + foreach (EUserSessionType sessionType in Enum.GetValues()) + { + usersAlreadyProcessed[sessionType] = new SortedDictionary(); + } - List lstUsersToSend = new(); + List lstUsersToSend = new(); // populate list of everyone in the room - foreach (KeyValuePair sessionData in m_dictUserSessions) + foreach (var sessionDataByClient in m_dictUserSessions) { - UserSession sess = sessionData.Value; - if (sess.networkRoomID == roomID) + foreach (var sessionData in sessionDataByClient.Value) { - if (!usersAlreadyProcessed.ContainsKey(sess.m_UserID)) + UserSession sess = sessionData.Value; + if (sess.networkRoomID == roomID) { - usersAlreadyProcessed[sess.m_UserID] = true; + EUserSessionType sessType = sessionData.Value.GetSessionType(); + if (!usersAlreadyProcessed[sessType].ContainsKey(sess.m_UserID)) + { + usersAlreadyProcessed[sessType][sess.m_UserID] = true; - // add to member list - string strDisplayName = sess.IsAdmin() ? String.Format("[\u2605\u2605GO STAFF\u2605\u2605] {0}", sess.m_strDisplayName) : sess.m_strDisplayName; - memberListUpdate.members.Add(new RoomMember(sess.m_UserID, strDisplayName, sess.IsAdmin())); + SharedUserData? sharedUserData = WebSocketManager.GetSharedDataForUser(sess.m_UserID); + if (sharedUserData != null) + { + // add to member list + string strDisplayName = sharedUserData.IsAdmin() ? String.Format("[\u2605\u2605GO STAFF\u2605\u2605] {0}", sharedUserData.m_strDisplayName) : sharedUserData.m_strDisplayName; + + // append client, if not game + if (sessType != EUserSessionType.GameClient) + { + if (sessType == EUserSessionType.GameLauncher) + { + strDisplayName += " [LAUNCHER]"; + } + else if (sessType == EUserSessionType.ChatClient) + { + strDisplayName += " [WEBCHAT]"; + } + } + - // also add to list of users who need this update, since they were in there - UserSession? targetWS = WebSocketManager.GetDataFromUser(sess.m_UserID); - if (targetWS != null) - { - lstUsersToSend.Add(targetWS); + memberListUpdate.members.Add(new RoomMember(sess.m_UserID, strDisplayName, sharedUserData.IsAdmin())); + + // also add to list of users who need this update, since they were in there + lstUsersToSend.Add(sess.m_UserID); + } } } } @@ -471,10 +672,19 @@ public static async Task TickRoomMemberList() byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(memberListUpdate)); + // what if they have clients in different net rooms? + // now send to everyone in the room - foreach (UserSession sess in lstUsersToSend) + foreach (Int64 user_id in lstUsersToSend) { - sess.QueueWebsocketSend(bytesJSON); + // find all of their websockets, and send it to any who are in this network room + foreach (UserSession sess in WebSocketManager.GetAllDataFromUser(user_id)) + { + if (sess.networkRoomID == roomID) + { + sess.QueueWebsocketSend(bytesJSON); + } + } } } @@ -487,15 +697,65 @@ public static async Task MarkRoomMemberListAsDirty(int roomID) } } - public class UserSession + // NOTE: only one instance for ALL websockets/sessions, and is destroyed when the last one of the former is destroyed + public class SharedUserData { + private int m_RefCount = 0; + + public void IncrementRefCount() + { + Interlocked.Increment(ref m_RefCount); + } + + public void DecrementRefCount() + { + Interlocked.Decrement(ref m_RefCount); + } + + public bool NeedsGC() + { + return m_RefCount <= 0; + } + public Int64 m_UserID = -1; public string m_strDisplayName = String.Empty; + private bool m_bIsAdmin; + + // contains ELO too + public PlayerStats? GameStats { get; private set; } = null; + + private UserSocialContainer m_socialContainer; + + public UserSocialContainer GetSocialContainer() { return m_socialContainer; } + + public bool IsAdmin() { return m_bIsAdmin; } + + public SharedUserData(Int64 ownerID, UserSocialContainer socialContainer, string strDisplayName, bool bIsAdmin, PlayerStats userStats) + { + m_strDisplayName = strDisplayName; + m_bIsAdmin = bIsAdmin; + + m_UserID = ownerID; + + m_socialContainer = socialContainer; + + GameStats = userStats; + + // upon creation, immediately increment ref count + IncrementRefCount(); + } + } + + public class UserSession + { + public Int64 m_UserID = -1; + public string m_strContinent; public string m_strCountry; public double m_dLatitude; public double m_dLongitude; - private bool m_bIsAdmin; + + private EUserSessionType m_sessionType = EUserSessionType.None; private string ACExeCRC = String.Empty; @@ -514,7 +774,7 @@ public class UserSession private string m_strMiddlewareUserID = String.Empty; - public string m_client_id = String.Empty; + public KnownClients.EKnownClients m_client_id = KnownClients.EKnownClients.unknown; DateTime m_CreateTime = DateTime.Now; public DateTime GetCreationTime() { @@ -531,6 +791,11 @@ public string GetMiddlewareID() return m_strMiddlewareUserID; } + public EUserSessionType GetSessionType() + { + return m_sessionType; + } + public UInt64 GetLatestMatchID() { UInt64 mostRecentMatchID = 0; @@ -547,15 +812,14 @@ public TimeSpan GetDuration() return DateTime.Now - m_CreateTime; } - public UserSession(Int64 ownerID, UserSocialContainer socialContainer, string client_id, string strDisplayName, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin, PlayerStats userStats) + public UserSession(Int64 ownerID, EUserSessionType sessionType, KnownClients.EKnownClients client_id, string strContinent, string strCountry, double dLatitude, double dLongitude) { + m_sessionType = sessionType; m_client_id = client_id; - m_strDisplayName = strDisplayName; m_strContinent = strContinent; m_strCountry = strCountry; m_dLatitude = dLatitude; m_dLongitude = dLongitude; - m_bIsAdmin = bIsAdmin; m_UserID = ownerID; @@ -565,10 +829,6 @@ public UserSession(Int64 ownerID, UserSocialContainer socialContainer, string cl ACExeCRC = Helpers.g_dictInitialExeCRCs[ownerID].ToUpper(); Helpers.g_dictInitialExeCRCs.Remove(ownerID, out string removedCRC); } - - m_socialContainer = socialContainer; - - GameStats = userStats; } public void MarkAbandoned() @@ -585,6 +845,7 @@ public bool IsAbandoned() return m_timeAbandoned != -1; } + // TODO_EFCORE: check all uses of QueueWebsocketSend, some might need to be SendToAllInstances public void QueueWebsocketSend(byte[] bytesJSON) { if (bytesJSON == null) @@ -628,34 +889,12 @@ public async Task TickWebsocket(CancellationToken tickToken = default) // TODO_CACHE: Size limit this? ConcurrentQueue m_lstPendingWebsocketSends = new ConcurrentQueue(); - public void NotifyFriendslistDirty() - { - UserSession? userData = WebSocketManager.GetDataFromUser(m_UserID); - - if (userData.IsSubscribedToRealtimeSocialUpdates()) - { - WebSocketMessage_Social_FriendsListDirty friendsListDirtyEvent = new(); - friendsListDirtyEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIENDS_LIST_DIRTY; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendsListDirtyEvent)); - QueueWebsocketSend(bytesJSON); - } - } - public bool NeedsCleanup() { const Int64 timeBeforeConsideredAbandoned = 30000; // 5 minutes return Environment.TickCount64 - m_timeAbandoned >= timeBeforeConsideredAbandoned; } - // contains ELO too - public PlayerStats? GameStats { get; private set; } = null; - - private UserSocialContainer m_socialContainer; - - public UserSocialContainer GetSocialContainer() { return m_socialContainer; } - - public bool IsAdmin() { return m_bIsAdmin; } - private bool m_bSubscribedToRealtimeSocialupdates = false; public void SetSubscribedToRealtimeSocialUpdates(bool bSubscribe) { @@ -737,6 +976,7 @@ public async Task UpdateSessionNetworkRoom(Int16 newRoomID) public void UpdateSessionLobbyID(Int64 newLobbyID) { + // TODO_EFCORE: Only if game client currentLobbyID = newLobbyID; } @@ -751,6 +991,7 @@ public void UpdateSessionLobbyID(Int64 newLobbyID) public class UserWebSocketInstance { // cached user data, useful + public EUserSessionType m_SessionType = EUserSessionType.None; public Int64 m_UserID = -1; public Int64 m_lastPingTime = Environment.TickCount64; // last time we pinged this user, used to detect disconnects @@ -772,31 +1013,10 @@ public async Task SendPong() private WebSocket? m_SockInternal = null; - public UserWebSocketInstance(Int64 ownerID, string strDisplayName, UserSocialContainer socialContainer, PlayerStats inGameStats) : base() + public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID) : base() { + m_SessionType = sessionType; m_UserID = ownerID; - - // TODO_SOCIAL: Move this to a class - // inform any friends who are online that this person just came online - WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); - friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; - friendStatusChangedEvent.display_name = strDisplayName; - friendStatusChangedEvent.online = true; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); - - // friends are reciprocal so we can just iterate our friends - foreach (Int64 friendID in socialContainer.Friends) - { - UserSession? friendSession = WebSocketManager.GetDataFromUser(friendID); - - if (friendSession != null) - { - // TODO_SOCIAL: Await? -#pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - friendSession.QueueWebsocketSend(bytesJSON); -#pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed - } - } } public void AttachWebsocket(WebSocket sock) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 97c5bbb..1c3d095 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -125,12 +125,15 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr UInt32 highestIDFound = 0; // which account should we use? var sessions = WebSocketManager.GetUserDataCache(); - foreach (KeyValuePair sessionData in sessions) + foreach (var sessionDataByClient in sessions) { - UserSession sessIter = sessionData.Value; - if (sessIter.m_UserID > highestIDFound) + foreach (var sessionData in sessionDataByClient.Value) { - highestIDFound = (UInt32)sessIter.m_UserID; + UserSession sessIter = sessionData.Value; + if (sessIter.m_UserID > highestIDFound) + { + highestIDFound = (UInt32)sessIter.m_UserID; + } } } @@ -156,9 +159,9 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr Int64 user_id = await Database.Functions.Auth.GetUserIDFromPendingLogin(GlobalDatabaseInstance.g_Database, gameCode); //string sess_id = await Database.Functions.Auth.StartSession(GlobalDatabaseInstance.g_Database, user_id, clientID); //string autologin_token = await Database.Functions.Auth.CreateAutoLogin(GlobalDatabaseInstance.g_Database, user_id); - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - bool bIsAdmin = await Database.Functions.Auth.IsUserAdmin(GlobalDatabaseInstance.g_Database, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); #endif if (state == EPendingLoginState.Waiting) @@ -181,10 +184,14 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr return result; } - // full login - if (clientID == "gen_online_60hz" || clientID == "gen_online_30hz" || clientID == "genhub") + // full login (known clients) + if (Enum.TryParse(typeof(KnownClients.EKnownClients), clientID, ignoreCase: true, out object knownClientIDObj)) { - if (clientID == "gen_online_60hz" || clientID == "gen_online_30hz") + KnownClients.EKnownClients knownClientID = (KnownClients.EKnownClients)knownClientIDObj; + EUserSessionType sessionType = KnownClients.KnownClientSessionTypes[knownClientID]; + + // Game clients should register the user device + if (sessionType == EUserSessionType.GameClient) { string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; @@ -195,8 +202,8 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, false); + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false); result.result = EPendingLoginState.LoginSuccess; result.session_token = sessiontoken; @@ -206,7 +213,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr result.ws_uri = Program.GetWebSocketAddress(bSecureWS); // clear cached data, its a refresh websocket connection - WebSocketManager.ClearDataFromUser(user_id); + WebSocketManager.ClearDataFromUser(user_id, sessionType); } else // limited login (auth partners) { diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index b7edf12..542b01a 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -23,6 +23,7 @@ using Microsoft.Extensions.Options; using System; using System.Net; +using System.Net.NetworkInformation; using System.Net.WebSockets; using System.Security.Claims; using System.Text; @@ -78,13 +79,14 @@ public SocialController(ILogger logger) private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int64 target_user_id) { - // target user does NOT need to be signed in - UserSession? sourceData = WebSocketManager.GetDataFromUser(source_user_id); - UserSession? targetData = WebSocketManager.GetDataFromUser(target_user_id); + SharedUserData? sharedUserDataSource = GenOnlineService.WebSocketManager.GetSharedDataForUser(source_user_id); + SharedUserData? sharedUserDataTarget = GenOnlineService.WebSocketManager.GetSharedDataForUser(target_user_id); + + // NOTE: target user does NOT need to be signed in // remove the request from requestor (online version) #pragma warning disable CS8602 // Dereference of a possibly null reference. - sourceData.GetSocialContainer().PendingRequests.Remove(target_user_id); + sharedUserDataSource.GetSocialContainer().PendingRequests.Remove(target_user_id); #pragma warning restore CS8602 // Dereference of a possibly null reference. // remove the request from requestor (db) @@ -98,40 +100,42 @@ private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int6 // source player { // sess - sourceData.GetSocialContainer().Friends.Add(target_user_id); + sharedUserDataSource.GetSocialContainer().Friends.Add(target_user_id); } // target player { // sess - if (targetData != null) + if (sharedUserDataTarget != null) { - targetData.GetSocialContainer().Friends.Add(source_user_id); + sharedUserDataTarget.GetSocialContainer().Friends.Add(source_user_id); } } // notify the source player that the target player is online, if they are - if (sourceData != null) + if (sharedUserDataTarget != null) { WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; - friendStatusChangedEvent.display_name = targetData.m_strDisplayName; + friendStatusChangedEvent.display_name = sharedUserDataTarget.m_strDisplayName; friendStatusChangedEvent.online = true; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); - sourceData.QueueWebsocketSend(bytesJSON); + // send to all sessions + WebsocketHelper.SendToAllSessionsOfUser(source_user_id, bytesJSON); } // notify the target player that the source player accepted their request - if (targetData != null) + if (sharedUserDataTarget != null) { WebSocketMessage_Social_FriendRequestAccepted friendRequestAcceptedEvent = new(); friendRequestAcceptedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_FRIEND_REQUEST_ACCEPTED_BY_TARGET; - friendRequestAcceptedEvent.display_name = sourceData.m_strDisplayName; + friendRequestAcceptedEvent.display_name = sharedUserDataSource.m_strDisplayName; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendRequestAcceptedEvent)); - targetData.QueueWebsocketSend(bytesJSON); - } + // send to all sessions + WebsocketHelper.SendToAllSessionsOfUser(target_user_id, bytesJSON); + } } // Accept a request @@ -139,9 +143,9 @@ private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int6 [Authorize(Roles = "Player")] public async Task AcceptPendingRequest(Int64 target_user_id) { - // source user must be signed in + // source user must be signed in (anywhere) Int64 source_user_id = TokenHelper.GetUserID(this); - if (source_user_id == -1 || WebSocketManager.GetDataFromUser(source_user_id) == null) + if (source_user_id == -1 || WebSocketManager.GetSharedDataForUser(source_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; @@ -149,18 +153,9 @@ public async Task AcceptPendingRequest(Int64 target_user_id) HelperFunction_AcceptFriendRequest(source_user_id, target_user_id); - UserSession? sourceSession = WebSocketManager.GetDataFromUser(source_user_id); - if (sourceSession != null) - { - sourceSession.NotifyFriendslistDirty(); - } - - UserSession? targetSession = WebSocketManager.GetDataFromUser(target_user_id); - if (targetSession != null) - { - targetSession.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(source_user_id); + SocialHelper.NotifyFriendslistDirty(target_user_id); + } // Reject a request [HttpDelete("Friends/Requests/{target_user_id}")] @@ -169,7 +164,7 @@ public async Task RejectPendingRequest(Int64 target_user_id) { // source user must be signed in Int64 source_user_id = TokenHelper.GetUserID(this); - if (source_user_id == -1 || WebSocketManager.GetDataFromUser(source_user_id) == null) + if (source_user_id == -1 || WebSocketManager.GetSharedDataForUser(source_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; @@ -177,7 +172,7 @@ public async Task RejectPendingRequest(Int64 target_user_id) // remove the request from requestor (online version) #pragma warning disable CS8602 // Dereference of a possibly null reference. - UserSession? userData = WebSocketManager.GetDataFromUser(source_user_id); + SharedUserData? userData = WebSocketManager.GetSharedDataForUser(source_user_id); userData.GetSocialContainer().PendingRequests.Remove(target_user_id); #pragma warning restore CS8602 // Dereference of a possibly null reference. @@ -185,17 +180,8 @@ public async Task RejectPendingRequest(Int64 target_user_id) // NOTE: Target and source are inverted here because the target is actually the person who sent the request, source is the person taking action on the friend request await Database.Functions.Auth.RemovePendingFriendRequest(GlobalDatabaseInstance.g_Database, target_user_id, source_user_id); - - if (userData != null) - { - userData.NotifyFriendslistDirty(); - } - - UserSession? targetSession = WebSocketManager.GetDataFromUser(target_user_id); - if (targetSession != null) - { - targetSession.NotifyFriendslistDirty(); - } + SocialHelper.NotifyFriendslistDirty(source_user_id); + SocialHelper.NotifyFriendslistDirty(target_user_id); } // Remove a friend @@ -205,7 +191,7 @@ public async Task RemoveFriend(Int64 target_user_id) { // source user must be signed in Int64 source_user_id = TokenHelper.GetUserID(this); - if (source_user_id == -1 || WebSocketManager.GetDataFromUser(source_user_id) == null) + if (source_user_id == -1 || WebSocketManager.GetSharedDataForUser(source_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; @@ -213,7 +199,7 @@ public async Task RemoveFriend(Int64 target_user_id) // must be friends #pragma warning disable CS8602 // Dereference of a possibly null reference. - UserSession? userData = WebSocketManager.GetDataFromUser(source_user_id); + SharedUserData? userData = WebSocketManager.GetSharedDataForUser(source_user_id); if (!userData.GetSocialContainer().Friends.Contains(target_user_id)) { Response.StatusCode = (int)HttpStatusCode.NotFound; @@ -225,7 +211,7 @@ public async Task RemoveFriend(Int64 target_user_id) userData.GetSocialContainer().Friends.Remove(target_user_id); // if the other player is online, remove from them too - UserSession? TargetUserData = WebSocketManager.GetDataFromUser(target_user_id); + SharedUserData? TargetUserData = WebSocketManager.GetSharedDataForUser(target_user_id); if (TargetUserData != null) { TargetUserData.GetSocialContainer().Friends.Remove(source_user_id); @@ -235,16 +221,9 @@ public async Task RemoveFriend(Int64 target_user_id) await Database.Functions.Auth.RemoveFriendship(GlobalDatabaseInstance.g_Database, source_user_id, target_user_id); // TODO_SOCIAL: This tells the client to do a GET, we could just send them their friends list directly to reduce latency + calls to service - if (userData != null) - { - userData.NotifyFriendslistDirty(); - } - - if (TargetUserData != null) - { - TargetUserData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(source_user_id); + SocialHelper.NotifyFriendslistDirty(target_user_id); + } // Send a request [HttpPut("Friends/Requests/{target_user_id}")] @@ -253,7 +232,7 @@ public async Task AddFriend(Int64 target_user_id) { // source user must be signed in Int64 requester_user_id = TokenHelper.GetUserID(this); - if (requester_user_id == -1 || WebSocketManager.GetDataFromUser(requester_user_id) == null) + if (requester_user_id == -1 || WebSocketManager.GetSharedDataForUser(requester_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; @@ -261,7 +240,7 @@ public async Task AddFriend(Int64 target_user_id) // too many friends? const int friendsLimit = 200; - UserSession? userData = WebSocketManager.GetDataFromUser(requester_user_id); + SharedUserData? userData = WebSocketManager.GetSharedDataForUser(requester_user_id); if (userData.GetSocialContainer().Friends.Count >= friendsLimit) { if (userData != null) @@ -269,7 +248,9 @@ public async Task AddFriend(Int64 target_user_id) WebSocketMessage_Social_FriendsListFull friendsListFullEvent = new(); friendsListFullEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_CANT_ADD_FRIEND_LIST_FULL; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendsListFullEvent)); - userData.QueueWebsocketSend(bytesJSON); + + // send to all sessions + WebsocketHelper.SendToAllSessionsOfUser(requester_user_id, bytesJSON); } } @@ -284,7 +265,7 @@ public async Task AddFriend(Int64 target_user_id) // the other user must be online, theres no way to add offline people in the client - UserSession? TargetUserData = WebSocketManager.GetDataFromUser(target_user_id); + SharedUserData? TargetUserData = WebSocketManager.GetSharedDataForUser(target_user_id); if (TargetUserData == null) { Response.StatusCode = (int)HttpStatusCode.NotFound; @@ -326,30 +307,21 @@ public async Task AddFriend(Int64 target_user_id) // add to list for target TargetUserData.GetSocialContainer().PendingRequests.Add(requester_user_id); - // inform them via websocket - if (TargetUserData != null) - { - WebSocketMessage_Social_NewFriendRequest socialInform = new WebSocketMessage_Social_NewFriendRequest(); - socialInform.msg_id = (int)EWebSocketMessageID.SOCIAL_NEW_FRIEND_REQUEST; - socialInform.display_name = userData.m_strDisplayName; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(socialInform)); - TargetUserData.QueueWebsocketSend(bytesJSON); - } + // inform them via websocket + WebSocketMessage_Social_NewFriendRequest socialInform = new WebSocketMessage_Social_NewFriendRequest(); + socialInform.msg_id = (int)EWebSocketMessageID.SOCIAL_NEW_FRIEND_REQUEST; + socialInform.display_name = userData.m_strDisplayName; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(socialInform)); + + WebsocketHelper.SendToAllSessionsOfUser(target_user_id, bytesJSON); // add it to DB for target (if not already exists) await Database.Functions.Auth.AddPendingFriendRequest(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); } - if (userData != null) - { - userData.NotifyFriendslistDirty(); - } - - if (TargetUserData != null) - { - TargetUserData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(requester_user_id); + SocialHelper.NotifyFriendslistDirty(target_user_id); + } [HttpGet("Friends")] [Authorize(Roles = "Player,Monitor")] @@ -360,14 +332,14 @@ public async Task Get_FriendsAndRequests() // source user must be signed in Int64 requester_user_id = TokenHelper.GetUserID(this); - if (requester_user_id == -1 || WebSocketManager.GetDataFromUser(requester_user_id) == null) + if (requester_user_id == -1 || WebSocketManager.GetSharedDataForUser(requester_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return result; } // get websockets & data - UserSession? sourceData = WebSocketManager.GetDataFromUser(requester_user_id); + SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(requester_user_id); #pragma warning disable CS8602 // Dereference of a possibly null reference. HashSet setFriends = sourceData.GetSocialContainer().Friends; @@ -411,9 +383,11 @@ public async Task Get_FriendsAndRequests() if (dictDisplayNames.ContainsKey(friend_user_id)) // no display name, they probably dont exist anymore, so dont return them { // are they online? - UserSession? targetUserData = WebSocketManager.GetDataFromUser(friend_user_id); + SharedUserData? targetUserData = WebSocketManager.GetSharedDataForUser(friend_user_id); - string strPresence = targetUserData != null ? UserPresence.DetermineUserStatus(targetUserData) : "Offline"; + // TODO_EFCORE: What user status do we use if the person is logged in multiple times? prefer in-game client? + //string strPresence = targetUserData != null ? UserPresence.DetermineUserStatus(targetUserData) : "Offline"; + string strPresence = "TODO_EFCORE"; result.friends.Add(new FriendEntry() { @@ -458,13 +432,13 @@ public async Task Get_Blocked() // source user must be signed in Int64 requester_user_id = TokenHelper.GetUserID(this); - if (requester_user_id == -1 || WebSocketManager.GetDataFromUser(requester_user_id) == null) + if (requester_user_id == -1 || WebSocketManager.GetSharedDataForUser(requester_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return result; } - UserSession? sourceData = WebSocketManager.GetDataFromUser(requester_user_id); + SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(requester_user_id); #pragma warning disable CS8602 // Dereference of a possibly null reference. HashSet setBlocked = sourceData.GetSocialContainer().Blocked; @@ -520,14 +494,14 @@ public async Task Add_Block(Int64 target_user_id) { // source user must be signed in Int64 requester_user_id = TokenHelper.GetUserID(this); - if (requester_user_id == -1 || WebSocketManager.GetDataFromUser(requester_user_id) == null) + if (requester_user_id == -1 || WebSocketManager.GetSharedDataForUser(requester_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; } // Check not already blocked - UserSession? sourceData = WebSocketManager.GetDataFromUser(requester_user_id); + SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(requester_user_id); #pragma warning disable CS8602 // Dereference of a possibly null reference. if (sourceData.GetSocialContainer().Blocked.Contains(target_user_id)) @@ -537,7 +511,7 @@ public async Task Add_Block(Int64 target_user_id) } // Target user cannot be an admin - UserSession? targetData = WebSocketManager.GetDataFromUser(target_user_id); + SharedUserData? targetData = WebSocketManager.GetSharedDataForUser(target_user_id); if (targetData != null) { if (targetData.IsAdmin()) @@ -576,16 +550,9 @@ public async Task Add_Block(Int64 target_user_id) // Add to block list (db) await Database.Functions.Auth.AddBlock(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); - if (sourceData != null) - { - sourceData.NotifyFriendslistDirty(); - } - - if (targetData != null) - { - targetData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(requester_user_id); + SocialHelper.NotifyFriendslistDirty(target_user_id); + } // Unblock user [HttpDelete("Blocked/{target_user_id}")] @@ -598,13 +565,13 @@ public async Task Remove_Block(Int64 target_user_id) // source user must be signed in Int64 requester_user_id = TokenHelper.GetUserID(this); - if (requester_user_id == -1 || WebSocketManager.GetDataFromUser(requester_user_id) == null) + if (requester_user_id == -1 || WebSocketManager.GetSharedDataForUser(requester_user_id) == null) { Response.StatusCode = (int)HttpStatusCode.Forbidden; return; } - UserSession? sourceData = WebSocketManager.GetDataFromUser(requester_user_id); + SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(requester_user_id); // Check blocked #pragma warning disable CS8602 // Dereference of a possibly null reference. @@ -622,10 +589,7 @@ public async Task Remove_Block(Int64 target_user_id) await Database.Functions.Auth.RemoveBlock(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); // only the source user needs an update here - if (sourceData != null) - { - sourceData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(requester_user_id); + } } } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index a3ec33e..bf76111 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -155,9 +155,10 @@ public async Task Get() Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.ServerListReadOnly)) { - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { @@ -197,7 +198,7 @@ public async Task Get() foreach (Lobby lobby in lstLobbies) { // SOCIAL: If the lobby owner has source user blocked, remove the lobby - UserSession? lobbyOwner = WebSocketManager.GetDataFromUser(lobby.Owner); + SharedUserData? lobbyOwner = WebSocketManager.GetSharedDataForUser(lobby.Owner); if (lobbyOwner != null) { @@ -357,15 +358,16 @@ public async Task Put() // get requesting user data from session token Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); // check nullables also - if (user_id != -1 && strName != null && strMapName != null && strMapPath != null && strPassword != null) + if (user_id != -1 && strName != null && strMapName != null && strMapPath != null && strPassword != null && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { // TODO: Handle failure here // TODO_ASP: Remove ip address from db, not needed string strIPAddr = ""; - UserSession playerSession = WebSocketManager.GetDataFromUser(user_id); + UserSession playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index c7921e9..c4aa403 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -214,7 +214,8 @@ public async Task Delete(Int64 lobbyID) // need a lobby ID int leavingPersonSlot = -1; Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) @@ -238,7 +239,7 @@ public async Task Delete(Int64 lobbyID) TURNCredentialManager.DeleteCredentialsForUser(user_id); // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { @@ -289,32 +290,36 @@ public async Task Delete(Int64 lobbyID) ) { Int64 user_id = TokenHelper.GetUserID(this); - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); - if (sourceData != null) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { - int buildings_built = data["buildings_built"].GetInt32(); - int buildings_killed = data["buildings_killed"].GetInt32(); - int buildings_lost = data["buildings_lost"].GetInt32(); - int units_built = data["units_built"].GetInt32(); - int units_killed = data["units_killed"].GetInt32(); - int units_lost = data["units_lost"].GetInt32(); - int total_money = data["total_money"].GetInt32(); - bool won = data["won"].GetBoolean(); - UInt64 match_id = data["match_id"].GetUInt64(); - - // were they really in the match they claim to be in? - if (!sourceData.WasPlayerInMatch(match_id, out int slotIndexInLobby, out int army)) + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); + if (sourceData != null) { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return null; - } + int buildings_built = data["buildings_built"].GetInt32(); + int buildings_killed = data["buildings_killed"].GetInt32(); + int buildings_lost = data["buildings_lost"].GetInt32(); + int units_built = data["units_built"].GetInt32(); + int units_killed = data["units_killed"].GetInt32(); + int units_lost = data["units_lost"].GetInt32(); + int total_money = data["total_money"].GetInt32(); + bool won = data["won"].GetBoolean(); + UInt64 match_id = data["match_id"].GetUInt64(); + + // were they really in the match they claim to be in? + if (!sourceData.WasPlayerInMatch(match_id, out int slotIndexInLobby, out int army)) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return null; + } - // register with daily stats - DailyStatsManager.RegisterOutcome(army, won); + // register with daily stats + DailyStatsManager.RegisterOutcome(army, won); - // store in DB - await Database.Functions.Lobby.CommitPlayerOutcome(GlobalDatabaseInstance.g_Database, slotIndexInLobby, match_id, - buildings_built, buildings_killed, buildings_lost, units_built, units_killed, units_lost, total_money, won); + // store in DB + await Database.Functions.Lobby.CommitPlayerOutcome(GlobalDatabaseInstance.g_Database, slotIndexInLobby, match_id, + buildings_built, buildings_killed, buildings_lost, units_built, units_killed, units_lost, total_money, won); + } } } } @@ -519,7 +524,7 @@ public async Task Post(Int64 lobbyID) TURNCredentialManager.DeleteCredentialsForUser(KickedUserID); // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetDataFromUser(KickedUserID); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(KickedUserID, EUserSessionType.GameClient); // user being kicked must be a game client if (sourceData != null) { @@ -677,7 +682,8 @@ public async Task Put(Int64 lobbyID) if (lobby != null) { Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { UInt16 userPreferredPort = data["preferred_port"].GetUInt16(); bool bHasMap = data["has_map"].GetBoolean(); @@ -711,7 +717,7 @@ public async Task Put(Int64 lobbyID) } } - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index a8ad7f1..38d48cc 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -93,80 +93,75 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { var data = JsonSerializer.Deserialize>(jsonData, options); - if (data != null && !data.ContainsKey("client_id")) + // TODO_EFCORE: remove client_id from the client, token has it, and is more trustworthy + KnownClients.EKnownClients clientID = TokenHelper.GetClientID(this); + if (clientID == KnownClients.EKnownClients.unknown) { result.result = EPendingLoginState.LoginFailed; Response.StatusCode = (int)HttpStatusCode.Unauthorized; } else { - if (data != null && data.ContainsKey("client_id")) - { - byte[] respNonce = new byte[32]; - using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(respNonce); } + byte[] respNonce = new byte[32]; + using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(respNonce); } - // TODO_JWT: Look refresh token up in the revoked list - // TODO_JWT: invalidate old refresh and session tokens + // TODO_JWT: Look refresh token up in the revoked list + // TODO_JWT: invalidate old refresh and session tokens - // If you reach here, the refresh token was valid because auth happens globally - string? clientID = data["client_id"].GetString(); + // If you reach here, the refresh token was valid because auth happens globally + if (Program.g_tokenGenerator != null) + { + // start their session etc + Int64 user_id = TokenHelper.GetUserID(this); + EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (clientID != null && Program.g_tokenGenerator != null) + // Game clients should register the user device + if (sessionType == EUserSessionType.GameClient) { - // start their session etc - Int64 user_id = TokenHelper.GetUserID(this); - string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; string hwid_2 = data.ContainsKey("reserved_2") ? data["reserved_2"].ToString() : "NONE"; await Database.Functions.Auth.RegisterUserDevice(GlobalDatabaseInstance.g_Database, user_id, hwid_0, hwid_1, hwid_2, ipAddr); + } - // ban check - bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); - if (bIsBanned) - { - result.result = EPendingLoginState.LoginFailed; - Response.StatusCode = (int)HttpStatusCode.Locked; - return result; - } + // ban check + bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); + if (bIsBanned) + { + result.result = EPendingLoginState.LoginFailed; + Response.StatusCode = (int)HttpStatusCode.Locked; + return result; + } - string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; - Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); + string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; + Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID); + string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); + await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID, sessionType); - bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); - result.result = EPendingLoginState.LoginSuccess; + result.result = EPendingLoginState.LoginSuccess; - // extend token - // TODO_TODAY_JWT: just get clientID from token - var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, bIsAdmin); - var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, false); - result.session_token = sessiontoken; - result.refresh_token = refreshtoken; + // extend token + // TODO_TODAY_JWT: just get clientID from token + var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, clientID, sessionType, bIsAdmin); + var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, clientID, sessionType, false); + result.session_token = sessiontoken; + result.refresh_token = refreshtoken; - result.user_id = user_id; - result.display_name = strDisplayName; + result.user_id = user_id; + result.display_name = strDisplayName; - result.ws_uri = Program.GetWebSocketAddress(bSecureWS); + result.ws_uri = Program.GetWebSocketAddress(bSecureWS); - // clear cached data, its a refresh websocket connection - WebSocketManager.ClearDataFromUser(user_id); - } - else - { - result.result = EPendingLoginState.LoginFailed; - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return result; - } + // clear cached data, its a refresh websocket connection + WebSocketManager.ClearDataFromUser(user_id, sessionType); } else { - // TODO: Log this - //sess.SendResponseAsync(sess.Response.MakeGetResponse("Missing Key")); - + result.result = EPendingLoginState.LoginFailed; + Response.StatusCode = (int)HttpStatusCode.Unauthorized; return result; } } diff --git a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs index ee623b7..b8cb68f 100644 --- a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs +++ b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs @@ -92,9 +92,10 @@ public async Task Post() // must be in a lobby Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) // technically a duplicate check, since role above should also validate this, but just to be safe and avoid any weird edge cases where somehow we get here without a valid user session, etc { - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { // TODO_QUICKMATCH: We need a way of checking if player is really in a match or not, so they cant just upload all the time, and also dont let them keep uploading replays if they already did, etc diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index 6b66fd9..e30b83c 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -217,9 +217,10 @@ public async Task Post() // must be in a lobby Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { // lobby cant have AI and must have at least 2 human players at some point diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 2554fc4..22a8ffb 100644 --- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs +++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs @@ -76,9 +76,10 @@ public MatchmakingController(ILogger logger) UInt32 ini_crc = data["ini_crc"].GetUInt32(); Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { @@ -103,9 +104,10 @@ public void Put_Widen() // TODO_QUICKMATCH: What if a user widens after already being matched? We should probably tell them no // widen the search Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); ; if (playerSession != null) { @@ -119,9 +121,10 @@ public void Put_Widen() public void Delete() { Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 9e7777d..4c4d5b4 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -22,6 +22,7 @@ using Microsoft.Extensions.Options; using Org.BouncyCastle.Security; using System; +using System.Collections.Concurrent; using System.Net; using System.Net.WebSockets; using System.Security.Claims; @@ -62,7 +63,7 @@ public class GET_ActiveUsers_UserEntry { public string? name { get; set; } public string? status { get; set; } - public string? client_id { get; set; } + public KnownClients.EKnownClients? client_id { get; set; } public string? duration { get; set; } } @@ -119,16 +120,25 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) // TODO_QUICKMATCH: We chekc maps are big enough, but the reverse needs checked too - dont let 8 playrs join a 6-8 ffa if only map is defcon6 for example - var allData = WebSocketManager.GetUserDataCache(); - foreach (var sessionData in allData) + // TODO_EFCORE: People can be isgned in multiple times, should we show all of them in the count? or what + ConcurrentDictionary> allData = WebSocketManager.GetUserDataCache(); + foreach (var sessionDataPerClientType in allData) { - GET_ActiveUsers_UserEntry userEntry = new(); - userEntry.name = sessionData.Value.m_strDisplayName; - userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); - userEntry.client_id = sessionData.Value.m_client_id; - userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); - - result.active_users.Add(userEntry); + foreach (var sessionData in sessionDataPerClientType.Value) + { + SharedUserData? userSharedData = WebSocketManager.GetSharedDataForUser(sessionData.Value.m_UserID); + + if (userSharedData != null) + { + GET_ActiveUsers_UserEntry userEntry = new(); + userEntry.name = userSharedData.m_strDisplayName; + userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); + userEntry.client_id = sessionData.Value.m_client_id; + userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); + + result.active_users.Add(userEntry); + } + } } diff --git a/GenOnlineService/Controllers/OID/OIDController.cs b/GenOnlineService/Controllers/OID/OIDController.cs index 19a6eae..2f0abd6 100644 --- a/GenOnlineService/Controllers/OID/OIDController.cs +++ b/GenOnlineService/Controllers/OID/OIDController.cs @@ -218,10 +218,10 @@ public async Task Post() string mwUserID = GetClaimValue(mw_token, "sub"); Int64 user_id = TokenHelper.GetUserID(this); - - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) // only game clients should be doing middleware login { - UserSession? session = WebSocketManager.GetDataFromUser(user_id); + UserSession? session = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (session != null) { session.SetMiddlewareID(mwUserID); diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index bf6921e..3b64770 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -89,11 +89,11 @@ public async Task Get(Int64 userID) PropertyNameCaseInsensitive = true }; - // get from cache - UserSession? userSession = WebSocketManager.GetDataFromUser(userID); + // get from cache (just get any user, all sessions will have stats stored against them) + SharedUserData? userData = WebSocketManager.GetSharedDataForUser(userID); // if user is offline, hit DB, could be a friends list inspection for example - if (userSession == null) + if (userData == null) { PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(GlobalDatabaseInstance.g_Database, userID); @@ -108,13 +108,13 @@ public async Task Get(Int64 userID) return result; } - else if (userSession.GameStats == null) // if the session exists but no stats exist, this is a problem + else if (userData.GameStats == null) // if the session exists but no stats exist, this is a problem { Response.StatusCode = (int)HttpStatusCode.NotFound; return result; } - result.stats = userSession.GameStats; + result.stats = userData.GameStats; return result; } @@ -140,19 +140,19 @@ public async Task PostBatched() // process each user foreach (Int64 userID in inputData.user_ids) { - // get from cache - UserSession? userSession = WebSocketManager.GetDataFromUser(userID); + // get all sessions for this user + SharedUserData userData = WebSocketManager.GetSharedDataForUser(userID); // NOTE: Batch is only supported for ONLINE users, DB will never be looked up - if (userSession != null) - { - if (userSession.GameStats != null) - { - result.stats.Add(userSession.GameStats); - - } - } - } + if (userData != null) + { + if (userData.GameStats != null) + { + result.stats.Add(userData.GameStats); + + } + } + } } return result; @@ -176,7 +176,8 @@ public async Task Put() { Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) { List? jsonReqData = JsonSerializer.Deserialize>(jsonData, options); @@ -196,12 +197,12 @@ public async Task Put() // update cache too if (user_id != -1) { - UserSession? sourceSession = WebSocketManager.GetDataFromUser(user_id); + SharedUserData? userData = WebSocketManager.GetSharedDataForUser(user_id); - if (sourceSession != null) + if (userData != null) { #pragma warning disable CS8602 // Dereference of a possibly null reference. - sourceSession.GameStats.ProcessFromDB((EStatIndex)stat_id, statValInt); + userData.GameStats.ProcessFromDB((EStatIndex)stat_id, statValInt); #pragma warning restore CS8602 // Dereference of a possibly null reference. } } diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index a62af96..82d6f51 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Security.Claims; using System.Text; @@ -88,16 +89,23 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) // TODO_QUICKMATCH: We chekc maps are big enough, but the reverse needs checked too - dont let 8 playrs join a 6-8 ffa if only map is defcon6 for example - var allData = WebSocketManager.GetUserDataCache(); - foreach (var sessionData in allData) + ConcurrentDictionary> allData = WebSocketManager.GetUserDataCache(); + foreach (var sessionDataPerClientType in allData) { - GET_ActiveUsers_UserEntry userEntry = new(); - userEntry.name = sessionData.Value.m_strDisplayName; - userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); - userEntry.client_id = sessionData.Value.m_client_id; - userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); - - result.active_users.Add(userEntry); + foreach (var sessionData in sessionDataPerClientType.Value) + { + SharedUserData? userSharedData = WebSocketManager.GetSharedDataForUser(sessionData.Value.m_UserID); + if (userSharedData != null) + { + GET_ActiveUsers_UserEntry userEntry = new(); + userEntry.name = userSharedData.m_strDisplayName; + userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); + userEntry.client_id = sessionData.Value.m_client_id; + userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); + + result.active_users.Add(userEntry); + } + } } @@ -124,17 +132,18 @@ public async Task Delete() Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) + EUserSessionType sessionType = TokenHelper.GetSessionType(this); + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Authenticate)) { // TODO_JWT: Add token used to a 'ban list' //string token = ""; // end session - UserSession? session = WebSocketManager.GetDataFromUser(user_id); + UserSession? session = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (session != null) { UserWebSocketInstance ws = await session.CloseWebsocket(WebSocketCloseStatus.NormalClosure, "User logged out"); - await WebSocketManager.DeleteSession(user_id, ws, true); + await WebSocketManager.DeleteSession(user_id, sessionType, ws, true); } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 8571f98..ebd2b58 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -107,9 +107,26 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) bool bIsAdmin = HttpContext.User.IsInRole("Admin"); - string client_id = firstEntryClientID.Value; + KnownClients.EKnownClients client_id = KnownClients.EKnownClients.unknown; + if (int.TryParse(firstEntryClientID.Value, out int clientIDInt32)) + { + // Validate if the int corresponds to a defined enum value + if (System.Enum.IsDefined(typeof(KnownClients.EKnownClients), clientIDInt32)) + { + client_id = (KnownClients.EKnownClients)clientIDInt32; + } + } + + // if unknown, error + if (client_id == KnownClients.EKnownClients.unknown) + { + HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + UserWebSocketInstance wsSess = await WebSocketManager.CreateSession( _db, + EUserSessionType.GameClient, bIsReconnect, user_id, client_id, @@ -175,19 +192,19 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) // slice only the valid part, no extra allocation var segment = new ArraySegment(buffer, 0, receiveResult.Count); - UserSession? sourceUserData = WebSocketManager.GetDataFromUser(wsSess.m_UserID); + UserSession? sourceUserData = WebSocketManager.GetSessionFromUser(wsSess.m_UserID, wsSess.m_SessionType); await ProcessWSMessage(wsSess, sourceUserData, receiveResult, segment); } Console.ForegroundColor = ConsoleColor.Cyan; - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + SharedUserData? sourceData = WebSocketManager.GetSharedDataForUser(user_id); Console.WriteLine("WEBSOCKET DISCONNECT FOR {0}", sourceData == null ? "NULL" : sourceData.m_strDisplayName); Console.ForegroundColor = ConsoleColor.Gray; // close the session if (wsSess != null) { - await WebSocketManager.DeleteSession(user_id, wsSess, false); + await WebSocketManager.DeleteSession(user_id, wsSess.m_SessionType, wsSess, false); } // do close (if in the correct state) @@ -211,9 +228,11 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession sourceUserSession, WebSocketReceiveResult receiveResult, ArraySegment buffer) { + SharedUserData sourceUserData = WebSocketManager.GetSharedDataForUser(sourceUserSession.m_UserID); + if (receiveResult.MessageType == WebSocketMessageType.Close) { - await WebSocketManager.DeleteSession(sourceWS.m_UserID, sourceWS, false); + await WebSocketManager.DeleteSession(sourceWS.m_UserID, sourceUserSession.GetSessionType(), sourceWS, false); return; } @@ -284,26 +303,25 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (chatMessage != null) { // must be online & friends - UserSession? targetSession = WebSocketManager.GetDataFromUser(chatMessage.target_user_id); - if (targetSession != null) + SharedUserData? targetUserData = WebSocketManager.GetSharedDataForUser(chatMessage.target_user_id); + + if (targetUserData != null) { - if (sourceUserSession.GetSocialContainer().Friends.Contains(chatMessage.target_user_id) - && targetSession.GetSocialContainer().Friends.Contains(sourceUserSession.m_UserID)) + if (sourceUserData.GetSocialContainer().Friends.Contains(chatMessage.target_user_id) + && targetUserData.GetSocialContainer().Friends.Contains(sourceUserSession.m_UserID)) { - // ok, they can chat, send the message to both of them + // make websocket msg WebSocketMessage_Social_FriendChatMessage_Outbound outboundMsg = new(); outboundMsg.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_CHAT_MESSAGE_SERVER_TO_CLIENT; outboundMsg.source_user_id = sourceWS.m_UserID; - outboundMsg.target_user_id = targetSession.m_UserID; - outboundMsg.message = String.Format("{0}: {1}", sourceUserSession.m_strDisplayName, chatMessage.message); - - // send to both + outboundMsg.target_user_id = chatMessage.target_user_id; + outboundMsg.message = String.Format("{0}: {1}", sourceUserData.m_strDisplayName, chatMessage.message); byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await sourceWS.SendAsync(bytesJSON, WebSocketMessageType.Text); - - targetSession.QueueWebsocketSend(bytesJSON); + // send to both on all websockets + WebsocketHelper.SendToAllSessionsOfUser(chatMessage.target_user_id, bytesJSON); + WebsocketHelper.SendToAllSessionsOfUser(sourceWS.m_UserID, bytesJSON); } } else @@ -341,19 +359,19 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (chatMessage.action) { - outboundMsg.message = String.Format("{0} {1}", sourceUserSession.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("{0} {1}", sourceUserData.m_strDisplayName, chatMessage.message); outboundMsg.admin = false; // dont care for actions } else { - if (sourceUserSession.IsAdmin()) + if (sourceUserData.IsAdmin()) { - outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserSession.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("[\u2605\u2605GO STAFF\u2605\u2605] [{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message); outboundMsg.admin = true; } else { - outboundMsg.message = String.Format("[{0}] {1}", sourceUserSession.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("[{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message); outboundMsg.admin = false; } } @@ -364,18 +382,26 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); // send it to everyone in the same room - foreach (KeyValuePair sessionData in WebSocketManager.GetUserDataCache()) + foreach (var sessionDataByClient in WebSocketManager.GetUserDataCache()) { - UserSession targetSess = sessionData.Value; - if (targetSess.networkRoomID == sourceUserSession.networkRoomID) + foreach (var sessionData in sessionDataByClient.Value) { - // is it blocked by either side? dont deliver the chat - bool bBlocked = targetSess.GetSocialContainer().Blocked.Contains(sourceUserSession.m_UserID) || - sourceUserSession.GetSocialContainer().Blocked.Contains(targetSess.m_UserID); - - if (!bBlocked) + UserSession targetSess = sessionData.Value; + if (targetSess.networkRoomID == sourceUserSession.networkRoomID) { - targetSess.QueueWebsocketSend(bytesJSON); + SharedUserData? targetUserSharedData = WebSocketManager.GetSharedDataForUser(targetSess.m_UserID); + + if (targetUserSharedData != null) + { + // is it blocked by either side? dont deliver the chat + bool bBlocked = targetUserSharedData.GetSocialContainer().Blocked.Contains(sourceUserSession.m_UserID) || + sourceUserData.GetSocialContainer().Blocked.Contains(targetSess.m_UserID); + + if (!bBlocked) + { + targetSess.QueueWebsocketSend(bytesJSON); + } + } } } } @@ -383,7 +409,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // send message to discord if (Program.g_Discord != null && chatMessage.message != null) { - Program.g_Discord.SendNetworkRoomChat(sourceUserSession.networkRoomID, sourceUserSession.m_UserID, sourceUserSession.m_strDisplayName, chatMessage.message); + Program.g_Discord.SendNetworkRoomChat(sourceUserSession.networkRoomID, sourceUserSession.m_UserID, sourceUserData.m_strDisplayName, chatMessage.message); } } } @@ -446,10 +472,10 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (nameChangeRequest.name.Length >= 3 && nameChangeRequest.name.Length <= 16) { await Database.Functions.Lobby.UpdateDisplayName(GlobalDatabaseInstance.g_Database, sourceUserSession.m_UserID, nameChangeRequest.name); - sourceUserSession.m_strDisplayName = nameChangeRequest.name; + sourceUserData.m_strDisplayName = nameChangeRequest.name; await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); } - } + } } else if (msgID == EWebSocketMessageID.LOBBY_CHANGE_PASSWORD) { @@ -508,7 +534,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (chatMessage.action) { - outboundMsg.message = String.Format("{0} {1}", sourceUserSession.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("{0} {1}", sourceUserData.m_strDisplayName, chatMessage.message); } else if (chatMessage.announcement) { @@ -516,7 +542,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession } else { - outboundMsg.message = String.Format("[{0}] {1}", sourceUserSession.m_strDisplayName, chatMessage.message); + outboundMsg.message = String.Format("[{0}] {1}", sourceUserData.m_strDisplayName, chatMessage.message); } outboundMsg.action = chatMessage.action; @@ -607,12 +633,17 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // Serialize once before broadcasting byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(startCommand)); - foreach (KeyValuePair sessionData in WebSocketManager.GetUserDataCache()) + foreach (LobbyMember lobbyMember in lobbyInfo.Members) { - UserSession sess = sessionData.Value; - if (sess.currentLobbyID == sourceUserSession.currentLobbyID) + if (lobbyMember != null) { - sess.QueueWebsocketSend(bytesJSON); + if (lobbyMember.GetSession().TryGetTarget(out UserSession? sess)) + { + if (sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } } } } @@ -651,12 +682,17 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // Serialize once before broadcasting byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(startCommand)); - foreach (KeyValuePair sessionData in WebSocketManager.GetUserDataCache()) + foreach (LobbyMember lobbyMember in lobbyInfo.Members) { - UserSession sess = sessionData.Value; - if (sess.currentLobbyID == sourceUserSession.currentLobbyID) + if (lobbyMember != null) { - sess.QueueWebsocketSend(bytesJSON); + if (lobbyMember.GetSession().TryGetTarget(out UserSession? sess)) + { + if (sess != null) + { + sess.QueueWebsocketSend(bytesJSON); + } + } } } } @@ -689,7 +725,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // And everything is in text. // find the dest players connection - UserSession? targetSession = WebSocketManager.GetDataFromUser(signalingRequest.target_user_id); + UserSession? targetSession = WebSocketManager.GetSessionFromUser(signalingRequest.target_user_id, EUserSessionType.GameClient); // signalling NEEDS a game client session if (targetSession != null) { Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); @@ -733,7 +769,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // And everything is in text. // find the dest players connection - UserSession? targetSession = WebSocketManager.GetDataFromUser(signal.target_user_id); + UserSession? targetSession = WebSocketManager.GetSessionFromUser(signal.target_user_id, EUserSessionType.GameClient); // network signals only goto game clients if (targetSession != null) { Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index d084d09..f84bcb6 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -453,11 +453,11 @@ public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance foreach (var eloPair in dictEloData) { // store on player if online - UserSession? playerSess = GenOnlineService.WebSocketManager.GetDataFromUser(eloPair.Key); - if (playerSess != null) + SharedUserData? sharedUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(eloPair.Key); + if (sharedUserData != null) { - playerSess.GameStats.EloRating = eloPair.Value.Rating; - playerSess.GameStats.EloMatches = eloPair.Value.NumMatches; + sharedUserData.GameStats.EloRating = eloPair.Value.Rating; + sharedUserData.GameStats.EloMatches = eloPair.Value.NumMatches; } await Database.Functions.Auth.SaveELOData(GlobalDatabaseInstance.g_Database, eloPair.Key, eloPair.Value); } @@ -1298,9 +1298,11 @@ public async static Task GetUserIDFromPendingLogin(MySQLInstance m_Inst, // TODO: How do we stop dev clients connecting to PROD? // TODO: Check more here, like IP, client, etc - public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, string clientIDStr) + public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) { - UInt16 clientID = clientIDStr == "gen_online_60hz" ? (UInt16)1 : (UInt16)0; + // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match + // TODO_EFCORE: Move away from db for this and just have website login call endpoint on service + //UInt16 clientID = clientIDStr == "gen_online_60hz" ? (UInt16)1 : (UInt16)0; Console.ForegroundColor = ConsoleColor.Cyan; Console.WriteLine("StartSession deleing other sessions for user {0}", userID); @@ -1308,7 +1310,7 @@ public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, str // kill any WS they had too, StartSession comes before WS connects // disconnect any other sessions with this ID - UserSession? sess = GenOnlineService.WebSocketManager.GetDataFromUser(userID); + UserSession? sess = GenOnlineService.WebSocketManager.GetSessionFromUser(userID, sessionType); if (sess != null) { Console.ForegroundColor = ConsoleColor.Cyan; @@ -1316,7 +1318,7 @@ public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, str Console.ForegroundColor = ConsoleColor.Gray; UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess); - await GenOnlineService.WebSocketManager.DeleteSession(userID, oldWS, false); + await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false); } } @@ -1564,12 +1566,12 @@ public enum EAccountType DevAccount = 3 } - public enum ESessionType - { - Unknown = -1, - Website = 0, - Game = 1 - } +// public enum ESessionType +// { +// Unknown = -1, +// Website = 0, +// Game = 1 +// } internal static async Task CreateUserIfNotExists_DevAccount(MySQLInstance m_Inst, Int64 user_id, string display_name) { diff --git a/GenOnlineService/Discord.cs b/GenOnlineService/Discord.cs index 7d3b1de..7c4d762 100644 --- a/GenOnlineService/Discord.cs +++ b/GenOnlineService/Discord.cs @@ -496,14 +496,21 @@ private async Task OnMessageReceived(SocketMessage message) string strUser = string.Join(' ', strComponents.Skip(1)); if (Int64.TryParse(strUser, out Int64 TargetUserID)) { - UserSession? targetData = GenOnlineService.WebSocketManager.GetDataFromUser(TargetUserID); + SharedUserData? targetData = GenOnlineService.WebSocketManager.GetSharedDataForUser(TargetUserID); if (targetData != null) { PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {TargetUserID} ({targetData.m_strDisplayName}) has been kicked from the server."); - UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(targetData); - await GenOnlineService.WebSocketManager.DeleteSession(TargetUserID, oldWS, true); + // we need to kill all websockets they have + List lstUserSessions = GenOnlineService.WebSocketManager.GetAllDataFromUser(TargetUserID); + foreach (UserSession userSession in lstUserSessions) + { + UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(userSession); + await GenOnlineService.WebSocketManager.DeleteSession(TargetUserID, userSession.GetSessionType(), oldWS, true); + } + + } else { @@ -559,26 +566,20 @@ private async Task OnMessageReceived(SocketMessage message) { string strname = string.Join(' ', strComponents.Skip(1)); - bool bFound = false; - var sessions = GenOnlineService.WebSocketManager.GetUserDataCache(); - foreach (var session in sessions) + + SharedUserData? userDataFound = GenOnlineService.WebSocketManager.GetSharedDataForUser(strname); + if (userDataFound != null) { - if (session.Value.m_strDisplayName.ToLower() == strname.ToLower()) - { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {session.Value.m_strDisplayName} is user ID {session.Key}."); - bFound = true; - break; - } + PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {userDataFound.m_strDisplayName} is user ID {userDataFound.m_UserID}."); } - - if (!bFound) + else { PushChannelMessage(EDiscordChannelIDs.AdminCommands, $"User {strname} is not active on the server."); } } else { - PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !kick (e.g. !kick 123)"); + PushChannelMessage(EDiscordChannelIDs.AdminCommands, "Invalid Command Syntax. !whois (e.g. !whois x64)"); } } else @@ -644,22 +645,25 @@ private async Task OnMessageReceived(SocketMessage message) // send to everyone int numDelivered = 0; - foreach (KeyValuePair sessionData in GenOnlineService.WebSocketManager.GetUserDataCache()) + foreach (var sessionDataByClient in GenOnlineService.WebSocketManager.GetUserDataCache()) { - UserSession sess = sessionData.Value; - - if (sess != null) + foreach (var sessionData in sessionDataByClient.Value) { - if (sess.currentLobbyID == -1) - { - sess.QueueWebsocketSend(outboundMsgRoomJSON); - } - else + UserSession sess = sessionData.Value; + + if (sess != null) { - sess.QueueWebsocketSend(outboundMsgLobbyJSON); + if (sess.currentLobbyID == -1) + { + sess.QueueWebsocketSend(outboundMsgRoomJSON); + } + else + { + sess.QueueWebsocketSend(outboundMsgLobbyJSON); + } + + ++numDelivered; } - - ++numDelivered; } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 3090212..6eefd71 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -159,8 +159,10 @@ public async Task ProcessPendingFullMeshConnectivityChecks() outcome.missing_connections = lstMissingConnections; } + // TODO_EFCORE: Later, these should really use lobby list instead of getting session from ID + // send to host - UserSession? hostSession = WebSocketManager.GetDataFromUser(Owner); + UserSession? hostSession = WebSocketManager.GetSessionFromUser(Owner, EUserSessionType.GameClient); // host should be a game client if (hostSession != null) { byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome)); @@ -460,7 +462,7 @@ public async Task Tick() { if (memberEntry.GetSession().TryGetTarget(out UserSession? session)) { - UserSession? sess = WebSocketManager.GetDataFromUser(session.m_UserID); + UserSession? sess = WebSocketManager.GetSessionFromUser(session.m_UserID, session.GetSessionType()); if (sess != null) { Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID); @@ -513,12 +515,12 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa // NOTE: Only check this for custom match, quick match checks it during matchmaking bucket stage if (LobbyType == ELobbyType.CustomGame) { - UserSession? lobbyOwnerSession = WebSocketManager.GetDataFromUser(Owner); + SharedUserData? lobbyOwnerSharedData = WebSocketManager.GetSharedDataForUser(Owner); // owner must be a game client - if (lobbyOwnerSession != null) + if (lobbyOwnerSharedData != null) { // dont allow join if blocked - if (lobbyOwnerSession.GetSocialContainer().Blocked.Contains(playerSession.m_UserID)) + if (lobbyOwnerSharedData.GetSocialContainer().Blocked.Contains(playerSession.m_UserID)) { return false; } @@ -527,7 +529,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa if (LobbyJoinability == ELobbyJoinability.FriendsOnly) { // If it's friends only, return false if they aren't friends - if (!lobbyOwnerSession.GetSocialContainer().Friends.Contains(playerSession.m_UserID)) + if (!lobbyOwnerSharedData.GetSocialContainer().Friends.Contains(playerSession.m_UserID)) { return false; } @@ -745,7 +747,7 @@ public void DirtyRetransmit() public async Task DirtyRetransmitToSingleMember(Int64 targetUserID) { - var session = WebSocketManager.GetDataFromUser(targetUserID); + var session = WebSocketManager.GetSessionFromUser(targetUserID, EUserSessionType.GameClient); // lobby member must be a game client if (session != null) { Console.WriteLine("[DIRTY LOBBY] Sending WS lobby update for lobby {0}", LobbyID); diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 19f8f4f..c24f310 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -223,7 +223,7 @@ public static void PlayerWidenSearch(UserSession playerSession) private static async Task SendMatchmakingMessage(UserSession cache, string message) { - UserSession? sess = GenOnlineService.WebSocketManager.GetDataFromUser(cache.m_UserID); + UserSession? sess = GenOnlineService.WebSocketManager.GetSessionFromUser(cache.m_UserID, cache.GetSessionType()); if (sess != null) { WebSocketMessage_MatchmakingMessage msg = new WebSocketMessage_MatchmakingMessage(); @@ -549,6 +549,7 @@ public int CurrentMemberCount() return m_lstMembers.Count; } + // TODO_EFCORE: Shared User data, and session<->websocket could be weakrefs public bool IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(UserSession? joiningUserSession, Int64 joining_user) { // NOTE: We check blocking in both directions, joiner blocked them, or joiner is blocked by a player @@ -557,9 +558,14 @@ public bool IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(UserSession? joini UserSession? memberSession = member.GetAssociatedSession(); if (memberSession != null) { - if (memberSession.GetSocialContainer().Blocked.Contains(joining_user) || joiningUserSession.GetSocialContainer().Blocked.Contains(memberSession.m_UserID)) + SharedUserData? memberUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(memberSession.m_UserID); + + if (memberUserData != null) { - return true; + if (memberUserData.GetSocialContainer().Blocked.Contains(joining_user) || memberUserData.GetSocialContainer().Blocked.Contains(memberSession.m_UserID)) + { + return true; + } } } } @@ -610,7 +616,12 @@ private int GetAvgElo() UserSession? memberSession = member.GetAssociatedSession(); if (memberSession != null) { - avgElo += memberSession.GameStats.EloRating; + SharedUserData? memberUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(memberSession.m_UserID); + + if (memberUserData != null) + { + avgElo += memberUserData.GameStats.EloRating; + } } } avgElo /= numMembers; @@ -759,26 +770,31 @@ await SendMatchmakingMessage(memberSession, // should have a user by now if (dummyHostUser != null) { - // make a lobby - DetermineMap(out string strMapName, out string strMapPath); + SharedUserData? dummyHostUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(dummyHostUser.m_UserID); - using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUser.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", - true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch); + if (dummyHostUserData != null) + { + // make a lobby + DetermineMap(out string strMapName, out string strMapPath); - // tell both to join our lobby - WebSocketMessage_MatchmakerJoinLobby joinAction = new WebSocketMessage_MatchmakerJoinLobby(); - joinAction.msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY; - joinAction.lobby_id = m_LobbyID; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinAction)); + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", + true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch); - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) + // tell both to join our lobby + WebSocketMessage_MatchmakerJoinLobby joinAction = new WebSocketMessage_MatchmakerJoinLobby(); + joinAction.msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY; + joinAction.lobby_id = m_LobbyID; + byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinAction)); + + foreach (MatchmakingBucketMember member in m_lstMembers) { - memberSession.QueueWebsocketSend(bytesJSON); + UserSession? memberSession = member.GetAssociatedSession(); + if (memberSession != null) + { + memberSession.QueueWebsocketSend(bytesJSON); + } } } } @@ -1054,77 +1070,86 @@ public static async Task Tick() } else { - if (g_Playlists.TryGetValue(thisSession.MatchmakingPlaylistID, out Playlist? playlist)) + SharedUserData? thisSessionUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(thisSession.m_UserID); + + if (thisSessionUserData == null) + { + lstDestroy.Add(wrSession); + } + else { - - // TODO_MATCHAMAKING: Better way of tracking this, we need to know who is already in a bucket - // Was the user in a bucket? if so theres nothing to do in terms of bucket management - bool bUseInBucket = false; - MatchmakingBucket? mmBucketUserIsIn = null; - foreach (MatchmakingBucket mmBucket in m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID]) + if (g_Playlists.TryGetValue(thisSession.MatchmakingPlaylistID, out Playlist? playlist)) { - if (mmBucket.HasPlayer(thisSession)) + + // TODO_MATCHAMAKING: Better way of tracking this, we need to know who is already in a bucket + // Was the user in a bucket? if so theres nothing to do in terms of bucket management + bool bUseInBucket = false; + MatchmakingBucket? mmBucketUserIsIn = null; + foreach (MatchmakingBucket mmBucket in m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID]) { - bUseInBucket = true; - mmBucketUserIsIn = mmBucket; - break; + if (mmBucket.HasPlayer(thisSession)) + { + bUseInBucket = true; + mmBucketUserIsIn = mmBucket; + break; + } } - } - if (!bUseInBucket) - { - // is there a suitable bucket for us - // TODO_MATCHMAKING: Optimize lookup - if (m_dictMatchmakingBuckets.ContainsKey(thisSession.MatchmakingPlaylistID)) + if (!bUseInBucket) { - MatchmakingBucket? bucketInUse = null; - foreach (MatchmakingBucket mmBucket in m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID]) + // is there a suitable bucket for us + // TODO_MATCHMAKING: Optimize lookup + if (m_dictMatchmakingBuckets.ContainsKey(thisSession.MatchmakingPlaylistID)) { - // must be within initial elo threshold for a join, otherwise we'll make a bucket and try to merge buckets using the elo iteration expansion algorithm - if (mmBucket.IsAvgEloWithinThreshold(thisSession.GameStats.EloRating, EloConfig.EloExpansionValue)) + MatchmakingBucket? bucketInUse = null; + foreach (MatchmakingBucket mmBucket in m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID]) { - // TODO_MATCHMAKING: Squads - if (mmBucket.HasSpaceForUsers(1, thisSession.ExeCRC, thisSession.IniCRC)) - { - // do the maps overlap? if so we can join - if (mmBucket.DoMapSelectionsIntersect(thisSession.MatchmakingMapIndicies)) - { - bool bJoined = await mmBucket.Join(thisSession); - - if (bJoined) - { - bucketInUse = mmBucket; - } - else + // must be within initial elo threshold for a join, otherwise we'll make a bucket and try to merge buckets using the elo iteration expansion algorithm + if (mmBucket.IsAvgEloWithinThreshold(thisSessionUserData.GameStats.EloRating, EloConfig.EloExpansionValue)) + { + // TODO_MATCHMAKING: Squads + if (mmBucket.HasSpaceForUsers(1, thisSession.ExeCRC, thisSession.IniCRC)) + { + // do the maps overlap? if so we can join + if (mmBucket.DoMapSelectionsIntersect(thisSession.MatchmakingMapIndicies)) { - bucketInUse = null; + bool bJoined = await mmBucket.Join(thisSession); + + if (bJoined) + { + bucketInUse = mmBucket; + } + else + { + bucketInUse = null; + } } - } - } - } - } + } + } + } - // didnt find a bucket? make one - if (bucketInUse == null) - { - MatchmakingBucket newBucket = new MatchmakingBucket(playlist.PlaylistID, thisSession, playlist.MinPlayers, playlist.DesiredPlayers, thisSession.MatchmakingMapIndicies, thisSession.ExeCRC, thisSession.IniCRC); - m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID].Add(newBucket); - bucketInUse = newBucket; - } + // didnt find a bucket? make one + if (bucketInUse == null) + { + MatchmakingBucket newBucket = new MatchmakingBucket(playlist.PlaylistID, thisSession, playlist.MinPlayers, playlist.DesiredPlayers, thisSession.MatchmakingMapIndicies, thisSession.ExeCRC, thisSession.IniCRC); + m_dictMatchmakingBuckets[thisSession.MatchmakingPlaylistID].Add(newBucket); + bucketInUse = newBucket; + } - // send status to use - await SendMatchmakingMessage(thisSession, String.Format("You are now matchmaking in playlist \"{0}\". There are currently {1} player(s) searching for a match in this playlist", playlist.Name, GetTotalQueuedPlayersInPlaylist(playlist.PlaylistID))); - await SendMatchmakingMessage(thisSession, String.Format("Status: {0}/{1} players. ({2} required to start)", bucketInUse.CurrentMemberCount(), bucketInUse.DesiredPlayers, bucketInUse.MinPlayers)); + // send status to use + await SendMatchmakingMessage(thisSession, String.Format("You are now matchmaking in playlist \"{0}\". There are currently {1} player(s) searching for a match in this playlist", playlist.Name, GetTotalQueuedPlayersInPlaylist(playlist.PlaylistID))); + await SendMatchmakingMessage(thisSession, String.Format("Status: {0}/{1} players. ({2} required to start)", bucketInUse.CurrentMemberCount(), bucketInUse.DesiredPlayers, bucketInUse.MinPlayers)); - // now remove us from lstSessions, this list is essentially people who need sorted into a bucket - lstDestroy.Add(wrSession); + // now remove us from lstSessions, this list is essentially people who need sorted into a bucket + lstDestroy.Add(wrSession); + } } } - } - else - { - // invalid playlist somehow - lstDestroy.Add(wrSession); + else + { + // invalid playlist somehow + lstDestroy.Add(wrSession); + } } } } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index c2556c5..c7c6d88 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -246,6 +246,37 @@ public static async Task Update(int numLobbies, int numPlayers) } } + public static class SessionHelpers + { + public enum ESessionAccessType + { + Authenticate, // log in and out + Social, // friends lists + ServerListReadOnly, // can read lobby list and players etc, but cannot join + StatsReadOnly, // can read stats for any user, but not write anything + Gameplay, // Create lobbies, Anticheat, Middleware login, Matchmaking, match screenshots, replays, join lobby, etc + }; + + public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAccessType accessType) + { + if (sessType == EUserSessionType.GameClient) // client can do anything + { + return true; + } + else if (sessType == EUserSessionType.ChatClient) + { + return false; + } + + else if (sessType == EUserSessionType.GameLauncher) + { + return false; + } + + return false; + } + } + public static class TokenHelper { public static Int64 GetUserID(ControllerBase controller) @@ -258,6 +289,40 @@ public static Int64 GetUserID(ControllerBase controller) return Convert.ToInt64(controller.User.Claims.First().Value); } + public static KnownClients.EKnownClients GetClientID(ControllerBase controller) + { + var first = controller.User.FindFirst("client_id"); + + if (int.TryParse(first.Value, out int clientIDInt32)) + { + // Validate if the int corresponds to a defined enum value + if (System.Enum.IsDefined(typeof(KnownClients.EKnownClients), clientIDInt32)) + { + KnownClients.EKnownClients knownClientID = (KnownClients.EKnownClients)clientIDInt32; + return knownClientID; + } + } + + return KnownClients.EKnownClients.unknown; + } + + public static EUserSessionType GetSessionType(ControllerBase controller) + { + var first = controller.User.FindFirst("session_type"); + + if (int.TryParse(first.Value, out int sessionTypeInt32)) + { + // Validate if the int corresponds to a defined enum value + if (System.Enum.IsDefined(typeof(EUserSessionType), sessionTypeInt32)) + { + EUserSessionType sessionType = (EUserSessionType)sessionTypeInt32; + return sessionType; + } + } + + return EUserSessionType.None; + } + public static string GetDisplayName(ControllerBase controller) { // TODO: Handle not finding claims, it is a critical error @@ -384,7 +449,7 @@ public enum ETokenType Refresh } - public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, string client_id, bool bIsAdmin) + public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETokenType tokenType, KnownClients.EKnownClients knownClientID, EUserSessionType sessionType, bool bIsAdmin) { var jwtSettings = _configuration.GetSection("JwtSettings"); @@ -409,10 +474,27 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo new Claim(JwtRegisteredClaimNames.Name, displayname), new Claim(JwtRegisteredClaimNames.Address, ipAddr), new Claim(JwtRegisteredClaimNames.Typ, ((int)tokenType).ToString()), - new Claim("client_id", client_id), - new Claim(ClaimTypes.Role, "Player") + new Claim("client_id", knownClientID.ToString()), + new Claim("session_type", ((int)sessionType).ToString()) }; + if (sessionType == EUserSessionType.GameClient) + { + claims.Add(new Claim(ClaimTypes.Role, "GameClient")); + } + else if (sessionType == EUserSessionType.ChatClient) + { + claims.Add(new Claim(ClaimTypes.Role, "ChatClient")); + } + else if (sessionType == EUserSessionType.GameLauncher) + { + claims.Add(new Claim(ClaimTypes.Role, "GameLauncher")); + } + else + { + throw new Exception("Unhandled session type: " + sessionType); + } + if (bIsAdmin) { claims.Add(new Claim(ClaimTypes.Role, "Admin")); From 1a61df1025687d311e6b3cd087d64064a0dfe06c Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:15:04 -0500 Subject: [PATCH 11/33] - OID endpoint now returns roles and client id associated with the token --- .../ConnectionOutcomeController.cs | 2 ++ GenOnlineService/Controllers/OID/OIDController.cs | 5 +++++ GenOnlineService/Program.cs | 14 ++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index f371313..28a34eb 100644 --- a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs +++ b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs @@ -39,6 +39,8 @@ public override Type GetReturnType() public bool success { get; set; } = false; } + // TODO_EFCORE: Move to a publish/subscribe model for rooms + // TODO_EFCORE: Must review all Roles=, we changed roles up [ApiController] [Authorize(Roles = "Player")] [Route("env/{environment}/contract/{contract_version}/[controller]")] diff --git a/GenOnlineService/Controllers/OID/OIDController.cs b/GenOnlineService/Controllers/OID/OIDController.cs index 2f0abd6..b7fd11e 100644 --- a/GenOnlineService/Controllers/OID/OIDController.cs +++ b/GenOnlineService/Controllers/OID/OIDController.cs @@ -45,6 +45,8 @@ public override Type GetReturnType() public string user_id { get; set; } = null; // string provides max compat public string display_name { get; set; } = null; + public List roles { get; set; } = new(); + public KnownClients.EKnownClients client_id { get; set; } = KnownClients.EKnownClients.unknown; } [ApiController] @@ -68,9 +70,12 @@ public async Task Post() if (user_id != -1) { string strDisplayName = TokenHelper.GetDisplayName(this); + KnownClients.EKnownClients client_id = TokenHelper.GetClientID(this); result.user_id = user_id.ToString(); result.display_name = strDisplayName; + result.roles = TokenHelper.GetRoles(this); + result.client_id = client_id; } return result; diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index c7c6d88..6b665ad 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -289,6 +289,17 @@ public static Int64 GetUserID(ControllerBase controller) return Convert.ToInt64(controller.User.Claims.First().Value); } + public static List GetRoles(ControllerBase controller) + { + var roles = controller.User.Claims.Where(c => c.Type == ClaimTypes.Role || c.Type == "role").Select(c => c.Value).ToList(); + return roles; + } + + public static bool IsAdmin(ControllerBase controller) + { + return controller.User.IsInRole("Admin"); + } + public static KnownClients.EKnownClients GetClientID(ControllerBase controller) { var first = controller.User.FindFirst("client_id"); @@ -478,6 +489,9 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo new Claim("session_type", ((int)sessionType).ToString()) }; + // everyone gets the player role + claims.Add(new Claim(ClaimTypes.Role, "Player")); + if (sessionType == EUserSessionType.GameClient) { claims.Add(new Claim(ClaimTypes.Role, "GameClient")); From 783be83cb88937671317b89fe130c056841a4b4f Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:51:08 -0500 Subject: [PATCH 12/33] Fix bug with parsing of known client ID --- GenOnlineService/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 6b665ad..c44cbb1 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -485,7 +485,7 @@ public string GenerateToken(string displayname, Int64 userID, string ipAddr, ETo new Claim(JwtRegisteredClaimNames.Name, displayname), new Claim(JwtRegisteredClaimNames.Address, ipAddr), new Claim(JwtRegisteredClaimNames.Typ, ((int)tokenType).ToString()), - new Claim("client_id", knownClientID.ToString()), + new Claim("client_id", ((int)knownClientID).ToString()), new Claim("session_type", ((int)sessionType).ToString()) }; From eb17ef5d0fa871dbf0289e228566521781be6b0e Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:00:14 -0500 Subject: [PATCH 13/33] Update endpoint permissions to match new token roles --- .../ConnectionOutcomeController.cs | 2 +- .../Controllers/Friends/SocialController.cs | 16 ++++++++-------- .../GlobalStats/GlobalStatsController.cs | 2 +- .../Controllers/Lobbies/LobbiesController.cs | 4 ++-- .../Controllers/Lobby/LobbyController.cs | 10 +++++----- .../LoginWithToken/LoginWithTokenController.cs | 3 +-- .../Controllers/MOTD/MOTDController.cs | 2 +- .../MatchReplay/MatchReplayController.cs | 2 +- .../MatchUpdate/MatchUpdateController.cs | 2 +- .../Matchmaking/MatchmakingController.cs | 10 +++++----- .../PlayerStats/PlayerStatsController.cs | 4 ++-- .../Controllers/Rooms/RoomsController.cs | 2 +- .../ServiceConfig/ServiceConfigController.cs | 2 +- .../Controllers/User/UserController.cs | 8 ++++---- .../Controllers/WebSocket/WebSocketController.cs | 2 +- GenOnlineService/Program.cs | 10 ++++++++-- 16 files changed, 43 insertions(+), 38 deletions(-) diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index 28a34eb..f457816 100644 --- a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs +++ b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs @@ -42,7 +42,7 @@ public override Type GetReturnType() // TODO_EFCORE: Move to a publish/subscribe model for rooms // TODO_EFCORE: Must review all Roles=, we changed roles up [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class ConnectionOutcomeController : ControllerBase { diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index 542b01a..71e8dcc 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -140,7 +140,7 @@ private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int6 // Accept a request [HttpPost("Friends/Requests/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task AcceptPendingRequest(Int64 target_user_id) { // source user must be signed in (anywhere) @@ -159,7 +159,7 @@ public async Task AcceptPendingRequest(Int64 target_user_id) // Reject a request [HttpDelete("Friends/Requests/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task RejectPendingRequest(Int64 target_user_id) { // source user must be signed in @@ -186,7 +186,7 @@ public async Task RejectPendingRequest(Int64 target_user_id) // Remove a friend [HttpDelete("Friends/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task RemoveFriend(Int64 target_user_id) { // source user must be signed in @@ -227,7 +227,7 @@ public async Task RemoveFriend(Int64 target_user_id) // Send a request [HttpPut("Friends/Requests/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task AddFriend(Int64 target_user_id) { // source user must be signed in @@ -324,7 +324,7 @@ public async Task AddFriend(Int64 target_user_id) } [HttpGet("Friends")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public async Task Get_FriendsAndRequests() { // TODO_ASP: Set error codes properly in all places (and use variable, not magic numbers) @@ -424,7 +424,7 @@ public async Task Get_FriendsAndRequests() } [HttpGet("Blocked")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public async Task Get_Blocked() { // TODO_ASP: Set error codes properly in all places (and use variable, not magic numbers) @@ -489,7 +489,7 @@ public async Task Get_Blocked() // Block user [HttpPut("Blocked/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task Add_Block(Int64 target_user_id) { // source user must be signed in @@ -556,7 +556,7 @@ public async Task Add_Block(Int64 target_user_id) // Unblock user [HttpDelete("Blocked/{target_user_id}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task Remove_Block(Int64 target_user_id) { // We must: diff --git a/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs b/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs index 36d4ae3..89dc432 100644 --- a/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs +++ b/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs @@ -47,7 +47,7 @@ public GlobalStatsController(ILogger logger) } [HttpGet(Name = "GlobalStats")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public APIResult Get() { RouteHandler_GET_GlobalStats_Result result = new RouteHandler_GET_GlobalStats_Result(); diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index bf76111..202f820 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -130,7 +130,7 @@ public static double EstimateLatency(double distanceKm) // END LATENCY ESTIMATIONS [HttpGet(Name = "GetLobbies")] - [Authorize(Policy = "PlayerOrMonitorOrApiKey")] + [Authorize(Policy = "AnyClientOrMonitorOrApiKey")] public async Task Get() { RouteHandler_GET_Lobbies_Result result = new RouteHandler_GET_Lobbies_Result(); @@ -282,7 +282,7 @@ public async Task Get() } [HttpPut(Name = "PutLobbies")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Put() { RouteHandler_PUT_Lobbies_Result result = new RouteHandler_PUT_Lobbies_Result(); diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index c4aa403..7b0e461 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -153,7 +153,7 @@ public LobbyController(LobbyManager lobbyManager, AppDbContext db, ILogger Get(string lobby_id) { RouteHandler_GET_Lobby_Result result = new RouteHandler_GET_Lobby_Result(); @@ -195,7 +195,7 @@ public async Task Get(string lobby_id) } [HttpDelete("{lobbyID}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Delete(Int64 lobbyID) { RouteHandler_DELETE_Lobby_Result result = new RouteHandler_DELETE_Lobby_Result(); @@ -261,7 +261,7 @@ public async Task Delete(Int64 lobbyID) } [HttpPost("Outcome")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task PostOutcome() { using (var reader = new StreamReader(HttpContext.Request.Body)) @@ -363,7 +363,7 @@ enum ELobbyUpdatePermissions [HttpPost("{lobbyID}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Post(Int64 lobbyID) { RouteHandler_POST_Lobby_Result result = new RouteHandler_POST_Lobby_Result(); @@ -652,7 +652,7 @@ public async Task Post(Int64 lobbyID) } [HttpPut("{lobbyID}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Put(Int64 lobbyID) { RouteHandler_PUT_Lobby_Result result = new RouteHandler_PUT_Lobby_Result(); diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 38d48cc..00825cd 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -45,7 +45,7 @@ public override Type GetReturnType() } [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class LoginWithToken : ControllerBase { @@ -93,7 +93,6 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { var data = JsonSerializer.Deserialize>(jsonData, options); - // TODO_EFCORE: remove client_id from the client, token has it, and is more trustworthy KnownClients.EKnownClients clientID = TokenHelper.GetClientID(this); if (clientID == KnownClients.EKnownClients.unknown) { diff --git a/GenOnlineService/Controllers/MOTD/MOTDController.cs b/GenOnlineService/Controllers/MOTD/MOTDController.cs index 2fa8a14..f0aad01 100644 --- a/GenOnlineService/Controllers/MOTD/MOTDController.cs +++ b/GenOnlineService/Controllers/MOTD/MOTDController.cs @@ -39,7 +39,7 @@ public override Type GetReturnType() [ApiController] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class MOTDController : ControllerBase { diff --git a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs index b8cb68f..156a1bf 100644 --- a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs +++ b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs @@ -58,7 +58,7 @@ public MatchReplayController(LobbyManager lobbyManager, ILogger Post() { RouteHandler_POST_Lobby_Result result = new RouteHandler_POST_Lobby_Result(); diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index e30b83c..e85a7bb 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -181,7 +181,7 @@ public MatchUpdateController(LobbyManager lobbyManager, ILogger Post() { this.HttpContext.Request.EnableBuffering(); diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 22a8ffb..0840dc0 100644 --- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs +++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs @@ -35,7 +35,7 @@ namespace GenOnlineService.Controllers { [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class MatchmakingController : ControllerBase { @@ -47,7 +47,7 @@ public MatchmakingController(ILogger logger) } [HttpPut] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Put() { using (var reader = new StreamReader(HttpContext.Request.Body)) @@ -98,7 +98,7 @@ public MatchmakingController(ILogger logger) } [HttpPost("Widen")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public void Put_Widen() { // TODO_QUICKMATCH: What if a user widens after already being matched? We should probably tell them no @@ -117,7 +117,7 @@ public void Put_Widen() } [HttpDelete] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public void Delete() { Int64 user_id = TokenHelper.GetUserID(this); @@ -145,7 +145,7 @@ public override Type GetReturnType() // Get playlists [HttpGet("Playlists")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public APIResult Get_Playlists() { RouteHandler_GET_Playlists_Result result = new RouteHandler_GET_Playlists_Result(); diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index 3b64770..5bc1ee7 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -120,7 +120,7 @@ public async Task Get(Int64 userID) // Bulk endpoint [HttpPost("Batch")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public async Task PostBatched() { RouteHandler_GET_PlayerStatsBatch_Result result = new RouteHandler_GET_PlayerStatsBatch_Result(); @@ -159,7 +159,7 @@ public async Task PostBatched() } [HttpPut] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Put() { RouteHandler_PUT_PlayerStats_Result result = new RouteHandler_PUT_PlayerStats_Result(); diff --git a/GenOnlineService/Controllers/Rooms/RoomsController.cs b/GenOnlineService/Controllers/Rooms/RoomsController.cs index 49d7113..21c1be9 100644 --- a/GenOnlineService/Controllers/Rooms/RoomsController.cs +++ b/GenOnlineService/Controllers/Rooms/RoomsController.cs @@ -47,7 +47,7 @@ public RoomsController(ILogger logger) } [HttpGet(Name = "GetRooms")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public async Task Get() { RouteHandler_GET_Rooms_Result result = new RouteHandler_GET_Rooms_Result(); diff --git a/GenOnlineService/Controllers/ServiceConfig/ServiceConfigController.cs b/GenOnlineService/Controllers/ServiceConfig/ServiceConfigController.cs index 8d177e1..4adcb65 100644 --- a/GenOnlineService/Controllers/ServiceConfig/ServiceConfigController.cs +++ b/GenOnlineService/Controllers/ServiceConfig/ServiceConfigController.cs @@ -27,7 +27,7 @@ namespace GenOnlineService.Controllers { [ApiController] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,Monitor")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class ServiceConfigController : ControllerBase { diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index 82d6f51..beaef8d 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -39,7 +39,7 @@ public override Type GetReturnType() } [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { @@ -52,7 +52,7 @@ public UsersController(AppDbContext db, ILogger logger) _db = db; } - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [HttpGet("Me")] public async Task MyUser() { @@ -71,7 +71,7 @@ public async Task MyUser() return result; } - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [HttpGet("Active")] public APIResult ActiveUsers() { @@ -114,7 +114,7 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) } [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UserController : ControllerBase { diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index ebd2b58..d0b95a8 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -55,7 +55,7 @@ private struct WSMessageEnvelope } [Route("/ws")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) { if (!HttpContext.WebSockets.IsWebSocketRequest) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index c44cbb1..634affa 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -728,11 +728,17 @@ public static async Task Main(string[] args) return false; })); - options.AddPolicy("PlayerOrMonitorOrApiKey", policy => + options.AddPolicy("AnyClientOrMonitorOrApiKey", policy => policy.RequireAssertion(context => { // Check roles - if (context.User.IsInRole("Player")) + if (context.User.IsInRole("GameClient")) + return true; + + if (context.User.IsInRole("ChatClient")) + return true; + + if (context.User.IsInRole("GameLauncher")) return true; if (context.User.IsInRole("Monitor")) From ccbd13105a3c76b1da37f33695686af32f16119a Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:01:24 -0500 Subject: [PATCH 14/33] Only allow Gameclient tokens to utilize UpdateSessionLobbyID --- GenOnlineService/Constants.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index deb3b1a..227f097 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -976,8 +976,10 @@ public async Task UpdateSessionNetworkRoom(Int16 newRoomID) public void UpdateSessionLobbyID(Int64 newLobbyID) { - // TODO_EFCORE: Only if game client - currentLobbyID = newLobbyID; + if (m_sessionType == EUserSessionType.GameClient) + { + currentLobbyID = newLobbyID; + } } // network room From 3ccb179b7a996884d58f359547f3d05755ee91b4 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:03:32 -0500 Subject: [PATCH 15/33] Added known client: "superhackers_community_patch_client" --- GenOnlineService/Constants.cs | 3 ++- .../Controllers/Monitoring/MonitoringController.cs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 227f097..508ee19 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -168,7 +168,8 @@ public enum EKnownClients gen_online_30hz = 0, gen_online_60hz = 1, genhub = 2, - communityoutpost_chat = 3 + communityoutpost_chat = 3, + superhackers_community_patch_client = 4 } public static ConcurrentDictionary KnownClientSessionTypes = new() diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 4c4d5b4..09fe7b1 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -187,8 +187,9 @@ public async Task Monitor_Database() // db call try { - // TODO_EFCORE: Pass DB properly - GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(null); + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(db); GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), true); return internalResult; } From c559a9ffad50b52a46a0959e160d9e838dac8ecd Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:06:13 -0500 Subject: [PATCH 16/33] Assigned known client 'superhackers_community_patch_client' role 'GameClient' --- GenOnlineService/Constants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 508ee19..0b01ca9 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -177,7 +177,8 @@ public enum EKnownClients [EKnownClients.gen_online_30hz] = EUserSessionType.GameClient, [EKnownClients.gen_online_60hz] = EUserSessionType.GameClient, [EKnownClients.genhub] = EUserSessionType.GameLauncher, - [EKnownClients.communityoutpost_chat] = EUserSessionType.ChatClient + [EKnownClients.communityoutpost_chat] = EUserSessionType.ChatClient, + [EKnownClients.superhackers_community_patch_client] = EUserSessionType.GameClient }; } From b34a03e81c814142bf57ed7c3b0fee7ba3f5a83b Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:12:31 -0500 Subject: [PATCH 17/33] Added a known client "custom_third_party_client" for people to build their own game against our service --- GenOnlineService/Constants.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 0b01ca9..9706617 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -169,7 +169,8 @@ public enum EKnownClients gen_online_60hz = 1, genhub = 2, communityoutpost_chat = 3, - superhackers_community_patch_client = 4 + superhackers_community_patch_client = 4, + custom_third_party_client = 5 } public static ConcurrentDictionary KnownClientSessionTypes = new() @@ -178,7 +179,8 @@ public enum EKnownClients [EKnownClients.gen_online_60hz] = EUserSessionType.GameClient, [EKnownClients.genhub] = EUserSessionType.GameLauncher, [EKnownClients.communityoutpost_chat] = EUserSessionType.ChatClient, - [EKnownClients.superhackers_community_patch_client] = EUserSessionType.GameClient + [EKnownClients.superhackers_community_patch_client] = EUserSessionType.GameClient, + [EKnownClients.custom_third_party_client] = EUserSessionType.GameClient }; } From 923867fa9994b18c8795e9de19dced2d04034c0a Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:23:28 -0500 Subject: [PATCH 18/33] - Added priorities for presence now that one user can sign in on multiple clients, highest is shown for true presence --- GenOnlineService/Constants.cs | 84 +++++++++++++------ .../Controllers/Friends/SocialController.cs | 10 +-- .../Monitoring/MonitoringController.cs | 2 +- .../Controllers/User/UserController.cs | 2 +- 4 files changed, 64 insertions(+), 34 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 9706617..fe34143 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -2091,46 +2091,80 @@ public enum EWebSocketMessageID public static class UserPresence { - public static string DetermineUserStatus(UserSession? userData) + public enum EPresencePriority { - if (userData == null) - { - return "Offline"; - } + Highest = 2, + Middle = 1, + Lowest = 0 + } - if (userData.currentLobbyID == -1) - { - return "In Server List / Chat Room"; - } - else + public static string DetermineUserStatusFromAllSessions(Int64 user_id, out bool IsOnline) + { + List lstUserSessions = WebSocketManager.GetAllDataFromUser(user_id); + IsOnline = false; + + string strOverallPresence = "Offline"; + UserPresence.EPresencePriority overallPriority = UserPresence.EPresencePriority.Lowest; + + foreach (UserSession userData in lstUserSessions) { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - Lobby? plrLobby = lobbyManager.GetLobby(userData.currentLobbyID); + string strThisPresence = "Offline"; + EPresencePriority thisPriority = EPresencePriority.Lowest; + + if (userData == null) + { + thisPriority = EPresencePriority.Lowest; + strThisPresence = "Offline"; + } - if (plrLobby == null) + IsOnline = true; + + if (userData.currentLobbyID == -1) { - return "In A Lobby"; + thisPriority = EPresencePriority.Middle; + strThisPresence = "In Server List / Chat Room"; } else { - if (plrLobby.State == ELobbyState.GAME_SETUP) - { - return String.Format("In lobby '{0}' - Waiting on game setup", plrLobby.Name); - } - else if (plrLobby.State == ELobbyState.INGAME) - { - return String.Format("In lobby '{0}' - Match In Progress", plrLobby.Name); - } - else if (plrLobby.State == ELobbyState.COMPLETE) + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + Lobby? plrLobby = lobbyManager.GetLobby(userData.currentLobbyID); + + thisPriority = EPresencePriority.Highest; + + if (plrLobby == null) { - return String.Format("In lobby '{0}' - Game Just Finished", plrLobby.Name); + strThisPresence = "In A Lobby"; } else { - return String.Format("In lobby '{0}'", plrLobby.Name); + if (plrLobby.State == ELobbyState.GAME_SETUP) + { + strThisPresence = String.Format("In lobby '{0}' - Waiting on game setup", plrLobby.Name); + } + else if (plrLobby.State == ELobbyState.INGAME) + { + strThisPresence = String.Format("In lobby '{0}' - Match In Progress", plrLobby.Name); + } + else if (plrLobby.State == ELobbyState.COMPLETE) + { + strThisPresence = String.Format("In lobby '{0}' - Game Just Finished", plrLobby.Name); + } + else + { + strThisPresence = String.Format("In lobby '{0}'", plrLobby.Name); + } } } + + // higher than our current priority? + if (thisPriority > overallPriority) + { + strOverallPresence = strThisPresence; + overallPriority = thisPriority; + } } + + return strOverallPresence; } } diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index 71e8dcc..aeff449 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -17,6 +17,7 @@ */ using Amazon.S3.Model; +using Discord.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -382,18 +383,13 @@ public async Task Get_FriendsAndRequests() { if (dictDisplayNames.ContainsKey(friend_user_id)) // no display name, they probably dont exist anymore, so dont return them { - // are they online? - SharedUserData? targetUserData = WebSocketManager.GetSharedDataForUser(friend_user_id); + string strPresence = UserPresence.DetermineUserStatusFromAllSessions(friend_user_id, out bool isOnline); - // TODO_EFCORE: What user status do we use if the person is logged in multiple times? prefer in-game client? - //string strPresence = targetUserData != null ? UserPresence.DetermineUserStatus(targetUserData) : "Offline"; - string strPresence = "TODO_EFCORE"; - result.friends.Add(new FriendEntry() { user_id = friend_user_id, display_name = dictDisplayNames[friend_user_id], - online = targetUserData != null, + online = isOnline, presence = strPresence }); } diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 09fe7b1..dd95894 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -132,7 +132,7 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) { GET_ActiveUsers_UserEntry userEntry = new(); userEntry.name = userSharedData.m_strDisplayName; - userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); + userEntry.status = UserPresence.DetermineUserStatusFromAllSessions(sessionData.Key, out bool isOnline); userEntry.client_id = sessionData.Value.m_client_id; userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index beaef8d..e4e94fa 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -99,7 +99,7 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) { GET_ActiveUsers_UserEntry userEntry = new(); userEntry.name = userSharedData.m_strDisplayName; - userEntry.status = UserPresence.DetermineUserStatus(sessionData.Value); + userEntry.status = UserPresence.DetermineUserStatusFromAllSessions(sessionData.Key, out bool isOnline); userEntry.client_id = sessionData.Value.m_client_id; userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); From 05f0703658734f01c9ac4eaa7afaf250fb430939 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:25:34 -0500 Subject: [PATCH 19/33] - Dedupe user list on /ActiveUsers endpoint --- .../ConnectionOutcome/ConnectionOutcomeController.cs | 1 - .../Controllers/Monitoring/MonitoringController.cs | 8 +++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index f457816..bce4037 100644 --- a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs +++ b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs @@ -40,7 +40,6 @@ public override Type GetReturnType() } // TODO_EFCORE: Move to a publish/subscribe model for rooms - // TODO_EFCORE: Must review all Roles=, we changed roles up [ApiController] [Authorize(Roles = "GameClient")] [Route("env/{environment}/contract/{contract_version}/[controller]")] diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index dd95894..bf89391 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -120,12 +120,18 @@ string TimeSpanToHumanReadableString(TimeSpan timeSpan) // TODO_QUICKMATCH: We chekc maps are big enough, but the reverse needs checked too - dont let 8 playrs join a 6-8 ffa if only map is defcon6 for example - // TODO_EFCORE: People can be isgned in multiple times, should we show all of them in the count? or what + HashSet setUsersAlreadyProcessed = new(); ConcurrentDictionary> allData = WebSocketManager.GetUserDataCache(); foreach (var sessionDataPerClientType in allData) { foreach (var sessionData in sessionDataPerClientType.Value) { + if (setUsersAlreadyProcessed.Contains(sessionData.Value.m_UserID)) + { + continue; + } + setUsersAlreadyProcessed.Add(sessionData.Value.m_UserID); + SharedUserData? userSharedData = WebSocketManager.GetSharedDataForUser(sessionData.Value.m_UserID); if (userSharedData != null) From b61f3f387d7594675c34c0a9ff05fb5fe5ca7b1a Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:37:39 -0500 Subject: [PATCH 20/33] - Migrated more of DB to EFCore --- .../CheckLogin/CheckLoginController.cs | 2 +- .../Controllers/Lobby/LobbyController.cs | 14 +- .../LoginWithTokenController.cs | 2 +- .../Database/Database.ServiceStats.cs | 114 +++++++++++++++ GenOnlineService/Database/Database.User.cs | 133 ++++++++++++++++- GenOnlineService/Database/Database.cs | 4 + GenOnlineService/Database/MySQL.cs | 136 ------------------ GenOnlineService/LobbyManager.cs | 21 +-- GenOnlineService/Program.cs | 5 +- 9 files changed, 274 insertions(+), 157 deletions(-) create mode 100644 GenOnlineService/Database/Database.ServiceStats.cs diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 1c3d095..3997520 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -196,7 +196,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; string hwid_2 = data.ContainsKey("reserved_2") ? data["reserved_2"].ToString() : "NONE"; - await Database.Functions.Auth.RegisterUserDevice(GlobalDatabaseInstance.g_Database, user_id, hwid_0, hwid_1, hwid_2, ipAddr); + await Database.UserDevices.RegisterUserDevice(_db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); } string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 7b0e461..fefaca5 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -442,7 +442,7 @@ public async Task Post(Int64 lobbyID) if (strMap != null && strMapPath != null) { - await lobby.UpdateMap(strMap, strMapPath, bOfficialMap, maxPlayers); + await lobby.UpdateMap(_db, strMap, strMapPath, bOfficialMap, maxPlayers); } } } @@ -454,7 +454,7 @@ public async Task Post(Int64 lobbyID) { int side = data["side"].GetInt32(); int start_pos = data["start_pos"].GetInt32(); - await SourceMember.UpdateSide(side, start_pos); + await SourceMember.UpdateSide(_db, side, start_pos); } } else if (field == ELobbyUpdateField.MY_COLOR) @@ -462,7 +462,7 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("color")) { int color = data["color"].GetInt32(); - await SourceMember.UpdateColor(color); + await SourceMember.UpdateColor(_db, color); } } else if (field == ELobbyUpdateField.MY_START_POS) @@ -486,7 +486,7 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("startingcash")) { UInt32 startingCash = data["startingcash"].GetUInt32(); - await lobby.UpdateStartingCash(startingCash); + await lobby.UpdateStartingCash(_db, startingCash); } } else if (field == ELobbyUpdateField.LOBBY_LIMIT_SUPERWEAPONS) @@ -494,7 +494,7 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("limit_superweapons")) { bool bLimitSuperweapons = data["limit_superweapons"].GetBoolean(); - await lobby.UpdateLimitSuperweapons(bLimitSuperweapons); + await lobby.UpdateLimitSuperweapons(_db, bLimitSuperweapons); } } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) @@ -563,7 +563,7 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateSide(side, start_pos); + await TargetMember.UpdateSide(_db, side, start_pos); } } } @@ -581,7 +581,7 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateColor(color); + await TargetMember.UpdateColor(_db, color); } } } diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 00825cd..64ef2e6 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -120,7 +120,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; string hwid_2 = data.ContainsKey("reserved_2") ? data["reserved_2"].ToString() : "NONE"; - await Database.Functions.Auth.RegisterUserDevice(GlobalDatabaseInstance.g_Database, user_id, hwid_0, hwid_1, hwid_2, ipAddr); + await Database.UserDevices.RegisterUserDevice(_db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); } // ban check diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs new file mode 100644 index 0000000..d2839ae --- /dev/null +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -0,0 +1,114 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Text.Json; + +// TODO_EFCORE: When updating this, make sure we preserve old ON DUPLICATE behavior, to overwrite the old data since day_of_year key will be re-used +public class ServiceStat +{ + public ServiceStat() + { + DayOfYear = DateTime.Now.DayOfYear; + HourOfDay = DateTime.Now.Hour; + } + + public int DayOfYear { get; set; } = -1; + public int HourOfDay { get; set; } = -1; + public int PlayerPeak { get; set; } = -1; + public int LobbiesPeak { get; set; } = -1; +} + +public class ServiceStatsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("service_stats"); + + // prim key + builder.HasKey(e => new { e.DayOfYear, e.HourOfDay, e.PlayerPeak, e.LobbiesPeak }); + + builder.Property(e => e.DayOfYear).HasColumnName("day_of_year"); + builder.Property(e => e.HourOfDay).HasColumnName("hour_of_day"); + builder.Property(e => e.PlayerPeak).HasColumnName("player_peak"); + builder.Property(e => e.LobbiesPeak).HasColumnName("lobbies_peak"); + } +} + +namespace Database +{ + public static class ServiceStats + { + public static readonly Func> FindStat = + EF.CompileAsyncQuery( + (AppDbContext db, int day, int hour) => + db.ServiceStats.FirstOrDefault(s => + s.DayOfYear == day && + s.HourOfDay == hour) + ); + + public static readonly Func> FindOldStats = + EF.CompileAsyncQuery( + (AppDbContext db, int cutoff) => + db.ServiceStats.Where(s => s.DayOfYear < cutoff) + ); + + public static async Task CommitStats( + AppDbContext db, + int day_of_year, + int hour_of_day, + int player_peak, + int lobbies_peak) + { + // UPSERT logic using precompiled query + var existing = await FindStat(db, day_of_year, hour_of_day); + + 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); + } + + await db.SaveChangesAsync(); + + // DELETE old rows (precompiled) + int cutoff = day_of_year - 30; + + await foreach (var old in FindOldStats(db, cutoff)) + db.ServiceStats.Remove(old); + + await db.SaveChangesAsync(); + } + + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 2bcd722..cb68381 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -20,6 +20,21 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; using static Database.Functions.Auth; + +public class UserDevice +{ + public Int64 UserID { get; set; } + + public string HWID_0 { get; set; } = String.Empty; + public string HWID_1 { get; set; } = String.Empty; + public string HWID_2 { get; set; } = String.Empty; + public string HWID_3 { get; set; } = String.Empty; + public string HWID_4 { get; set; } = String.Empty; + public string HWID_5 { get; set; } = String.Empty; + + public string IPAddress { get; set; } = String.Empty; +} + public class User { public Int64 ID { get; set; } @@ -73,6 +88,26 @@ public class UserLobbyPreferences public bool favorite_limit_superweapons = false; } +public class UserDevicesConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_devices"); + + // prim key + builder.HasKey(e => new { e.UserID, e.HWID_0, e.HWID_1, e.HWID_2, e.IPAddress}); + + builder.Property(e => e.UserID).HasColumnName("user_id"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_0").HasColumnType("varchar(128)"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_1").HasColumnType("varchar(128)"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_2").HasColumnType("varchar(128)"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_3").HasColumnType("varchar(50)"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_4").HasColumnType("varchar(50)"); + builder.Property(e => e.HWID_0).HasColumnName("hwid_5").HasColumnType("varchar(50)"); + builder.Property(e => e.HWID_0).HasColumnName("ip_addr").HasColumnType("varchar(45)"); + } +} + public class UserConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -106,12 +141,66 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.BanReason).HasColumnName("ban_reason").HasColumnType("varchar(128)"); ; builder.Property(e => e.BannedBy).HasColumnName("banned_by").HasColumnType("varchar(50)"); ; builder.Property(e => e.BanVerifiedBy).HasColumnName("ban_verified_by").HasColumnType("varchar(50)"); ; - builder.Property(e => e.BanAliases).HasColumnName("ban_alises").HasColumnType("varchar(50)"); ; + builder.Property(e => e.BanAliases).HasColumnName("ban_aliases").HasColumnType("varchar(50)"); ; } } namespace Database { + public static class UserDevices + { + public static readonly Func> FindDevice = + EF.CompileAsyncQuery( + (AppDbContext db, long userId, string h0, string h1, string h2) => + db.UserDevices.FirstOrDefault(d => + d.UserID == userId && + d.HWID_0 == h0 && + d.HWID_1 == h1 && + d.HWID_2 == h2) + ); + + public static async Task RegisterUserDevice( + AppDbContext db, + long userId, + string hwid_0, + string hwid_1, + 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(); + } + } + + public static class Users { private static readonly Func> _isUserAdminQuery = @@ -175,5 +264,47 @@ public static async Task GetDisplayName(AppDbContext db, long userId) { return _getUserLobbyPreferencesQuery(db, userId); } + + // TODO_EFCORE: check all queries, determine which ones should be moved to precompiled query + public static async Task SetFavorite_LimitSuperweapons( + AppDbContext db, + 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)); + } + + public static async Task SetFavorite_Map( + AppDbContext db, + long userId, + string strMap) + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteMap, strMap)); + } + + public static async Task SetFavorite_StartingMoney( + AppDbContext db, + long userId, + int startingMoney) + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteStartingMoney, startingMoney)); + } + + public static async Task SetFavorite_Side( + AppDbContext db, + long userId, + int side) + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteSide, side)); + } + + public static async Task SetFavorite_Color( + AppDbContext db, + long userId, + int color) + { + await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteColor, color)); + } } } \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 0ef588f..00b9626 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -23,10 +23,12 @@ public class AppDbContext : DbContext { public DbSet Users => Set(); + public DbSet UserDevices => Set(); public DbSet DailyStats => Set(); public DbSet LeaderboardDaily => Set(); public DbSet LeaderboardMonthly => Set(); public DbSet LeaderboardYearly => Set(); + public DbSet ServiceStats => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -39,9 +41,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); modelBuilder.ApplyConfiguration(new UserConfiguration()); + modelBuilder.ApplyConfiguration(new UserDevicesConfiguration()); modelBuilder.ApplyConfiguration(new DailyStatsConfiguration()); modelBuilder.ApplyConfiguration(new LeaderboardDailyConfiguration()); modelBuilder.ApplyConfiguration(new LeaderboardMonthlyConfiguration()); modelBuilder.ApplyConfiguration(new LeaderboardYearlyConfiguration()); + modelBuilder.ApplyConfiguration(new ServiceStatsConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index f84bcb6..64bc7d6 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -50,17 +50,6 @@ using static Database.Functions.Auth; using static Database.Functions.Lobby; -public enum EAccountType -{ - Unknown = -1, - Steam = 0, - Discord = 1, - Reserved = 2, - Reserved2 = 3, - GameReplays = 4, -} - - @@ -90,26 +79,7 @@ public static class Functions { public static class ServiceStats { - public async static Task CommitStats(MySQLInstance m_Inst, int day_of_year, int hour_of_day, int player_peak, int lobbies_peak) - { - await m_Inst.Query("INSERT INTO service_stats SET day_of_year=@day_of_year, hour_of_day=@hour_of_day, player_peak=@player_peak, lobbies_peak=@lobbies_peak ON DUPLICATE KEY UPDATE player_peak=GREATEST(player_peak, @player_peak), lobbies_peak=GREATEST(lobbies_peak, @lobbies_peak);", - new() - { - { "@day_of_year", day_of_year }, - { "@hour_of_day", hour_of_day }, - { "@player_peak", player_peak }, - { "@lobbies_peak", lobbies_peak } - } - ); - // TODO_URGENT: Handle year roll over - await m_Inst.Query("DELETE FROM service_stats WHERE day_of_year<(@day_of_year - 30);", - new() - { - { "@day_of_year", day_of_year } - } - ); - } } public static class MatchHistory @@ -965,62 +935,6 @@ public async static Task Cleanup(MySQLInstance m_Inst, bool bStartup) ); } - - public async static Task SetFavorite_Color(MySQLInstance m_Inst, Int64 user_id, int favorite_color) - { - await m_Inst.Query("UPDATE users SET favorite_color=@favorite_color WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@favorite_color", favorite_color }, - { "@user_id", user_id } - } - ); - } - - public async static Task SetFavorite_Side(MySQLInstance m_Inst, Int64 user_id, int favorite_side) - { - await m_Inst.Query("UPDATE users SET favorite_side=@favorite_side WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@favorite_side", favorite_side }, - { "@user_id", user_id } - } - ); - } - - public async static Task SetFavorite_Map(MySQLInstance m_Inst, Int64 user_id, string favorite_map) - { - await m_Inst.Query("UPDATE users SET favorite_map=@favorite_map WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@favorite_map", favorite_map }, - { "@user_id", user_id } - } - ); - } - - public async static Task SetFavorite_StartingMoney(MySQLInstance m_Inst, Int64 user_id, int favorite_starting_money) - { - await m_Inst.Query("UPDATE users SET favorite_starting_money=@favorite_starting_money WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@favorite_starting_money", favorite_starting_money }, - { "@user_id", user_id } - } - ); - } - - public async static Task SetFavorite_LimitSuperweapons(MySQLInstance m_Inst, Int64 user_id, bool favorite_limit_superweapons) - { - await m_Inst.Query("UPDATE users SET favorite_limit_superweapons=@favorite_limit_superweapons WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@favorite_limit_superweapons", favorite_limit_superweapons }, - { "@user_id", user_id } - } - ); - } - public async static Task UpdatePlayerStat(MySQLInstance m_Inst, Int64 user_id, int stat_id, int stat_val) { await m_Inst.Query("INSERT INTO user_stats_v2 (user_id, stats) VALUES (@user_id, JSON_OBJECT(@stat_key_raw, @stat_val)) ON DUPLICATE KEY UPDATE stats = JSON_SET(stats, @stat_key_formatted, @stat_val);", @@ -1322,21 +1236,6 @@ public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, Kno } } - - private static string GenerateSessionToken() - { - const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - StringBuilder sb = new StringBuilder(32); - Random random = new Random(); - - for (int i = 0; i < 32; i++) - { - sb.Append(chars[random.Next(chars.Length)]); - } - - return sb.ToString(); - } - public static async Task CleanupPendingLogin(MySQLInstance m_Inst, string strGameCode) { strGameCode = strGameCode.ToUpper(); @@ -1349,34 +1248,6 @@ public static async Task CleanupPendingLogin(MySQLInstance m_Inst, string strGam ); } - public async static Task RegisterUserDevice(MySQLInstance m_Inst, Int64 userID, string hwid_0, string hwid_1, string hwid_2, string ipAddr) - { - // raw version - string hwid_3 = hwid_0.ToUpper(); - string hwid_4 = hwid_1.ToUpper(); - string hwid_5 = hwid_2.ToUpper(); - - // hash everything - hwid_0 = Helpers.ComputeMD5Hash(hwid_0).ToUpper(); - hwid_1 = Helpers.ComputeMD5Hash(hwid_1).ToUpper(); - hwid_2 = Helpers.ComputeMD5Hash(hwid_2).ToUpper(); - - var res = await m_Inst.Query("INSERT IGNORE INTO user_devices(user_id, hwid_0, hwid_1, hwid_2, hwid_3, hwid_4, hwid_5, ip_addr) VALUES (@user_id, @hwid_0, @hwid_1, @hwid_2, @hwid_3, @hwid_4, @hwid_5, @ip_addr);", - new() - { - { "@user_id", userID }, - { "@hwid_0", hwid_0 }, - { "@hwid_1", hwid_1 }, - { "@hwid_2", hwid_2 }, - { "@hwid_3", hwid_3 }, - { "@hwid_4", hwid_4 }, - { "@hwid_5", hwid_5 }, - { "@ip_addr", ipAddr } - } - ); - } - - public async static Task> GetFriends(MySQLInstance m_Inst, Int64 user_id) { HashSet setFriends = new(); @@ -1566,13 +1437,6 @@ public enum EAccountType DevAccount = 3 } -// public enum ESessionType -// { -// Unknown = -1, -// Website = 0, -// Game = 1 -// } - internal static async Task CreateUserIfNotExists_DevAccount(MySQLInstance m_Inst, Int64 user_id, string display_name) { var res = await m_Inst.Query("SELECT user_id FROM users WHERE user_id=@user_id LIMIT 1;", diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 6eefd71..07043e5 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -19,6 +19,7 @@ using Amazon.S3.Model; using Discord; using GenOnlineService.Controllers; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI; using System.Collections; using System.Collections.Concurrent; @@ -788,7 +789,7 @@ private static String FixMapPathForGame(string strMapPath) return strMapPath; } - public async Task UpdateMap(string strMap, string strMapPath, bool bOfficialMap, int newMaxPlayers) + public async Task UpdateMap(AppDbContext _db, string strMap, string strMapPath, bool bOfficialMap, int newMaxPlayers) { int oldMaxPlayers = MaxPlayers; MapName = strMap; @@ -818,26 +819,26 @@ public async Task UpdateMap(string strMap, string strMapPath, bool bOfficialMap, // only if official, since we cant guarantee if they log in on another machine that the map is installed if (bOfficialMap) { - await Database.Functions.Auth.SetFavorite_Map(GlobalDatabaseInstance.g_Database, Owner, strMapPath); + await Database.Users.SetFavorite_Map(_db, Owner, strMapPath); } DirtyRetransmit(); } - public async Task UpdateStartingCash(UInt32 newStartingCash) + public async Task UpdateStartingCash(AppDbContext _db, UInt32 newStartingCash) { StartingCash = newStartingCash; - await Database.Functions.Auth.SetFavorite_StartingMoney(GlobalDatabaseInstance.g_Database, Owner, (int)newStartingCash); + await Database.Users.SetFavorite_StartingMoney(_db, Owner, (int)newStartingCash); DirtyRetransmit(); } - public async Task UpdateLimitSuperweapons(bool bLimitSuperweapons) + public async Task UpdateLimitSuperweapons(AppDbContext _db, bool bLimitSuperweapons) { IsLimitSuperweapons = bLimitSuperweapons; - await Database.Functions.Auth.SetFavorite_LimitSuperweapons(GlobalDatabaseInstance.g_Database, Owner, bLimitSuperweapons); + await Database.Users.SetFavorite_LimitSuperweapons(_db, Owner, bLimitSuperweapons); DirtyRetransmit(); } @@ -1064,19 +1065,19 @@ public void SetPlayerSlotState(EPlayerType newState) DirtyRetransmit(); } - public async Task UpdateSide(int newSide, int start_pos) + public async Task UpdateSide(AppDbContext _db, int newSide, int start_pos) { Side = newSide; - await Database.Functions.Auth.SetFavorite_Side(GlobalDatabaseInstance.g_Database, UserID, newSide); + await Database.Users.SetFavorite_Side(_db, UserID, newSide); DirtyRetransmit(); } - public async Task UpdateColor(int newColor) + public async Task UpdateColor(AppDbContext _db, int newColor) { Color = newColor; - await Database.Functions.Auth.SetFavorite_Color(GlobalDatabaseInstance.g_Database, UserID, newColor); + await Database.Users.SetFavorite_Color(_db, UserID, newColor); DirtyRetransmit(); } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 634affa..2d8ebc1 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -242,7 +242,10 @@ public static async Task Update(int numLobbies, int numPlayers) { int hourOfDay = DateTime.Now.Hour; // store stats - await Database.Functions.ServiceStats.CommitStats(GlobalDatabaseInstance.g_Database, DateTime.Now.DayOfYear, hourOfDay, numPlayers, numLobbies); + + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Database.ServiceStats.CommitStats(db, DateTime.Now.DayOfYear, hourOfDay, numPlayers, numLobbies); } } From 0582950b5a9c2684bb314cb8e50d591319fb0966 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 03:13:23 -0500 Subject: [PATCH 21/33] More raw SQL -> EFCore migration --- GenOnlineService/Constants.cs | 4 +- .../CheckLogin/CheckLoginController.cs | 10 +- .../Controllers/Lobby/LobbyController.cs | 8 +- .../PlayerStats/PlayerStatsController.cs | 6 +- .../WebSocket/WebSocketController.cs | 2 +- .../Database/Database.DailyStats.cs | 1 + GenOnlineService/Database/Database.User.cs | 148 ++++++++++++++++ GenOnlineService/Database/Database.cs | 2 + GenOnlineService/Database/MySQL.cs | 165 +----------------- GenOnlineService/Program.cs | 10 +- 10 files changed, 174 insertions(+), 182 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index fe34143..1da5844 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -228,7 +228,7 @@ public static async Task CreateSession(AppDbContext _db, socialContainer.Blocked = await Database.Functions.Auth.GetBlocked(GlobalDatabaseInstance.g_Database, ownerID); // get stats - PlayerStats GameStats = await Database.Functions.Auth.GetPlayerStats(GlobalDatabaseInstance.g_Database, ownerID); + PlayerStats GameStats = await Database.Functions.Auth.GetPlayerStats(_db, GlobalDatabaseInstance.g_Database, ownerID); userCacheData = new UserSession(ownerID, sessionType, client_id, strContinent, strCountry, dLatitude, dLongitude); m_dictUserSessions[sessionType][ownerID] = userCacheData; @@ -275,7 +275,7 @@ public static async Task CreateSession(AppDbContext _db, m_dictWebsockets[sessionType][ownerID] = newSess; // update last login and last ip - await Database.Functions.Auth.UpdateLastLoginData(GlobalDatabaseInstance.g_Database, ownerID, ipAddr); + await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); int numSessions = m_dictWebsockets.Count; if (numSessions > g_PeakConnectionCount) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 3997520..228cfd2 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -142,7 +142,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr // make user - await Database.Functions.Auth.CreateUserIfNotExists_DevAccount(GlobalDatabaseInstance.g_Database, user_id, result.display_name); + await Database.Users.CreateUserIfNotExists_DevAccount(_db, user_id, result.display_name); } bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); @@ -156,9 +156,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { EPendingLoginState state = (EPendingLoginState)Convert.ToInt32(sqlRes.GetRow(0)["state"]); - Int64 user_id = await Database.Functions.Auth.GetUserIDFromPendingLogin(GlobalDatabaseInstance.g_Database, gameCode); - //string sess_id = await Database.Functions.Auth.StartSession(GlobalDatabaseInstance.g_Database, user_id, clientID); - //string autologin_token = await Database.Functions.Auth.CreateAutoLogin(GlobalDatabaseInstance.g_Database, user_id); + Int64 user_id = await Database.PendingLogins.GetUserIDFromPendingLogin(_db, gameCode); string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); @@ -225,7 +223,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr result.ws_uri = null; } - await Database.Functions.Auth.CleanupPendingLogin(GlobalDatabaseInstance.g_Database, gameCode); + await Database.PendingLogins.CleanupPendingLogin(_db, gameCode); return result; } @@ -240,7 +238,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { result.result = EPendingLoginState.LoginFailed; Response.StatusCode = (int)HttpStatusCode.Forbidden; - await Database.Functions.Auth.CleanupPendingLogin(GlobalDatabaseInstance.g_Database, gameCode); + await Database.PendingLogins.CleanupPendingLogin(_db, gameCode); } #if !DEBUG } diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index fefaca5..52fae15 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -428,7 +428,7 @@ public async Task Post(Int64 lobbyID) lobby.ResetReadyStates(); } - if (field == ELobbyUpdateField.LOBBY_MAP) // TODO_NGMP: We should enforce hos for some of these updates + if (field == ELobbyUpdateField.LOBBY_MAP) { if (data.ContainsKey("map") && data.ContainsKey("map_path") @@ -586,7 +586,7 @@ public async Task Post(Int64 lobbyID) } } } - else if (field == ELobbyUpdateField.AI_TEAM) // TODO: these funcs should check the slot is ACTUALLY AI, host could abuse it to change others teams etc... + else if (field == ELobbyUpdateField.AI_TEAM) { if (data.ContainsKey("slot") && data.ContainsKey("team")) @@ -604,12 +604,12 @@ public async Task Post(Int64 lobbyID) } } } - else if (field == ELobbyUpdateField.AI_START_POS) // TODO: these funcs should check the slot is ACTUALLY AI, host could abuse it to change others teams etc... + else if (field == ELobbyUpdateField.AI_START_POS) { if (data.ContainsKey("slot") && data.ContainsKey("start_pos")) { - // TODO: All these AI funcs should check the player being operated upon is AI, otherwise host could use fiddler to alter other users + int slot = data["slot"].GetInt32(); int start_pos = data["start_pos"].GetInt32(); diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index 5bc1ee7..fda8830 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -69,11 +69,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class PlayerStatsController : ControllerBase { + private readonly AppDbContext _db; private readonly ILogger _logger; - public PlayerStatsController(ILogger logger) + public PlayerStatsController(AppDbContext db, ILogger logger) { _logger = logger; + _db = db; } [HttpGet("{userID}")] @@ -95,7 +97,7 @@ public async Task Get(Int64 userID) // if user is offline, hit DB, could be a friends list inspection for example if (userData == null) { - PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(GlobalDatabaseInstance.g_Database, userID); + PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(_db, GlobalDatabaseInstance.g_Database, userID); if (playerStats == null) { diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index d0b95a8..4069ef7 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -471,7 +471,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (nameChangeRequest.name.Length >= 3 && nameChangeRequest.name.Length <= 16) { - await Database.Functions.Lobby.UpdateDisplayName(GlobalDatabaseInstance.g_Database, sourceUserSession.m_UserID, nameChangeRequest.name); + await Database.Users.SetDisplayName(_db, sourceUserSession.m_UserID, nameChangeRequest.name); sourceUserData.m_strDisplayName = nameChangeRequest.name; await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); } diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs index 949f1ff..b1b64d5 100644 --- a/GenOnlineService/Database/Database.DailyStats.cs +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -79,6 +79,7 @@ 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; diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index cb68381..56d9de3 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -16,11 +16,21 @@ ** along with this program. If not, see . */ +using GenOnlineService; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; using static Database.Functions.Auth; +public class PendingLogin +{ + public Int64 UserID { get; set; } + + public DateTime Created { get; set; } = DateTime.UnixEpoch; + public EPendingLoginState State { get; set; } = EPendingLoginState.None; + public string LoginCode { get; set; } = String.Empty; +} + public class UserDevice { public Int64 UserID { get; set; } @@ -88,6 +98,20 @@ public class UserLobbyPreferences public bool favorite_limit_superweapons = false; } +// TODO_EFCORE: add index for code +public class PendingLoginConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("pending_logins"); + + builder.Property(e => e.UserID).HasColumnName("user_id"); + builder.Property(e => e.LoginCode).HasColumnName("code").HasColumnType("varchar(32)"); + builder.Property(e => e.State).HasColumnName("state").HasColumnType("int(1)"); + builder.Property(e => e.Created).HasColumnName("created"); + } +} + public class UserDevicesConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -147,6 +171,48 @@ public void Configure(EntityTypeBuilder builder) namespace Database { + public static class PendingLogins + { + private static readonly Func> GetUserIdFromCode = + EF.CompileAsyncQuery( + (AppDbContext db, string code) => + db.PendingLogins + .Where(p => p.LoginCode == code) + .Select(p => (long?)p.UserID) + .FirstOrDefault() + ); + + public static async Task Cleanup(AppDbContext db, bool startup) + { + TimeSpan threshold = startup ? TimeSpan.FromSeconds(1) : TimeSpan.FromMinutes(5); + + DateTime cutoff = DateTime.UtcNow - threshold; + + await db.PendingLogins + .Where(p => p.Created <= cutoff) + .ExecuteDeleteAsync(); + } + + public static async Task GetUserIDFromPendingLogin(AppDbContext db, string gameCode) + { + gameCode = gameCode.ToUpper(); + + var result = await GetUserIdFromCode(db, gameCode); + + return result ?? -1; + } + + public static async Task CleanupPendingLogin(AppDbContext db, string gameCode) + { + gameCode = gameCode.ToUpper(); + + await db.PendingLogins + .Where(p => p.LoginCode == gameCode) + .ExecuteDeleteAsync(); + } + + } + public static class UserDevices { public static readonly Func> FindDevice = @@ -203,6 +269,15 @@ public static async Task RegisterUserDevice( public static class Users { + private static readonly Func> GetEloData = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.Users + .Where(u => u.ID == userId) + .Select(u => new EloData(u.EloRating, u.EloNumberOfMatches)) + .FirstOrDefault() + ); + private static readonly Func> _isUserAdminQuery = EF.CompileAsyncQuery((AppDbContext db, long userId) => db.Users @@ -244,6 +319,36 @@ public static class Users .FirstOrDefault() ); +#if DEBUG + public static readonly Func> UserExists = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.Users.Any(u => u.ID == userId) + ); + + internal static async Task CreateUserIfNotExists_DevAccount( + AppDbContext db, long userId, string displayName) + { + // Normalize input + displayName = displayName?.Trim(); + + // Fast existence check + bool exists = await UserExists(db, userId); + + if (!exists) + { + db.Users.Add(new User + { + ID = userId, + AccountType = EAccountType.DevAccount, + DisplayName = displayName + }); + + await db.SaveChangesAsync(); + } + } + +#endif public static Task IsUserAdmin(AppDbContext db, long userId) { @@ -255,6 +360,7 @@ public static Task IsUserBanned(AppDbContext db, long userId) return _isUserBannedQuery(db, userId); } + public static async Task GetDisplayName(AppDbContext db, long userId) { return await _getDisplayNameQuery(db, userId) ?? string.Empty; @@ -275,6 +381,17 @@ public static async Task SetFavorite_LimitSuperweapons( await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.LimitSuperweapons, bLimitSuperweapons)); } + public static async Task GetELOData(AppDbContext db, long userId) + { + var result = await GetEloData(db, userId); + + if (result != null) + return result; + + return new EloData(EloConfig.BaseRating, 0); + } + + public static async Task SetFavorite_Map( AppDbContext db, long userId, @@ -283,6 +400,17 @@ public static async Task SetFavorite_Map( await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteMap, strMap)); } + 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) + ); + } + + public static async Task SetFavorite_StartingMoney( AppDbContext db, long userId, @@ -299,6 +427,15 @@ public static async Task SetFavorite_Side( await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteSide, side)); } + 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) + ); + } + public static async Task SetFavorite_Color( AppDbContext db, long userId, @@ -306,5 +443,16 @@ public static async Task SetFavorite_Color( { await db.Users.Where(u => u.ID == userId).ExecuteUpdateAsync(setters => setters.SetProperty(u => u.FavoriteColor, color)); } + + 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) + ); + } + } } \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 00b9626..5eb8071 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -29,6 +29,7 @@ public class AppDbContext : DbContext public DbSet LeaderboardMonthly => Set(); public DbSet LeaderboardYearly => Set(); public DbSet ServiceStats => Set(); + public DbSet PendingLogins => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -47,5 +48,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new LeaderboardMonthlyConfiguration()); modelBuilder.ApplyConfiguration(new LeaderboardYearlyConfiguration()); modelBuilder.ApplyConfiguration(new ServiceStatsConfiguration()); + modelBuilder.ApplyConfiguration(new PendingLoginConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 64bc7d6..ac9b1b7 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -429,7 +429,7 @@ public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance sharedUserData.GameStats.EloRating = eloPair.Value.Rating; sharedUserData.GameStats.EloMatches = eloPair.Value.NumMatches; } - await Database.Functions.Auth.SaveELOData(GlobalDatabaseInstance.g_Database, eloPair.Key, eloPair.Value); + await Database.Users.SaveELOData(db, eloPair.Key, eloPair.Value); } } @@ -604,16 +604,6 @@ public async static Task CreateUserEntriesIfNotExists(MySQLInstance m_Inst, Int6 public static class Lobby { - public async static Task UpdateDisplayName(MySQLInstance m_Inst, Int64 playerID, string strNewName) - { - await m_Inst.Query("UPDATE users SET displayname=@displayname WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@displayname", strNewName }, - { "@user_id", playerID } - } - ); - } // Called when a lobby is deleted, thats the true end of a match public async static Task CommitLobbyToMatchHistory(MySQLInstance m_Inst, GenOnlineService.Lobby lobby) @@ -916,25 +906,6 @@ public async static Task CommitPlayerOutcome(MySQLInstance m_Inst, int slotIndex // TODO: Cleanup things when a user disconnects, e.g. lobby they're in etc public static class Auth { - public async static Task Cleanup(MySQLInstance m_Inst, bool bStartup) - { - string strTimeString = "00:05:00"; - - if (bStartup) - { - strTimeString = "00:00:01"; - - } - - // cleanup unused pending logins - await m_Inst.Query("DELETE FROM `pending_logins` WHERE TIMEDIFF(NOW(), created) >= @time_string;", - new() - { - { "@time_string", strTimeString } - } - ); - } - public async static Task UpdatePlayerStat(MySQLInstance m_Inst, Int64 user_id, int stat_id, int stat_val) { await m_Inst.Query("INSERT INTO user_stats_v2 (user_id, stats) VALUES (@user_id, JSON_OBJECT(@stat_key_raw, @stat_val)) ON DUPLICATE KEY UPDATE stats = JSON_SET(stats, @stat_key_formatted, @stat_val);", @@ -948,20 +919,6 @@ public async static Task UpdatePlayerStat(MySQLInstance m_Inst, Int64 user_id, i ); } - public async static Task StoreDailyStats(MySQLInstance m_Inst, DailyStatsStructure stats) - { - string strJSON = JsonSerializer.Serialize(stats); - - int day_of_year = DateTime.Now.DayOfYear; - await m_Inst.Query(String.Format("INSERT INTO daily_stats SET day_of_year=@day_of_year, stats_structure=@stats_structure ON DUPLICATE KEY UPDATE stats_structure=@stats_structure;"), - new() - { - { "@day_of_year", day_of_year }, - { "@stats_structure", strJSON } - } - ); - } - public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion protocol, EConnectionState outcome) { if (outcome != EConnectionState.CONNECTED_DIRECT && outcome != EConnectionState.CONNECTED_RELAY && outcome != EConnectionState.CONNECTION_FAILED) // states we dont track @@ -1038,49 +995,6 @@ public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion ); } - public async static Task SaveELOData(MySQLInstance m_Inst, Int64 user_id, EloData newEloData) - { - await m_Inst.Query("UPDATE users SET elo_rating=@elo_rating, elo_num_matches=@elo_num_matches WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", user_id}, - { "@elo_rating", newEloData.Rating}, - { "@elo_num_matches", newEloData.NumMatches} - } - ); - } - - public async static Task UpdateLastLoginData(MySQLInstance m_Inst, Int64 user_id, string ipAddr) - { - await m_Inst.Query("UPDATE users SET lastlogin=current_timestamp(), last_ip=@ip_addr WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@ip_addr", ipAddr }, - { "@user_id", user_id } - } - ); - } - - public async static Task GetELOData(MySQLInstance m_Inst, Int64 user_id) - { - var res = await m_Inst.Query("SELECT elo_rating, elo_num_matches FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", user_id } - } - ); - - if (res.NumRows() > 0) - { - CMySQLRow row = res.GetRow(0); - - EloData retData = new(Convert.ToInt32(row["elo_rating"]), Convert.ToInt32(row["elo_num_matches"])); - return retData; - } - - return new(EloConfig.BaseRating, 0); - } - public async static Task> GetBulkELOData(MySQLInstance m_Inst, List user_ids) { Dictionary results = new(); @@ -1114,10 +1028,10 @@ public async static Task> GetBulkELOData(MySQLInstanc return results; } - public async static Task GetPlayerStats(MySQLInstance m_Inst, Int64 user_id) + public async static Task GetPlayerStats(AppDbContext _db, MySQLInstance m_Inst, Int64 user_id) { // TODO: Return null if user doesnt actually exist, instead of empty stats - EloData eloData = await GetELOData(m_Inst, user_id); + EloData eloData = await Database.Users.GetELOData(_db, user_id); PlayerStats ps = new PlayerStats(user_id, eloData.Rating, eloData.NumMatches); var res = await m_Inst.Query("SELECT stats FROM user_stats_v2 WHERE user_id=@user_id LIMIT 1;", @@ -1188,30 +1102,6 @@ public static async Task FullyDestroyPlayerSession(MySQLInstance m_Inst, Int64 u // TODO: Client needs to handle this... itll start returning 404 } - public async static Task GetUserIDFromPendingLogin(MySQLInstance m_Inst, string gameCode) - { - gameCode = gameCode.ToUpper(); - - CMySQLResult res = await m_Inst.Query("SELECT user_id FROM pending_logins WHERE code=@game_code LIMIT 1;", - new() - { - { "@game_code", gameCode} - } - ); - - if (res.NumRows() > 0) - { - CMySQLRow row = res.GetRow(0); - Int64 user_id = Convert.ToInt64(row["user_id"]); - return user_id; - } - - return -1; - } - - // TODO: How do we stop dev clients connecting to PROD? - // TODO: Check more here, like IP, client, etc - public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) { // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match @@ -1236,18 +1126,6 @@ public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, Kno } } - public static async Task CleanupPendingLogin(MySQLInstance m_Inst, string strGameCode) - { - strGameCode = strGameCode.ToUpper(); - - await m_Inst.Query("DELETE FROM pending_logins WHERE code=@game_code LIMIT 1;", - new() - { - { "@game_code", strGameCode} - } - ); - } - public async static Task> GetFriends(MySQLInstance m_Inst, Int64 user_id) { HashSet setFriends = new(); @@ -1436,28 +1314,6 @@ public enum EAccountType Ghost = 2, DevAccount = 3 } - - internal static async Task CreateUserIfNotExists_DevAccount(MySQLInstance m_Inst, Int64 user_id, string display_name) - { - var res = await m_Inst.Query("SELECT user_id FROM users WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", user_id} - } - ); - - if (res == null || res.NumRows() == 0) // doesnt exist, create it - { - await m_Inst.Query("INSERT INTO users(user_id, account_type, displayname) VALUES (@user_id, @account_type, @displayname);", - new() - { - { "@user_id", user_id}, - { "@account_type", EAccountType.DevAccount}, - { "@displayname", display_name}, - } - ); - } - } } } @@ -1545,21 +1401,6 @@ protected virtual void Dispose(bool disposing) // Written with Interlocked so concurrent threads don't race on a shared DateTime field. private long m_LastQueryTimeTicks = DateTime.Now.Ticks; - public async Task KeepAlive() - { - long lastTicks = Interlocked.Read(ref m_LastQueryTimeTicks); - double timeSinceLastQueryMs = TimeSpan.FromTicks(DateTime.Now.Ticks - lastTicks).TotalMilliseconds; - if (timeSinceLastQueryMs > 300000) - { - await Query("SELECT user_id FROM users LIMIT 1;", null).ConfigureAwait(false); - } - } - - public async static Task TestQuery(MySQLInstance m_Inst) - { - await m_Inst.Query("SELECT * FROM users LIMIT 1", null); - } - public async Task Initialize(WebApplicationBuilder builder, bool bIsStartup = true) { if (Program.g_Config == null) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 2d8ebc1..2949e66 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -356,11 +356,13 @@ public class Program { public static IConfiguration? g_Config = null; public static DiscordBot? g_Discord = null; + + // TODO_EFCORE: Do this regularly static async Task DoCleanup(bool bStartup) { - await Database.Functions.Auth.Cleanup(GlobalDatabaseInstance.g_Database, bStartup); - - // clean up on startup + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Database.PendingLogins.Cleanup(db, bStartup); } private static Task AdditionalValidation(TokenValidatedContext context) @@ -948,8 +950,6 @@ public static async Task Main(string[] args) app.UseAuthentication(); app.UseAuthorization(); - await Database.MySQLInstance.TestQuery(GlobalDatabaseInstance.g_Database); - app.MapControllers(); // cleanup From b0fbc15f5173ebe29e41e876bb1f2f6ca6b1a0fd Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 04:05:52 -0500 Subject: [PATCH 22/33] - Migrated more to EFCore + some optimizations --- .../Database/Database.DailyStats.cs | 2 +- .../Database/Database.Leaderboards.cs | 57 +++++++++++++ .../Database/Database.ServiceStats.cs | 9 ++- GenOnlineService/Database/Database.User.cs | 32 ++++++++ GenOnlineService/Database/MySQL.cs | 79 ++----------------- GenOnlineService/MatchmakingManager.cs | 4 +- 6 files changed, 104 insertions(+), 79 deletions(-) diff --git a/GenOnlineService/Database/Database.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs index b1b64d5..5bd047c 100644 --- a/GenOnlineService/Database/Database.DailyStats.cs +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -84,7 +84,7 @@ public static async Task SaveToDB(AppDbContext db) { int day_of_year = DateTime.Now.DayOfYear; - var entity = await db.DailyStats + var entity = await db.DailyStats.AsTracking() .FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); // Insert if new, otherwise update diff --git a/GenOnlineService/Database/Database.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs index a45ac60..e2621c2 100644 --- a/GenOnlineService/Database/Database.Leaderboards.cs +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -240,6 +240,63 @@ 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(); + } + catch (DbUpdateException ex) + { + // Ignore duplicate key errors (INSERT IGNORE behavior) + if (!IsDuplicateKeyException(ex)) + throw; + } + } + + private static bool IsDuplicateKeyException(DbUpdateException ex) + { + return ex.InnerException?.Message.Contains("Duplicate entry") == true; + } + + private static async Task> MaterializeAsync(IAsyncEnumerable source) { var list = new List(); diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs index d2839ae..3d87c24 100644 --- a/GenOnlineService/Database/Database.ServiceStats.cs +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -55,10 +55,10 @@ namespace Database { public static class ServiceStats { - public static readonly Func> FindStat = + public static readonly Func> FindStatTracked = EF.CompileAsyncQuery( (AppDbContext db, int day, int hour) => - db.ServiceStats.FirstOrDefault(s => + db.ServiceStats.AsTracking().FirstOrDefault(s => s.DayOfYear == day && s.HourOfDay == hour) ); @@ -77,7 +77,7 @@ public static async Task CommitStats( int lobbies_peak) { // UPSERT logic using precompiled query - var existing = await FindStat(db, day_of_year, hour_of_day); + var existing = await FindStatTracked(db, day_of_year, hour_of_day); if (existing == null) { @@ -99,7 +99,8 @@ public static async Task CommitStats( existing.LobbiesPeak = Math.Max(existing.LobbiesPeak, lobbies_peak); } - await db.SaveChangesAsync(); + // NOTE: duplicate, unnecessary + //await db.SaveChangesAsync(); // DELETE old rows (precompiled) int cutoff = day_of_year - 30; diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 56d9de3..82741e5 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -319,6 +319,38 @@ public static class Users .FirstOrDefault() ); + private static readonly Func, IAsyncEnumerable> _compiledBulkQuery = + EF.CompileAsyncQuery( + (AppDbContext db, List ids) => + db.Users.Where(u => ids.Contains(u.ID)) + ); + + + public static async Task> GetBulkELOData( + AppDbContext db, List userIds) + { + Dictionary results = new(); + + if (userIds == null || userIds.Count == 0) + return results; + + // 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) + { + if (!results.ContainsKey(id)) + results[id] = new EloData(EloConfig.BaseRating, 0); + } + + return results; + } + + #if DEBUG public static readonly Func> UserExists = EF.CompileAsyncQuery( diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index ac9b1b7..aa06cb5 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -387,7 +387,7 @@ public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance // initialize data with bulk query (1 query instead of N) List userIds = lstMembers.Select(m => m.user_id).ToList(); - dictEloData = await Database.Functions.Auth.GetBulkELOData(GlobalDatabaseInstance.g_Database, userIds); + dictEloData = await Database.Users.GetBulkELOData(db, userIds); foreach (MatchdataMemberModel member in lstMembers) { @@ -561,45 +561,6 @@ public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance } } - - public async static Task CreateUserEntriesIfNotExists(MySQLInstance m_Inst, Int64 playerID) - { - int dayOfYear = DateTime.UtcNow.DayOfYear; - int monthOfYear = DateTime.UtcNow.Month; - int year = DateTime.UtcNow.Year; - - // OK to try and insert here, will fail if key combination already exists - await m_Inst.Query("INSERT IGNORE INTO leaderboard_daily SET user_id=@user_id, points=@points, day_of_year=@day_of_year, year=@year, wins=0, losses=0;", - new() - { - { "@user_id", playerID }, - { "@points", EloConfig.BaseRating }, - { "@day_of_year", dayOfYear }, - { "@year", year } - } - ); - - // Month - await m_Inst.Query("INSERT IGNORE INTO leaderboard_monthly SET user_id=@user_id, points=@points, month_of_year=@month_of_year, year=@year, wins=0, losses=0;", - new() - { - { "@user_id", playerID }, - { "@points", EloConfig.BaseRating }, - { "@month_of_year", monthOfYear }, - { "@year", year } - } - ); - - // Year - await m_Inst.Query("INSERT IGNORE INTO leaderboard_yearly SET user_id=@user_id, points=@points, year=@year, wins=0, losses=0;", - new() - { - { "@user_id", playerID }, - { "@points", EloConfig.BaseRating }, - { "@year", year } - } - ); - } } public static class Lobby @@ -995,39 +956,6 @@ public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion ); } - public async static Task> GetBulkELOData(MySQLInstance m_Inst, List user_ids) - { - Dictionary results = new(); - - if (user_ids == null || user_ids.Count == 0) - { - return results; - } - - // Build IN clause with parameters - string inClause = string.Join(",", user_ids); - var res = await m_Inst.Query($"SELECT user_id, elo_rating, elo_num_matches FROM users WHERE user_id IN ({inClause});", null); - - foreach (var row in res.GetRows()) - { - Int64 userId = Convert.ToInt64(row["user_id"]); - int rating = Convert.ToInt32(row["elo_rating"]); - int numMatches = Convert.ToInt32(row["elo_num_matches"]); - results[userId] = new EloData(rating, numMatches); - } - - // Fill in default values for users not found - foreach (Int64 userId in user_ids) - { - if (!results.ContainsKey(userId)) - { - results[userId] = new EloData(EloConfig.BaseRating, 0); - } - } - - return results; - } - public async static Task GetPlayerStats(AppDbContext _db, MySQLInstance m_Inst, Int64 user_id) { // TODO: Return null if user doesnt actually exist, instead of empty stats @@ -1476,11 +1404,16 @@ public async Task Initialize(WebApplicationBuilder builder, bool bIsStartu SslMode = MySqlSslMode.Preferred }; + // TODO_EFCORE: Consider use of ExecuteDeleteAsync and options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + // TODO_EFCORE: Move to AddPooledDbContextFactory instead and use private readonly IDbContextFactory _factory; builder.Services.AddDbContext(options => { options.UseMySql( csb.ConnectionString, ServerVersion.AutoDetect(csb.ConnectionString)); + + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); } diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index c24f310..4501da5 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -757,7 +757,9 @@ await SendMatchmakingMessage(memberSession, if (memberSession != null) { // create lb data if necessary - await Database.Functions.Leaderboards.CreateUserEntriesIfNotExists(GlobalDatabaseInstance.g_Database, memberSession.m_UserID); + using var scope = ServiceLocator.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await Database.Leaderboards.CreateUserEntriesIfNotExists(db, memberSession.m_UserID); if (dummyHostUser == null) { From 9ebf9d08e6ccd0139fa524abc4bdd1423dc89c42 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:32:10 -0500 Subject: [PATCH 23/33] Bug fixes + converted bulk display name func to efcore --- .../Controllers/Friends/SocialController.cs | 8 ++-- .../Database/Database.ServiceStats.cs | 2 +- GenOnlineService/Database/Database.User.cs | 41 +++++++++++++++--- GenOnlineService/Database/MySQL.cs | 42 ------------------- GenOnlineService/Program.cs | 6 +-- 5 files changed, 44 insertions(+), 55 deletions(-) diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index aeff449..1d06da3 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -66,11 +66,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class SocialController : ControllerBase { + private readonly AppDbContext _db; private readonly ILogger _logger; - public SocialController(ILogger logger) + public SocialController(AppDbContext db, ILogger logger) { _logger = logger; + _db = db; } // Friends/Requests/ @@ -351,7 +353,7 @@ public async Task Get_FriendsAndRequests() lstCombined.AddRange(setFriends); lstCombined.AddRange(setPendingRequests); - Dictionary dictDisplayNames = await Database.Functions.Auth.GetDisplayNameBulk(GlobalDatabaseInstance.g_Database, lstCombined); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(_db, lstCombined); var options = new JsonSerializerOptions { @@ -440,7 +442,7 @@ public async Task Get_Blocked() HashSet setBlocked = sourceData.GetSocialContainer().Blocked; #pragma warning restore CS8602 // Dereference of a possibly null reference. - Dictionary dictDisplayNames = await Database.Functions.Auth.GetDisplayNameBulk(GlobalDatabaseInstance.g_Database, setBlocked.ToList()); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(_db, setBlocked.ToList()); var options = new JsonSerializerOptions { diff --git a/GenOnlineService/Database/Database.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs index 3d87c24..8251626 100644 --- a/GenOnlineService/Database/Database.ServiceStats.cs +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -42,7 +42,7 @@ public void Configure(EntityTypeBuilder builder) builder.ToTable("service_stats"); // prim key - builder.HasKey(e => new { e.DayOfYear, e.HourOfDay, e.PlayerPeak, e.LobbiesPeak }); + builder.HasKey(e => new { e.DayOfYear, e.HourOfDay }); builder.Property(e => e.DayOfYear).HasColumnName("day_of_year"); builder.Property(e => e.HourOfDay).HasColumnName("hour_of_day"); diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 82741e5..251af97 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -105,6 +105,8 @@ public void Configure(EntityTypeBuilder builder) { builder.ToTable("pending_logins"); + builder.HasNoKey(); + builder.Property(e => e.UserID).HasColumnName("user_id"); builder.Property(e => e.LoginCode).HasColumnName("code").HasColumnType("varchar(32)"); builder.Property(e => e.State).HasColumnName("state").HasColumnType("int(1)"); @@ -123,12 +125,12 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.UserID).HasColumnName("user_id"); builder.Property(e => e.HWID_0).HasColumnName("hwid_0").HasColumnType("varchar(128)"); - builder.Property(e => e.HWID_0).HasColumnName("hwid_1").HasColumnType("varchar(128)"); - builder.Property(e => e.HWID_0).HasColumnName("hwid_2").HasColumnType("varchar(128)"); - builder.Property(e => e.HWID_0).HasColumnName("hwid_3").HasColumnType("varchar(50)"); - builder.Property(e => e.HWID_0).HasColumnName("hwid_4").HasColumnType("varchar(50)"); - builder.Property(e => e.HWID_0).HasColumnName("hwid_5").HasColumnType("varchar(50)"); - builder.Property(e => e.HWID_0).HasColumnName("ip_addr").HasColumnType("varchar(45)"); + builder.Property(e => e.HWID_1).HasColumnName("hwid_1").HasColumnType("varchar(128)"); + builder.Property(e => e.HWID_2).HasColumnName("hwid_2").HasColumnType("varchar(128)"); + builder.Property(e => e.HWID_3).HasColumnName("hwid_3").HasColumnType("varchar(50)"); + builder.Property(e => e.HWID_4).HasColumnName("hwid_4").HasColumnType("varchar(50)"); + builder.Property(e => e.HWID_5).HasColumnName("hwid_5").HasColumnType("varchar(50)"); + builder.Property(e => e.IPAddress).HasColumnName("ip_addr").HasColumnType("varchar(45)"); } } @@ -303,6 +305,18 @@ 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> _getUserLobbyPreferencesQuery = EF.CompileAsyncQuery((AppDbContext db, long userId) => db.Users @@ -387,6 +401,21 @@ public static Task IsUserAdmin(AppDbContext db, long userId) return _isUserAdminQuery(db, userId); } + 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 Task IsUserBanned(AppDbContext db, long userId) { return _isUserBannedQuery(db, userId); diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index aa06cb5..1efc989 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -1192,48 +1192,6 @@ public async static Task AddPendingFriendRequest(MySQLInstance m_Inst, Int64 sou ); } - public async static Task> GetDisplayNameBulk(MySQLInstance m_Inst, List lstUserIDs) - { - Dictionary dictResult = new(); - - // Build parameter placeholders - var parameters = new List(); - Dictionary dictParams = new(); - - for (int i = 0; i < lstUserIDs.Count; i++) - { - // for query string - parameters.Add($"@id{i}"); - - // actual param - dictParams.Add($"@id{i}", lstUserIDs[i]); - } - - var res = await m_Inst.Query($"SELECT user_id, displayname FROM users WHERE user_id IN ({string.Join(",", parameters)})", - dictParams - ); - - foreach (var row in res.GetRows()) - { - Int64 user_id = Convert.ToInt64(row["user_id"]); - string? displayname = Convert.ToString(row["displayname"]); - - if (displayname != null) - { - try - { - dictResult.Add(user_id, displayname); - } - catch // probably duplicate - { - - } - } - } - - return dictResult; - } - public enum EAccountType { Unknown = -1, diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 2949e66..526bcc4 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -628,9 +628,6 @@ public static async Task Main(string[] args) await GlobalDatabaseInstance.g_Database.Initialize(builder); - // do a cleanup on startup - await DoCleanup(true); - builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => @@ -952,6 +949,9 @@ public static async Task Main(string[] args) app.MapControllers(); + // do a cleanup on startup + await DoCleanup(true); + // cleanup System.Timers.Timer timerCleanup = new System.Timers.Timer(5000); // 5s tick timerCleanup.AutoReset = false; From 3767c10a0e428d9be7d7cf5ca6974c23db391c5a Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:51:58 -0500 Subject: [PATCH 24/33] - Migrate away from DI'ed db contexts to db factories for speed + thread safety --- .../CheckLogin/CheckLoginController.cs | 21 ++++---- .../Controllers/Friends/SocialController.cs | 13 +++-- .../Controllers/Lobbies/LobbiesController.cs | 13 +++-- .../Controllers/Lobby/LobbyController.cs | 37 ++++++++----- .../LoginWithTokenController.cs | 17 +++--- .../Monitoring/MonitoringController.cs | 17 +++--- .../PlayerStats/PlayerStatsController.cs | 10 ++-- .../Controllers/User/UserController.cs | 10 ++-- .../WebSocket/WebSocketController.cs | 13 +++-- GenOnlineService/Database/MySQL.cs | 2 +- GenOnlineService/LobbyManager.cs | 4 +- GenOnlineService/MatchmakingManager.cs | 9 +++- GenOnlineService/Program.cs | 53 ++++++++++--------- 13 files changed, 133 insertions(+), 86 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 228cfd2..9df926f 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -18,6 +18,7 @@ using Database; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI.Common; using System; using System.Net; @@ -48,11 +49,11 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class CheckLoginController : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; - public CheckLoginController(AppDbContext db) + public CheckLoginController(IDbContextFactory dbFactory) { - _db = db; + _dbFactory = dbFactory; } [HttpPost] @@ -104,6 +105,8 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { if (data != null && data.ContainsKey("code") && data.ContainsKey("client_id")) { + await using var db = await _dbFactory.CreateDbContextAsync(); + //byte[] respNonce = new byte[32]; //using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) { rng.GetBytes(respNonce); } @@ -142,10 +145,10 @@ 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, result.display_name); } - bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); #else CMySQLResult sqlRes = await GlobalDatabaseInstance.g_Database.Query("SELECT state FROM pending_logins WHERE code=@game_code LIMIT 1;", new() @@ -174,7 +177,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr if (clientID != null && Program.g_tokenGenerator != null) { // ban check - bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); + bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); if (bIsBanned) { result.result = EPendingLoginState.LoginFailed; @@ -194,7 +197,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; string hwid_2 = data.ContainsKey("reserved_2") ? data["reserved_2"].ToString() : "NONE"; - await Database.UserDevices.RegisterUserDevice(_db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); + await Database.UserDevices.RegisterUserDevice(db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); } string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; @@ -223,7 +226,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr result.ws_uri = null; } - await Database.PendingLogins.CleanupPendingLogin(_db, gameCode); + await Database.PendingLogins.CleanupPendingLogin(db, gameCode); return result; } @@ -238,7 +241,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { result.result = EPendingLoginState.LoginFailed; Response.StatusCode = (int)HttpStatusCode.Forbidden; - await Database.PendingLogins.CleanupPendingLogin(_db, gameCode); + await Database.PendingLogins.CleanupPendingLogin(db, gameCode); } #if !DEBUG } diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index 1d06da3..320a898 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Net; @@ -66,13 +67,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class SocialController : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public SocialController(AppDbContext db, ILogger logger) + public SocialController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; - _db = db; + _dbFactory = dbFactory; } // Friends/Requests/ @@ -353,7 +354,8 @@ public async Task Get_FriendsAndRequests() lstCombined.AddRange(setFriends); lstCombined.AddRange(setPendingRequests); - Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(_db, lstCombined); + await using var db = await _dbFactory.CreateDbContextAsync(); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(db, lstCombined); var options = new JsonSerializerOptions { @@ -442,7 +444,8 @@ public async Task Get_Blocked() HashSet setBlocked = sourceData.GetSocialContainer().Blocked; #pragma warning restore CS8602 // Dereference of a possibly null reference. - Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(_db, setBlocked.ToList()); + await using var db = await _dbFactory.CreateDbContextAsync(); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(db, setBlocked.ToList()); var options = new JsonSerializerOptions { diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 202f820..f143312 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Net; @@ -64,14 +65,14 @@ public class LobbiesController : ControllerBase private static readonly object s_roomsLock = new object(); private readonly LobbyManager _lobbyManager; - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; - public LobbiesController(LobbyManager lobbyManager, AppDbContext db, ILogger logger) + public LobbiesController(LobbyManager lobbyManager, IDbContextFactory dbFactory, ILogger logger) { _logger = logger; _lobbyManager = lobbyManager; - _db = db; + _dbFactory = dbFactory; } // Cache rooms.json data to avoid disk I/O on every request @@ -374,8 +375,10 @@ public async Task Put() // cleanup any zombie lobbies await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - Int64 newLobbyID = await _lobbyManager.CreateLobby(_db, playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); + + Int64 newLobbyID = await _lobbyManager.CreateLobby(db, playerSession, strDisplayName, strName, strMapName, strMapPath, bMapOfficial, maxPlayers, strIPAddr, hostPreferredPort, bVanillaTeamsOnly, bTrackStats, starting_cash, bPassworded, strPassword, playerSession.networkRoomID, bAllowObservers, maxCamHeight, exe_crc, ini_crc, ELobbyType.CustomGame); if (newLobbyID >= 0) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 52fae15..f3ffeed 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Collections.Concurrent; @@ -143,13 +144,13 @@ public class LobbyController : ControllerBase { private readonly ILogger _logger; private readonly LobbyManager _lobbyManager; - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; - public LobbyController(LobbyManager lobbyManager, AppDbContext db, ILogger logger) + public LobbyController(LobbyManager lobbyManager, IDbContextFactory dbFactory, ILogger logger) { _logger = logger; _lobbyManager = lobbyManager; - _db = db; + _dbFactory = dbFactory; } [HttpGet("{lobby_id}")] @@ -442,7 +443,8 @@ public async Task Post(Int64 lobbyID) if (strMap != null && strMapPath != null) { - await lobby.UpdateMap(_db, strMap, strMapPath, bOfficialMap, maxPlayers); + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateMap(db, strMap, strMapPath, bOfficialMap, maxPlayers); } } } @@ -454,7 +456,9 @@ public async Task Post(Int64 lobbyID) { int side = data["side"].GetInt32(); int start_pos = data["start_pos"].GetInt32(); - await SourceMember.UpdateSide(_db, side, start_pos); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await SourceMember.UpdateSide(db, side, start_pos); } } else if (field == ELobbyUpdateField.MY_COLOR) @@ -462,7 +466,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("color")) { int color = data["color"].GetInt32(); - await SourceMember.UpdateColor(_db, color); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await SourceMember.UpdateColor(db, color); } } else if (field == ELobbyUpdateField.MY_START_POS) @@ -486,7 +492,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("startingcash")) { UInt32 startingCash = data["startingcash"].GetUInt32(); - await lobby.UpdateStartingCash(_db, startingCash); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateStartingCash(db, startingCash); } } else if (field == ELobbyUpdateField.LOBBY_LIMIT_SUPERWEAPONS) @@ -494,7 +502,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("limit_superweapons")) { bool bLimitSuperweapons = data["limit_superweapons"].GetBoolean(); - await lobby.UpdateLimitSuperweapons(_db, bLimitSuperweapons); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateLimitSuperweapons(db, bLimitSuperweapons); } } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) @@ -563,7 +573,8 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateSide(_db, side, start_pos); + await using var db = await _dbFactory.CreateDbContextAsync(); + await TargetMember.UpdateSide(db, side, start_pos); } } } @@ -581,7 +592,8 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateColor(_db, color); + await using var db = await _dbFactory.CreateDbContextAsync(); + await TargetMember.UpdateColor(db, color); } } } @@ -724,8 +736,9 @@ public async Task Put(Int64 lobbyID) // leave any lobby _lobbyManager.LeaveAnyLobby(user_id); - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); - bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(_db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); + bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); result.success = bJoinedSuccessfully; diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index 64ef2e6..a48febe 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI.Common; using System; using System.Net; @@ -49,11 +50,11 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class LoginWithToken : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; - public LoginWithToken(AppDbContext db) + public LoginWithToken(IDbContextFactory dbFactory) { - _db = db; + _dbFactory = dbFactory; } [HttpPost(Name = "PostLoginWithToken")] @@ -114,17 +115,19 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); + await using var db = await _dbFactory.CreateDbContextAsync(); + // Game clients should register the user device if (sessionType == EUserSessionType.GameClient) { string hwid_0 = data.ContainsKey("reserved_0") ? data["reserved_0"].ToString() : "NONE"; string hwid_1 = data.ContainsKey("reserved_1") ? data["reserved_1"].ToString() : "NONE"; string hwid_2 = data.ContainsKey("reserved_2") ? data["reserved_2"].ToString() : "NONE"; - await Database.UserDevices.RegisterUserDevice(_db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); + await Database.UserDevices.RegisterUserDevice(db, user_id, hwid_0, hwid_1, hwid_2, ipAddr); } // ban check - bool bIsBanned = await Database.Users.IsUserBanned(_db, user_id); + bool bIsBanned = await Database.Users.IsUserBanned(db, user_id); if (bIsBanned) { result.result = EPendingLoginState.LoginFailed; @@ -135,10 +138,10 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE"; Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID, sessionType); - bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); result.result = EPendingLoginState.LoginSuccess; diff --git a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index bf89391..266bbdc 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Org.BouncyCastle.Security; using System; @@ -92,13 +93,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class MonitoringController : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public MonitoringController(AppDbContext db, ILogger logger) + public MonitoringController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; - _db = db; + _dbFactory = dbFactory; } [Route("ActiveUsers")] @@ -167,7 +168,8 @@ public async Task Monitor_Database() // db call try { - string strDontCare = await Database.Users.GetDisplayName(_db, 0); + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDontCare = await Database.Users.GetDisplayName(db, 0); result.ok = true; } catch @@ -194,8 +196,9 @@ public async Task Monitor_Database() try { using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(db); + var factory = scope.ServiceProvider.GetRequiredService>(); + + GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(factory); GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result internalResult = (GenOnlineService.Controllers.LoginWithToken.POST_LoginWithToken_Result)await loginWithTokenController.Post_InternalHandler("{\"challenge\": \"abc\", \"token\": \"iamatest\", \"client_id\": \"gen_online_60hz\"}", IPAddress.Loopback.ToString(), true); return internalResult; } @@ -273,7 +276,7 @@ public APIResult Monitor_Uptime() { try { - GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(_db); + GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(_dbFactory); APIResult internalResult = await checkLoginController.Post_InternalHandler("{\"challenge\": \"abc\", \"nonce\": \"def\", \"code\": \"iamatest\", \"client_id\": \"gen_online_30hz\"}", IPAddress.Loopback.ToString(), true); return internalResult; } diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index fda8830..80136a5 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Net; @@ -69,13 +70,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class PlayerStatsController : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public PlayerStatsController(AppDbContext db, ILogger logger) + public PlayerStatsController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; - _db = db; + _dbFactory = dbFactory; } [HttpGet("{userID}")] @@ -97,7 +98,8 @@ public async Task Get(Int64 userID) // if user is offline, hit DB, could be a friends list inspection for example if (userData == null) { - PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(_db, GlobalDatabaseInstance.g_Database, userID); + await using var db = await _dbFactory.CreateDbContextAsync(); + PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(db, GlobalDatabaseInstance.g_Database, userID); if (playerStats == null) { diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index e4e94fa..76a94ce 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -19,6 +19,7 @@ using Amazon.S3.Model; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using System; using System.Collections.Concurrent; using System.Net.WebSockets; @@ -43,13 +44,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public UsersController(AppDbContext db, ILogger logger) + public UsersController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; - _db = db; + _dbFactory = dbFactory; } [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] @@ -62,7 +63,8 @@ public async Task MyUser() if (user_id != -1) { - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); result.display_name = strDisplayName; result.user_id = user_id; diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 4069ef7..d7f5250 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -20,6 +20,7 @@ using MaxMind.GeoIP2; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using System; using System.Buffers; using System.Net.WebSockets; @@ -32,12 +33,12 @@ namespace GenOnlineService.Controllers public class WebSocketController : ControllerBase { private readonly LobbyManager _lobbyManager; - private readonly AppDbContext _db; + private readonly IDbContextFactory _dbFactory; - public WebSocketController(LobbyManager lobbyManager, AppDbContext db) + public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory) { _lobbyManager = lobbyManager; - _db = db; + _dbFactory = dbFactory; } private static readonly JsonSerializerOptions JsonOpts = new() @@ -124,8 +125,9 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) return; } + await using var db = await _dbFactory.CreateDbContextAsync(); UserWebSocketInstance wsSess = await WebSocketManager.CreateSession( - _db, + db, EUserSessionType.GameClient, bIsReconnect, user_id, @@ -471,7 +473,8 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession if (nameChangeRequest.name.Length >= 3 && nameChangeRequest.name.Length <= 16) { - await Database.Users.SetDisplayName(_db, sourceUserSession.m_UserID, nameChangeRequest.name); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.Users.SetDisplayName(db, sourceUserSession.m_UserID, nameChangeRequest.name); sourceUserData.m_strDisplayName = nameChangeRequest.name; await WebSocketManager.MarkRoomMemberListAsDirty(sourceUserSession.networkRoomID); } diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 1efc989..93ddbfa 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -1364,7 +1364,7 @@ public async Task Initialize(WebApplicationBuilder builder, bool bIsStartu // TODO_EFCORE: Consider use of ExecuteDeleteAsync and options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); // TODO_EFCORE: Move to AddPooledDbContextFactory instead and use private readonly IDbContextFactory _factory; - builder.Services.AddDbContext(options => + builder.Services.AddPooledDbContextFactory(options => { options.UseMySql( csb.ConnectionString, diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 07043e5..bc47b63 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1424,7 +1424,9 @@ public async Task DeleteLobby(Lobby lobby) if (lobby.LobbyType == ELobbyType.QuickMatch) { using var scope = _services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(db, GlobalDatabaseInstance.g_Database, lobby); } } diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 4501da5..166eda3 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -21,6 +21,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Org.BouncyCastle.Tls; using System; @@ -758,7 +759,9 @@ await SendMatchmakingMessage(memberSession, { // create lb data if necessary using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.Leaderboards.CreateUserEntriesIfNotExists(db, memberSession.m_UserID); if (dummyHostUser == null) @@ -780,7 +783,9 @@ await SendMatchmakingMessage(memberSession, DetermineMap(out string strMapName, out string strMapPath); using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, 123, 456, ELobbyType.QuickMatch); diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 526bcc4..884209a 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -16,34 +16,35 @@ ** along with this program. If not, see . */ +using Google.Protobuf.WellKnownTypes; +using MaxMind.GeoIP2; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.AspNetCore.WebSockets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Org.BouncyCastle.Crypto; using Org.BouncyCastle.OpenSsl; using Org.BouncyCastle.Pkcs; using Org.BouncyCastle.Security; -using Org.BouncyCastle.Crypto; -using System.Security.Cryptography.X509Certificates; +using Sentry; +using System.Collections.Concurrent; +using System.Drawing; +using System.IdentityModel.Tokens.Jwt; +using System.Net.Http.Headers; +using System.Net.WebSockets; using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; +using System.Security.Cryptography.X509Certificates; using System.Text; -using System.Net.Http.Headers; -using Microsoft.Extensions.Options; using System.Text.Encodings.Web; -using Microsoft.AspNetCore.WebSockets; -using System.Collections.Concurrent; -using System.Net.WebSockets; -using Google.Protobuf.WellKnownTypes; -using System.Xml; -using System.Drawing; using System.Text.Json; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; -using Sentry; -using MaxMind.GeoIP2; -using Microsoft.AspNetCore.RateLimiting; using System.Threading.RateLimiting; +using System.Threading.Tasks; +using System.Xml; namespace GenOnlineService { @@ -244,7 +245,8 @@ public static async Task Update(int numLobbies, int numPlayers) // store stats using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); await Database.ServiceStats.CommitStats(db, DateTime.Now.DayOfYear, hourOfDay, numPlayers, numLobbies); } } @@ -361,7 +363,8 @@ public class Program static async Task DoCleanup(bool bStartup) { using var scope = ServiceLocator.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); await Database.PendingLogins.Cleanup(db, bStartup); } @@ -1060,7 +1063,8 @@ public static async Task Main(string[] args) { using (var scope = app.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); await DailyStatsManager.SaveToDB(db); } } @@ -1088,7 +1092,8 @@ public static async Task Main(string[] args) // load daily stats using (var scope = app.Services.CreateScope()) { - var db = scope.ServiceProvider.GetRequiredService(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); await DailyStatsManager.LoadFromDB(db); } From 4ab057e1da00b2f5eb08cd36466b6967da092b2b Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:57:00 -0500 Subject: [PATCH 25/33] - Start migration of matchhistory to efcore --- .../Controllers/Lobby/LobbyController.cs | 3 +- .../MatchUpdate/MatchUpdateController.cs | 20 +- GenOnlineService/Database/Database.cs | 2 + GenOnlineService/Database/MySQL.cs | 339 ------------------ GenOnlineService/LobbyManager.cs | 5 +- 5 files changed, 22 insertions(+), 347 deletions(-) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index f3ffeed..e305295 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -318,7 +318,8 @@ public async Task Delete(Int64 lobbyID) DailyStatsManager.RegisterOutcome(army, won); // store in DB - await Database.Functions.Lobby.CommitPlayerOutcome(GlobalDatabaseInstance.g_Database, slotIndexInLobby, match_id, + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.MatchHistory.CommitPlayerOutcome(db, slotIndexInLobby, match_id, buildings_built, buildings_killed, buildings_lost, units_built, units_killed, units_lost, total_money, won); } } diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index e85a7bb..bc58420 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -16,20 +16,21 @@ ** along with this program. If not, see . */ +using Amazon.S3; +using Amazon.S3.Model; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; +using System.ComponentModel.DataAnnotations; using System.Net; using System.Net.WebSockets; using System.Security.Claims; using System.Text; using System.Text.Json; -using Amazon.S3; -using Amazon.S3.Model; -using System.ComponentModel.DataAnnotations; using static Database.Functions.Lobby; namespace GenOnlineService.Controllers @@ -117,6 +118,12 @@ UInt16 max_camera_height [Route("env/{environment}/contract/{contract_version}/MatchHistory")] public class API_MatchHistoryController : ControllerBase { + private readonly IDbContextFactory _dbFactory; + public API_MatchHistoryController(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; + } + [HttpGet("{startingMatchID}")] // TODO: Move to Authorize for this public async Task GetHistorySince([FromHeader(Name = "X-Api-Key")] string apiKey, Int64 startingMatchID) @@ -137,8 +144,8 @@ public async Task GetHistorySince([FromHeader(Name = "X-Api-Key")] st const Int64 maxLobbiesPerRequest = 99; // actually 100, but query is <= - - result.matches = await Database.Functions.MatchHistory.GetMatchesInRange(GlobalDatabaseInstance.g_Database, startingMatchID, startingMatchID + maxLobbiesPerRequest); + await using var db = await _dbFactory.CreateDbContextAsync(); + result.matches = await Database.MatchHistory.GetMatchesInRange(db, startingMatchID, startingMatchID + maxLobbiesPerRequest); return result; } @@ -161,7 +168,8 @@ public async Task GetHighestMatchID([FromHeader(Name = "X-Api-Key")] return result; } - result.highest_match_id = await Database.Functions.MatchHistory.GetHighestMatchID(GlobalDatabaseInstance.g_Database); + await using var db = await _dbFactory.CreateDbContextAsync(); + result.highest_match_id = await Database.MatchHistory.GetHighestMatchID(db); return result; } } diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 5eb8071..766743c 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -30,6 +30,7 @@ public class AppDbContext : DbContext public DbSet LeaderboardYearly => Set(); public DbSet ServiceStats => Set(); public DbSet PendingLogins => Set(); + public DbSet MatchHistory => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -49,5 +50,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new LeaderboardYearlyConfiguration()); modelBuilder.ApplyConfiguration(new ServiceStatsConfiguration()); modelBuilder.ApplyConfiguration(new PendingLoginConfiguration()); + modelBuilder.ApplyConfiguration(new MatchHistoryConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 93ddbfa..2bdba57 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -77,125 +77,6 @@ namespace Database { public static class Functions { - public static class ServiceStats - { - - } - - public static class MatchHistory - { - public async static Task GetHighestMatchID(MySQLInstance m_Inst) - { - var res = await m_Inst.Query("SELECT MAX(match_id) as highest_id FROM `match_history`;", null); - - if (res.NumRows() > 0) - { - CMySQLRow row = res.GetRow(0); - - Int64 highestMatchID = Convert.ToInt64(row["highest_id"]); - - return highestMatchID; - } - - return -1; - } - - - public async static Task GetMatchesInRange(MySQLInstance m_Inst, Int64 startID, Int64 endID) - { - var res = await m_Inst.Query("SELECT match_id, owner, name, finished, started, time_finished, map_name, map_path, match_roster_type, map_official, vanilla_teams, starting_cash, limit_superweapons, track_stats, allow_observers, max_cam_height, member_slot_0, member_slot_1, member_slot_2, member_slot_3, member_slot_4, member_slot_5, member_slot_6, member_slot_7 FROM match_history WHERE match_id>=@startID AND match_id<=@endID AND finished=true;", - new() - { - { "@startID", startID }, - { "@endID", endID } - } - ); - - MatchHistoryCollection collection = new(); - foreach (var row in res.GetRows()) - { - - Int64 match_id = Convert.ToInt64(row["match_id"]); - Int64 owner = Convert.ToInt64(row["owner"]); - string? name = Convert.ToString(row["name"]); - bool finished = Convert.ToBoolean(row["finished"]); - string? time_started = Convert.ToString(row["started"]); - string? time_ended = Convert.ToString(row["time_finished"]); - string? map_name = Convert.ToString(row["map_name"]); - string? map_path = Convert.ToString(row["map_path"]); - string? match_roster_type = Convert.ToString(row["match_roster_type"]); - bool map_official = Convert.ToBoolean(row["map_official"]); - bool vanilla_teams = Convert.ToBoolean(row["vanilla_teams"]); - UInt32 starting_cash = Convert.ToUInt32(row["starting_cash"]); - bool limit_superweapons = Convert.ToBoolean(row["limit_superweapons"]); - bool track_stats = Convert.ToBoolean(row["track_stats"]); - bool allow_observers = Convert.ToBoolean(row["allow_observers"]); - UInt16 max_cam_height = Convert.ToUInt16(row["max_cam_height"]); - - string? strJson_Slot0 = Convert.ToString(row["member_slot_0"]); - string? strJson_Slot1 = Convert.ToString(row["member_slot_1"]); - string? strJson_Slot2 = Convert.ToString(row["member_slot_2"]); - string? strJson_Slot3 = Convert.ToString(row["member_slot_3"]); - string? strJson_Slot4 = Convert.ToString(row["member_slot_4"]); - string? strJson_Slot5 = Convert.ToString(row["member_slot_5"]); - string? strJson_Slot6 = Convert.ToString(row["member_slot_6"]); - string? strJson_Slot7 = Convert.ToString(row["member_slot_7"]); - - if (name == null || time_started == null || time_ended == null || map_name == null || map_path == null) - { - continue; - } - - string strMatchRosterType = String.Empty; - - MatchHistory_Entry collection_entry = new( - match_id, - owner, - name, - finished, - time_started, - time_ended, - map_name, - map_path, - match_roster_type, - map_official, - vanilla_teams, - starting_cash, - limit_superweapons, - track_stats, - allow_observers, - max_cam_height - ); - - // add members - // TODO: Optimize, we deserialize to reserialize... just return the JSON directly - MatchdataMemberModel? member0 = String.IsNullOrEmpty(strJson_Slot0) ? null : JsonSerializer.Deserialize(strJson_Slot0); - MatchdataMemberModel? member1 = String.IsNullOrEmpty(strJson_Slot1) ? null : JsonSerializer.Deserialize(strJson_Slot1); - MatchdataMemberModel? member2 = String.IsNullOrEmpty(strJson_Slot2) ? null : JsonSerializer.Deserialize(strJson_Slot2); - MatchdataMemberModel? member3 = String.IsNullOrEmpty(strJson_Slot3) ? null : JsonSerializer.Deserialize(strJson_Slot3); - MatchdataMemberModel? member4 = String.IsNullOrEmpty(strJson_Slot4) ? null : JsonSerializer.Deserialize(strJson_Slot4); - MatchdataMemberModel? member5 = String.IsNullOrEmpty(strJson_Slot5) ? null : JsonSerializer.Deserialize(strJson_Slot5); - MatchdataMemberModel? member6 = String.IsNullOrEmpty(strJson_Slot6) ? null : JsonSerializer.Deserialize(strJson_Slot6); - MatchdataMemberModel? member7 = String.IsNullOrEmpty(strJson_Slot7) ? null : JsonSerializer.Deserialize(strJson_Slot7); - - // add members to collection - if (member0 != null) { collection_entry.members.Add(member0); } - if (member1 != null) { collection_entry.members.Add(member1); } - if (member2 != null) { collection_entry.members.Add(member2); } - if (member3 != null) { collection_entry.members.Add(member3); } - if (member4 != null) { collection_entry.members.Add(member4); } - if (member5 != null) { collection_entry.members.Add(member5); } - if (member6 != null) { collection_entry.members.Add(member6); } - if (member7 != null) { collection_entry.members.Add(member7); } - - // commit match - collection.matches.Add(collection_entry); - } - - return collection; - } - } - public static class Leaderboards { public async static Task DetermineLobbyWinnerIfNotPresent(MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) @@ -634,32 +515,6 @@ public async static Task UpdateMatchHistoryMakeWinner(MySQLInstance m_Inst, UInt } } - public struct MatchdataMemberModel - { - public Int64 user_id { get; set; } = -1; // bigint(20) NOT NULL - public string display_name { get; set; } = String.Empty; // varchar(32) NOT NULL - public EPlayerType slot_state { get; set; } = EPlayerType.SLOT_CLOSED; // smallint(6) unsigned NOT NULL - public int side { get; set; } = -1; // int(2) NOT NULL - public int color { get; set; } = -1; // int(2) NOT NULL - public int team { get; set; } = -1; // int(1) NOT NULL - public int startpos { get; set; } = -1; // int(1) NOT NULL - public int buildings_built { get; set; } = 0; // int(11) DEFAULT NULL - public int buildings_killed { get; set; } = 0; // int(11) DEFAULT NULL - public int buildings_lost { get; set; } = 0; // int(11) DEFAULT NULL - public int units_built { get; set; } = 0; // int(11) DEFAULT NULL - public int units_killed { get; set; } = 0; // int(11) DEFAULT NULL - public int units_lost { get; set; } = 0; // int(11) DEFAULT NULL - public int total_money { get; set; } = 0; // int(11) DEFAULT NULL - - [JsonConverter(typeof(IntToBoolConverter))] - public bool won { get; set; } = false; // tinyint(4) DEFAULT NULL - public List metadata { get; set; } = new List(); - - public MatchdataMemberModel() - { - } - } - public class IntToBoolConverter : JsonConverter { public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -672,196 +527,6 @@ public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOpti writer.WriteNumberValue(value ? 1 : 0); } } - - public struct MemberMetadataModel - { - public string file_name { get; set; } - public EMetadataFileType file_type { get; set; } - } - - public async static Task CreatePlaceholderMatchHistory(MySQLInstance m_Inst, GenOnlineService.Lobby lobby) - { - if (lobby == null) - { - return 0; - } - - // initial members - - // since this is a DB entry, we only insert occupied slots, different behavior from LobbyManager - MatchdataMemberModel?[] arrMembers = new MatchdataMemberModel?[GenOnlineService.Lobby.maxLobbySize] - { - null, - null, - null, - null, - null, - null, - null, - null - }; - - - string strTeamRosterType = String.Empty; - Dictionary playersPerTeam = new(); - int playersSeen = 0; - - //List lstMembers = new List(); - foreach (var member in lobby.Members) - { - // dont care about empty/closed slots - if (member.SlotState == EPlayerType.SLOT_OPEN || member.SlotState == EPlayerType.SLOT_CLOSED) - { - continue; - } - - MatchdataMemberModel newMember = new(); - newMember.user_id = member.UserID; - newMember.display_name = member.DisplayName; - newMember.slot_state = member.SlotState; - newMember.side = member.Side; - newMember.color = member.Color; - newMember.team = member.Team; - newMember.startpos = member.StartingPosition; - newMember.buildings_built = 0; - newMember.buildings_killed = 0; - newMember.buildings_lost = 0; - newMember.units_built = 0; - newMember.units_killed = 0; - newMember.units_lost = 0; - newMember.total_money = 0; - newMember.won = false; - arrMembers[member.SlotIndex] = newMember; - - ++playersSeen; - // used later to determine roster type - if (playersPerTeam.ContainsKey(newMember.team)) - { - ++playersPerTeam[newMember.team]; - } - else - { - playersPerTeam[newMember.team] = 1; - } - } - - // determine FFA, needs no more than 1 player per team, and must be more than 2 players total (cant be 1v1) - bool bIsFFA = true; - if (playersSeen <= 2) - { - bIsFFA = false; - } - else - { - foreach (var kvPair in playersPerTeam) - { - if (kvPair.Key != -1) // no team is ok for FFA - { - if (kvPair.Value > 1) // more than 1 person on a real team, so not FFA - { - bIsFFA = false; - break; - } - } - } - } - - if (bIsFFA) - { - strTeamRosterType = String.Format("{0} Player FFA", playersSeen); - } - else - { - // now determine roster type - foreach (var kvPair in playersPerTeam) - { - if (kvPair.Key == -1) - { - for (int i = 0; i < playersPerTeam[-1]; ++i) - { - if (String.IsNullOrEmpty(strTeamRosterType)) - { - strTeamRosterType = "1"; - } - else - { - strTeamRosterType += "v1"; - } - } - } - else - { - if (String.IsNullOrEmpty(strTeamRosterType)) - { - strTeamRosterType = kvPair.Value.ToString(); - } - else - { - strTeamRosterType += String.Format("v{0}", kvPair.Value.ToString()); - } - } - } - } - -#pragma warning disable CS8604 // Possible null reference argument. - CMySQLResult resMatch = await m_Inst.Query("INSERT INTO match_history(owner, name, map_name, map_path, map_official, match_roster_type, vanilla_teams, starting_cash, limit_superweapons, track_stats, allow_observers, max_cam_height, member_slot_0, member_slot_1, member_slot_2, member_slot_3, member_slot_4, member_slot_5, member_slot_6, member_slot_7) VALUES (@owner, @name, @map_name, @map_path, @map_official, @match_roster_type, @vanilla_teams, @starting_cash, @limit_superweapons, @track_stats, @allow_observers, @max_cam_height, @member_slot_0, @member_slot_1, @member_slot_2, @member_slot_3, @member_slot_4, @member_slot_5, @member_slot_6, @member_slot_7);", - new() - { - { "@owner", lobby.Owner }, - { "@name", lobby.Name }, - { "@map_name", lobby.MapName }, - { "@map_path", lobby.MapPath }, - { "@map_official", lobby.IsMapOfficial }, - { "@match_roster_type", strTeamRosterType }, - { "@vanilla_teams", lobby.IsVanillaTeamsOnly }, - { "@starting_cash", lobby.StartingCash }, - { "@limit_superweapons", lobby.IsLimitSuperweapons }, - { "@track_stats", lobby.IsTrackingStats }, - { "@allow_observers", lobby.AllowObservers }, - { "@max_cam_height", lobby.MaximumCameraHeight }, - { "@member_slot_0", arrMembers[0] == null ? null : JsonSerializer.Serialize(arrMembers[0])}, - { "@member_slot_1", arrMembers[1] == null ? null : JsonSerializer.Serialize(arrMembers[1])}, - { "@member_slot_2", arrMembers[2] == null ? null : JsonSerializer.Serialize(arrMembers[2])}, - { "@member_slot_3", arrMembers[3] == null ? null : JsonSerializer.Serialize(arrMembers[3])}, - { "@member_slot_4", arrMembers[4] == null ? null : JsonSerializer.Serialize(arrMembers[4])}, - { "@member_slot_5", arrMembers[5] == null ? null : JsonSerializer.Serialize(arrMembers[5])}, - { "@member_slot_6", arrMembers[6] == null ? null : JsonSerializer.Serialize(arrMembers[6])}, - { "@member_slot_7", arrMembers[7] == null ? null : JsonSerializer.Serialize(arrMembers[7])}, - } - ); -#pragma warning restore CS8604 // Possible null reference argument. - - UInt64 matchID = resMatch.GetInsertID(); - lobby.SetMatchID(matchID); - - return matchID; - } - - public async static Task CommitPlayerOutcome(MySQLInstance m_Inst, int slotIndex, UInt64 match_id, - int buildingsBuilt, int buildingsKilled, int buildingsLost, - int unitsBuilt, int unitsKilled, int unitsLost, - int totalMoney, bool bWon) - { - if (slotIndex < 0) - { - return; - } - - CMySQLResult resMember = await m_Inst.Query(String.Format("UPDATE match_history SET member_slot_{0} = JSON_SET(member_slot_{0}, '$.buildings_built', @buildings_built, '$.buildings_killed', @buildings_killed, '$.buildings_killed', @buildings_killed, '$.units_built', @units_built, '$.units_killed', @units_killed, '$.units_killed', @units_killed, '$.total_money', @total_money, '$.won', @won) WHERE match_id = @match_id;", slotIndex), - new() - { - { "@match_id", match_id }, - { "@buildings_built", buildingsBuilt }, - { "@buildings_killed", buildingsKilled }, - { "@buildings_lost", buildingsLost }, - { "@units_built", unitsBuilt }, - { "@units_killed", unitsKilled }, - { "@units_lost", unitsLost }, - { "@total_money",totalMoney }, - { "@won", bWon } - } - ); - } } // TODO: Cleanup things when a user disconnects, e.g. lobby they're in etc @@ -1345,11 +1010,8 @@ public async Task Initialize(WebApplicationBuilder builder, bool bIsStartu Directory.CreateDirectory("Exceptions"); } - // TODO_EFCORE: Use more config params here // EFCore connect { - //var builder = WebApplication.CreateBuilder(args); - var csb = new MySqlConnectionStringBuilder { Server = hostname, @@ -1363,7 +1025,6 @@ public async Task Initialize(WebApplicationBuilder builder, bool bIsStartu }; // TODO_EFCORE: Consider use of ExecuteDeleteAsync and options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - // TODO_EFCORE: Move to AddPooledDbContextFactory instead and use private readonly IDbContextFactory _factory; builder.Services.AddPooledDbContextFactory(options => { options.UseMySql( diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index bc47b63..645d9d4 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -902,7 +902,10 @@ public async Task UpdateState(ELobbyState state) if (WasPVPAtStart() && !HadAIAtStart()) { // create placeholder - await Database.Functions.Lobby.CreatePlaceholderMatchHistory(GlobalDatabaseInstance.g_Database, this); + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.MatchHistory.CreatePlaceholderMatchHistory(db, this); // calculate first probe time CalculateNextProbeTime(true); From f7cbba0bd094060396f865e49faa8c7cb3747fd6 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:23:08 -0500 Subject: [PATCH 26/33] Matchhistory file --- .../Database/Database.MatchHistory.cs | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 GenOnlineService/Database/Database.MatchHistory.cs diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs new file mode 100644 index 0000000..8a41535 --- /dev/null +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -0,0 +1,518 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using GenOnlineService; +using GenOnlineService.Controllers; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Query; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using static Database.Functions.Lobby; + +public class MatchHistoryEntry +{ + public long MatchId { get; set; } + public long Owner { get; set; } + public string Name { get; set; } = string.Empty; + public bool Finished { get; set; } + public DateTime Started { get; set; } + public DateTime TimeFinished { get; set; } + public string MapName { get; set; } = string.Empty; + public bool MapOfficial { get; set; } + public string MatchRosterType { get; set; } = string.Empty; + public bool VanillaTeams { get; set; } + public uint StartingCash { get; set; } + public bool LimitSuperweapons { get; set; } + public bool TrackStats { get; set; } + public bool AllowObservers { get; set; } + public ushort MaxCamHeight { get; set; } + public string? MapPath { get; set; } + + // JSON slots + public string? MemberSlot0 { get; set; } + public string? MemberSlot1 { get; set; } + public string? MemberSlot2 { get; set; } + public string? MemberSlot3 { get; set; } + public string? MemberSlot4 { get; set; } + public string? MemberSlot5 { get; set; } + public string? MemberSlot6 { get; set; } + public string? MemberSlot7 { get; set; } +} + +public class MatchHistoryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder entity) + { + entity.ToTable("match_history"); + + entity.HasKey(e => e.MatchId); + + entity.Property(e => e.MatchId) + .HasColumnName("match_id") + .ValueGeneratedOnAdd(); + + entity.Property(e => e.Owner) + .HasColumnName("owner"); + + entity.Property(e => e.Name) + .HasColumnName("name") + .HasMaxLength(64) + .IsRequired(); + + entity.Property(e => e.Finished) + .HasColumnName("finished"); + + entity.Property(e => e.Started) + .HasColumnName("started") + .HasColumnType("datetime") + .HasDefaultValueSql("current_timestamp()"); + + entity.Property(e => e.TimeFinished) + .HasColumnName("time_finished") + .HasColumnType("datetime") + .HasDefaultValueSql("current_timestamp()"); + + entity.Property(e => e.MapName) + .HasColumnName("map_name") + .HasMaxLength(128) + .IsRequired(); + + entity.Property(e => e.MapOfficial) + .HasColumnName("map_official"); + + entity.Property(e => e.MatchRosterType) + .HasColumnName("match_roster_type") + .HasMaxLength(32) + .HasDefaultValue(""); + + entity.Property(e => e.VanillaTeams) + .HasColumnName("vanilla_teams"); + + entity.Property(e => e.StartingCash) + .HasColumnName("starting_cash") + .HasColumnType("int unsigned"); + + entity.Property(e => e.LimitSuperweapons) + .HasColumnName("limit_superweapons"); + + entity.Property(e => e.TrackStats) + .HasColumnName("track_stats"); + + entity.Property(e => e.AllowObservers) + .HasColumnName("allow_observers"); + + entity.Property(e => e.MaxCamHeight) + .HasColumnName("max_cam_height") + .HasColumnType("smallint unsigned"); + + entity.Property(e => e.MapPath) + .HasColumnName("map_path") + .HasMaxLength(128); + + // JSON columns + for (int i = 0; i < 8; i++) + { + entity.Property($"MemberSlot{i}") + .HasColumnName($"member_slot_{i}") + .HasColumnType("longtext") + .HasCharSet("utf8mb4") + .HasCollation("utf8mb4_bin"); + } + } +} + +// TODO_EFCORE: put everything in below namespace +namespace GenOnlineService +{ + public struct MemberMetadataModel + { + public string file_name { get; set; } + public EMetadataFileType file_type { get; set; } + } + + public struct MatchdataMemberModel + { + public Int64 user_id { get; set; } = -1; // bigint(20) NOT NULL + public string display_name { get; set; } = String.Empty; // varchar(32) NOT NULL + public EPlayerType slot_state { get; set; } = EPlayerType.SLOT_CLOSED; // smallint(6) unsigned NOT NULL + public int side { get; set; } = -1; // int(2) NOT NULL + public int color { get; set; } = -1; // int(2) NOT NULL + public int team { get; set; } = -1; // int(1) NOT NULL + public int startpos { get; set; } = -1; // int(1) NOT NULL + public int buildings_built { get; set; } = 0; // int(11) DEFAULT NULL + public int buildings_killed { get; set; } = 0; // int(11) DEFAULT NULL + public int buildings_lost { get; set; } = 0; // int(11) DEFAULT NULL + public int units_built { get; set; } = 0; // int(11) DEFAULT NULL + public int units_killed { get; set; } = 0; // int(11) DEFAULT NULL + public int units_lost { get; set; } = 0; // int(11) DEFAULT NULL + public int total_money { get; set; } = 0; // int(11) DEFAULT NULL + + [JsonConverter(typeof(IntToBoolConverter))] + public bool won { get; set; } = false; // tinyint(4) DEFAULT NULL + public List metadata { get; set; } = new List(); + + public MatchdataMemberModel() + { + } + } +} + +namespace Database +{ + // TODO_EFCORE: Consider moving to zero-serialization model + public static class MatchHistory + { + private static readonly Expression>[] _slotSelectors = + { + m => m.MemberSlot0, + m => m.MemberSlot1, + m => m.MemberSlot2, + m => m.MemberSlot3, + m => m.MemberSlot4, + m => m.MemberSlot5, + m => m.MemberSlot6, + m => m.MemberSlot7 + }; + + 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 + ); + } + + + private static readonly Action, string?>[] _slotSetters = +{ + (s, v) => s.SetProperty(m => m.MemberSlot0, v), + (s, v) => s.SetProperty(m => m.MemberSlot1, v), + (s, v) => s.SetProperty(m => m.MemberSlot2, v), + (s, v) => s.SetProperty(m => m.MemberSlot3, v), + (s, v) => s.SetProperty(m => m.MemberSlot4, v), + (s, v) => s.SetProperty(m => m.MemberSlot5, v), + (s, v) => s.SetProperty(m => m.MemberSlot6, v), + (s, v) => s.SetProperty(m => m.MemberSlot7, v) +}; + + + private static readonly Func> _getMemberSlot = + EF.CompileAsyncQuery( + (AppDbContext db, long matchId, int slotIndex) => + db.MatchHistory + .Where(m => m.MatchId == matchId) + .Select(_slotSelectors[slotIndex]) + .FirstOrDefault() + ); + + + + private static readonly Func> _getHighestMatchId = + EF.CompileAsyncQuery( + (AppDbContext db) => + db.MatchHistory + .Max(m => (long?)m.MatchId) + ); + + private static readonly Func _insertMatch = + EF.CompileAsyncQuery( + (AppDbContext db, MatchHistoryEntry m) => + db.MatchHistory.Add(m) + ); + + + + + private static readonly Func> _getMatchesInRange = + EF.CompileAsyncQuery( + (AppDbContext db, long startId, long endId) => + db.MatchHistory + .Where(m => m.MatchId >= startId && + m.MatchId <= endId && + m.Finished) + .Select(m => new MatchHistory_Entry( + m.MatchId, + m.Owner, + m.Name, + m.Finished, + m.Started.ToString("O"), + m.TimeFinished.ToString("O"), + m.MapName, + m.MapPath!, + m.MatchRosterType, + m.MapOfficial, + m.VanillaTeams, + m.StartingCash, + m.LimitSuperweapons, + m.TrackStats, + m.AllowObservers, + m.MaxCamHeight + )) + ); + + public static async Task CommitPlayerOutcome( + AppDbContext db, + int slotIndex, + ulong matchId, + int buildingsBuilt, + int buildingsKilled, + int buildingsLost, + int unitsBuilt, + int unitsKilled, + int unitsLost, + int totalMoney, + 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); + } + + public static async Task _updateMemberSlot( + AppDbContext db, long matchId, int slotIndex, string? json) + { + var setter = BuildSetter(slotIndex, json); + + await db.MatchHistory + .Where(m => m.MatchId == matchId) + .ExecuteUpdateAsync(setter); + } + + + private static string ComputeRosterType(int playersSeen, Dictionary playersPerTeam) + { + // FFA check + bool isFFA = playersSeen > 2 && + playersPerTeam.All(kv => kv.Key == -1 || kv.Value == 1); + + if (isFFA) + return $"{playersSeen} Player FFA"; + + // Team roster type + string roster = ""; + + foreach (var kv in playersPerTeam) + { + int count = kv.Value; + + if (string.IsNullOrEmpty(roster)) + roster = count.ToString(); + else + roster += $"v{count}"; + } + + return roster; + } + + + public static async Task CreatePlaceholderMatchHistory( + AppDbContext db, + GenOnlineService.Lobby lobby) + { + if (lobby == null) + return 0; + + // 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); + + // Build EF entity + var entity = new MatchHistoryEntry + { + Owner = lobby.Owner, + Name = lobby.Name, + MapName = lobby.MapName, + MapPath = lobby.MapPath, + MapOfficial = lobby.IsMapOfficial, + MatchRosterType = rosterType, + VanillaTeams = lobby.IsVanillaTeamsOnly, + StartingCash = lobby.StartingCash, + LimitSuperweapons = lobby.IsLimitSuperweapons, + TrackStats = lobby.IsTrackingStats, + AllowObservers = lobby.AllowObservers, + MaxCamHeight = lobby.MaximumCameraHeight, + + MemberSlot0 = jsonSlots[0], + MemberSlot1 = jsonSlots[1], + MemberSlot2 = jsonSlots[2], + MemberSlot3 = jsonSlots[3], + MemberSlot4 = jsonSlots[4], + MemberSlot5 = jsonSlots[5], + MemberSlot6 = jsonSlots[6], + MemberSlot7 = jsonSlots[7] + }; + + // Precompiled Add() + await _insertMatch(db, entity); + + // Save + await db.SaveChangesAsync(); + + ulong id = (ulong)entity.MatchId; + lobby.SetMatchID(id); + + return id; + } + + + public static async Task GetMatchesInRange( + AppDbContext db, long startID, long endID) + { + 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); + } + + return collection; + } + + private static void AddMemberIfNotNull(MatchHistory_Entry entry, string? json) + { + if (!string.IsNullOrEmpty(json)) + { + var model = JsonSerializer.Deserialize(json); + if (model != null) + entry.members.Add(model); + } + } + + + + public static async Task GetHighestMatchID(AppDbContext db) + { + long? result = await _getHighestMatchId(db); + return result ?? -1; + } + + + } +} \ No newline at end of file From e849635130a85d825f273c0344ec99f8d59307cc Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:31:18 -0500 Subject: [PATCH 27/33] More match history progress --- .../Database/Database.MatchHistory.cs | 170 ++++++++ GenOnlineService/Database/MySQL.cs | 386 ------------------ GenOnlineService/LobbyManager.cs | 10 +- 3 files changed, 175 insertions(+), 391 deletions(-) diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 8a41535..abb82f4 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -195,6 +195,26 @@ 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 Expression, SetPropertyCalls>> BuildSetter(int slotIndex, string? json) { @@ -231,6 +251,29 @@ private static Expression, SetPropertyC }; + private static Expression, SetPropertyCalls>> + BuildWinnerSetter(int slotIndex, string updatedJson) + { + var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); + + var call = Expression.Call( + param, + nameof(SetPropertyCalls.SetProperty), + typeArguments: null, + arguments: new Expression[] + { + _slotSelectors[slotIndex], + Expression.Constant(updatedJson, typeof(string)) + } + ); + + return Expression.Lambda, SetPropertyCalls>>( + call, + param + ); + } + + private static readonly Func> _getMemberSlot = EF.CompileAsyncQuery( (AppDbContext db, long matchId, int slotIndex) => @@ -455,6 +498,133 @@ public static async Task CreatePlaceholderMatchHistory( return id; } + 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( AppDbContext db, long startID, long endID) diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 2bdba57..369fd41 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -77,373 +77,6 @@ namespace Database { public static class Functions { - public static class Leaderboards - { - public async static Task DetermineLobbyWinnerIfNotPresent(MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) - { - // NOTE: this works only when you call this function BEFORE updating ELO, as elo will read it all to award points - - // get each lobby member - var res = await m_Inst.Query("SELECT member_slot_0, member_slot_1, member_slot_2, member_slot_3, member_slot_4, member_slot_5, member_slot_6, member_slot_7 FROM match_history WHERE match_id=@matchID LIMIT 1;", - new() - { - { "@matchID", lobbyInst.MatchID } - } - ); - - Dictionary lstMembers = new Dictionary(); - foreach (var row in res.GetRows()) - { - string? strJson_Slot0 = Convert.ToString(row["member_slot_0"]); - string? strJson_Slot1 = Convert.ToString(row["member_slot_1"]); - string? strJson_Slot2 = Convert.ToString(row["member_slot_2"]); - string? strJson_Slot3 = Convert.ToString(row["member_slot_3"]); - string? strJson_Slot4 = Convert.ToString(row["member_slot_4"]); - string? strJson_Slot5 = Convert.ToString(row["member_slot_5"]); - string? strJson_Slot6 = Convert.ToString(row["member_slot_6"]); - string? strJson_Slot7 = Convert.ToString(row["member_slot_7"]); - - // TODO: Optimize, we deserialize to reserialize... just return the JSON directly - MatchdataMemberModel? member0 = String.IsNullOrEmpty(strJson_Slot0) ? null : JsonSerializer.Deserialize(strJson_Slot0); - MatchdataMemberModel? member1 = String.IsNullOrEmpty(strJson_Slot1) ? null : JsonSerializer.Deserialize(strJson_Slot1); - MatchdataMemberModel? member2 = String.IsNullOrEmpty(strJson_Slot2) ? null : JsonSerializer.Deserialize(strJson_Slot2); - MatchdataMemberModel? member3 = String.IsNullOrEmpty(strJson_Slot3) ? null : JsonSerializer.Deserialize(strJson_Slot3); - MatchdataMemberModel? member4 = String.IsNullOrEmpty(strJson_Slot4) ? null : JsonSerializer.Deserialize(strJson_Slot4); - MatchdataMemberModel? member5 = String.IsNullOrEmpty(strJson_Slot5) ? null : JsonSerializer.Deserialize(strJson_Slot5); - MatchdataMemberModel? member6 = String.IsNullOrEmpty(strJson_Slot6) ? null : JsonSerializer.Deserialize(strJson_Slot6); - MatchdataMemberModel? member7 = String.IsNullOrEmpty(strJson_Slot7) ? null : JsonSerializer.Deserialize(strJson_Slot7); - - // add members to collection with slot index as key - if (member0 != null) { lstMembers[0] = member0.Value; } - if (member1 != null) { lstMembers[1] = member1.Value; } - if (member2 != null) { lstMembers[2] = member2.Value; } - if (member3 != null) { lstMembers[3] = member3.Value; } - if (member4 != null) { lstMembers[4] = member4.Value; } - if (member5 != null) { lstMembers[5] = member5.Value; } - if (member6 != null) { lstMembers[6] = member6.Value; } - if (member7 != null) { lstMembers[7] = member7.Value; } - } - - // do we have a winner already? - bool bHasWinner = false; - int winnerTeam = -1; - foreach (var kvp in lstMembers) - { - if (kvp.Value.won) - { - bHasWinner = true; - winnerTeam = kvp.Value.team; - break; - } - } - - // if we have a winner, and they have a team, make everyone else on that team a winner - if (bHasWinner) - { - if (winnerTeam != -1) - { - foreach (var kvp in lstMembers) - { - int slotIndex = kvp.Key; - MatchdataMemberModel lobbyMember = kvp.Value; - - if (lobbyMember.team == winnerTeam) // same team, and not '-1' - { - // save it - await Database.Functions.Lobby.UpdateMatchHistoryMakeWinner(GlobalDatabaseInstance.g_Database, lobbyInst.MatchID, slotIndex); - } - } - } - } - - // no winner? pick one - if (!bHasWinner) - { - // pick the last person to leave - DateTime mostRecentlyLeftTimestamp = DateTime.UnixEpoch; - MatchdataMemberModel? lastPlayerToLeave = null; - int lastPlayerSlotIndex = -1; - foreach (var kvp in lstMembers) - { - MatchdataMemberModel lobbyMember = kvp.Value; - if (lobbyInst.TimeMemberLeft.ContainsKey(lobbyMember.user_id)) - { - if (lobbyInst.TimeMemberLeft[lobbyMember.user_id] >= mostRecentlyLeftTimestamp) - { - mostRecentlyLeftTimestamp = lobbyInst.TimeMemberLeft[lobbyMember.user_id]; - lastPlayerToLeave = lobbyMember; - lastPlayerSlotIndex = kvp.Key; - } - } - } - - if (lastPlayerToLeave != null) - { - int winningPlayerTeam = lastPlayerToLeave.Value.team; - - // this player + everyone on the same team is also a winner! - foreach (var kvp in lstMembers) - { - int slotIndex = kvp.Key; - MatchdataMemberModel lobbyMember = kvp.Value; - - // is it this guy? - if (lobbyMember.user_id == lastPlayerToLeave.Value.user_id) - { - // save it - await Database.Functions.Lobby.UpdateMatchHistoryMakeWinner(GlobalDatabaseInstance.g_Database, lobbyInst.MatchID, slotIndex); - } - else if (winningPlayerTeam != -1 && lobbyMember.team == winningPlayerTeam) // same team, and not '-1' - { - // save it - await Database.Functions.Lobby.UpdateMatchHistoryMakeWinner(GlobalDatabaseInstance.g_Database, lobbyInst.MatchID, slotIndex); - } - } - } - - - - } - } - - public async static Task UpdateLeaderboardAndElo(AppDbContext db, MySQLInstance m_Inst, GenOnlineService.Lobby lobbyInst) - { - // must be in a QM - if (lobbyInst.LobbyType != ELobbyType.QuickMatch) - { - return; - } - - // TODO_QUICKMATCH: This is a bit slow probably, quite a few queries - - // we use the time at which the lobby was created, not when it ended, since the day of year etc might have changed - int dayOfYear = lobbyInst.TimeCreated.DayOfYear; - int monthOfYear = lobbyInst.TimeCreated.Month; - int year = lobbyInst.TimeCreated.Year; - - // process each member - var res = await m_Inst.Query("SELECT member_slot_0, member_slot_1, member_slot_2, member_slot_3, member_slot_4, member_slot_5, member_slot_6, member_slot_7 FROM match_history WHERE match_id=@matchID LIMIT 1;", - new() - { - { "@matchID", lobbyInst.MatchID } - } - ); - - List lstMembers = new List(); - foreach (var row in res.GetRows()) - { - string? strJson_Slot0 = Convert.ToString(row["member_slot_0"]); - string? strJson_Slot1 = Convert.ToString(row["member_slot_1"]); - string? strJson_Slot2 = Convert.ToString(row["member_slot_2"]); - string? strJson_Slot3 = Convert.ToString(row["member_slot_3"]); - string? strJson_Slot4 = Convert.ToString(row["member_slot_4"]); - string? strJson_Slot5 = Convert.ToString(row["member_slot_5"]); - string? strJson_Slot6 = Convert.ToString(row["member_slot_6"]); - string? strJson_Slot7 = Convert.ToString(row["member_slot_7"]); - - // TODO: Optimize, we deserialize to reserialize... just return the JSON directly - MatchdataMemberModel? member0 = String.IsNullOrEmpty(strJson_Slot0) ? null : JsonSerializer.Deserialize(strJson_Slot0); - MatchdataMemberModel? member1 = String.IsNullOrEmpty(strJson_Slot1) ? null : JsonSerializer.Deserialize(strJson_Slot1); - MatchdataMemberModel? member2 = String.IsNullOrEmpty(strJson_Slot2) ? null : JsonSerializer.Deserialize(strJson_Slot2); - MatchdataMemberModel? member3 = String.IsNullOrEmpty(strJson_Slot3) ? null : JsonSerializer.Deserialize(strJson_Slot3); - MatchdataMemberModel? member4 = String.IsNullOrEmpty(strJson_Slot4) ? null : JsonSerializer.Deserialize(strJson_Slot4); - MatchdataMemberModel? member5 = String.IsNullOrEmpty(strJson_Slot5) ? null : JsonSerializer.Deserialize(strJson_Slot5); - MatchdataMemberModel? member6 = String.IsNullOrEmpty(strJson_Slot6) ? null : JsonSerializer.Deserialize(strJson_Slot6); - MatchdataMemberModel? member7 = String.IsNullOrEmpty(strJson_Slot7) ? null : JsonSerializer.Deserialize(strJson_Slot7); - - // add members to collection - if (member0 != null) { lstMembers.Add((MatchdataMemberModel)member0); } - if (member1 != null) { lstMembers.Add((MatchdataMemberModel)member1); } - if (member2 != null) { lstMembers.Add((MatchdataMemberModel)member2); } - if (member3 != null) { lstMembers.Add((MatchdataMemberModel)member3); } - if (member4 != null) { lstMembers.Add((MatchdataMemberModel)member4); } - if (member5 != null) { lstMembers.Add((MatchdataMemberModel)member5); } - if (member6 != null) { lstMembers.Add((MatchdataMemberModel)member6); } - if (member7 != null) { lstMembers.Add((MatchdataMemberModel)member7); } - } - - // ELO (current) - { - Dictionary dictEloData = new Dictionary(); - - // initialize data with bulk query (1 query instead of N) - List userIds = lstMembers.Select(m => m.user_id).ToList(); - dictEloData = await Database.Users.GetBulkELOData(db, userIds); - - foreach (MatchdataMemberModel member in lstMembers) - { - // TODO_ELO: Opt, this is O(n^2) - // for this member, check results vs every other member we were against - foreach (MatchdataMemberModel compareToMember in lstMembers) - { - if (compareToMember.user_id != member.user_id) - { - ref EloData playerAData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData, member.user_id, out bool existsA); - ref EloData playerBData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData, compareToMember.user_id, out bool existsB); - - if (existsA && existsB) // should always exist... - { - // must be on different teams, otherwise we dont care, we can't win against our own team - if (compareToMember.team != member.team || member.team == -1) - { - Elo.ApplyResult(ref playerAData, ref playerBData, member.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - } - } - } - - // now update num matches for everyone, we cant do this above because we iterate player A X times for example, so it increases incorrectly - foreach (MatchdataMemberModel member in lstMembers) - { - ref EloData playerData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData, member.user_id, out bool existsA); - ++playerData.NumMatches; - } - - // save each ELO data to DB - foreach (var eloPair in dictEloData) - { - // store on player if online - SharedUserData? sharedUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(eloPair.Key); - if (sharedUserData != null) - { - sharedUserData.GameStats.EloRating = eloPair.Value.Rating; - sharedUserData.GameStats.EloMatches = eloPair.Value.NumMatches; - } - await Database.Users.SaveELOData(db, eloPair.Key, eloPair.Value); - } - } - - // ELO DAILY, MONTHLY AND ANNUAL - { - Dictionary dictEloData_Daily = new Dictionary(); - Dictionary dictEloData_Monthly = new Dictionary(); - Dictionary dictEloData_Yearly = new Dictionary(); - - // initialize data with bulk query (3 queries instead of N*3) - List userIds = lstMembers.Select(m => m.user_id).ToList(); - - var bulkLbData = await Database.Leaderboards.GetBulkLeaderboardData(db, userIds, dayOfYear, monthOfYear, year); - - foreach (MatchdataMemberModel member in lstMembers) - { - Database.Leaderboards.LeaderboardPoints userLBPoints = bulkLbData[member.user_id]; - dictEloData_Daily[member.user_id] = new EloData(userLBPoints.daily, userLBPoints.daily_matches); - dictEloData_Monthly[member.user_id] = new EloData(userLBPoints.monthly, userLBPoints.monthly_matches); - dictEloData_Yearly[member.user_id] = new EloData(userLBPoints.yearly, userLBPoints.yearly_matches); - } - - foreach (MatchdataMemberModel member in lstMembers) - { - // TODO_ELO: Opt, this is O(n^2) - // for this member, check results vs every other member we were against - foreach (MatchdataMemberModel compareToMember in lstMembers) - { - if (compareToMember.user_id != member.user_id) - { - - // Daily - { - ref EloData playerAData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Daily, member.user_id, out bool existsA); - ref EloData playerBData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Daily, compareToMember.user_id, out bool existsB); - - if (existsA && existsB) // should always exist... - { - // must be on different teams, otherwise we dont care, we can't win against our own team - if (compareToMember.team != member.team || member.team == -1) - { - Elo.ApplyResult(ref playerAData, ref playerBData, member.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - } - - // Monthly - { - ref EloData playerAData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Monthly, member.user_id, out bool existsA); - ref EloData playerBData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Monthly, compareToMember.user_id, out bool existsB); - - if (existsA && existsB) // should always exist... - { - // must be on different teams, otherwise we dont care, we can't win against our own team - if (compareToMember.team != member.team || member.team == -1) - { - Elo.ApplyResult(ref playerAData, ref playerBData, member.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - } - - // Yearly - { - ref EloData playerAData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Yearly, member.user_id, out bool existsA); - ref EloData playerBData = ref CollectionsMarshal.GetValueRefOrAddDefault(dictEloData_Yearly, compareToMember.user_id, out bool existsB); - - if (existsA && existsB) // should always exist... - { - // must be on different teams, otherwise we dont care, we can't win against our own team - if (compareToMember.team != member.team || member.team == -1) - { - Elo.ApplyResult(ref playerAData, ref playerBData, member.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - } - } - - } - } - } - - // save each ELO data to DB using batched transaction - // Build all UPDATE statements and execute in single transaction - List dailyUpdates = new(); - List monthlyUpdates = new(); - List yearlyUpdates = new(); - - foreach (MatchdataMemberModel member in lstMembers) - { - EloData playerData_Daily = dictEloData_Daily[member.user_id]; - EloData playerData_Monthly = dictEloData_Monthly[member.user_id]; - EloData playerData_Yearly = dictEloData_Yearly[member.user_id]; - - int winsModifier = 0; - int lossesModifier = 0; - - if (member.won) - { - ++winsModifier; - } - else - { - ++lossesModifier; - } - - // Build UPDATE statements (sanitized parameters) - dailyUpdates.Add($"UPDATE leaderboard_daily SET points={playerData_Daily.Rating}, losses=losses+{lossesModifier}, wins=wins+{winsModifier} WHERE user_id={member.user_id} AND day_of_year={dayOfYear} AND year={year} LIMIT 1;"); - monthlyUpdates.Add($"UPDATE leaderboard_monthly SET points={playerData_Monthly.Rating}, losses=losses+{lossesModifier}, wins=wins+{winsModifier} WHERE user_id={member.user_id} AND month_of_year={monthOfYear} AND year={year} LIMIT 1;"); - yearlyUpdates.Add($"UPDATE leaderboard_yearly SET points={playerData_Yearly.Rating}, losses=losses+{lossesModifier}, wins=wins+{winsModifier} WHERE user_id={member.user_id} AND year={year} LIMIT 1;"); - } - - // Execute all updates in single batch (3 queries instead of N*3) - if (dailyUpdates.Count > 0) - { - string batchedDaily = string.Join("\n", dailyUpdates); - await m_Inst.Query(batchedDaily, null); - } - - if (monthlyUpdates.Count > 0) - { - string batchedMonthly = string.Join("\n", monthlyUpdates); - await m_Inst.Query(batchedMonthly, null); - } - - if (yearlyUpdates.Count > 0) - { - string batchedYearly = string.Join("\n", yearlyUpdates); - await m_Inst.Query(batchedYearly, null); - } - - } - } - } - public static class Lobby { @@ -496,25 +129,6 @@ public async static Task AttachMatchHistoryMetadata(MySQLInstance m_Inst, UInt64 } } - public async static Task UpdateMatchHistoryMakeWinner(MySQLInstance m_Inst, UInt64 MatchID, int slotIndex) - { - if (MatchID != 0) // 0 is invalid - { - if (slotIndex < 0) - { - return; - } - - CMySQLResult resMember = await m_Inst.Query(String.Format("UPDATE match_history SET member_slot_{0} = JSON_SET(member_slot_{0}, '$.won', @won) WHERE match_id = @match_id;", slotIndex), - new() - { - { "@match_id", MatchID }, - { "@won", true } - } - ); - } - } - public class IntToBoolConverter : JsonConverter { public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 645d9d4..51645aa 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1420,16 +1420,16 @@ public async Task DeleteLobby(Lobby lobby) // unsubscribe from self-destruct event lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; + using var scope = _services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + // make sure we have a winner - await Database.Functions.Leaderboards.DetermineLobbyWinnerIfNotPresent(GlobalDatabaseInstance.g_Database, lobby); + await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); // if its a quickmatch, update our leaderboards if (lobby.LobbyType == ELobbyType.QuickMatch) { - using var scope = _services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(db, GlobalDatabaseInstance.g_Database, lobby); } } From d6a0ab3ee8041d58d4e432ebff6556a195d8fee1 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:42:22 -0500 Subject: [PATCH 28/33] Last parts of match history --- GenOnlineService/BackgroundS3Uploader.cs | 10 +- .../Database/Database.MatchHistory.cs | 400 +++++++++++++++--- GenOnlineService/Database/MySQL.cs | 70 +-- GenOnlineService/LobbyManager.cs | 12 +- 4 files changed, 366 insertions(+), 126 deletions(-) diff --git a/GenOnlineService/BackgroundS3Uploader.cs b/GenOnlineService/BackgroundS3Uploader.cs index d210030..f8178f7 100644 --- a/GenOnlineService/BackgroundS3Uploader.cs +++ b/GenOnlineService/BackgroundS3Uploader.cs @@ -3,6 +3,7 @@ using Amazon.S3; using Amazon.S3.Model; using GenOnlineService; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI.Common; using Sentry.Protocol; using System.Collections.Concurrent; @@ -100,7 +101,7 @@ private static async Task DoUpload(S3QueuedUploadEntry entry) string strPerMatchUserIDKey = Helpers.ComputeMD5Hash(String.Format("{0}_{1}", entry.m_MatchID, entry.m_UserID)); ; string strFileName = null; - Database.Functions.Lobby.EMetadataFileType fileType = EMetadataFileType.UNKNOWN; + EMetadataFileType fileType = EMetadataFileType.UNKNOWN; if (entry.m_uploadType == ES3UploadType.Screenshot) @@ -188,8 +189,11 @@ private static async Task DoUpload(S3QueuedUploadEntry entry) var response = await client.PutObjectAsync(putRequest); Console.WriteLine($"SCREENSHOT uploaded successfully. {entry.m_FileData.Count} bytes. HHTTP Status Code: {response.HttpStatusCode}"); - // store in DB - await Database.Functions.Lobby.AttachMatchHistoryMetadata(GlobalDatabaseInstance.g_Database, entry.m_MatchID, entry.m_slotIndexInLobby, strFileName, fileType); + // store in DB + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.MatchHistory.AttachMatchHistoryMetadata(db, entry.m_MatchID, entry.m_slotIndexInLobby, strFileName, fileType); } catch (Exception ex) { diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index abb82f4..00bb47a 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -25,6 +25,7 @@ using System; using System.Collections.Generic; using System.Linq.Expressions; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -144,7 +145,24 @@ 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 struct MemberMetadataModel { public string file_name { get; set; } @@ -179,12 +197,12 @@ 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, m => m.MemberSlot2, @@ -342,19 +360,19 @@ public static async Task CommitPlayerOutcome( bool won) { if (slotIndex < 0 || slotIndex > 7) - return; - - // 1. Load JSON for this slot + return; + + // 1. Load JSON for this slot string? json = await _getMemberSlot(db, (long)matchId, slotIndex); if (string.IsNullOrEmpty(json)) - return; - - // 2. Deserialize + return; + + // 2. Deserialize MatchdataMemberModel? modelNullable = JsonSerializer.Deserialize(json); if (modelNullable == null) - return; - - // 3. Update fields + return; + + // 3. Update fields MatchdataMemberModel model = modelNullable.Value; model.buildings_built = buildingsBuilt; model.buildings_killed = buildingsKilled; @@ -363,12 +381,12 @@ public static async Task CommitPlayerOutcome( 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) + model.won = won; + + // 4. Serialize back + string updatedJson = JsonSerializer.Serialize(model); + + // 5. Update DB (single SQL UPDATE) await _updateMemberSlot(db, (long)matchId, slotIndex, updatedJson); } @@ -384,15 +402,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) @@ -414,9 +432,9 @@ public static async Task CreatePlaceholderMatchHistory( GenOnlineService.Lobby lobby) { if (lobby == null) - return 0; - - // Build member JSON array + return 0; + + // Build member JSON array string?[] jsonSlots = new string?[8]; Dictionary playersPerTeam = new(); @@ -455,12 +473,12 @@ public static async Task CreatePlaceholderMatchHistory( playersPerTeam[model.team]++; else playersPerTeam[model.team] = 1; - } - - // Determine roster type - string rosterType = ComputeRosterType(playersSeen, playersPerTeam); - - // Build EF entity + } + + // Determine roster type + string rosterType = ComputeRosterType(playersSeen, playersPerTeam); + + // Build EF entity var entity = new MatchHistoryEntry { Owner = lobby.Owner, @@ -484,12 +502,12 @@ public static async Task CreatePlaceholderMatchHistory( MemberSlot5 = jsonSlots[5], MemberSlot6 = jsonSlots[6], MemberSlot7 = jsonSlots[7] - }; - - // Precompiled Add() - await _insertMatch(db, entity); - - // Save + }; + + // Precompiled Add() + await _insertMatch(db, entity); + + // Save await db.SaveChangesAsync(); ulong id = (ulong)entity.MatchId; @@ -632,8 +650,8 @@ public static async Task GetMatchesInRange( MatchHistoryCollection collection = new(); await foreach (var entry in _getMatchesInRange(db, startID, endID)) - { - // Load JSON members (optional optimization below) + { + // Load JSON members (optional optimization below) var entity = await db.MatchHistory .Where(m => m.MatchId == entry.match_id) .Select(m => new @@ -647,9 +665,9 @@ public static async Task GetMatchesInRange( m.MemberSlot6, m.MemberSlot7 }) - .FirstAsync(); - - // Deserialize only if not null + .FirstAsync(); + + // Deserialize only if not null AddMemberIfNotNull(entry, entity.MemberSlot0); AddMemberIfNotNull(entry, entity.MemberSlot1); AddMemberIfNotNull(entry, entity.MemberSlot2); @@ -681,8 +699,294 @@ 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)); + } + } + + + + + } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 369fd41..4c6c4cf 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -52,83 +52,15 @@ - - - - -/* - * 2, // USA - 3, // CHINA - 4, // GLA - 5, // USA Super Weapon - 6, // USA Laser - 7, // USA Airforce - 8, // China Tank - 9, // China Infantry - 10, // China Nuke - 11, // GLA Toxin - 12, // GLA Demo - 13 // GLA Stealth -*/ - - - namespace Database { public static class Functions { public static class Lobby { - - // Called when a lobby is deleted, thats the true end of a match - public async static Task CommitLobbyToMatchHistory(MySQLInstance m_Inst, GenOnlineService.Lobby lobby) - { - if (lobby.MatchID != 0) // 0 is invalid - { - await m_Inst.Query("UPDATE match_history SET finished=true, time_finished=current_timestamp() WHERE match_id=@match_id AND finished=false LIMIT 1;", - new() - { - { "@match_id", lobby.MatchID } - }); - } - } - - 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 async static Task AttachMatchHistoryMetadata(MySQLInstance m_Inst, UInt64 MatchID, int slotIndex, string strVal, EMetadataFileType fileType) - { - if (MatchID != 0) // 0 is invalid - { - if (slotIndex < 0) - { - return; - } - - CMySQLResult resMember = await m_Inst.Query(String.Format("UPDATE match_history SET member_slot_{0} = JSON_ARRAY_APPEND(member_slot_{0}, '$.metadata', JSON_OBJECT('file_name', @file_name, 'file_type', @file_type)) WHERE match_id = @match_id;", slotIndex), - new() - { - { "@match_id", MatchID }, - { "@file_name", strVal }, - { "@file_type", (int)fileType }, - } - ); - } - } - + public class IntToBoolConverter : JsonConverter { public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 51645aa..e7b3b16 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1401,13 +1401,17 @@ 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) { // make done await lobby.UpdateState(ELobbyState.COMPLETE); // attempt to commit it - await Database.Functions.Lobby.CommitLobbyToMatchHistory(GlobalDatabaseInstance.g_Database, lobby); + await Database.MatchHistory.CommitLobbyToMatchHistory(db, lobby); } // delete @@ -1420,17 +1424,13 @@ public async Task DeleteLobby(Lobby lobby) // unsubscribe from self-destruct event lobby.OnLobbyNeedsDestroyed -= HandleLobbyNeedsDestroyed; - using var scope = _services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - // make sure we have a winner await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); // if its a quickmatch, update our leaderboards if (lobby.LobbyType == ELobbyType.QuickMatch) { - await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(db, GlobalDatabaseInstance.g_Database, lobby); + await Database.MatchHistory.UpdateLeaderboardAndElo(db, lobby); } } From 646344fdca250d94820b2cf61973fa4b92894f76 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:56:46 -0500 Subject: [PATCH 29/33] User stats ported --- GenOnlineService/BackgroundS3Uploader.cs | 1 - GenOnlineService/Constants.cs | 2 +- .../MatchReplay/MatchReplayController.cs | 1 - .../MatchUpdate/MatchUpdateController.cs | 1 - .../PlayerStats/PlayerStatsController.cs | 5 +- .../Database/Database.MatchHistory.cs | 2 - .../Database/Database.PlayerStats.cs | 169 ++++++++++++++++++ GenOnlineService/Database/Database.cs | 2 + GenOnlineService/Database/MySQL.cs | 74 +------- 9 files changed, 176 insertions(+), 81 deletions(-) create mode 100644 GenOnlineService/Database/Database.PlayerStats.cs diff --git a/GenOnlineService/BackgroundS3Uploader.cs b/GenOnlineService/BackgroundS3Uploader.cs index f8178f7..3975278 100644 --- a/GenOnlineService/BackgroundS3Uploader.cs +++ b/GenOnlineService/BackgroundS3Uploader.cs @@ -8,7 +8,6 @@ using Sentry.Protocol; using System.Collections.Concurrent; using System.Net; -using static Database.Functions.Lobby; public enum ES3UploadType { diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 1da5844..80c63b5 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -228,7 +228,7 @@ public static async Task CreateSession(AppDbContext _db, socialContainer.Blocked = await Database.Functions.Auth.GetBlocked(GlobalDatabaseInstance.g_Database, ownerID); // get stats - PlayerStats GameStats = await Database.Functions.Auth.GetPlayerStats(_db, GlobalDatabaseInstance.g_Database, ownerID); + PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); userCacheData = new UserSession(ownerID, sessionType, client_id, strContinent, strCountry, dLatitude, dLongitude); m_dictUserSessions[sessionType][ownerID] = userCacheData; diff --git a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs index 156a1bf..a9e0d3d 100644 --- a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs +++ b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs @@ -31,7 +31,6 @@ using Amazon.S3.Model; using System.ComponentModel.DataAnnotations; using Org.BouncyCastle.Tls; -using static Database.Functions.Lobby; namespace GenOnlineService.Controllers { diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index bc58420..8f4491f 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -31,7 +31,6 @@ using System.Security.Claims; using System.Text; using System.Text.Json; -using static Database.Functions.Lobby; namespace GenOnlineService.Controllers { diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index 80136a5..c49fa75 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -99,7 +99,7 @@ public async Task Get(Int64 userID) if (userData == null) { await using var db = await _dbFactory.CreateDbContextAsync(); - PlayerStats playerStats = await Database.Functions.Auth.GetPlayerStats(db, GlobalDatabaseInstance.g_Database, userID); + PlayerStats playerStats = await Database.UserStats.GetPlayerStats(db, userID); if (playerStats == null) { @@ -211,7 +211,8 @@ public async Task Put() } } - await Database.Functions.Auth.UpdatePlayerStat(GlobalDatabaseInstance.g_Database, user_id, stat_id, statValInt); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.UserStats.UpdatePlayerStat(db, user_id, stat_id, statValInt); //Console.WriteLine("Stat {0} is valid and is {1}", (EStatIndex)stat_id, statValInt); // game tracks the progress, so these are full writes, not incremental diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index 00bb47a..ceab442 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -29,7 +29,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using static Database.Functions.Lobby; public class MatchHistoryEntry { @@ -186,7 +185,6 @@ 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(IntToBoolConverter))] public bool won { get; set; } = false; // tinyint(4) DEFAULT NULL public List metadata { get; set; } = new List(); diff --git a/GenOnlineService/Database/Database.PlayerStats.cs b/GenOnlineService/Database/Database.PlayerStats.cs new file mode 100644 index 0000000..8a40383 --- /dev/null +++ b/GenOnlineService/Database/Database.PlayerStats.cs @@ -0,0 +1,169 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using GenOnlineService; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System.Text.Json; + +// TODO_EFCORE: When updating this, make sure we preserve old ON DUPLICATE behavior, to overwrite the old data since day_of_year key will be re-used +public class UserStatsEntry +{ + public long UserId { get; set; } + public string Stats { get; set; } = "{}"; +} + +// TODO_EFCORE: rename to user_stats +public class UserStatsConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_stats_v2"); + + builder.HasKey(x => x.UserId); + + builder.Property(x => x.UserId) + .HasColumnName("user_id"); + + builder.Property(x => x.Stats) + .HasColumnName("stats") + .HasColumnType("longtext") + .UseCollation("utf8mb4_bin") // matches your CREATE TABLE + .IsRequired(); + + // JSON validity constraint + builder.HasCheckConstraint( + "CK_user_stats_v2_stats_json_valid", + "json_valid(`stats`)" + ); + } +} + + +namespace Database +{ + public static class UserStats + { + private static readonly Func> _getUserStatsJson = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.UserStats + .Where(s => s.UserId == userId) + .Select(s => s.Stats) + .FirstOrDefault() + ); + + + private static readonly Func> _getUserStats = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.UserStats + .Where(s => s.UserId == userId) + .Select(s => s.Stats) + .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 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)); + } + + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 766743c..23a9410 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -31,6 +31,7 @@ public class AppDbContext : DbContext public DbSet ServiceStats => Set(); public DbSet PendingLogins => Set(); public DbSet MatchHistory => Set(); + public DbSet UserStats => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -51,5 +52,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new ServiceStatsConfiguration()); modelBuilder.ApplyConfiguration(new PendingLoginConfiguration()); modelBuilder.ApplyConfiguration(new MatchHistoryConfiguration()); + modelBuilder.ApplyConfiguration(new UserStatsConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index 4c6c4cf..ff57a39 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -48,49 +48,14 @@ using System.Threading.Tasks; using static Database.Functions; using static Database.Functions.Auth; -using static Database.Functions.Lobby; - - namespace Database { public static class Functions { - public static class Lobby - { - - - - public class IntToBoolConverter : JsonConverter - { - public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - return reader.GetInt32() != 0; - } - - public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) - { - writer.WriteNumberValue(value ? 1 : 0); - } - } - } - // TODO: Cleanup things when a user disconnects, e.g. lobby they're in etc public static class Auth { - public async static Task UpdatePlayerStat(MySQLInstance m_Inst, Int64 user_id, int stat_id, int stat_val) - { - await m_Inst.Query("INSERT INTO user_stats_v2 (user_id, stats) VALUES (@user_id, JSON_OBJECT(@stat_key_raw, @stat_val)) ON DUPLICATE KEY UPDATE stats = JSON_SET(stats, @stat_key_formatted, @stat_val);", - new() - { - { "@user_id", user_id }, - { "@stat_key_raw", stat_id }, - { "@stat_key_formatted", String.Format("$.{0}", stat_id) }, - { "@stat_val", stat_val } - } - ); - } - public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion protocol, EConnectionState outcome) { if (outcome != EConnectionState.CONNECTED_DIRECT && outcome != EConnectionState.CONNECTED_RELAY && outcome != EConnectionState.CONNECTION_FAILED) // states we dont track @@ -167,44 +132,7 @@ public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion ); } - public async static Task GetPlayerStats(AppDbContext _db, MySQLInstance m_Inst, Int64 user_id) - { - // TODO: Return null if user doesnt actually exist, instead of empty stats - EloData eloData = await Database.Users.GetELOData(_db, user_id); - PlayerStats ps = new PlayerStats(user_id, eloData.Rating, eloData.NumMatches); - - var res = await m_Inst.Query("SELECT stats FROM user_stats_v2 WHERE user_id=@user_id LIMIT 1;", - new() - { - { "@user_id", user_id } - } - ); - - if (res.NumRows() == 0) - { - return ps; - } - - string? jsonData = Convert.ToString(res.GetRow(0)["stats"]); -#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type. -#pragma warning disable CS8604 // Converting null literal or possible null value to non-nullable type. - Dictionary dictStats = JsonSerializer.Deserialize>(jsonData); -#pragma warning restore CS8604 // Converting null literal or possible null value to non-nullable type. -#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type. - - //foreach (var row in res.GetRows()) -#pragma warning disable CS8602 // Dereference of a possibly null reference. - foreach (var statPair in dictStats) - { - EStatIndex stat_id = (EStatIndex)Convert.ToUInt16(statPair.Key); - int stat_value = statPair.Value; - - ps.ProcessFromDB(stat_id, stat_value); - } -#pragma warning restore CS8602 // Dereference of a possibly null reference. - - return ps; - } + public static async Task FullyDestroyPlayerSession(MySQLInstance m_Inst, Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent) { From b3875b525e29014e2ae6942ee60994a577009f52 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:08:46 -0500 Subject: [PATCH 30/33] Social migration --- GenOnlineService/Constants.cs | 6 +- .../Controllers/Friends/SocialController.cs | 26 ++- GenOnlineService/Database/Database.Social.cs | 216 ++++++++++++++++++ GenOnlineService/Database/Database.cs | 9 + GenOnlineService/Database/MySQL.cs | 138 ----------- 5 files changed, 245 insertions(+), 150 deletions(-) create mode 100644 GenOnlineService/Database/Database.Social.cs diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 80c63b5..26dae30 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -223,9 +223,9 @@ public static async Task CreateSession(AppDbContext _db, // get and cache social container UserSocialContainer socialContainer = new(); - socialContainer.Friends = await Database.Functions.Auth.GetFriends(GlobalDatabaseInstance.g_Database, ownerID); - socialContainer.PendingRequests = await Database.Functions.Auth.GetPendingFriendsRequests(GlobalDatabaseInstance.g_Database, ownerID); - socialContainer.Blocked = await Database.Functions.Auth.GetBlocked(GlobalDatabaseInstance.g_Database, ownerID); + socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); + socialContainer.PendingRequests = await Database.Social.GetPendingFriendsRequests(_db, ownerID); + socialContainer.Blocked = await Database.Social.GetBlocked(_db, ownerID); // get stats PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, ownerID); diff --git a/GenOnlineService/Controllers/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index 320a898..3c4ba57 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -86,6 +86,7 @@ private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int6 SharedUserData? sharedUserDataSource = GenOnlineService.WebSocketManager.GetSharedDataForUser(source_user_id); SharedUserData? sharedUserDataTarget = GenOnlineService.WebSocketManager.GetSharedDataForUser(target_user_id); + await using var db = await _dbFactory.CreateDbContextAsync(); // NOTE: target user does NOT need to be signed in // remove the request from requestor (online version) @@ -94,12 +95,12 @@ private async Task HelperFunction_AcceptFriendRequest(Int64 source_user_id, Int6 #pragma warning restore CS8602 // Dereference of a possibly null reference. // remove the request from requestor (db) - await Database.Functions.Auth.RemovePendingFriendRequest(GlobalDatabaseInstance.g_Database, source_user_id, target_user_id); + await Database.Social.RemovePendingFriendRequest(db, source_user_id, target_user_id); // Add to both players friends list (online version and db) // SHARED db (we only have to add this once and it covers both players) - await Database.Functions.Auth.CreateFriendship(GlobalDatabaseInstance.g_Database, source_user_id, target_user_id); + await Database.Social.CreateFriendship(db, source_user_id, target_user_id); // source player { @@ -182,7 +183,8 @@ public async Task RejectPendingRequest(Int64 target_user_id) // remove the request from requestor (db) // NOTE: Target and source are inverted here because the target is actually the person who sent the request, source is the person taking action on the friend request - await Database.Functions.Auth.RemovePendingFriendRequest(GlobalDatabaseInstance.g_Database, target_user_id, source_user_id); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.Social.RemovePendingFriendRequest(db, target_user_id, source_user_id); SocialHelper.NotifyFriendslistDirty(source_user_id); SocialHelper.NotifyFriendslistDirty(target_user_id); @@ -222,7 +224,8 @@ public async Task RemoveFriend(Int64 target_user_id) } // remove the request from requestor (db) - await Database.Functions.Auth.RemoveFriendship(GlobalDatabaseInstance.g_Database, source_user_id, target_user_id); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.Social.RemoveFriendship(db, source_user_id, target_user_id); // TODO_SOCIAL: This tells the client to do a GET, we could just send them their friends list directly to reduce latency + calls to service SocialHelper.NotifyFriendslistDirty(source_user_id); @@ -267,6 +270,8 @@ public async Task AddFriend(Int64 target_user_id) } #pragma warning restore CS8602 // Dereference of a possibly null reference. + await using var db = await _dbFactory.CreateDbContextAsync(); + // the other user must be online, theres no way to add offline people in the client SharedUserData? TargetUserData = WebSocketManager.GetSharedDataForUser(target_user_id); @@ -297,7 +302,7 @@ public async Task AddFriend(Int64 target_user_id) userData.GetSocialContainer().Blocked.Remove(target_user_id); // - Remove from block list (DB) - await Database.Functions.Auth.RemoveBlock(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); + await Database.Social.RemoveBlock(db, requester_user_id, target_user_id); } // If the other user has a pending request to us, just accept it on both ends, they both want to be friends @@ -320,7 +325,7 @@ public async Task AddFriend(Int64 target_user_id) WebsocketHelper.SendToAllSessionsOfUser(target_user_id, bytesJSON); // add it to DB for target (if not already exists) - await Database.Functions.Auth.AddPendingFriendRequest(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); + await Database.Social.AddPendingFriendRequest(db, requester_user_id, target_user_id); } SocialHelper.NotifyFriendslistDirty(requester_user_id); @@ -531,10 +536,12 @@ public async Task Add_Block(Int64 target_user_id) //// - Remove from target friends, cache (if present) //// - Add to block list (cache) //// - Add to block list (DB) + /// + await using var db = await _dbFactory.CreateDbContextAsync(); // Remove from source friends, DB (if present) // Remove from target friends, DB (if present) - await Database.Functions.Auth.RemoveFriendship(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); + await Database.Social.RemoveFriendship(db, requester_user_id, target_user_id); // Remove from source friends, Cache (if present - Remove checks Contains) sourceData.GetSocialContainer().Friends.Remove(target_user_id); @@ -549,7 +556,7 @@ public async Task Add_Block(Int64 target_user_id) sourceData.GetSocialContainer().Blocked.Add(target_user_id); // Add to block list (db) - await Database.Functions.Auth.AddBlock(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); + await Database.Social.AddBlock(db, requester_user_id, target_user_id); SocialHelper.NotifyFriendslistDirty(requester_user_id); SocialHelper.NotifyFriendslistDirty(target_user_id); @@ -587,7 +594,8 @@ public async Task Remove_Block(Int64 target_user_id) sourceData.GetSocialContainer().Blocked.Remove(target_user_id); // - Remove from block list (DB) - await Database.Functions.Auth.RemoveBlock(GlobalDatabaseInstance.g_Database, requester_user_id, target_user_id); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.Social.RemoveBlock(db, requester_user_id, target_user_id); // only the source user needs an update here SocialHelper.NotifyFriendslistDirty(requester_user_id); diff --git a/GenOnlineService/Database/Database.Social.cs b/GenOnlineService/Database/Database.Social.cs new file mode 100644 index 0000000..3a08e62 --- /dev/null +++ b/GenOnlineService/Database/Database.Social.cs @@ -0,0 +1,216 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using GenOnlineService; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class FriendEntry +{ + public long UserId1 { get; set; } + public long UserId2 { get; set; } +} +public class BlockedUserEntry +{ + public long SourceUserId { get; set; } + public long TargetUserId { get; set; } +} + +public class FriendRequestEntry +{ + public long SourceUserId { get; set; } + public long TargetUserId { get; set; } +} + +public class FriendConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("friends"); + + builder.HasKey(f => new { f.UserId1, f.UserId2 }); + + builder.Property(f => f.UserId1) + .HasColumnName("user_id_1"); + + builder.Property(f => f.UserId2) + .HasColumnName("user_id_2"); + } +} + +public class FriendRequestConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("friends_requests"); + + builder.HasKey(f => new { f.SourceUserId, f.TargetUserId }); + + builder.Property(f => f.SourceUserId) + .HasColumnName("source_user_id"); + + builder.Property(f => f.TargetUserId) + .HasColumnName("target_user_id"); + } +} + + +public class BlockedUserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("friends_blocked"); + + builder.HasKey(f => new { f.SourceUserId, f.TargetUserId }); + + builder.Property(f => f.SourceUserId) + .HasColumnName("source_user_id"); + + builder.Property(f => f.TargetUserId) + .HasColumnName("target_user_id"); + } +} + + + + +namespace Database +{ + public static class Social + { + private static readonly Func> _getFriends = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.Friends + .Where(f => f.UserId1 == userId || f.UserId2 == userId) + ); + + private static readonly Func> _getBlocked = + EF.CompileAsyncQuery( + (AppDbContext db, long userId) => + db.BlockedUsers + .Where(b => b.SourceUserId == userId) + .Select(b => b.TargetUserId) + ); + private static readonly Func> _getPendingRequests = + EF.CompileAsyncQuery( + (AppDbContext db, long targetUserId) => + db.FriendRequests + .Where(r => r.TargetUserId == targetUserId) + .Select(r => r.SourceUserId) + ); + + + + + 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> 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> 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 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 CreateFriendship(AppDbContext db, long userId1, long userId2) + { + db.Friends.Add(new FriendEntry + { + UserId1 = userId1, + UserId2 = userId2 + }); + + await db.SaveChangesAsync(); + } + + 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 AddBlock(AppDbContext db, long sourceUserId, long targetUserId) + { + db.BlockedUsers.Add(new BlockedUserEntry + { + SourceUserId = sourceUserId, + TargetUserId = targetUserId + }); + + await db.SaveChangesAsync(); + } + + 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 AddPendingFriendRequest(AppDbContext db, long sourceUserId, long targetUserId) + { + db.FriendRequests.Add(new FriendRequestEntry + { + SourceUserId = sourceUserId, + TargetUserId = targetUserId + }); + + await db.SaveChangesAsync(); + } + + + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 23a9410..4b27a6b 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -33,6 +33,12 @@ public class AppDbContext : DbContext public DbSet MatchHistory => Set(); public DbSet UserStats => Set(); + public DbSet Friends => Set(); + + public DbSet BlockedUsers => Set(); + + public DbSet FriendRequests => Set(); + public AppDbContext(DbContextOptions options) : base(options) { @@ -53,5 +59,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new PendingLoginConfiguration()); modelBuilder.ApplyConfiguration(new MatchHistoryConfiguration()); modelBuilder.ApplyConfiguration(new UserStatsConfiguration()); + modelBuilder.ApplyConfiguration(new FriendConfiguration()); + modelBuilder.ApplyConfiguration(new FriendRequestConfiguration()); + modelBuilder.ApplyConfiguration(new BlockedUserConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs index ff57a39..f871101 100644 --- a/GenOnlineService/Database/MySQL.cs +++ b/GenOnlineService/Database/MySQL.cs @@ -193,144 +193,6 @@ public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, Kno } } - public async static Task> GetFriends(MySQLInstance m_Inst, Int64 user_id) - { - HashSet setFriends = new(); - - var res = await m_Inst.Query("SELECT user_id_1, user_id_2 FROM friends WHERE user_id_1=@user_id OR user_id_2=@user_id;", - new() - { - { "@user_id", user_id } - } - ); - - foreach (var row in res.GetRows()) - { - Int64 user_id_1 = Convert.ToInt64(row["user_id_1"]); - Int64 user_id_2 = Convert.ToInt64(row["user_id_2"]); - - if (user_id_1 == user_id) - { - setFriends.Add(user_id_2); - } - else - { - setFriends.Add(user_id_1); - } - - } - - return setFriends; - } - - public async static Task> GetBlocked(MySQLInstance m_Inst, Int64 source_user_id) - { - HashSet setBlocked = new(); - - var res = await m_Inst.Query("SELECT target_user_id FROM friends_blocked WHERE source_user_id=@source_user_id;", - new() - { - { "@source_user_id", source_user_id } - } - ); - - foreach (var row in res.GetRows()) - { - Int64 blocked_user_id = Convert.ToInt64(row["target_user_id"]); - setBlocked.Add(blocked_user_id); - } - - return setBlocked; - } - - public async static Task> GetPendingFriendsRequests(MySQLInstance m_Inst, Int64 target_user_id) - { - HashSet setRequests = new(); - - var res = await m_Inst.Query("SELECT source_user_id FROM friends_requests WHERE target_user_id=@target_user_id;", - new() - { - { "@target_user_id", target_user_id } - } - ); - - foreach (var row in res.GetRows()) - { - Int64 source_user_id = Convert.ToInt64(row["source_user_id"]); - setRequests.Add(source_user_id); - } - - return setRequests; - } - - public async static Task RemovePendingFriendRequest(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - // delete in either direction - var res = await m_Inst.Query("DELETE FROM friends_requests WHERE (source_user_id=@source_user_id AND target_user_id=@target_user_id) OR (source_user_id=@target_user_id AND target_user_id=@source_user_id) LIMIT 1;", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - - public async static Task CreateFriendship(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - var res = await m_Inst.Query("INSERT INTO friends(user_id_1, user_id_2) VALUES (@source_user_id, @target_user_id);", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - - public async static Task RemoveFriendship(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - var res = await m_Inst.Query("DELETE FROM friends WHERE (user_id_1=@source_user_id AND user_id_2=@target_user_id) OR (user_id_1=@target_user_id AND user_id_2=@source_user_id ) LIMIT 1;", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - - public async static Task AddBlock(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - var res = await m_Inst.Query("INSERT INTO friends_blocked(source_user_id, target_user_id) VALUES (@source_user_id, @target_user_id);", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - - public async static Task RemoveBlock(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - var res = await m_Inst.Query("DELETE FROM friends_blocked WHERE source_user_id=@source_user_id AND target_user_id=@target_user_id LIMIT 1;", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - - - public async static Task AddPendingFriendRequest(MySQLInstance m_Inst, Int64 source_user_id, Int64 target_user_id) - { - var res = await m_Inst.Query("INSERT INTO friends_requests(source_user_id, target_user_id) VALUES (@source_user_id, @target_user_id);", - new() - { - { "@source_user_id", source_user_id }, - { "@target_user_id", target_user_id } - } - ); - } - public enum EAccountType { Unknown = -1, From bddb7af0e5a874cd9c019ec67389761605a2b2b2 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:17:26 -0500 Subject: [PATCH 31/33] Legacy sql implementation fully removed --- GenOnlineService/Constants.cs | 100 ++- .../ConnectionOutcomeController.cs | 8 +- .../Controllers/Lobbies/LobbiesController.cs | 4 +- .../Controllers/Lobby/LobbyController.cs | 7 +- .../LoginWithTokenController.cs | 2 +- .../MatchReplay/MatchReplayController.cs | 2 +- .../MatchUpdate/MatchUpdateController.cs | 2 +- .../Matchmaking/MatchmakingController.cs | 6 +- .../Controllers/OID/OIDController.cs | 2 +- .../PlayerStats/PlayerStatsController.cs | 2 +- .../Controllers/User/UserController.cs | 2 +- .../Database/Database.ConnectionOutcomes.cs | 126 ++++ GenOnlineService/Database/Database.User.cs | 1 - GenOnlineService/Database/Database.cs | 2 + GenOnlineService/Database/MySQL.cs | 607 ------------------ GenOnlineService/Database/MySQLTypes.cs | 48 -- GenOnlineService/LobbyManager.cs | 2 - GenOnlineService/Program.cs | 33 - 18 files changed, 244 insertions(+), 712 deletions(-) create mode 100644 GenOnlineService/Database/Database.ConnectionOutcomes.cs delete mode 100644 GenOnlineService/Database/MySQL.cs delete mode 100644 GenOnlineService/Database/MySQLTypes.cs diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 26dae30..1ee0eba 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -30,7 +30,6 @@ using System.Text.Json; using System.Threading.Tasks; using ZstdSharp.Unsafe; -using static Database.Functions.Auth; namespace GenOnlineService { @@ -573,7 +572,7 @@ public static async Task ClearDataFromUser(Int64 userID, EUserSessionType { userData = m_dictUserSessions[sessionType][userID]; } - await Database.Functions.Auth.FullyDestroyPlayerSession(GlobalDatabaseInstance.g_Database, userID, userData, true); + await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); } catch { @@ -1110,9 +1109,102 @@ public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDes } } - public static class GlobalDatabaseInstance + public enum ESessionAccessType { - public static Database.MySQLInstance g_Database = new Database.MySQLInstance(); + Authenticate, // log in and out + Social, // friends lists + ServerListReadOnly, // can read lobby list and players etc, but cannot join + StatsReadOnly, // can read stats for any user, but not write anything + Gameplay, // Create lobbies, Anticheat, Middleware login, Matchmaking, match screenshots, replays, join lobby, etc + }; + public static class SessionHelpers + { + public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAccessType accessType) + { + if (sessType == EUserSessionType.GameClient) // client can do anything + { + return true; + } + else if (sessType == EUserSessionType.ChatClient) + { + return false; + } + + else if (sessType == EUserSessionType.GameLauncher) + { + return false; + } + + return false; + } + + public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent) + { + // NOTE: Dont assume userData is valid, use user_id for user id + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("FullyDestroyPlayerSession for user {0}", user_id); + Console.ForegroundColor = ConsoleColor.Gray; + + // invalidate any TURN credentials + TURNCredentialManager.DeleteCredentialsForUser(user_id); + + // TODO: Implement single point of presence? gets dicey if multiple logins + // TODO: Dont destroy this, just mark inactive/offline, we use this as a saved credential system + + // session tied to this token (keep other ones attached to user_id, could be other machines) + // TODO_JWT: Remove table fully + set logged out + //await m_Inst.Query("DELETE FROM sessions WHERE user_id={0} AND session_type={1};", user_id, (int)ESessionType.Game); + + // leave any lobby + Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); + + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + lobbyManager.LeaveAnyLobby(user_id); + + + await lobbyManager.CleanupUserLobbiesNotStarted(user_id); + + // remove from any matchmaking + if (userData != null) + { + MatchmakingManager.DeregisterPlayer(userData); + } + + // TODO: Client needs to handle this... itll start returning 404 + } + + public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) + { + // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match + // TODO_EFCORE: Move away from db for this and just have website login call endpoint on service + //UInt16 clientID = clientIDStr == "gen_online_60hz" ? (UInt16)1 : (UInt16)0; + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("StartSession deleing other sessions for user {0}", userID); + Console.ForegroundColor = ConsoleColor.Gray; + + // kill any WS they had too, StartSession comes before WS connects + // disconnect any other sessions with this ID + UserSession? sess = GenOnlineService.WebSocketManager.GetSessionFromUser(userID, sessionType); + if (sess != null) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("Found duplicate session for user {0}", userID); + Console.ForegroundColor = ConsoleColor.Gray; + + UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess); + await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false); + } + } + } + + public enum EAccountType + { + Unknown = -1, + Steam = 0, + Discord = 1, + Ghost = 2, + DevAccount = 3 } public class PlayerStats diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index bce4037..02f2edf 100644 --- a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs +++ b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs @@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Net; @@ -47,11 +48,13 @@ public class ConnectionOutcomeController : ControllerBase { private readonly ILogger _logger; private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; - public ConnectionOutcomeController(LobbyManager lobbyManager, ILogger logger) + public ConnectionOutcomeController(LobbyManager lobbyManager, ILogger logger, IDbContextFactory dbFactory) { _logger = logger; _lobbyManager = lobbyManager; + _dbFactory = dbFactory; } [HttpPost] @@ -129,7 +132,8 @@ public async Task Post() outcome = EConnectionState.NOT_CONNECTED; } - await Database.Functions.Auth.StoreConnectionOutcome(GlobalDatabaseInstance.g_Database, protocol, outcome); + await using var db = await _dbFactory.CreateDbContextAsync(); + await Database.ConnectionOutcomes.StoreConnectionOutcome(db, protocol, outcome); Response.StatusCode = (int)HttpStatusCode.OK; } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index f143312..124bac9 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -157,7 +157,7 @@ public async Task Get() Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.ServerListReadOnly)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.ServerListReadOnly)) { UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); @@ -362,7 +362,7 @@ public async Task Put() EUserSessionType sessionType = TokenHelper.GetSessionType(this); // check nullables also - if (user_id != -1 && strName != null && strMapName != null && strMapPath != null && strPassword != null && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && strName != null && strMapName != null && strMapPath != null && strPassword != null && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { // TODO: Handle failure here // TODO_ASP: Remove ip address from db, not needed diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index e305295..eedb746 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -29,7 +29,6 @@ using System.Security.Claims; using System.Text; using System.Text.Json; -using static Database.Functions; public class LatencyEntry { public Int64 user_id { get; set; } @@ -216,7 +215,7 @@ public async Task Delete(Int64 lobbyID) int leavingPersonSlot = -1; Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) @@ -292,7 +291,7 @@ public async Task Delete(Int64 lobbyID) { Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) @@ -696,7 +695,7 @@ public async Task Put(Int64 lobbyID) { Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UInt16 userPreferredPort = data["preferred_port"].GetUInt16(); bool bHasMap = data["has_map"].GetBoolean(); diff --git a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs index a48febe..2796259 100644 --- a/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs +++ b/GenOnlineService/Controllers/LoginWithToken/LoginWithTokenController.cs @@ -139,7 +139,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc); string strDisplayName = await Database.Users.GetDisplayName(db, user_id); - await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID, sessionType); + await SessionHelpers.SetUsedLoggedIn(user_id, clientID, sessionType); bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); diff --git a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs index a9e0d3d..7ab9724 100644 --- a/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs +++ b/GenOnlineService/Controllers/MatchReplay/MatchReplayController.cs @@ -92,7 +92,7 @@ public async Task Post() // must be in a lobby Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) // technically a duplicate check, since role above should also validate this, but just to be safe and avoid any weird edge cases where somehow we get here without a valid user session, etc + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) // technically a duplicate check, since role above should also validate this, but just to be safe and avoid any weird edge cases where somehow we get here without a valid user session, etc { UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) diff --git a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs index 8f4491f..af80ca5 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -225,7 +225,7 @@ public async Task Post() // must be in a lobby Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 0840dc0..097ed89 100644 --- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs +++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs @@ -77,7 +77,7 @@ public MatchmakingController(ILogger logger) Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); @@ -105,7 +105,7 @@ public void Put_Widen() // widen the search Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); ; @@ -122,7 +122,7 @@ public void Delete() { Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); diff --git a/GenOnlineService/Controllers/OID/OIDController.cs b/GenOnlineService/Controllers/OID/OIDController.cs index b7fd11e..0e5b004 100644 --- a/GenOnlineService/Controllers/OID/OIDController.cs +++ b/GenOnlineService/Controllers/OID/OIDController.cs @@ -224,7 +224,7 @@ public async Task Post() Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) // only game clients should be doing middleware login + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) // only game clients should be doing middleware login { UserSession? session = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (session != null) diff --git a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs index c49fa75..9099a11 100644 --- a/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs +++ b/GenOnlineService/Controllers/PlayerStats/PlayerStatsController.cs @@ -181,7 +181,7 @@ public async Task Put() Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Gameplay)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) { List? jsonReqData = JsonSerializer.Deserialize>(jsonData, options); diff --git a/GenOnlineService/Controllers/User/UserController.cs b/GenOnlineService/Controllers/User/UserController.cs index 76a94ce..3580fc0 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -135,7 +135,7 @@ public async Task Delete() Int64 user_id = TokenHelper.GetUserID(this); EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, SessionHelpers.ESessionAccessType.Authenticate)) + if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Authenticate)) { // TODO_JWT: Add token used to a 'ban list' //string token = ""; diff --git a/GenOnlineService/Database/Database.ConnectionOutcomes.cs b/GenOnlineService/Database/Database.ConnectionOutcomes.cs new file mode 100644 index 0000000..9fc16ef --- /dev/null +++ b/GenOnlineService/Database/Database.ConnectionOutcomes.cs @@ -0,0 +1,126 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using GenOnlineService; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class ConnectionOutcome +{ + public int DayOfYear { get; set; } + + public int? Ipv4Count { get; set; } + public int? Ipv6Count { get; set; } + public int? SuccessCount { get; set; } + public int? FailedCount { get; set; } +} + +public class ConnectionOutcomeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("connection_outcomes"); + + builder.HasKey(x => x.DayOfYear); + + builder.Property(x => x.DayOfYear) + .HasColumnName("day_of_year"); + + builder.Property(x => x.Ipv4Count) + .HasColumnName("ipv4_count"); + + builder.Property(x => x.Ipv6Count) + .HasColumnName("ipv6_count"); + + builder.Property(x => x.SuccessCount) + .HasColumnName("success_count"); + + builder.Property(x => x.FailedCount) + .HasColumnName("failed_count"); + } +} + + + +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(); + } + + } +} \ No newline at end of file diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 251af97..8445eb6 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -20,7 +20,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; using System.Text.Json; -using static Database.Functions.Auth; public class PendingLogin { diff --git a/GenOnlineService/Database/Database.cs b/GenOnlineService/Database/Database.cs index 4b27a6b..78402c1 100644 --- a/GenOnlineService/Database/Database.cs +++ b/GenOnlineService/Database/Database.cs @@ -38,6 +38,7 @@ public class AppDbContext : DbContext public DbSet BlockedUsers => Set(); public DbSet FriendRequests => Set(); + public DbSet ConnectionOutcomes => Set(); public AppDbContext(DbContextOptions options) : base(options) @@ -62,5 +63,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new FriendConfiguration()); modelBuilder.ApplyConfiguration(new FriendRequestConfiguration()); modelBuilder.ApplyConfiguration(new BlockedUserConfiguration()); + modelBuilder.ApplyConfiguration(new ConnectionOutcomeConfiguration()); } } \ No newline at end of file diff --git a/GenOnlineService/Database/MySQL.cs b/GenOnlineService/Database/MySQL.cs deleted file mode 100644 index f871101..0000000 --- a/GenOnlineService/Database/MySQL.cs +++ /dev/null @@ -1,607 +0,0 @@ -/* -** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour -** Copyright (C) 2025 GeneralsOnline Development Team -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU Affero General Public License as -** published by the Free Software Foundation, either version 3 of the -** License, or (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU Affero General Public License for more details. -** -** You should have received a copy of the GNU Affero General Public License -** along with this program. If not, see . -*/ - -#define USE_PER_QUERY_CONNECTION - -using Amazon.S3.Model; -using Discord; -using GenOnlineService; -using GenOnlineService.Controllers; -using Microsoft.AspNetCore.Connections.Features; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Hosting; -using MySql.Data.MySqlClient; -using MySqlX.XDevAPI; -using MySqlX.XDevAPI.Common; -using Sentry.Protocol; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Drawing; -using System.IO; -using System.Net; -using System.Net.WebSockets; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using System.Security.Policy; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using static Database.Functions; -using static Database.Functions.Auth; - -namespace Database -{ - public static class Functions - { - // TODO: Cleanup things when a user disconnects, e.g. lobby they're in etc - public static class Auth - { - public async static Task StoreConnectionOutcome(MySQLInstance m_Inst, EIPVersion protocol, EConnectionState outcome) - { - if (outcome != EConnectionState.CONNECTED_DIRECT && outcome != EConnectionState.CONNECTED_RELAY && outcome != EConnectionState.CONNECTION_FAILED) // states we dont track - { - return; - } - - // increment count - int day_of_year = DateTime.Now.DayOfYear; - - // these are used for creation, so we need to determine 0 1, if already exists, we increment instead - int create_ipv4_count = protocol == EIPVersion.IPV4 ? 1 : 0; - int create_ipv6_count = protocol == EIPVersion.IPV6 ? 1 : 0; - int create_success_count = (outcome == EConnectionState.CONNECTED_DIRECT || outcome == EConnectionState.CONNECTED_RELAY) ? 1 : 0; - int create_failed_count = (outcome == EConnectionState.CONNECTION_FAILED) ? 1 : 0; - - string onDupeAction = ""; - - // what action do we want? - if (protocol == EIPVersion.IPV4) - { - if (onDupeAction.Length > 0) - { - onDupeAction += ", "; - } - - onDupeAction += "ipv4_count=ipv4_count+1"; - } - else if (protocol == EIPVersion.IPV6) - { - if (onDupeAction.Length > 0) - { - onDupeAction += ", "; - } - - onDupeAction += "ipv6_count=ipv6_count+1"; - } - - // 2nd part of action - if (outcome == EConnectionState.CONNECTED_DIRECT || outcome == EConnectionState.CONNECTED_RELAY) - { - if (onDupeAction.Length > 0) - { - onDupeAction += ", "; - } - onDupeAction += "success_count=success_count+1"; - } - else if (outcome == EConnectionState.CONNECTION_FAILED) - { - if (onDupeAction.Length > 0) - { - onDupeAction += ", "; - } - onDupeAction += "failed_count=failed_count+1"; - } - - await m_Inst.Query(String.Format("INSERT INTO connection_outcomes SET day_of_year=@day_of_year, ipv4_count=@ipv4_count, ipv6_count=@ipv6_count, success_count=@success_count, failed_count=@failed_count ON DUPLICATE KEY UPDATE {0};", onDupeAction), - new() - { - { "@day_of_year", day_of_year }, - { "@ipv4_count", create_ipv4_count }, - { "@ipv6_count", create_ipv6_count }, - { "@success_count", create_success_count }, - { "@failed_count", create_failed_count } - } - ); - - // TODO_URGENT: Handle year roll over - await m_Inst.Query("DELETE FROM connection_outcomes WHERE day_of_year<(@day_of_year - 30);", - new() - { - { "@day_of_year", day_of_year } - } - ); - } - - - - public static async Task FullyDestroyPlayerSession(MySQLInstance m_Inst, Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent) - { - // NOTE: Dont assume userData is valid, use user_id for user id - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("FullyDestroyPlayerSession for user {0}", user_id); - Console.ForegroundColor = ConsoleColor.Gray; - - // invalidate any TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(user_id); - - // TODO: Implement single point of presence? gets dicey if multiple logins - // TODO: Dont destroy this, just mark inactive/offline, we use this as a saved credential system - - // session tied to this token (keep other ones attached to user_id, could be other machines) - // TODO_JWT: Remove table fully + set logged out - //await m_Inst.Query("DELETE FROM sessions WHERE user_id={0} AND session_type={1};", user_id, (int)ESessionType.Game); - - // leave any lobby - Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); - - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - lobbyManager.LeaveAnyLobby(user_id); - - - await lobbyManager.CleanupUserLobbiesNotStarted(user_id); - - // remove from any matchmaking - if (userData != null) - { - MatchmakingManager.DeregisterPlayer(userData); - } - - // TODO: Client needs to handle this... itll start returning 404 - } - - public async static Task SetUsedLoggedIn(MySQLInstance m_Inst, Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) - { - // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match - // TODO_EFCORE: Move away from db for this and just have website login call endpoint on service - //UInt16 clientID = clientIDStr == "gen_online_60hz" ? (UInt16)1 : (UInt16)0; - - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("StartSession deleing other sessions for user {0}", userID); - Console.ForegroundColor = ConsoleColor.Gray; - - // kill any WS they had too, StartSession comes before WS connects - // disconnect any other sessions with this ID - UserSession? sess = GenOnlineService.WebSocketManager.GetSessionFromUser(userID, sessionType); - if (sess != null) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("Found duplicate session for user {0}", userID); - Console.ForegroundColor = ConsoleColor.Gray; - - UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess); - await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false); - } - } - - public enum EAccountType - { - Unknown = -1, - Steam = 0, - Discord = 1, - Ghost = 2, - DevAccount = 3 - } - } - } - - // Updated MySQLInstance class to fix memory leaks by ensuring proper disposal of resources. - public class MySQLInstance : IDisposable - { - // Connection string is built once from config and reused across all concurrent queries. - // The MySQL connector's built-in connection pool (MySqlConnection with Pooling=true) is - // fully thread-safe: each call to OpenAsync() leases an independent physical connection - // from the pool, so queries on different threads never share a connection object. - private static string? _cachedConnectionString; - private static readonly object _connStringLock = new object(); - - private static string GetConnectionString() - { - if (_cachedConnectionString != null) - return _cachedConnectionString; - - lock (_connStringLock) - { - if (_cachedConnectionString != null) - return _cachedConnectionString; - - if (Program.g_Config == null) - throw new Exception("Config is null. Check config file exists."); - - IConfiguration? dbSettings = Program.g_Config.GetSection("Database"); - if (dbSettings == null) - throw new Exception("Database section in config is null / not set in config"); - - string? db_host = dbSettings.GetValue("db_host") ?? throw new Exception("DB Hostname is null / not set in config"); - string? db_name = dbSettings.GetValue("db_name") ?? throw new Exception("DB Name is null / not set in config"); - string? db_username = dbSettings.GetValue("db_username") ?? throw new Exception("DB Username is null / not set in config"); - string? db_password = dbSettings.GetValue("db_password") ?? throw new Exception("DB Password is null / not set in config"); - ushort db_port = dbSettings.GetValue("db_port"); - - int db_min_poolsize = dbSettings.GetValue("db_min_poolsize") ?? 50; - int db_max_poolsize = dbSettings.GetValue("db_max_poolsize") ?? 500; - bool db_use_pooling = dbSettings.GetValue("db_use_pooling") ?? true; - bool db_conn_reset = dbSettings.GetValue("db_conn_reset") ?? true; - int db_connect_timeout = dbSettings.GetValue("db_connect_timeout") ?? 10; - int db_command_timeout = dbSettings.GetValue("db_command_timeout") ?? 10; - - _cachedConnectionString = string.Format( - "Server={0}; database={1}; user={2}; password={3}; port={4};" + - "Pooling={5};DefaultCommandTimeout={9};Connect Timeout={10};" + - "MinimumPoolSize={6};maximumpoolsize={7};AllowUserVariables=true;ConnectionReset={8};", - db_host, db_name, db_username, db_password, db_port, - db_use_pooling, db_min_poolsize, db_max_poolsize, db_conn_reset, - db_command_timeout, db_connect_timeout); - - return _cachedConnectionString; - } - } - -#if !USE_PER_QUERY_CONNECTION - private MySqlConnection m_Connection = null; -#endif - - public MySQLInstance() - { - - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { -#if !USE_PER_QUERY_CONNECTION - if (m_Connection != null) - { - m_Connection.Dispose(); - m_Connection = null; - } -#endif - } - } - - // Written with Interlocked so concurrent threads don't race on a shared DateTime field. - private long m_LastQueryTimeTicks = DateTime.Now.Ticks; - - public async Task Initialize(WebApplicationBuilder builder, bool bIsStartup = true) - { - if (Program.g_Config == null) - { - throw new Exception("Config is null. Check config file exists."); - } - - IConfiguration? dbSettings = Program.g_Config.GetSection("Database"); - - if (dbSettings == null) - { - throw new Exception("Database section in config is null / not set in config"); - } - - string? hostname = dbSettings.GetValue("db_host"); - string? dbname = dbSettings.GetValue("db_name"); - string? username = dbSettings.GetValue("db_username"); - string? password = dbSettings.GetValue("db_password"); - UInt16? port = dbSettings.GetValue("db_port"); - - int? db_min_poolsize = dbSettings.GetValue("db_min_poolsize"); - int? db_max_poolsize = dbSettings.GetValue("db_max_poolsize"); - bool? db_use_pooling = dbSettings.GetValue("db_use_pooling"); - bool? db_conn_reset = dbSettings.GetValue("db_conn_reset"); - int? db_connect_timeout = dbSettings.GetValue("db_connect_timeout"); - int? db_command_timeout = dbSettings.GetValue("db_command_timeout"); - - if (hostname == null) - { - throw new Exception("DB Hostname is null / not set in config"); - } - - if (dbname == null) - { - throw new Exception("DB Hostname is null / not set in config"); - } - - if (username == null) - { - throw new Exception("DB Hostname is null / not set in config"); - } - - if (password == null) - { - throw new Exception("DB Hostname is null / not set in config"); - } - - if (port == null) - { - throw new Exception("DB Hostname is null / not set in config"); - } - - - if (!Directory.Exists("Exceptions")) - { - Directory.CreateDirectory("Exceptions"); - } - - // EFCore connect - { - var csb = new MySqlConnectionStringBuilder - { - Server = hostname, - Port = (uint)port, - Database = dbname, - UserID = username, - Password = password, - ConnectionTimeout = (uint)db_connect_timeout, - DefaultCommandTimeout = (uint)db_command_timeout, - SslMode = MySqlSslMode.Preferred - }; - - // TODO_EFCORE: Consider use of ExecuteDeleteAsync and options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - builder.Services.AddPooledDbContextFactory(options => - { - options.UseMySql( - csb.ConnectionString, - ServerVersion.AutoDetect(csb.ConnectionString)); - - options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - - }); - - } - - try - { - Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); - -#if !USE_PER_QUERY_CONNECTION - //m_Connection = new MySqlConnection(String.Format("Server={0}; database={1}; user={2}; password={3}; port={4};Pooling=true;Connect Timeout=10;MinimumPoolSize=1;maximumpoolsize=100;AllowUserVariables=true;ConnectionReset=false;SslMode=Required;", dbSettings)); - m_Connection = new MySqlConnection(String.Format("Server={0}; database={1}; user={2}; password={3}; port={4};Pooling=true;Connect Timeout=10;MinimumPoolSize=1;maximumpoolsize=100;AllowUserVariables=true;ConnectionReset=false;", hostname, dbname, username, password, port)); - - //Console.WriteLine(String.Format("Server={0}; database={1}; user={2}; password={3}; port={4};Pooling=true;Connect Timeout=100;MinimumPoolSize=1;maximumpoolsize=100;AllowUserVariables=true;ConnectionReset=false;SslMode=Required;", dbSettings)); - - Console.WriteLine("Connecting to DB..."); - await m_Connection.OpenAsync().ConfigureAwait(false); - - Console.WriteLine("Connected to: " + m_Connection.ServerVersion); - - - Console.WriteLine("MySQL Initialized"); - - var t = Database.Functions.Lobby.GetAllLobbyInfo(this, 0, true, true, true, true, true); - - List lstLobbies = await t; -#endif - - return true; - } - catch (MySqlException ex) - { - Console.WriteLine(ex.ToString()); - HandleMySqlException(ex, bIsStartup); - return false; - } - catch (InvalidOperationException ex) - { - Console.WriteLine(ex.ToString()); - Console.WriteLine("MySQL Connection Failed. Potentially Malformed Connection String."); - if (bIsStartup) - { - Console.WriteLine("\tPress any key to exit"); - Console.Read(); - Environment.Exit(1); - } - return false; - } - catch (Exception e) - { - Console.WriteLine(e.ToString()); - Console.WriteLine("\tPress any key to exit"); - return false; - } - } - - private void HandleMySqlException(MySqlException ex, bool bIsStartup) - { - File.WriteAllText(Path.Combine("Exceptions", "MYSQL_1_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ".txt"), ex.ToString()); - - switch (ex.Number) - { - case 0: - Console.WriteLine("MySQL Connection Failed. Cannot Connect to Server."); - break; - case 1: - Console.WriteLine("MySQL Connection Failed. Invalid username/password."); - break; - case 1042: - Console.WriteLine("MySQL Connection Failed. Connection Timed Out."); - break; - } - - if (bIsStartup) - { - Console.WriteLine("\tFATAL ERROR, Press any key to exit"); - Console.Read(); - Environment.Exit(1); - } - } - - private string EscapeAllAndFormatQuery(string strQuery, params object[] formatParams) - { - for (int i = 0; i < formatParams.Length; ++i) - { - if (formatParams[i].GetType() == typeof(string)) - { - formatParams[i] = MySqlHelper.EscapeString((string)formatParams[i]); - } - else if (formatParams[i].GetType().IsEnum) - { - formatParams[i] = (int)formatParams[i]; - } - } - - return String.Format(strQuery, formatParams); - } - - public async Task Query(string commandStr, Dictionary? dictCommandValues, int attempt = 0) - { - // After 3 attempts, give up. - if (attempt >= 3) - return new CMySQLResult(0); - - Interlocked.Exchange(ref m_LastQueryTimeTicks, DateTime.Now.Ticks); - - // Each call opens its own connection leased from the shared pool. - // No serializing lock is needed: MySqlConnection instances are never shared between callers. - try - { - using (var connection = new MySqlConnection(GetConnectionString())) - { - await connection.OpenAsync().ConfigureAwait(false); - - try - { - using (var command = new MySqlCommand(commandStr, connection)) - { - if (dictCommandValues != null) - { - foreach (var kvPair in dictCommandValues) - command.Parameters.AddWithValue(kvPair.Key, kvPair.Value); - } - - if (commandStr.ToUpper().StartsWith("DELETE") || commandStr.ToUpper().StartsWith("UPDATE")) - { - int numRowsModified = await command.ExecuteNonQueryAsync().ConfigureAwait(false); - return new CMySQLResult(numRowsModified); - } - else - { - using (System.Data.Common.DbDataReader reader = await command.ExecuteReaderAsync().ConfigureAwait(false)) - { - return new CMySQLResult(reader, (ulong)command.LastInsertedId); - } - } - } - } - catch (InvalidOperationException e) - { - string strExceptionMsg = e.InnerException != null ? e.InnerException.ToString() : e.Message; - Console.WriteLine("MySQL Query Error (will retry): {0}", strExceptionMsg); - File.WriteAllText(Path.Combine("Exceptions", "MYSQL_2_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ".txt"), "MySQL Query Error:" + strExceptionMsg); - - // The pool will surface a fresh physical connection on the next attempt. - return await Query(commandStr, dictCommandValues, attempt + 1).ConfigureAwait(false); - } - catch (MySqlException ex) - { - Console.WriteLine(ex.ToString()); - HandleMySqlException(ex, false); - } - catch (Exception e) - { - string strErrorMsg = string.Format("MySQL Query Error: {0}", e.Message); - Console.WriteLine(strErrorMsg); - File.WriteAllText(Path.Combine("Exceptions", "MYSQL_3_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ".txt"), strErrorMsg); - - if (System.Diagnostics.Debugger.IsAttached) - throw; - } - } - } - catch (Exception e) - { - string strErrorMsg = string.Format("MySQL Query Error: {0}", e.Message); - Console.WriteLine(strErrorMsg); - File.WriteAllText(Path.Combine("Exceptions", "MYSQL_4_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff") + ".txt"), strErrorMsg); - } - - return new CMySQLResult(0); - } - } - - public class CMySQLResult - { - public CMySQLResult(int rowsAffected) - { - m_RowsAffected = rowsAffected; - } - - public CMySQLResult(System.Data.Common.DbDataReader dbReader, ulong InsertID) - { - try - { - while (dbReader.Read()) - { - CMySQLRow thisRow = new CMySQLRow(); - for (int i = 0; i < dbReader.FieldCount; i++) - { - object? value = !dbReader.IsDBNull(i) ? dbReader.GetValue(i) : null; - string fieldName = dbReader.GetName(i); - thisRow[fieldName] = value; - } - m_Rows.Add(thisRow); - } - } - finally - { - dbReader.Close(); - dbReader.Dispose(); // Ensure proper disposal - } - - m_InsertID = InsertID; - m_RowsAffected = 0; - } - - public List GetRows() - { - return m_Rows; - } - - public CMySQLRow GetRow(int a_Index) - { - return m_Rows[a_Index]; - } - - public int NumRows() - { - return m_Rows.Count; - } - - public ulong GetInsertID() - { - return m_InsertID; - } - - public int GetNumRowsAffected() - { - return m_RowsAffected; - } - - private List m_Rows = new List(); - private readonly ulong m_InsertID = 0; - private readonly int m_RowsAffected = 0; - } -} diff --git a/GenOnlineService/Database/MySQLTypes.cs b/GenOnlineService/Database/MySQLTypes.cs deleted file mode 100644 index 7b92c4a..0000000 --- a/GenOnlineService/Database/MySQLTypes.cs +++ /dev/null @@ -1,48 +0,0 @@ -/* -** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour -** Copyright (C) 2025 GeneralsOnline Development Team -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU Affero General Public License as -** published by the Free Software Foundation, either version 3 of the -** License, or (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU Affero General Public License for more details. -** -** You should have received a copy of the GNU Affero General Public License -** along with this program. If not, see . -*/ - -using System; -using System.Collections.Generic; -using Dimension = System.UInt32; -using EntityDatabaseID = System.Int64; - -public class CMySQLRow -{ - public CMySQLRow() - { - - } - - public T? GetValue(string strKey) - { - return (T?)Convert.ChangeType(m_Fields[strKey], typeof(T?)); - } - - public Dictionary GetFields() - { - return m_Fields; - } - - public object? this[string strKey] - { - get => m_Fields[strKey]; - set => m_Fields[strKey] = value; - } - - private readonly Dictionary m_Fields = new Dictionary(); -} \ No newline at end of file diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index e7b3b16..334f771 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -34,8 +34,6 @@ using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; -using static Database.Functions; -using static Database.Functions.Auth; namespace GenOnlineService { diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 884209a..ef33b44 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -251,37 +251,6 @@ public static async Task Update(int numLobbies, int numPlayers) } } - public static class SessionHelpers - { - public enum ESessionAccessType - { - Authenticate, // log in and out - Social, // friends lists - ServerListReadOnly, // can read lobby list and players etc, but cannot join - StatsReadOnly, // can read stats for any user, but not write anything - Gameplay, // Create lobbies, Anticheat, Middleware login, Matchmaking, match screenshots, replays, join lobby, etc - }; - - public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAccessType accessType) - { - if (sessType == EUserSessionType.GameClient) // client can do anything - { - return true; - } - else if (sessType == EUserSessionType.ChatClient) - { - return false; - } - - else if (sessType == EUserSessionType.GameLauncher) - { - return false; - } - - return false; - } - } - public static class TokenHelper { public static Int64 GetUserID(ControllerBase controller) @@ -629,8 +598,6 @@ public static async Task Main(string[] args) g_Discord = new DiscordBot(); } - await GlobalDatabaseInstance.g_Database.Initialize(builder); - builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => From 2884b444ab68b61066b169f114e7673cc696274e Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:23:35 -0500 Subject: [PATCH 32/33] Cleaner db init --- GenOnlineService/Program.cs | 102 +++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index ef33b44..0e77526 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -329,14 +329,100 @@ public class Program public static DiscordBot? g_Discord = null; // TODO_EFCORE: Do this regularly - static async Task DoCleanup(bool bStartup) + static async Task DoCleanup(AppDbContext db, bool bStartup) { - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); await Database.PendingLogins.Cleanup(db, bStartup); } + private static async Task InitializeDatabase(WebApplicationBuilder builder) + { + // TODO_EFCORE: Check connection immediately like old impl + if (Program.g_Config == null) + { + throw new Exception("Config is null. Check config file exists."); + } + + IConfiguration? dbSettings = Program.g_Config.GetSection("Database"); + + if (dbSettings == null) + { + throw new Exception("Database section in config is null / not set in config"); + } + + string? hostname = dbSettings.GetValue("db_host"); + string? dbname = dbSettings.GetValue("db_name"); + string? username = dbSettings.GetValue("db_username"); + string? password = dbSettings.GetValue("db_password"); + UInt16? port = dbSettings.GetValue("db_port"); + + int? db_min_poolsize = dbSettings.GetValue("db_min_poolsize"); + int? db_max_poolsize = dbSettings.GetValue("db_max_poolsize"); + bool? db_use_pooling = dbSettings.GetValue("db_use_pooling"); + bool? db_conn_reset = dbSettings.GetValue("db_conn_reset"); + int? db_connect_timeout = dbSettings.GetValue("db_connect_timeout"); + int? db_command_timeout = dbSettings.GetValue("db_command_timeout"); + + if (hostname == null) + { + throw new Exception("DB Hostname is null / not set in config"); + } + + if (dbname == null) + { + throw new Exception("DB Hostname is null / not set in config"); + } + + if (username == null) + { + throw new Exception("DB Hostname is null / not set in config"); + } + + if (password == null) + { + throw new Exception("DB Hostname is null / not set in config"); + } + + if (port == null) + { + throw new Exception("DB Hostname is null / not set in config"); + } + + // TODO_EFCORE: Log exceptions to disk again + if (!Directory.Exists("Exceptions")) + { + Directory.CreateDirectory("Exceptions"); + } + + // EFCore connect + { + //var builder = WebApplication.CreateBuilder(args); + + var csb = new MySql.Data.MySqlClient.MySqlConnectionStringBuilder + { + Server = hostname, + Port = (uint)port, + Database = dbname, + UserID = username, + Password = password, + ConnectionTimeout = (uint)db_connect_timeout, + DefaultCommandTimeout = (uint)db_command_timeout, + SslMode = MySql.Data.MySqlClient.MySqlSslMode.Preferred + }; + + // TODO_EFCORE: Consider use of ExecuteDeleteAsync and options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + // TODO_EFCORE: Move to AddPooledDbContextFactory instead and use private readonly IDbContextFactory _factory; + builder.Services.AddPooledDbContextFactory(options => + { + options.UseMySql( + csb.ConnectionString, + ServerVersion.AutoDetect(csb.ConnectionString)); + + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + + }); + } + } + private static Task AdditionalValidation(TokenValidatedContext context) { //controller.User.Claims.First().Value @@ -878,6 +964,9 @@ public static async Task Main(string[] args) }); + // add DB + await InitializeDatabase(builder); + var app = builder.Build(); ServiceLocator.Services = app.Services; @@ -919,8 +1008,6 @@ public static async Task Main(string[] args) app.MapControllers(); - // do a cleanup on startup - await DoCleanup(true); // cleanup System.Timers.Timer timerCleanup = new System.Timers.Timer(5000); // 5s tick @@ -1061,6 +1148,9 @@ public static async Task Main(string[] args) { var factory = scope.ServiceProvider.GetRequiredService>(); await using var db = await factory.CreateDbContextAsync(); + + // do a cleanup on startup + await DoCleanup(db, true); await DailyStatsManager.LoadFromDB(db); } From d1dad5b10b05c169be97b94131d20cdc2a007559 Mon Sep 17 00:00:00 2001 From: x64-dev <202863051+x64-dev@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:26:46 -0500 Subject: [PATCH 33/33] Release build fixes --- .../CheckLogin/CheckLoginController.cs | 19 ++++++++----------- GenOnlineService/Database/Database.User.cs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 9df926f..65aadff 100644 --- a/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs +++ b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs @@ -150,19 +150,16 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); #else + EPendingLoginState? loginState = await Database.PendingLogins.GetPendingLoginState(db, gameCode.ToUpper()); - CMySQLResult sqlRes = await GlobalDatabaseInstance.g_Database.Query("SELECT state FROM pending_logins WHERE code=@game_code LIMIT 1;", new() - { - { "@game_code", gameCode.ToUpper()} - }); - if (sqlRes.NumRows() > 0) - { - EPendingLoginState state = (EPendingLoginState)Convert.ToInt32(sqlRes.GetRow(0)["state"]); + if (loginState != null) + { + EPendingLoginState state = loginState.Value; - Int64 user_id = await Database.PendingLogins.GetUserIDFromPendingLogin(_db, gameCode); - string strDisplayName = await Database.Users.GetDisplayName(_db, user_id); + Int64 user_id = await Database.PendingLogins.GetUserIDFromPendingLogin(db, gameCode); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); - bool bIsAdmin = await Database.Users.IsUserAdmin(_db, user_id); + bool bIsAdmin = await Database.Users.IsUserAdmin(db, user_id); #endif if (state == EPendingLoginState.Waiting) @@ -244,7 +241,7 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr await Database.PendingLogins.CleanupPendingLogin(db, gameCode); } #if !DEBUG - } + } #endif } else diff --git a/GenOnlineService/Database/Database.User.cs b/GenOnlineService/Database/Database.User.cs index 8445eb6..8b44101 100644 --- a/GenOnlineService/Database/Database.User.cs +++ b/GenOnlineService/Database/Database.User.cs @@ -174,6 +174,23 @@ namespace Database { public static class PendingLogins { + private static readonly Func> _getPendingLoginState = + EF.CompileAsyncQuery( + (AppDbContext db, string code) => + db.PendingLogins + .Where(p => p.LoginCode == code) + .Select(p => (EPendingLoginState?)p.State) + .FirstOrDefault() + ); + + public static async Task GetPendingLoginState(AppDbContext db, string gameCode) + { + string code = gameCode.ToUpper(); + return await _getPendingLoginState(db, code); + } + + + private static readonly Func> GetUserIdFromCode = EF.CompileAsyncQuery( (AppDbContext db, string code) =>