diff --git a/GenOnlineService/BackgroundS3Uploader.cs b/GenOnlineService/BackgroundS3Uploader.cs index d210030..3975278 100644 --- a/GenOnlineService/BackgroundS3Uploader.cs +++ b/GenOnlineService/BackgroundS3Uploader.cs @@ -3,11 +3,11 @@ using Amazon.S3; using Amazon.S3.Model; using GenOnlineService; +using Microsoft.EntityFrameworkCore; using MySqlX.XDevAPI.Common; using Sentry.Protocol; using System.Collections.Concurrent; using System.Net; -using static Database.Functions.Lobby; public enum ES3UploadType { @@ -100,7 +100,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 +188,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/Constants.cs b/GenOnlineService/Constants.cs index ca1ecfc..1ee0eba 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; @@ -29,7 +30,6 @@ using System.Text.Json; using System.Threading.Tasks; using ZstdSharp.Unsafe; -using static Database.Functions.Auth; namespace GenOnlineService { @@ -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, @@ -146,23 +116,92 @@ 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, + superhackers_community_patch_client = 4, + custom_third_party_client = 5 + } + + 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, + [EKnownClients.superhackers_community_patch_client] = EUserSessionType.GameClient, + [EKnownClients.custom_third_party_client] = EUserSessionType.GameClient + }; + } + + + // TODO 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, 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.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); + 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; } @@ -171,37 +210,71 @@ public static async Task CreateSession(bool bIsReconnect, // 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); - 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.Functions.Auth.GetPlayerStats(GlobalDatabaseInstance.g_Database, ownerID); + PlayerStats GameStats = await Database.UserStats.GetPlayerStats(_db, 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.Users.UpdateLastLoginData(_db, ownerID, ipAddr); int numSessions = m_dictWebsockets.Count; if (numSessions > g_PeakConnectionCount) @@ -211,14 +284,16 @@ public static async Task CreateSession(bool bIsReconnect, 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; } @@ -246,65 +321,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 { @@ -314,7 +410,7 @@ public static async Task DeleteSession(Int64 user_id, UserWebSocketInstance? old if (bShouldInvalidatePlayerCacheToBlockReconnect) { - WebSocketManager.ClearDataFromUser(user_id); + WebSocketManager.ClearDataFromUser(user_id, sessionType); } else { @@ -325,29 +421,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); } } } @@ -381,9 +471,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; } @@ -394,18 +485,46 @@ 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_dictUserSessions.TryGetValue(userID, out UserSession? retVal)) + if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? retVal)) { return retVal; } @@ -415,24 +534,52 @@ public static ConcurrentDictionary GetUserDataCache() } } - public static async Task ClearDataFromUser(Int64 userID) + public static UserSession? GetSessionFromUser(Int64 userID, EUserSessionType sessionType) + { + if (m_dictUserSessions[sessionType].TryGetValue(userID, out UserSession? retVal)) + { + return retVal; + } + else + { + return null; + } + } + + 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); + await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); } catch { } - return m_dictUserSessions.Remove(userID, out var itemRemoved); + return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); } @@ -447,13 +594,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); + } } } } @@ -471,29 +621,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); + } } } } @@ -501,10 +675,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); + } + } } } @@ -517,15 +700,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; @@ -544,7 +777,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() { @@ -561,6 +794,11 @@ public string GetMiddlewareID() return m_strMiddlewareUserID; } + public EUserSessionType GetSessionType() + { + return m_sessionType; + } + public UInt64 GetLatestMatchID() { UInt64 mostRecentMatchID = 0; @@ -577,15 +815,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; @@ -595,10 +832,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() @@ -615,6 +848,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) @@ -658,34 +892,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) { @@ -767,7 +979,10 @@ public async Task UpdateSessionNetworkRoom(Int16 newRoomID) public void UpdateSessionLobbyID(Int64 newLobbyID) { - currentLobbyID = newLobbyID; + if (m_sessionType == EUserSessionType.GameClient) + { + currentLobbyID = newLobbyID; + } } // network room @@ -781,6 +996,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 @@ -802,31 +1018,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) @@ -914,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 @@ -1895,45 +2183,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) { - Lobby? plrLobby = LobbyManager.GetLobby(userData.currentLobbyID); + string strThisPresence = "Offline"; + EPresencePriority thisPriority = EPresencePriority.Lowest; + + if (userData == null) + { + thisPriority = EPresencePriority.Lowest; + strThisPresence = "Offline"; + } + + IsOnline = true; - if (plrLobby == null) + 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/CheckLogin/CheckLoginController.cs b/GenOnlineService/Controllers/CheckLogin/CheckLoginController.cs index 5c25e91..65aadff 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,9 +49,11 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class CheckLoginController : ControllerBase { - public CheckLoginController() - { + private readonly IDbContextFactory _dbFactory; + public CheckLoginController(IDbContextFactory dbFactory) + { + _dbFactory = dbFactory; } [HttpPost] @@ -102,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); } @@ -123,12 +128,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; + } } } @@ -137,26 +145,21 @@ 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.Functions.Auth.IsUserAdmin(GlobalDatabaseInstance.g_Database, user_id); + 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.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); + Int64 user_id = await Database.PendingLogins.GetUserIDFromPendingLogin(db, gameCode); + 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) @@ -171,7 +174,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; @@ -179,22 +182,26 @@ 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"; 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"; 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; @@ -204,7 +211,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) { @@ -216,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; } @@ -231,10 +238,10 @@ 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 - } + } #endif } else diff --git a/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs b/GenOnlineService/Controllers/ConnectionOutcome/ConnectionOutcomeController.cs index 39e1997..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; @@ -39,16 +40,21 @@ public override Type GetReturnType() public bool success { get; set; } = false; } + // TODO_EFCORE: Move to a publish/subscribe model for rooms [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class ConnectionOutcomeController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; - public ConnectionOutcomeController(ILogger logger) + public ConnectionOutcomeController(LobbyManager lobbyManager, ILogger logger, IDbContextFactory dbFactory) { _logger = logger; + _lobbyManager = lobbyManager; + _dbFactory = dbFactory; } [HttpPost] @@ -82,7 +88,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) { @@ -126,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/Friends/SocialController.cs b/GenOnlineService/Controllers/Friends/SocialController.cs index b7edf12..3c4ba57 100644 --- a/GenOnlineService/Controllers/Friends/SocialController.cs +++ b/GenOnlineService/Controllers/Friends/SocialController.cs @@ -17,12 +17,15 @@ */ using Amazon.S3.Model; +using Discord.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; using System.Net; +using System.Net.NetworkInformation; using System.Net.WebSockets; using System.Security.Claims; using System.Text; @@ -64,11 +67,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class SocialController : ControllerBase { + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public SocialController(ILogger logger) + public SocialController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _dbFactory = dbFactory; } // Friends/Requests/ @@ -78,70 +83,74 @@ 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); + + await using var db = await _dbFactory.CreateDbContextAsync(); + // 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) - 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 { // 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 [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 + // 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,27 +158,18 @@ 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}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] 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,35 +177,27 @@ 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. // 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); - 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 [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 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 +205,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,35 +217,29 @@ 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); } // 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 - 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}")] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] 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 +247,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 +255,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); } } @@ -282,9 +270,11 @@ 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 - UserSession? TargetUserData = WebSocketManager.GetDataFromUser(target_user_id); + SharedUserData? TargetUserData = WebSocketManager.GetSharedDataForUser(target_user_id); if (TargetUserData == null) { Response.StatusCode = (int)HttpStatusCode.NotFound; @@ -312,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 @@ -326,33 +316,24 @@ 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); + await Database.Social.AddPendingFriendRequest(db, 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")] + [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) @@ -360,14 +341,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; @@ -378,7 +359,8 @@ public async Task Get_FriendsAndRequests() lstCombined.AddRange(setFriends); lstCombined.AddRange(setPendingRequests); - Dictionary dictDisplayNames = await Database.Functions.Auth.GetDisplayNameBulk(GlobalDatabaseInstance.g_Database, lstCombined); + await using var db = await _dbFactory.CreateDbContextAsync(); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(db, lstCombined); var options = new JsonSerializerOptions { @@ -410,16 +392,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? - UserSession? targetUserData = WebSocketManager.GetDataFromUser(friend_user_id); + string strPresence = UserPresence.DetermineUserStatusFromAllSessions(friend_user_id, out bool isOnline); - string strPresence = targetUserData != null ? UserPresence.DetermineUserStatus(targetUserData) : "Offline"; - result.friends.Add(new FriendEntry() { user_id = friend_user_id, display_name = dictDisplayNames[friend_user_id], - online = targetUserData != null, + online = isOnline, presence = strPresence }); } @@ -450,7 +429,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) @@ -458,19 +437,20 @@ 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; #pragma warning restore CS8602 // Dereference of a possibly null reference. - Dictionary dictDisplayNames = await Database.Functions.Auth.GetDisplayNameBulk(GlobalDatabaseInstance.g_Database, setBlocked.ToList()); + await using var db = await _dbFactory.CreateDbContextAsync(); + Dictionary dictDisplayNames = await Database.Users.GetDisplayNameBulk(db, setBlocked.ToList()); var options = new JsonSerializerOptions { @@ -515,19 +495,19 @@ 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 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 +517,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()) @@ -556,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); @@ -574,22 +556,15 @@ 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); - - if (sourceData != null) - { - sourceData.NotifyFriendslistDirty(); - } + await Database.Social.AddBlock(db, requester_user_id, target_user_id); - if (targetData != null) - { - targetData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(requester_user_id); + SocialHelper.NotifyFriendslistDirty(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: @@ -598,13 +573,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. @@ -619,13 +594,11 @@ 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 - if (sourceData != null) - { - sourceData.NotifyFriendslistDirty(); - } - } + SocialHelper.NotifyFriendslistDirty(requester_user_id); + } } } diff --git a/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs b/GenOnlineService/Controllers/GlobalStats/GlobalStatsController.cs index 1fb957d..89dc432 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] @@ -47,12 +47,12 @@ 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(); - result.globalstats = DailyStatsManager.g_Stats; + result.globalstats = DailyStatsManager.g_StatsContainer.Stats; return result; } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 5fd6d62..124bac9 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; @@ -63,9 +64,15 @@ 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; + private readonly IDbContextFactory _dbFactory; + + + public LobbiesController(LobbyManager lobbyManager, IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; + _dbFactory = dbFactory; } // Cache rooms.json data to avoid disk I/O on every request @@ -124,7 +131,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(); @@ -149,9 +156,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, ESessionAccessType.ServerListReadOnly)) { - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { @@ -182,7 +190,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(); @@ -191,7 +199,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) { @@ -252,7 +260,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 { @@ -275,7 +283,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(); @@ -351,23 +359,26 @@ 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, 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) { // cleanup any zombie lobbies - await LobbyManager.CleanupUserLobbiesNotStarted(user_id); + await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); + + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDisplayName = await Database.Users.GetDisplayName(db, 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(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 1664707..eedb746 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; @@ -28,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; } @@ -142,14 +142,18 @@ public override Type GetReturnType() public class LobbyController : ControllerBase { private readonly ILogger _logger; + private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; - public LobbyController(ILogger logger) + public LobbyController(LobbyManager lobbyManager, IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _lobbyManager = lobbyManager; + _dbFactory = dbFactory; } [HttpGet("{lobby_id}")] - [Authorize(Roles = "Player,Monitor")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher,Monitor")] public async Task Get(string lobby_id) { RouteHandler_GET_Lobby_Result result = new RouteHandler_GET_Lobby_Result(); @@ -167,7 +171,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; } @@ -191,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(); @@ -210,9 +214,10 @@ 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, ESessionAccessType.Gameplay)) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(lobbyID); if (lobby != null) { foreach (var member in lobby.Members) @@ -228,13 +233,13 @@ 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); // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetDataFromUser(user_id); + UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (sourceData != null) { @@ -256,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)) @@ -285,32 +290,37 @@ 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, 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 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); + } } } } @@ -354,7 +364,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(); @@ -382,7 +392,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) { @@ -419,7 +429,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") @@ -433,7 +443,8 @@ public async Task Post(Int64 lobbyID) if (strMap != null && strMapPath != null) { - await lobby.UpdateMap(strMap, strMapPath, bOfficialMap, maxPlayers); + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateMap(db, strMap, strMapPath, bOfficialMap, maxPlayers); } } } @@ -445,7 +456,9 @@ 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 using var db = await _dbFactory.CreateDbContextAsync(); + await SourceMember.UpdateSide(db, side, start_pos); } } else if (field == ELobbyUpdateField.MY_COLOR) @@ -453,7 +466,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("color")) { int color = data["color"].GetInt32(); - await SourceMember.UpdateColor(color); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await SourceMember.UpdateColor(db, color); } } else if (field == ELobbyUpdateField.MY_START_POS) @@ -477,7 +492,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("startingcash")) { UInt32 startingCash = data["startingcash"].GetUInt32(); - await lobby.UpdateStartingCash(startingCash); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateStartingCash(db, startingCash); } } else if (field == ELobbyUpdateField.LOBBY_LIMIT_SUPERWEAPONS) @@ -485,7 +502,9 @@ public async Task Post(Int64 lobbyID) if (data.ContainsKey("limit_superweapons")) { bool bLimitSuperweapons = data["limit_superweapons"].GetBoolean(); - await lobby.UpdateLimitSuperweapons(bLimitSuperweapons); + + await using var db = await _dbFactory.CreateDbContextAsync(); + await lobby.UpdateLimitSuperweapons(db, bLimitSuperweapons); } } else if (field == ELobbyUpdateField.HOST_ACTION_FORCE_START) @@ -509,13 +528,13 @@ 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); // 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) { @@ -554,7 +573,8 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateSide(side, start_pos); + await using var db = await _dbFactory.CreateDbContextAsync(); + await TargetMember.UpdateSide(db, side, start_pos); } } } @@ -572,12 +592,13 @@ public async Task Post(Int64 lobbyID) { if (TargetMember.IsAI()) { - await TargetMember.UpdateColor(color); + await using var db = await _dbFactory.CreateDbContextAsync(); + await TargetMember.UpdateColor(db, color); } } } } - 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")) @@ -595,12 +616,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(); @@ -643,7 +664,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(); @@ -668,12 +689,13 @@ public async Task Put(Int64 lobbyID) ) { - Lobby? lobby = LobbyManager.GetLobby(lobbyID); + Lobby? lobby = _lobbyManager.GetLobby(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, ESessionAccessType.Gameplay)) { UInt16 userPreferredPort = data["preferred_port"].GetUInt16(); bool bHasMap = data["has_map"].GetBoolean(); @@ -707,15 +729,16 @@ public async Task Put(Int64 lobbyID) } } - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); 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); + 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 443d151..2796259 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; @@ -45,14 +46,15 @@ public override Type GetReturnType() } [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class LoginWithToken : ControllerBase { + private readonly IDbContextFactory _dbFactory; - public LoginWithToken() + public LoginWithToken(IDbContextFactory dbFactory) { - + _dbFactory = dbFactory; } [HttpPost(Name = "PostLoginWithToken")] @@ -92,80 +94,76 @@ public async Task Post_InternalHandler(string jsonData, string ipAddr { var data = JsonSerializer.Deserialize>(jsonData, options); - if (data != null && !data.ContainsKey("client_id")) + 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) - { - // start their session etc - Int64 user_id = TokenHelper.GetUserID(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.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 - bool bIsBanned = await Database.Functions.Auth.IsUserBanned(GlobalDatabaseInstance.g_Database, 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.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, user_id); - await Database.Functions.Auth.SetUsedLoggedIn(GlobalDatabaseInstance.g_Database, user_id, clientID); + string strDisplayName = await Database.Users.GetDisplayName(db, user_id); + await SessionHelpers.SetUsedLoggedIn(user_id, clientID, sessionType); - 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; + 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/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 67cb0a0..7ab9724 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 { @@ -48,15 +47,17 @@ 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] [RequestSizeLimit(2097152)] // 2MB - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Post() { RouteHandler_POST_Lobby_Result result = new RouteHandler_POST_Lobby_Result(); @@ -90,15 +91,16 @@ 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, 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 // 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..af80ca5 100644 --- a/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs +++ b/GenOnlineService/Controllers/MatchUpdate/MatchUpdateController.cs @@ -16,21 +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 +117,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 +143,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 +167,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; } } @@ -171,15 +178,17 @@ 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] [RequestSizeLimit(2097152)] // 2MB - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Post() { this.HttpContext.Request.EnableBuffering(); @@ -215,13 +224,14 @@ 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, 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 - 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/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 2554fc4..097ed89 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)) @@ -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, ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { @@ -97,15 +98,16 @@ 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 // 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, ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); ; if (playerSession != null) { @@ -115,13 +117,14 @@ public void Put_Widen() } [HttpDelete] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] 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, ESessionAccessType.Gameplay)) { - UserSession? playerSession = WebSocketManager.GetDataFromUser(user_id); ; + UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); if (playerSession != null) { @@ -142,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/Monitoring/MonitoringController.cs b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs index 51245b5..266bbdc 100644 --- a/GenOnlineService/Controllers/Monitoring/MonitoringController.cs +++ b/GenOnlineService/Controllers/Monitoring/MonitoringController.cs @@ -19,9 +19,11 @@ 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; +using System.Collections.Concurrent; using System.Net; using System.Net.WebSockets; using System.Security.Claims; @@ -62,7 +64,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; } } @@ -91,11 +93,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class MonitoringController : ControllerBase { + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public MonitoringController(ILogger logger) + public MonitoringController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _dbFactory = dbFactory; } [Route("ActiveUsers")] @@ -117,16 +121,31 @@ 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) + HashSet setUsersAlreadyProcessed = new(); + 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) + { + 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) + { + GET_ActiveUsers_UserEntry userEntry = new(); + userEntry.name = userSharedData.m_strDisplayName; + userEntry.status = UserPresence.DetermineUserStatusFromAllSessions(sessionData.Key, out bool isOnline); + userEntry.client_id = sessionData.Value.m_client_id; + userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); + + result.active_users.Add(userEntry); + } + } } @@ -149,7 +168,8 @@ public async Task Monitor_Database() // db call try { - string strDontCare = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, 0); + await using var db = await _dbFactory.CreateDbContextAsync(); + string strDontCare = await Database.Users.GetDisplayName(db, 0); result.ok = true; } catch @@ -175,7 +195,10 @@ public async Task Monitor_Database() // db call try { - GenOnlineService.Controllers.LoginWithToken.LoginWithToken loginWithTokenController = new GenOnlineService.Controllers.LoginWithToken.LoginWithToken(); + using var scope = ServiceLocator.Services.CreateScope(); + 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; } @@ -253,7 +276,7 @@ public APIResult Monitor_Uptime() { try { - GenOnlineService.Controllers.CheckLoginController checkLoginController = new GenOnlineService.Controllers.CheckLoginController(); + 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/OID/OIDController.cs b/GenOnlineService/Controllers/OID/OIDController.cs index 19a6eae..0e5b004 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; @@ -218,10 +223,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, 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..9099a11 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,11 +70,13 @@ public override Type GetReturnType() [Route("env/{environment}/contract/{contract_version}/[controller]")] public class PlayerStatsController : ControllerBase { + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public PlayerStatsController(ILogger logger) + public PlayerStatsController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _dbFactory = dbFactory; } [HttpGet("{userID}")] @@ -89,13 +92,14 @@ 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); + await using var db = await _dbFactory.CreateDbContextAsync(); + PlayerStats playerStats = await Database.UserStats.GetPlayerStats(db, userID); if (playerStats == null) { @@ -108,19 +112,19 @@ 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; } // 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(); @@ -140,26 +144,26 @@ 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; } [HttpPut] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient")] public async Task Put() { RouteHandler_PUT_PlayerStats_Result result = new RouteHandler_PUT_PlayerStats_Result(); @@ -176,7 +180,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, ESessionAccessType.Gameplay)) { List? jsonReqData = JsonSerializer.Deserialize>(jsonData, options); @@ -196,17 +201,18 @@ 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. } } - 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/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 ea98802..3580fc0 100644 --- a/GenOnlineService/Controllers/User/UserController.cs +++ b/GenOnlineService/Controllers/User/UserController.cs @@ -19,7 +19,9 @@ 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; using System.Security.Claims; using System.Text; @@ -38,18 +40,20 @@ public override Type GetReturnType() } [ApiController] - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [Route("env/{environment}/contract/{contract_version}/[controller]")] public class UsersController : ControllerBase { + private readonly IDbContextFactory _dbFactory; private readonly ILogger _logger; - public UsersController(ILogger logger) + public UsersController(IDbContextFactory dbFactory, ILogger logger) { _logger = logger; + _dbFactory = dbFactory; } - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [HttpGet("Me")] public async Task MyUser() { @@ -59,7 +63,8 @@ public async Task MyUser() if (user_id != -1) { - string strDisplayName = await Database.Functions.Auth.GetDisplayName(GlobalDatabaseInstance.g_Database, 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; @@ -68,7 +73,7 @@ public async Task MyUser() return result; } - [Authorize(Roles = "Player")] + [Authorize(Roles = "GameClient,ChatClient,GameLauncher")] [HttpGet("Active")] public APIResult ActiveUsers() { @@ -86,16 +91,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.DetermineUserStatusFromAllSessions(sessionData.Key, out bool isOnline); + userEntry.client_id = sessionData.Value.m_client_id; + userEntry.duration = TimeSpanToHumanReadableString(sessionData.Value.GetDuration()); + + result.active_users.Add(userEntry); + } + } } @@ -104,7 +116,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 { @@ -122,17 +134,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, 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 a716864..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; @@ -31,6 +32,15 @@ namespace GenOnlineService.Controllers { public class WebSocketController : ControllerBase { + private readonly LobbyManager _lobbyManager; + private readonly IDbContextFactory _dbFactory; + + public WebSocketController(LobbyManager lobbyManager, IDbContextFactory dbFactory) + { + _lobbyManager = lobbyManager; + _dbFactory = dbFactory; + } + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true, @@ -46,7 +56,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) @@ -98,8 +108,27 @@ 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; + } + + await using var db = await _dbFactory.CreateDbContextAsync(); UserWebSocketInstance wsSess = await WebSocketManager.CreateSession( + db, + EUserSessionType.GameClient, bIsReconnect, user_id, client_id, @@ -165,19 +194,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) @@ -201,9 +230,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; } @@ -274,26 +305,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 @@ -331,19 +361,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; } } @@ -354,18 +384,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); + } + } } } } @@ -373,7 +411,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); } } } @@ -395,7 +433,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); @@ -435,16 +473,17 @@ 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; + 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); } - } + } } 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 +502,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 +526,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) { @@ -498,7 +537,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) { @@ -506,7 +545,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; @@ -549,7 +588,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 +611,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) { @@ -597,12 +636,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); + } + } } } } @@ -615,7 +659,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) { @@ -641,12 +685,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); + } + } } } } @@ -659,7 +708,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); @@ -679,10 +728,10 @@ 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); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { @@ -723,10 +772,10 @@ 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); + Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); if (lobby != null) { 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.DailyStats.cs b/GenOnlineService/Database/Database.DailyStats.cs new file mode 100644 index 0000000..5bd047c --- /dev/null +++ b/GenOnlineService/Database/Database.DailyStats.cs @@ -0,0 +1,133 @@ +/* +** 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 +{ + 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 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(); + + 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(); + } + } + + // TODO_EFCORE: This can be optimized + public static async Task SaveToDB(AppDbContext db) + { + int day_of_year = DateTime.Now.DayOfYear; + + var entity = await db.DailyStats.AsTracking() + .FirstOrDefaultAsync(x => x.DayOfYear == day_of_year); + + // Insert if new, otherwise update + if (entity == null) + { + entity = g_StatsContainer; + db.DailyStats.Add(entity); + } + 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.Leaderboards.cs b/GenOnlineService/Database/Database.Leaderboards.cs new file mode 100644 index 0000000..e2621c2 --- /dev/null +++ b/GenOnlineService/Database/Database.Leaderboards.cs @@ -0,0 +1,369 @@ +/* +** 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; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +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"); + } +} + + +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) + }) + ); + } + + 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(); + + 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.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs new file mode 100644 index 0000000..ceab442 --- /dev/null +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -0,0 +1,990 @@ +/* +** 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.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +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 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; } + 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 + + 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 readonly Func> _getAllMemberSlots = + EF.CompileAsyncQuery( + (AppDbContext db, long matchId) => + db.MatchHistory + .Where(m => m.MatchId == matchId) + .Select(m => new string?[] + { + m.MemberSlot0, + m.MemberSlot1, + m.MemberSlot2, + m.MemberSlot3, + m.MemberSlot4, + m.MemberSlot5, + m.MemberSlot6, + m.MemberSlot7 + }) + .FirstOrDefault() + ); + + + private static Expression, SetPropertyCalls>> + BuildSetter(int slotIndex, string? json) + { + var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); + + var call = Expression.Call( + param, + nameof(SetPropertyCalls.SetProperty), + typeArguments: null, + arguments: new Expression[] + { + _slotSelectors[slotIndex], + Expression.Constant(json, typeof(string)) + } + ); + + return Expression.Lambda, SetPropertyCalls>>( + call, + param + ); + } + + + 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 Expression, SetPropertyCalls>> + BuildWinnerSetter(int slotIndex, string updatedJson) + { + var param = Expression.Parameter(typeof(SetPropertyCalls), "s"); + + var call = Expression.Call( + param, + nameof(SetPropertyCalls.SetProperty), + typeArguments: null, + arguments: new Expression[] + { + _slotSelectors[slotIndex], + Expression.Constant(updatedJson, typeof(string)) + } + ); + + return Expression.Lambda, SetPropertyCalls>>( + call, + param + ); + } + + + private static readonly Func> _getMemberSlot = + EF.CompileAsyncQuery( + (AppDbContext db, long matchId, int slotIndex) => + db.MatchHistory + .Where(m => m.MatchId == matchId) + .Select(_slotSelectors[slotIndex]) + .FirstOrDefault() + ); + + + + private static 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 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) + { + 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; + } + + // 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/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.ServiceStats.cs b/GenOnlineService/Database/Database.ServiceStats.cs new file mode 100644 index 0000000..8251626 --- /dev/null +++ b/GenOnlineService/Database/Database.ServiceStats.cs @@ -0,0 +1,115 @@ +/* +** 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 }); + + 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> FindStatTracked = + EF.CompileAsyncQuery( + (AppDbContext db, int day, int hour) => + db.ServiceStats.AsTracking().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 FindStatTracked(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); + } + + // NOTE: duplicate, unnecessary + //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.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.User.cs b/GenOnlineService/Database/Database.User.cs new file mode 100644 index 0000000..8b44101 --- /dev/null +++ b/GenOnlineService/Database/Database.User.cs @@ -0,0 +1,535 @@ +/* +** 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; + +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; } + + 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; } + 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 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; +} + +// TODO_EFCORE: add index for code +public class PendingLoginConfiguration : IEntityTypeConfiguration +{ + 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)"); + builder.Property(e => e.Created).HasColumnName("created"); + } +} + +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_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)"); + } +} + +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_aliases").HasColumnType("varchar(50)"); ; + } +} + +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) => + 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 = + 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> 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 + .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()); + + private static readonly Func> _getDisplayNameQuery = + EF.CompileAsyncQuery((AppDbContext db, long userId) => + db.Users + .AsNoTracking() + .Where(u => u.ID == userId) + .Select(u => u.DisplayName) + .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 + .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() + ); + + 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( + (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) + { + 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); + } + + + 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); + } + + // 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 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, + string strMap) + { + 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, + 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 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, + int 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 new file mode 100644 index 0000000..78402c1 --- /dev/null +++ b/GenOnlineService/Database/Database.cs @@ -0,0 +1,68 @@ +/* +** 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 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 DbSet PendingLogins => Set(); + public DbSet MatchHistory => Set(); + public DbSet UserStats => Set(); + + public DbSet Friends => Set(); + + public DbSet BlockedUsers => Set(); + + public DbSet FriendRequests => Set(); + public DbSet ConnectionOutcomes => Set(); + + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Fluent configuration here + 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()); + modelBuilder.ApplyConfiguration(new PendingLoginConfiguration()); + modelBuilder.ApplyConfiguration(new MatchHistoryConfiguration()); + modelBuilder.ApplyConfiguration(new UserStatsConfiguration()); + 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 6163d7a..0000000 --- a/GenOnlineService/Database/MySQL.cs +++ /dev/null @@ -1,2379 +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.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.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; -using static Database.Functions.Lobby; - -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 - 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 -*/ - -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 -{ - 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 - { - 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 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 - - // 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(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.Functions.Auth.GetBulkELOData(GlobalDatabaseInstance.g_Database, 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 - UserSession? playerSess = GenOnlineService.WebSocketManager.GetDataFromUser(eloPair.Key); - if (playerSess != null) - { - playerSess.GameStats.EloRating = eloPair.Value.Rating; - playerSess.GameStats.EloMatches = eloPair.Value.NumMatches; - } - await Database.Functions.Auth.SaveELOData(GlobalDatabaseInstance.g_Database, 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(); - Dictionary bulkLbData = await GetBulkLeaderboardData(m_Inst, userIds, dayOfYear, monthOfYear, year); - - foreach (MatchdataMemberModel member in lstMembers) - { - 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 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 - { - 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) - { - 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 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 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) - { - return reader.GetInt32() != 0; - } - - public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) - { - 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 - 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 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) - { - 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);", - 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 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) - { - 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 - { - 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 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(); - - 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(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); - 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) - { - // 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); - 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 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, string clientIDStr) - { - 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.GetDataFromUser(userID); - 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, oldWS, false); - } - } - - - 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(); - - await m_Inst.Query("DELETE FROM pending_logins WHERE code=@game_code LIMIT 1;", - new() - { - { "@game_code", strGameCode} - } - ); - } - - - 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 - 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 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) - { - 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 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, - Steam = 0, - Discord = 1, - Ghost = 2, - 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;", - 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}, - } - ); - } - } - - 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(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 GetDisplayName(m_Inst, 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 _); - } - } - } - } - - // 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 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(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"); - - 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"); - } - - 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/Discord.cs b/GenOnlineService/Discord.cs index f57e11c..7c4d762 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) @@ -495,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 { @@ -558,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 @@ -643,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/GenOnlineService.csproj b/GenOnlineService/GenOnlineService.csproj index 12cec23..cb2d5e0 100644 --- a/GenOnlineService/GenOnlineService.csproj +++ b/GenOnlineService/GenOnlineService.csproj @@ -12,7 +12,12 @@ False - + + + + + + 0 False @@ -45,6 +50,7 @@ + diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 9a43137..334f771 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; @@ -33,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 { @@ -159,8 +158,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)); @@ -332,6 +333,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 +349,7 @@ public async Task OnAfterPlayerLeft(Int64 leavingUserID) Console.WriteLine("DeleteLobby: Source A"); Console.ForegroundColor = ConsoleColor.Gray; - await LobbyManager.DeleteLobby(this); + OnLobbyNeedsDestroyed?.Invoke(this); } else { @@ -458,7 +461,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); @@ -511,12 +514,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; } @@ -525,7 +528,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; } @@ -743,7 +746,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); @@ -784,7 +787,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; @@ -814,26 +817,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(); } @@ -897,7 +900,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); @@ -1060,19 +1066,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(); } @@ -1105,13 +1111,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 readonly IServiceProvider _services; - private static Int64 m_NextLobbyID = 0; + public LobbyManager(IServiceProvider services) + { + _services = services; + } - public static async Task Cleanup() + 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 +1149,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(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) { @@ -1145,7 +1163,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(); @@ -1157,8 +1175,8 @@ public static async Task CreateLobby(UserSession owningSession, string st 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) @@ -1170,10 +1188,15 @@ 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 { - bool bJoined = await JoinLobby(newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + bool bJoined = await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); } newLobby.DirtyRetransmit(); @@ -1184,7 +1207,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,9 +1215,9 @@ public static async Task Tick() } } - public static 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) { @@ -1205,12 +1228,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 +1245,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 +1292,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 +1302,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 +1340,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 +1354,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 +1370,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 +1384,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,15 +1397,19 @@ public static async Task LeaveAnyLobby(Int64 userID) } } - public static async Task DeleteLobby(Lobby lobby) + 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 @@ -1392,20 +1419,23 @@ 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); + await Database.MatchHistory.DetermineLobbyWinnerIfNotPresent(db, lobby); // if its a quickmatch, update our leaderboards if (lobby.LobbyType == ELobbyType.QuickMatch) { - await Database.Functions.Leaderboards.UpdateLeaderboardAndElo(GlobalDatabaseInstance.g_Database, lobby); + await Database.MatchHistory.UpdateLeaderboardAndElo(db, 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..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; @@ -223,7 +224,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 +550,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 +559,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 +617,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; @@ -666,6 +678,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)) { @@ -744,7 +758,11 @@ 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 factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + + await Database.Leaderboards.CreateUserEntriesIfNotExists(db, memberSession.m_UserID); if (dummyHostUser == null) { @@ -757,24 +775,33 @@ 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); - 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); + 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 factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) + 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); + + // 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); + } } } } @@ -818,7 +845,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 +918,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); @@ -1050,77 +1077,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); + } } } } @@ -1157,6 +1193,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 +1206,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 +1231,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 abdf87c..0e77526 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 { @@ -242,7 +243,11 @@ 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 factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await Database.ServiceStats.CommitStats(db, DateTime.Now.DayOfYear, hourOfDay, numPlayers, numLobbies); } } @@ -258,6 +263,51 @@ 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"); + + 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 @@ -277,11 +327,100 @@ public class Program { public static IConfiguration? g_Config = null; public static DiscordBot? g_Discord = null; - static async Task DoCleanup(bool bStartup) + + // TODO_EFCORE: Do this regularly + static async Task DoCleanup(AppDbContext db, bool bStartup) + { + await Database.PendingLogins.Cleanup(db, bStartup); + } + + private static async Task InitializeDatabase(WebApplicationBuilder builder) { - await Database.Functions.Auth.Cleanup(GlobalDatabaseInstance.g_Database, bStartup); + // 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); - // clean up on startup + }); + } } private static Task AdditionalValidation(TokenValidatedContext context) @@ -384,7 +523,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 +548,30 @@ 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", ((int)knownClientID).ToString()), + 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")); + } + 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")); @@ -525,12 +684,7 @@ public static async Task Main(string[] args) g_Discord = new DiscordBot(); } - await GlobalDatabaseInstance.g_Database.Initialize(); - - // do a cleanup on startup - await DoCleanup(true); - - + builder.Services.AddSingleton(); builder.Services.AddRateLimiter(options => { @@ -632,11 +786,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")) @@ -804,7 +964,11 @@ public static async Task Main(string[] args) }); + // add DB + await InitializeDatabase(builder); + var app = builder.Build(); + ServiceLocator.Services = app.Services; app.UseRateLimiter(); @@ -842,10 +1006,9 @@ public static async Task Main(string[] args) app.UseAuthentication(); app.UseAuthorization(); - await Database.MySQLInstance.TestQuery(GlobalDatabaseInstance.g_Database); - app.MapControllers(); + // cleanup System.Timers.Timer timerCleanup = new System.Timers.Timer(5000); // 5s tick timerCleanup.AutoReset = false; @@ -855,10 +1018,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 +1047,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) @@ -949,7 +1115,12 @@ public static async Task Main(string[] args) { try { - await DailyStatsManager.SaveToDB(); + using (var scope = app.Services.CreateScope()) + { + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await DailyStatsManager.SaveToDB(db); + } } catch (Exception ex) { @@ -973,8 +1144,15 @@ 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 factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + // do a cleanup on startup + await DoCleanup(db, true); + await DailyStatsManager.LoadFromDB(db); + } app.Run(); @@ -1064,4 +1242,10 @@ public static void GlobalExceptionHandler(object sender, UnhandledExceptionEvent } } } + + public static class ServiceLocator + { + public static IServiceProvider Services { get; set; } = default!; + } + } 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,